From ea0694898ec9e335760728a24792695d20b7831d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 9 Oct 2026 23:05:52 +0200 Subject: [PATCH] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF-=D1=81=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D0=BA:=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B8=D1=87=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20grechka-game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.err | 0 build.js | 2 +- index.html | 35 + src/worms/sim.js | 636 ++++++ src/worms/worms-main.js | 2002 ++++++++++++++++++ style.css | 14 +- worms-pub/audio/CC0.txt | 2 + worms-pub/audio/shotgun.mp3 | Bin 0 -> 14778 bytes worms-pub/audio/uzi.mp3 | Bin 0 -> 13210 bytes worms-pub/index.html | 169 ++ worms-pub/vendor/socket.io.min.js | 7 + worms-pub/worm-blue-bazooka.png | Bin 0 -> 34694 bytes worms-pub/worm-red-bazooka.png | Bin 0 -> 33283 bytes worms-pub/worms.js | 3218 +++++++++++++++++++++++++++++ worms.html | 55 + worms.js | 3218 +++++++++++++++++++++++++++++ 16 files changed, 9350 insertions(+), 8 deletions(-) create mode 100644 build.err create mode 100644 src/worms/sim.js create mode 100644 src/worms/worms-main.js create mode 100644 worms-pub/audio/CC0.txt create mode 100644 worms-pub/audio/shotgun.mp3 create mode 100644 worms-pub/audio/uzi.mp3 create mode 100644 worms-pub/index.html create mode 100644 worms-pub/vendor/socket.io.min.js create mode 100644 worms-pub/worm-blue-bazooka.png create mode 100644 worms-pub/worm-red-bazooka.png create mode 100644 worms-pub/worms.js create mode 100644 worms.html create mode 100644 worms.js diff --git a/build.err b/build.err new file mode 100644 index 0000000..e69de29 diff --git a/build.js b/build.js index 2e1ee8c..9c2c57d 100644 --- a/build.js +++ b/build.js @@ -8,7 +8,7 @@ const fs = require('fs'); const path = require('path'); const SRC = path.join(__dirname, 'src'); -const ENTRY = 'main.js'; +const ENTRY = process.argv[2] || 'main.js'; const visited = new Set(); const chunks = []; diff --git a/index.html b/index.html index e5beac8..6336513 100644 --- a/index.html +++ b/index.html @@ -115,5 +115,40 @@ + + +
+

Во что играем?

+ + +
+ + diff --git a/src/worms/sim.js b/src/worms/sim.js new file mode 100644 index 0000000..f8feb35 --- /dev/null +++ b/src/worms/sim.js @@ -0,0 +1,636 @@ +// 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; diff --git a/src/worms/worms-main.js b/src/worms/worms-main.js new file mode 100644 index 0000000..c7a991d --- /dev/null +++ b/src/worms/worms-main.js @@ -0,0 +1,2002 @@ +// Worms-Grechka: хотсит-клиент (2 игрока на одном экране). +// Мир, земля, вода, спрайт — настоящая Гречка. Бой — пошаговый, как Worms. +import { state } from '../core/state.js'; +import { TILE, SEA_GY, BEDROCK_GY } from '../core/constants.js'; +import { canvas, ctx, resize } from '../core/canvas.js'; +import { BLOCKS } from '../data/blocks.js'; +import { grid, blocks, k, getBlock, isSolid, setBlock, removeBlock } from '../world/world-storage.js'; +import { genColumn, surfaceGyAt } from '../world/generation.js'; +import { tex, initTextures } from '../render/textures.js'; +import { playSound } from '../audio/sound-engine.js'; +import { Sim, WEAPONS, TURN_TIME, RETREAT_TIME, MAX_WIND, previewShot, solveShot } from './sim.js'; + +const WEAPON_ORDER = ['bazooka', 'homing', 'grenade', 'cluster', 'sheep', 'shotgun', 'uzi', 'dynamite', 'mine', 'shovel', 'airstrike', 'teleport', 'skip']; +const TEAM_COLORS = ['#e74c3c', '#3498db']; +const TEAM_NAMES = ['Красные', 'Синие']; +const WORMS_PER_TEAM = 5; +const ARENA_TILES = 100; +const GAP_HALF = 3; // полуширина пропасти с водой (в тайлах) +const HILL_RUN = 18; // длина склона горы в тайлах (узкие пики — над бойцами открытое небо) +const HILL_TOP = 14; // вершина горы: SEA_GY - 14 + +// ---------- состояние боя ---------- +const F = { + sim: null, grid: null, + seed: (Math.random() * 1000000) | 0, + mode: 'ai', // 'ai' | 'hotseat' + ax0: 0, arenaW: ARENA_TILES, + xMinPx: 0, xMaxPx: 0, + order: [], turnPos: 0, lastTeam: 1, + activeWorm: null, teamTurn: 0, + phase: 'move', timeLeft: TURN_TIME, retreatT: 0, shotsLeft: 0, + weapon: 'bazooka', fuseIdx: 2, + ammo: [{}, {}], + aim: -0.7, power: 0, charging: false, chargeT: 0, + camX: 0, camY: 0, zoom: 1, + zoomManual: false, panT: 0, // panT обязан быть числом: undefined глушит ветку камеры (undefined <= 0 === false) + boomX: 0, boomY: 0, boomT: 0, // память о взрыве: камера держится 2 с + night: false, torches: [], + particles: [], floaters: [], + over: false, winner: -1, + keys: {}, + stickId: null, stickOX: 0, stickOY: 0, + pinchD: 0, +}; + +function newAmmo() { + const a = {}; + for (const id of WEAPON_ORDER) a[id] = WEAPONS[id].ammo; + return a; +} + +// Целевая поверхность арены: две горы с пропастью посередине. +function arenaSurfaceGy(gx) { + const gapC = F.ax0 + F.arenaW / 2; + const d = Math.abs(gx - gapC); + if (d < GAP_HALF) return SEA_GY + 3; // мелкое дно пропасти — под водой + const t = Math.min(1, (d - GAP_HALF) / HILL_RUN); + const peak = SEA_GY - HILL_TOP, floor = SEA_GY + 3; + return Math.round(floor + (peak - floor) * Math.sin(t * Math.PI / 2)); +} + +function sculptArena(flats) { + // th-карта + выравнивание площадок под спавн + const thMap = {}; + for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) thMap[gx] = arenaSurfaceGy(gx); + for (const f of flats || []) { + for (let dx = -2; dx <= 2; dx++) { + const gx = f.gx + dx; + if (thMap[gx] != null && Math.abs(thMap[gx] - f.gy) <= 4) thMap[gx] = f.gy; + } + } + for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) { + const th = thMap[gx]; + for (let gy = 0; gy < th; gy++) removeBlock(gx, gy); + const cap = th < SEA_GY - 10 ? 'stone' : 'grass'; + if (!getBlock(gx, th)) setBlock(gx, th, cap); + for (let gy = th + 1; gy < BEDROCK_GY; gy++) { + if (!getBlock(gx, gy)) setBlock(gx, gy, gy <= th + 3 ? 'dirt' : 'stone'); + } + if (th > SEA_GY) { + for (let gy = SEA_GY - 2; gy <= th; gy++) { + const b = getBlock(gx, gy); + if (b && BLOCKS[b.t].fluid) continue; + if (b) removeBlock(gx, gy); + setBlock(gx, gy, 'water'); + } + } + } +} + +function setupArena() { + state.worldSeed = F.seed; + state.placedBlocks = []; + state.removedBlocks = []; + grid.clear(); blocks.length = 0; + state.generated.clear(); + F.ax0 = 5000 + (F.seed % 1000); + F.xMinPx = F.ax0 * TILE; + F.xMaxPx = (F.ax0 + F.arenaW) * TILE; + for (let gx = F.ax0 - 4; gx <= F.ax0 + F.arenaW + 4; gx++) genColumn(gx); + + // --- арена «две горы + пропасть»: лепим рельеф поверх генерации --- + // точки спавна считаем ДО лепки, площадки выравниваем в sculpt + const gapC = F.ax0 + F.arenaW / 2; + const peakSpots = (gx0, gx1) => { + const out = []; + for (let gx = gx0; gx <= gx1 && out.length < 10; gx += 2) { + const sgy = arenaSurfaceGy(gx); + if (sgy < SEA_GY) out.push({ gx, x: gx * TILE + TILE / 2, y: sgy * TILE - 80 }); + } + return out; + }; + const left = peakSpots(gapC - 28, gapC - 12); + const right = peakSpots(gapC + 12, gapC + 28); + const flats = left.concat(right).map((s) => ({ gx: s.gx, gy: Math.round((s.y + 80) / TILE) })); + sculptArena(flats); + + F.grid = { + isSolid: (gx, gy) => isSolid(gx, gy), + isFluid: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].fluid); }, + unbreakable: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].unbreakable); }, + remove: (gx, gy) => !!removeBlock(gx, gy), + groundBelowPx: (x, yTop) => { + const gx = Math.floor(x / TILE); + let gy = Math.max(0, Math.floor(yTop / TILE)); + for (; gy <= BEDROCK_GY + 5; gy++) if (isSolid(gx, gy)) return gy * TILE; + return null; + }, + waterYPx: SEA_GY * TILE, + xMinPx: F.xMinPx, xMaxPx: F.xMaxPx, + killYPx: (BEDROCK_GY + 8) * TILE, + }; + + F.sim = new Sim(F.grid, F.seed); + F.ammo = [newAmmo(), newAmmo()]; + F.cool = [{}, {}]; // кулдауны: {airstrike: ходы} + F.digsLeft = 10; // копок лопатой за ход + // спавн: команды на вершинах гор (точки уже посчитаны выше) + const mk = (list, team, tag) => { + const offs = [-80, -40, 0, 40, 80]; // каждый боец — на своей позиции, без кучи + for (let i = 0; i < WORMS_PER_TEAM; i++) { + const s = list.length ? list[(i * 2) % list.length] : { x: (F.xMinPx + F.xMaxPx) / 2, y: 0 }; + F.sim.addWorm(team, tag + (i + 1), s.x + offs[i % offs.length], s.y - (i * 30)); + } + }; + mk(left.length ? left : peakSpots(F.ax0 + 8, gapC - GAP_HALF - 2), 0, 'Гречка R'); + mk(right.length ? right : peakSpots(gapC + GAP_HALF + 2, F.ax0 + F.arenaW - 8), 1, 'Гречка B'); + // отстой физики + чистый старт: никакого урона от спавна + for (let i = 0; i < 300; i++) F.sim.step(1 / 60); + // вытолкнуть закопанных: вокруг каждого должно быть свободное место + for (const w of F.sim.worms) { + let n = 0; + while (n++ < 15 && F.sim.solidBox(w.x + 2, w.y + 2, w.w - 4, w.h - 4)) w.y -= 20; + } + for (let i = 0; i < 120; i++) F.sim.step(1 / 60); + for (const w of F.sim.worms) { w.hp = 100; w.fallStartY = w.y; w.vx = 0; w.vy = 0; } + F.sim.drainEvents(); + // ночью — факелы рядом с персонажами + F.torches = []; + if (F.night) placeTorches(); + F.lastTeam = 1; F.order = []; F.turnPos = 0; + F.over = false; F.winner = -1; + F.zoom = 0.55; // старт с общим планом: видны оба острова + F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150; + F.boomT = 0; + nextTurn(); + console.log('[DBG] spawn hp=' + F.sim.worms.map((w) => w.name + ':' + Math.round(w.hp)).join(' ')); + updateHud(); +} + +// Ночь: факел в 2–3 тайлах от каждого червяка, на поверхности. +function placeTorches() { + for (const w of F.sim.worms) { + const side = (w.id % 2 === 0) ? 1 : -1; + for (let dx = 2; dx <= 4; dx++) { + const gx = Math.floor((w.x + 17) / TILE) + side * dx; + if (gx * TILE < F.xMinPx || gx * TILE > F.xMaxPx) continue; + const gyTop = F.grid.groundBelowPx(gx * TILE + TILE / 2, 0); + if (gyTop == null) continue; + const gy = Math.floor(gyTop / TILE) - 1; + if (gy <= 0 || gyTop > SEA_GY * TILE + TILE) continue; // не в воду + if (getBlock(gx, gy)) continue; + if (setBlock(gx, gy, 'torch')) { + F.torches.push({ x: gx * TILE + TILE / 2, y: gy * TILE + TILE / 2 }); + break; + } + } + } +} +function aliveWorms(team) { return F.sim.worms.filter((w) => w.alive && (team == null || w.team === team)); } + +function nextTurn() { + const teams = F.sim.aliveTeams(); + if (teams.length <= 1) return gameOver(teams[0]); + // следующий червяк другой команды (round-robin внутри команды) + const prevTeam = F.lastTeam; + const nt = teams.find((t) => t !== prevTeam) ?? teams[0]; + const cands = aliveWorms(nt); + const prev = F.activeWorm; + let w = cands.find((c) => !prev || c.id !== prev.id) || cands[0]; + if (cands.length > 1 && prev && prev.team === nt) { + const i = cands.findIndex((c) => c.id === prev.id); + w = cands[(i + 1) % cands.length]; + } + F.activeWorm = w; F.teamTurn = nt; F.lastTeam = nt; + if (F.cool[nt].airstrike > 0) F.cool[nt].airstrike--; + F.digsLeft = 10; + F.phase = 'move'; F.timeLeft = TURN_TIME; F.shotsLeft = 0; + F.sim.wind = Math.round((Math.random() * 2 - 1) * MAX_WIND); + F.aim = -0.7 * 1; // вверх; направление по dir червяка при отрисовке + F.aimLocked = false; + F.power = 0; F.charging = false; F.firedThisTurn = false; F.zoomManual = false; + F.pendingAim = null; + if (F.ammo[nt][F.weapon] === 0) F.weapon = 'bazooka'; + console.log('[DBG] turn team=' + nt + ' worm=' + w.name); + updateHud(); + if (F.mode === 'ai' && nt === 1 && !F.over) aiTakeTurn(); +} + +// ---------- комп (синие) ---------- +function aiTakeTurn() { + setTimeout(() => { + if (F.over || !F.activeWorm || F.activeWorm.team !== 1 || F.phase !== 'move') return; + aiAct(); + }, 1400); +} + +function aiAct() { + const w = F.activeWorm; + const foes = aliveWorms(0); + console.log('[DBG] ai acts worm=' + w.name); + if (!foes.length) return; + let tgt = foes[0], bd = 1e12; + for (const e of foes) { + const d = Math.hypot(e.x - w.x, e.y - w.y); + if (d < bd) { bd = d; tgt = e; } + } + const tx = tgt.x + 17, ty = tgt.y + 11; + const ammo = F.ammo[1]; + const tryWeapon = (id, fn) => { if (ammo[id] !== 0) { F.weapon = id; fn(); return true; } return false; }; + // вплотную — очередь + if (bd < 130) { + if (tryWeapon('uzi', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); F.power = 1; doFire(); })) return; + if (tryWeapon('shotgun', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); doFire(); })) return; + } + // средняя — граната с подбором таймера + if (bd < 320) { + if (tryWeapon('grenade', () => { + const a = solveShotLocal(w, tx, ty, true, F.sim.wind); + F.aim = a.ang; F.power = a.pow; + F.fuseIdx = bd < 150 ? 0 : bd < 230 ? 1 : 2; + doFire(); + })) return; + } + // далеко — базука с пристрелкой + if (tryWeapon('bazooka', () => { + const a = solveShotLocal(w, tx, ty, false, F.sim.wind); + F.aim = a.ang + (Math.random() - 0.5) * 0.05; + F.power = Math.min(1, Math.max(0.25, a.pow)); + doFire(); + })) return; + // запасной вариант + tryWeapon('skip', () => doFire()); +} + +// Перебор углов/сил — общий код в sim.js (там же тесты). +function solveShotLocal(w, tx, ty, thrown, wind) { + return solveShot(F.grid, w.x + 17, w.y + 11, tx, ty, thrown, wind, w.dir); +} + +function endTurn() { + F.sim.setMove(F.activeWorm.id, 0); + F.charging = false; F.power = 0; + nextTurn(); +} + +function settled() { + const s = F.sim; + if (s.proj.length || s.strikes.length || s.dyn.length) return false; + if (s.mines.some((m) => m.fuse > 0)) return false; + return s.worms.every((w) => !w.alive || w.grounded); +} + +// выстрелы: настоящие сэмплы (CC0, audio/CC0.txt), запасной — синтез +const gunAudio = {}; +function gunSample(kind) { + let a = gunAudio[kind]; + if (!a) { + a = new Audio('audio/' + kind + '.mp3'); + a.preload = 'auto'; gunAudio[kind] = a; + } + return a; +} +let gunCtx = null; +// овца бекует: «бе-е-е» — пила с вибрато, два слога +function playBaa() { + try { + gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)(); + const ctx = gunCtx, t = ctx.currentTime; + for (let s = 0; s < 2; s++) { + const tt = t + s * 0.22; + const o = ctx.createOscillator(), g = ctx.createGain(), lfo = ctx.createOscillator(), lg = ctx.createGain(); + o.type = 'sawtooth'; o.frequency.value = 520 + Math.random() * 60; + lfo.type = 'sine'; lfo.frequency.value = 28; lg.gain.value = 90; + lfo.connect(lg); lg.connect(o.frequency); + g.gain.setValueAtTime(0.0001, tt); + g.gain.exponentialRampToValueAtTime(0.35, tt + 0.03); + g.gain.exponentialRampToValueAtTime(0.01, tt + 0.2); + o.connect(g); g.connect(ctx.destination); + o.start(tt); o.stop(tt + 0.22); lfo.start(tt); lfo.stop(tt + 0.22); + } + } catch (e) {} +} +function playGun(kind) { + try { + const a = gunSample(kind); + a.currentTime = 0; + const p = a.play(); + if (p && p.catch) p.catch(() => playGunSynth(kind)); + } catch (e) { playGunSynth(kind); } +} +function playGunSynth(kind) { + try { + gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)(); + const ctx = gunCtx, t = ctx.currentTime; + if (kind === 'shotgun') { + const len = Math.floor(ctx.sampleRate * 0.35); + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = ctx.createBufferSource(); src.buffer = buf; + const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 480; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.01, t + 0.35); + src.connect(f); f.connect(g); g.connect(ctx.destination); src.start(t); + const o = ctx.createOscillator(), og = ctx.createGain(); + o.type = 'sine'; o.frequency.setValueAtTime(160, t); + o.frequency.exponentialRampToValueAtTime(45, t + 0.3); + og.gain.setValueAtTime(0.7, t); og.gain.exponentialRampToValueAtTime(0.01, t + 0.3); + o.connect(og); og.connect(ctx.destination); o.start(t); o.stop(t + 0.32); + } else { + for (let i = 0; i < 5; i++) { + const tt = t + i * 0.055; + const o = ctx.createOscillator(), g = ctx.createGain(); + o.type = 'square'; o.frequency.value = 900 + Math.random() * 300; + g.gain.setValueAtTime(0.22, tt); g.gain.exponentialRampToValueAtTime(0.01, tt + 0.04); + o.connect(g); g.connect(ctx.destination); o.start(tt); o.stop(tt + 0.05); + } + } + } catch (e) {} +} +function doFire(point) { + if (NET.on) return netFire(point); + const w = F.activeWorm; + if (!w || F.phase !== 'move' || F.over) return; + const def = WEAPONS[F.weapon]; + if (F.ammo[F.teamTurn][F.weapon] === 0) return; + const ang = F.aim; + let ok = false; + if (def.kind === 'target' || def.kind === 'teleport') { + const tgt = point || F.pendingAim; + if (!tgt) return; // нет метки — нет удара + if (F.weapon === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун + ok = F.sim.fire(w.id, F.weapon, { x: tgt.x, y: tgt.y }); + F.pendingAim = null; + if (ok && F.weapon === 'airstrike') F.cool[F.teamTurn].airstrike = 2; // 2 своих хода ждать + } else if (def.kind === 'hitscan') { + if (F.shotsLeft <= 0) F.shotsLeft = def.shots; + ok = F.sim.fire(w.id, F.weapon, { angle: ang }); + if (ok) { + F.shotsLeft--; + if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--; + if (F.shotsLeft <= 0) { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; } + updateHud(); + return; + } + } else if (def.kind === 'dig') { + // лопата: копает туда, куда наведён прицел (как дробовик), ход не тратится (10 копок за ход) + if (F.digsLeft <= 0) return; + ok = F.sim.fire(w.id, F.weapon, { angle: ang }); + if (ok) { + F.digsLeft--; + playSound('sand1'); + const dpt = digPoint(w.x + 17, w.y + 17, ang); + burst(dpt ? dpt[0] : w.x + 17 + Math.cos(ang) * 52, dpt ? dpt[1] : w.y + 17 + Math.sin(ang) * 52, 14, ['#8d6e63', '#a1887f', '#6d4c41']); + updateHud(); + } + return; + } else { + const params = { angle: ang }; + if (def.power) params.power = F.power; + if (def.fuse) params.fuse = def.fuse[F.fuseIdx] || def.fuse[0]; + ok = F.sim.fire(w.id, F.weapon, params); + } + if (!ok) return; + if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--; + // звук выстрела — через событие fired (там же услышат и сетевые выстрелы) + console.log('[DBG] fired team=' + F.teamTurn + ' weapon=' + F.weapon); + F.firedThisTurn = true; + if (def.kind === 'skip' || def.kind === 'teleport') endTurn(); + else { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; } + updateHud(); +} + +function gameOver(team) { + F.over = true; + F.winner = team == null ? -1 : team; + const el = document.getElementById('banner'); + el.style.display = 'flex'; + el.querySelector('.b-text').textContent = team == null || team === undefined + ? 'Ничья! Все утонули.' + : 'Победа: ' + TEAM_NAMES[team] + '!'; +} + +// ---------- события сима -> эффекты ---------- +function pumpEvents() { + for (const e of F.sim.drainEvents()) { + if (e.t === 'explode') { + playSound('explode1'); + boomFx(e.x, e.y, e.r || 2); + F.boomX = e.x; F.boomY = e.y; F.boomT = 2.0; // камера смотрит взрыв ещё 2 с + } else if (e.t === 'splash') playSound('splash'); + else if (e.t === 'dig') burst(e.x, e.y, 10, ['#8d6e63', '#a1887f']); + else if (e.t === 'damage') { + if (e.dmg > 0) { + console.log('[DBG] damage worm=' + e.worm + ' dmg=' + e.dmg); + F.floaters.push({ x: 0, y: 0, worm: e.worm, text: '-' + e.dmg, t: 1.2 }); + playSound('hit1'); + } + } else if (e.t === 'die') { + const w = F.sim.wormById(e.worm); + if (w) burst(w.x + 17, w.y + 17, 14, ['#ffffff', '#00a1ff']); + } else if (e.t === 'fired') { + // выстрел услышали все: дробовик/узи — сэмплами, остальное — штатным звуком атаки (и сеть, и локалка) + if (e.weapon === 'shotgun') playGun('shotgun'); + else if (e.weapon === 'uzi') playGun('uzi'); + else if (e.weapon === 'sheep') { playBaa(); F.baaNext = performance.now() + 900; } + else playSound('attack'); + } else if (e.t === 'mine_beep') playSound('click'); + else if (e.t === 'teleport') playSound('click'); + else if (e.t === 'strike_incoming') playSound('click'); + } + // овца скачет — периодически бекует, пока жива (и сеть, и локалка). Вне цикла событий: иначе молчит между событиями + try { + if (F.sim && F.sim.proj.some((p) => p.k === 'sheep') && performance.now() > (F.baaNext || 0)) { + playBaa(); F.baaNext = performance.now() + 1000; + } + } catch (e) {} +} + +function burst(x, y, n, colors, sz) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2, sp = 60 + Math.random() * 320; + F.particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 120, t: 0.5 + Math.random() * 0.5, c: colors[(Math.random() * colors.length) | 0], sz: sz || 6 }); + } +} +// красивый взрыв: вспышка + огонь + дым + искры + комья + кольцо + тряска экрана. R — радиус взрыва с сервера +function boomFx(x, y, R) { + const P = (o) => F.particles.push(Object.assign({ x, y, vx: 0, vy: 0, t: 0.5, c: '#fff', sz: 6 }, o)); + P({ c: '#fff7d6', sz: R * 44, t: 0.14 }); // вспышка + for (let i = 0; i < 14; i++) { const a = Math.random() * Math.PI * 2, sp = 40 + Math.random() * 200; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 160, t: 0.4 + Math.random() * 0.3, c: ['#f39c12', '#e67e22', '#e74c3c'][(Math.random() * 3) | 0], sz: 8 + Math.random() * 8 }); } // огонь + for (let i = 0; i < 10; i++) { const a = Math.random() * Math.PI * 2, sp = 20 + Math.random() * 80; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 90, t: 1.2 + Math.random() * 0.8, c: ['#7f8c8d', '#95a5a6', '#57606f'][(Math.random() * 3) | 0], sz: 10 + Math.random() * 10 }); } // дым + for (let i = 0; i < 18; i++) { const a = Math.random() * Math.PI * 2, sp = 400 + Math.random() * 500; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 100, t: 0.5 + Math.random() * 0.4, c: ['#ffe66d', '#ffffff', '#ffd76e'][(Math.random() * 3) | 0], sz: 4 }); } // искры + for (let i = 0; i < 12; i++) { const a = Math.random() * Math.PI * 2, sp = 120 + Math.random() * 300; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 200, t: 0.8 + Math.random() * 0.5, c: ['#8d6e63', '#6d4c41', '#5d4037'][(Math.random() * 3) | 0], sz: 6 + Math.random() * 5 }); } // комья земли + F.particles.push({ x, y, vx: 0, vy: 0, t: 0.35, t0: 0.35, c: '#ffd76e', ring: 1, r1: R * 40 }); // кольцо + F.shakeT = 0.3; F.shakeR = Math.min(16, 5 + R * 3); // тряска экрана +} + +// ---------- ввод ---------- +function screenToWorld(sx, sy) { + const vw = canvas.clientWidth, vh = canvas.clientHeight; + return { x: (sx - vw / 2) / F.zoom + F.camX, y: (sy - vh / 2) / F.zoom + F.camY }; +} + +// hold-кнопки как в оригинале Гречки (bindHold) +function bindHold(id, down, up) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('pointerdown', (e) => { e.preventDefault(); down(); }); + el.addEventListener('pointerup', (e) => { e.preventDefault(); if (up) up(); }); + el.addEventListener('pointerleave', () => { if (up) up(); }); + el.addEventListener('pointercancel', () => { if (up) up(); }); +} + +function showStick(sx, sy) { + const st = document.getElementById('stick'); + st.style.display = 'block'; + st.style.left = (sx - 60) + 'px'; + st.style.top = (sy - 60) + 'px'; + moveStickKnob(0, 0); +} +function hideStick() { document.getElementById('stick').style.display = 'none'; } +function moveStickKnob(dx, dy) { + const k = document.getElementById('stickKnob'); + k.style.left = (34 + dx) + 'px'; + k.style.top = (34 + dy) + 'px'; +} + +function bindInput() { + window.addEventListener('keydown', (e) => { + if (e.repeat) { F.keys[e.code] = true; return; } + F.keys[e.code] = true; + if (e.code === 'Tab') { e.preventDefault(); cycleWeapon(1); } + if (e.code === 'KeyC') cycleWorm(); + if (e.code === 'KeyF') { + const def = WEAPONS[F.weapon]; + if (def.fuse) { F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length; updateHud(); } + } + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') jump(); + if (e.code === 'Space') { e.preventDefault(); startCharge(); } + }); + window.addEventListener('keyup', (e) => { + F.keys[e.code] = false; + if (e.code === 'Space') { e.preventDefault(); releaseFire(); } + }); + // кнопки движения как в оригинале + bindHold('left', () => (F.touchMove = -1), () => { if (F.touchMove < 0) F.touchMove = 0; }); + bindHold('right', () => (F.touchMove = 1), () => { if (F.touchMove > 0) F.touchMove = 0; }); + bindHold('jumpBtn', () => jump()); + // огонь: нажал — начинается заряд (для оружия с силой), отпустил — выстрел. + // держишь дольше — летит дальше; быстрое нажатие — короткий выстрел. Как пробел на компе. + bindHold('fireBtn', () => { + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); // пробел после клика — выстрел, а не повтор кнопки + if (F.phase !== 'move' || F.over) return; + if (F.mode === 'ai' && F.teamTurn === 1) return; + startCharge(); + }, () => { releaseFire(); }); + // таймер гранаты/кластера + bindHold('fuseBtn', () => { + const def = WEAPONS[F.weapon]; + if (def.fuse) { + F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length; + playSound('click'); + updateHud(); + } + }); + // навёл курсор на кнопку — прицел замирает, вернулся на карту — снова следует за мышью + for (const bid of ['fireBtn', 'jumpBtn', 'wOpen', 'fuseBtn', 'zIn', 'zOut']) { + const bel = document.getElementById(bid); + if (!bel) continue; + bel.addEventListener('pointerenter', () => { F.uiHover = true; }); + bel.addEventListener('pointerleave', () => { F.uiHover = false; }); + } + // зум: колесо + кнопки + щипок + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + setZoom(F.zoom * (e.deltaY > 0 ? 0.88 : 1.14)); + }, { passive: false }); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // ПКМ — пан карты, не меню + bindHold('zIn', () => setZoom(F.zoom * 1.25)); + bindHold('zOut', () => setZoom(F.zoom * 0.8)); + const pts = new Map(); + canvas.addEventListener('pointerdown', (e) => { + // правая кнопка мыши — тянуть карту (пан), как два пальца на телефоне + if (e.button === 2) { F.rpan = { x: e.clientX, y: e.clientY, id: e.pointerId }; return; } + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pts.size === 2) { + const p = [...pts.values()]; + F.pinchD = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + F.pinchMX = (p[0].x + p[1].x) / 2; F.pinchMY = (p[0].y + p[1].y) / 2; + if (F.stickId != null) { F.stickId = null; hideStick(); } + return; + } + const def = WEAPONS[F.weapon]; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left, sy = e.clientY - r.top; + if (def.kind === 'target' || def.kind === 'teleport') { + // клик — только метка цели, удар — строго с кнопки ОГОНЬ + const wpt = screenToWorld(sx, sy); + F.pendingAim = wpt; + playSound('click'); + updateHud(); + return; + } + if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray') { + // в сети чужим прицелом не водим + if (NET.on && !myTurn()) return; + // стик прицела появится под пальцем после сдвига; быстрый тап — смена бойца + F.stickId = e.pointerId; F.stickOX = sx; F.stickOY = sy; + F.stickDX = 0; F.stickDY = 0; F.stickShown = false; F.stickT = performance.now(); + } + }); + canvas.addEventListener('pointermove', (e) => { + if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + // десктоп: прицел следует за мышью без зажатия (тач — только стиком) + // во время зарядки прицел заморожен — иначе уползёт за курсором вниз + if (e.pointerType === 'mouse' && e.buttons === 0 && F.stickId == null && !F.rpan && !F.aimLocked && !F.uiHover + && F.activeWorm && F.phase === 'move' && !F.over && (!NET.on || myTurn())) { + const dk = WEAPONS[F.weapon].kind; + if (dk === 'aimed' || dk === 'thrown' || dk === 'hitscan' || dk === 'spray' || dk === 'dig') { + const r = canvas.getBoundingClientRect(); + const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top); + F.aim = Math.atan2(wpt.y - (F.activeWorm.y + 11), wpt.x - (F.activeWorm.x + 17)); + } + } + // пан правой кнопкой мыши + if (F.rpan && e.pointerId === F.rpan.id) { + F.camX -= (e.clientX - F.rpan.x) / F.zoom; + F.camY -= (e.clientY - F.rpan.y) / F.zoom; + F.rpan.x = e.clientX; F.rpan.y = e.clientY; + F.zoomManual = true; F.panT = 4; + return; + } + if (pts.size === 2) { + const p = [...pts.values()]; + const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + // два пальца: сдвиг середины — двигает карту, развод — зум + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + if (F.pinchMX != null) { + F.camX -= (mx - F.pinchMX) / F.zoom; + F.camY -= (my - F.pinchMY) / F.zoom; + F.zoomManual = true; + F.panT = 4; // карту подержать: камера не убегает 4 с + } + F.pinchMX = mx; F.pinchMY = my; + if (F.pinchD > 0) setZoom(F.zoom * (d / F.pinchD)); + F.pinchD = d; + return; + } + if (e.pointerId !== F.stickId) return; + const r = canvas.getBoundingClientRect(); + let dx = (e.clientX - r.left) - F.stickOX, dy = (e.clientY - r.top) - F.stickOY; + const len = Math.hypot(dx, dy), max = 60; + if (!F.stickShown) { + if (len < 12) return; // ещё тап, не тянем + F.stickShown = true; + showStick(F.stickOX, F.stickOY); + } + if (len > max) { dx = (dx / len) * max; dy = (dy / len) * max; } + F.stickDX = dx; F.stickDY = dy; + moveStickKnob(dx, dy); + F.aimLocked = false; // явное перетаскивание стика — главный жест, разморозить + F.aim = Math.atan2(dy, dx); + F.power = Math.min(1, len / max); + const w = F.activeWorm; + if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; + }); + const endPt = (e) => { + pts.delete(e.pointerId); + if (F.rpan && e.pointerId === F.rpan.id) { F.rpan = null; return; } + if (pts.size < 2) { F.pinchD = 0; F.pinchMX = null; } + if (e.pointerId !== F.stickId) return; + const wasTap = !F.stickShown; + F.stickId = null; F.stickShown = false; hideStick(); + if (wasTap) { + // тап по своему бойцу — смена активного (кроме оружия с меткой: там тап = цель) + const def2 = WEAPONS[F.weapon]; + if (def2.kind !== 'target' && def2.kind !== 'teleport') { + const r = canvas.getBoundingClientRect(); + const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top); + if (!selectWormAt(wpt.x, wpt.y)) { + // по бойцу не попали — тап наводит прицел в точку (как мышь на компе): удобно копать/целиться с телефона + const w = F.activeWorm; + if (w && F.phase === 'move' && !F.over && (!NET.on || myTurn())) { + F.aim = Math.atan2(wpt.y - (w.y + 17), wpt.x - (w.x + 17)); + w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; + } + } + } + return; + } + // отпустил стик — только конец наведения, огонь СТРОГО с кнопки ОГОНЬ + hideStick(); + }; + canvas.addEventListener('pointerup', endPt); + canvas.addEventListener('pointercancel', endPt); +} + +function setZoom(z) { + if (!isFinite(z)) return; // планшетный развал: битый зум не пускаем + F.zoomManual = true; // ручной зум отменяет автозум до конца хода + F.zoom = Math.max(0.1, Math.min(1.6, z)); +} + +// Смена бойца: клик по своему живому червяку до выстрела. +function selectWormAt(wx, wy) { + if (NET.on) return netSwitch(wx, wy); + if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return false; + if (F.mode === 'ai' && F.teamTurn === 1) return false; + let best = null, bd = 40; + for (const w of F.sim.worms) { + if (!w.alive || w.team !== F.teamTurn || w.id === F.activeWorm.id) continue; + const d = Math.hypot(w.x + 17 - wx, w.y + 17 - wy); + if (d < bd) { bd = d; best = w; } + } + if (best) { + F.sim.setMove(F.activeWorm.id, 0); + F.activeWorm = best; + F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false; + playSound('click'); + updateHud(); + return true; + } + return false; +} + +function jump() { + if (NET.on) { netJump(); return; } + if (F.mode === 'ai' && F.teamTurn === 1) return; // комп думает — его бойцом не прыгаем + if (F.activeWorm && F.phase !== 'over') { F.sim.jump(F.activeWorm.id); playSound('step'); } +} +function cycleWeapon(d) { + let i = WEAPON_ORDER.indexOf(F.weapon); + for (let k = 0; k < WEAPON_ORDER.length; k++) { + i = (i + d + WEAPON_ORDER.length) % WEAPON_ORDER.length; + if (F.ammo[F.teamTurn][WEAPON_ORDER[i]] !== 0) break; + } + F.weapon = WEAPON_ORDER[i]; + F.pendingAim = null; // смена оружия снимает старую метку (иначе телепорт/удар уйдёт по ней) + updateHud(); +} +// Смена бойца клавиатурой: следующий живой свой. +function cycleWorm() { + if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return; + if (F.mode === 'ai' && F.teamTurn === 1) return; + const mine = F.sim.worms.filter((w) => w.alive && w.team === F.teamTurn); + if (mine.length < 2) return; + const i = mine.findIndex((w) => w.id === F.activeWorm.id); + F.sim.setMove(F.activeWorm.id, 0); + F.activeWorm = mine[(i + 1) % mine.length]; + F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false; + playSound('click'); + updateHud(); +} +function startCharge() { + const def = WEAPONS[F.weapon]; + if (F.phase !== 'move' || F.over) return; + F.aimLocked = true; // прицел замер: навёл мышью — жми пробел, курсор больше не уведёт его + if (def.kind === 'hitscan' || def.kind === 'spray') { F.power = 1; doFire(); F.aimLocked = false; return; } + if (!def.power) { doFire(); F.aimLocked = false; return; } + F.charging = true; F.chargeT = 0; +} +function releaseFire() { + if (!F.charging) { F.aimLocked = false; return; } + F.charging = false; F.aimLocked = false; + // быстрое нажатие пробела — выстрел сразу с рабочей силой, держать — добрать силу вручную + const def = WEAPONS[F.weapon]; + if (def.power && (F.chargeT || 0) < 0.25) F.power = 0.65; + doFire(); +} + +function pollKeys(dt) { + const w = F.activeWorm; + if (!w || F.over) return; + const k = F.keys; + let mv = 0; + if (k['ArrowLeft'] || k['KeyA']) mv -= 1; + if (k['ArrowRight'] || k['KeyD']) mv += 1; + if (F.touchMove) mv = F.touchMove; + // движение — только своим червяком в свой ход + if (F.mode === 'ai' && F.teamTurn === 1) mv = 0; + if (F.phase !== 'over') F.sim.setMove(w.id, mv); + if (k['ArrowUp'] || k['KeyW']) { F.aim -= 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; } + if (k['ArrowDown'] || k['KeyS']) { F.aim += 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; } + if (F.charging) { + F.chargeT += dt; + F.power = Math.min(1, F.chargeT / 1.5); + if (F.power >= 1) { F.charging = false; doFire(); } + } +} + +// ---------- HUD: оружие справа сверху, пиксельные иконки ---------- +// иконки рисуются кодом, пиксель-арт 12x12 +function drawIcon(cv, id) { + const g = cv.getContext('2d'); + const P = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); }; + g.clearRect(0, 0, 48, 48); + g.save(); g.scale(4, 4); // 12x12 пикселей + const Q = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); }; + switch (id) { + case 'bazooka': Q(1, 5, 9, 2, '#2d3436'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#e74c3c'); break; + case 'grenade': Q(4, 3, 4, 5, '#27ae60'); Q(5, 1, 2, 2, '#7f8c8d'); Q(7, 0, 2, 1, '#95a5a6'); break; + case 'homing': Q(1, 5, 9, 2, '#c0392b'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#ffe66d'); Q(2, 3, 2, 2, '#c0392b'); break; + case 'sheep': Q(2, 4, 8, 6, '#f5f6fa'); Q(0, 6, 2, 4, '#f5f6fa'); Q(8, 2, 4, 4, '#2d3436'); Q(9, 3, 1, 1, '#fff'); break; + case 'cluster': Q(3, 4, 4, 4, '#8e44ad'); Q(1, 6, 2, 2, '#8e44ad'); Q(8, 2, 2, 2, '#8e44ad'); Q(8, 8, 2, 2, '#8e44ad'); break; + case 'shotgun': Q(1, 4, 8, 1, '#6d4c41'); Q(1, 6, 8, 1, '#6d4c41'); Q(8, 3, 3, 5, '#a1887f'); Q(0, 4, 1, 3, '#e74c3c'); break; + case 'uzi': Q(2, 4, 6, 3, '#2d3436'); Q(8, 5, 3, 1, '#2d3436'); Q(4, 7, 1, 3, '#7f8c8d'); Q(0, 2, 2, 1, '#f1c40f'); Q(0, 0, 1, 1, '#f1c40f'); break; + case 'dynamite': Q(3, 3, 2, 7, '#c0392b'); Q(6, 3, 2, 7, '#c0392b'); Q(3, 5, 5, 1, '#7f8c8d'); Q(8, 1, 2, 1, '#f1c40f'); break; + case 'mine': Q(3, 6, 6, 4, '#2d3436'); Q(4, 4, 4, 2, '#7f8c8d'); Q(5, 2, 2, 2, '#e74c3c'); break; + case 'airstrike': Q(1, 2, 8, 2, '#95a5a6'); Q(4, 0, 3, 2, '#e74c3c'); Q(2, 6, 1, 2, '#e74c3c'); Q(5, 6, 1, 3, '#e74c3c'); Q(8, 6, 1, 2, '#e74c3c'); break; + case 'teleport': Q(4, 0, 4, 2, '#00a1ff'); Q(2, 3, 8, 2, '#00a1ff'); Q(4, 6, 4, 2, '#00a1ff'); Q(0, 4, 3, 4, '#00a1ff'); Q(9, 4, 3, 4, '#00a1ff'); Q(4, 9, 4, 2, '#00a1ff'); break; + case 'shovel': Q(5, 0, 2, 6, '#8d6e63'); Q(4, 0, 4, 1, '#5d4037'); Q(3, 6, 6, 5, '#b0bec5'); Q(4, 7, 4, 3, '#78909c'); Q(5, 11, 2, 1, '#546e7a'); break; + case 'skip': Q(1, 2, 4, 8, '#f1c40f'); Q(6, 2, 4, 8, '#f1c40f'); Q(1, 2, 9, 2, '#f1c40f'); Q(1, 8, 9, 2, '#f1c40f'); break; + } + g.restore(); + P(0, 0, 0, 0, '#000'); // no-op, держим P используемой +} + +function buildWeaponPopup() { + const pop = document.getElementById('wPop'); + pop.innerHTML = ''; + for (const id of WEAPON_ORDER) { + const row = document.createElement('div'); + row.className = 'wrow'; row.dataset.w = id; + const cv = document.createElement('canvas'); + cv.width = 48; cv.height = 48; + drawIcon(cv, id); + const nm = document.createElement('span'); + nm.textContent = WEAPONS[id].name; + const am = document.createElement('span'); + am.className = 'ammo'; + row.appendChild(cv); row.appendChild(nm); row.appendChild(am); + row.onclick = () => { + if (F.ammo[F.teamTurn][id] === 0) return; + if (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун + if (F.weapon !== id) F.pendingAim = null; // смена оружия снимает метку + F.weapon = id; + pop.classList.remove('on'); + playSound('click'); + updateHud(); + }; + pop.appendChild(row); + } + const btn = document.getElementById('wOpen'); + btn.onclick = (e) => { e.stopPropagation(); pop.classList.toggle('on'); updateHud(); }; + document.addEventListener('pointerdown', (e) => { + if (pop.classList.contains('on') && !pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) + pop.classList.remove('on'); + }); +} + +function updateHud() { + const s = F.sim; + if (!s) return; + const t0 = aliveWorms(0).reduce((sm, w) => sm + Math.max(0, w.hp), 0); + const t1 = aliveWorms(1).reduce((sm, w) => sm + Math.max(0, w.hp), 0); + setText('hudTeams', '🔴 ' + TEAM_NAMES[0] + ' ' + Math.round(t0) + ' vs 🔵 ' + TEAM_NAMES[1] + ' ' + Math.round(t1)); + setText('hudTurn', F.activeWorm && !F.over + ? (NET.simMode ? '⚡ Одновременно — ' + F.activeWorm.name + : 'Ходит: ' + F.activeWorm.name + (F.mode === 'ai' && F.teamTurn === 1 ? ' (комп думает…)' : '') + ' — ' + Math.ceil(F.timeLeft) + 'с') + : ''); + const wd = s.wind, wa = Math.min(5, Math.abs(wd)); + setText('hudWind', 'Ветер ' + (wd === 0 ? '· штиль' : (wd > 0 ? '▶' : '◀') + ' ' + '■'.repeat(wa) + '□'.repeat(5 - wa))); + // голос: бойцам — микрофон 🎤, зрителям — слушать 🔊; смена роли — голос гаснет, включить заново одним нажатием + const vRole = !NET.on ? '' : (NET.myTeam >= 0 ? 'p' : 's'); + if (vRole !== lastVoiceRole) { lastVoiceRole = vRole; if (voiceOn) voiceOff(true); } + document.getElementById('micBtn').style.display = vRole === 'p' ? 'block' : 'none'; + document.getElementById('earBtn').style.display = vRole === 's' ? 'block' : 'none'; + // зрителям — только смотреть: кнопки действий прячем, зум и осмотр карты остаются + const showCtl = vRole !== 's' ? '' : 'none'; + for (const id of ['controls', 'jumpBtn', 'fireBtn', 'wOpen', 'fuseBtn']) { + const el = document.getElementById(id); + if (el && (id !== 'fuseBtn' || showCtl === 'none')) el.style.display = showCtl; + } + // кнопка текущего оружия + const btn = document.getElementById('wOpen'); + btn.innerHTML = ''; + const cv = document.createElement('canvas'); + cv.width = 48; cv.height = 48; + drawIcon(cv, F.weapon); + btn.appendChild(cv); + const def = WEAPONS[F.weapon]; + btn.title = def.name + (def.fuse ? ' ⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с' : ''); + // кнопка таймера — только для оружия с таймером (зрителям не показываем вовсе) + const fuseBtn = document.getElementById('fuseBtn'); + if (fuseBtn) { + if (def.fuse && vRole !== 's') { + fuseBtn.style.display = 'block'; + fuseBtn.textContent = '⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с'; + } else fuseBtn.style.display = 'none'; + } + // подсказки убраны — вверху только важные сообщения + setText('hint', ''); + canvas.style.cursor = (def.kind === 'target' || def.kind === 'teleport') ? 'crosshair' : 'default'; + // строки попапа + const pop = document.getElementById('wPop'); + for (const row of pop.children) { + const id = row.dataset.w; + const left = F.ammo[F.teamTurn][id]; + const cd = (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) ? F.cool[F.teamTurn].airstrike : 0; + row.classList.toggle('sel', id === F.weapon); + row.classList.toggle('empty', left === 0 || cd > 0); + row.querySelector('.ammo').textContent = cd > 0 ? '⏳' + cd : (left === Infinity ? '∞' : '×' + left); + } +} +function setText(id, s) { const el = document.getElementById(id); if (el) el.textContent = s; } + +// ---------- рендер ---------- +// точка копки лопаты (клиентский рейкаст 1:1 с симом) — для кружка-прицела +function digPoint(cx, cy, ang) { + const dx = Math.cos(ang), dy = Math.sin(ang); + for (let d = 8; d <= 95; d += 8) { + const gx = Math.floor((cx + dx * d) / TILE), gy = Math.floor((cy + dy * d) / TILE); + if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue; + if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2]; + } + const px = cx + dx * 52, py = cy + dy * 52; + for (let dd = 0; dd <= 120; dd += 10) { + const gx = Math.floor(px / TILE), gy = Math.floor((py + dd) / TILE); + if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue; + if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2]; + } + return null; +} +// Точки превью траектории активного оружия (для отрисовки и автозума). +function aimPreview() { + const w = F.activeWorm; + if (!w || F.phase !== 'move' || F.over) return null; + const def = WEAPONS[F.weapon]; + if (!def || (def.kind !== 'aimed' && def.kind !== 'thrown' && def.kind !== 'hitscan' && def.kind !== 'spray')) return null; + const cx = w.x + 17, cy = w.y + 11; + if (def.kind === 'hitscan' || def.kind === 'spray') + return previewStraight(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26, F.aim, 520); + return previewShot(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26, + F.aim, def.power ? F.power : 0.6, + { thrown: def.kind === 'thrown', wind: F.sim.wind, windK: def.windK || 0, fuse: def.fuse ? def.fuse[F.fuseIdx] : 3, dir: w.dir }); +} +// Автозум: держит в кадре червяка и конец прицела. Ручной зум отменяет до конца хода. +function autoZoom(vw, vh) { + if (F.zoomManual || F.panT > 0) return; + if (F.sim.proj.length || F.boomT > 0 || !F.activeWorm) return; + const pts = aimPreview(); + if (!pts || !pts.length) return; + const w = F.activeWorm; + const end = pts[pts.length - 1]; + // широкий кадр: не налезаем на стреляющего, держим контекст вокруг + const needW = Math.abs(end[0] - (w.x + 17)) + 520; + const needH = Math.abs(end[1] - (w.y + 17)) + 480; + const zt = Math.max(0.15, Math.min(0.7, vw / needW, vh / needH)); + F.zoom += (zt - F.zoom) * 0.08; +} + +function render() { + const vw = canvas.clientWidth, vh = canvas.clientHeight; + // отладка камеры/прицела для тестов (window.WDBG()) + try { + window.WDBG = () => { + const s = F.sim, pts = (typeof aimPreview === 'function') ? aimPreview() : null; + return { + v: 51, weapon: F.weapon, phase: F.phase, over: !!F.over, + active: F.activeWorm ? { n: F.activeWorm.name, x: Math.round(F.activeWorm.x), y: Math.round(F.activeWorm.y) } : null, + aim: +((typeof F.aim === 'number') ? F.aim.toFixed(2) : -99), power: F.power, + cam: { x: Math.round(F.camX), y: Math.round(F.camY) }, zoom: +F.zoom.toFixed(2), + pts: pts ? pts.length : null, + end: pts && pts.length ? pts[pts.length - 1].map(Math.round) : null, + proj: s ? s.proj.length : -1, boomT: +(F.boomT || 0).toFixed(1), panT: +(F.panT || 0).toFixed(1), + }; + }; + } catch (e) {} + // автозум за прицелом: червяк + конец траектории в кадре + try { autoZoom(vw, vh); } catch (e) { try { window.WERR = 'autoZoom: ' + e.message; } catch (_) {} } + const z = F.zoom; + // камера за активным червяком / снарядом (мировые координаты центра экрана) + let tx = F.camX, ty = F.camY; + const s = F.sim; + if (!s) return; + // следим только за СВОИМ выстрелом: чужой снаряд камеру не дёргает. + // в локалке и у зрителя — за последним снарядом, как раньше. + // команда не опознана — лучше стоять на бойце, чем следить за чужим + let fp = null; + if (s.proj.length) { + if (!NET.on || NET.myTeam == null || NET.myTeam < 0) { + fp = s.proj[s.proj.length - 1]; + } else { + for (let i = s.proj.length - 1; i >= 0; i--) { + const p0 = s.proj[i]; + const pt = p0.t != null ? p0.t : (((s.wormById && s.wormById(p0.o)) || {}).team); + if (pt === NET.myTeam) { fp = p0; break; } + } + } + } + if (fp) { + // следим за полётом + tx = fp.x; ty = fp.y; + F.boomT = 0; + } else if (F.boomT > 0) { + // взрыв был только что — держим камеру на нём + tx = F.boomX; ty = F.boomY; + } else if (F.activeWorm && F.panT <= 0) { + // прицел: камера всегда на КОНЧИКЕ дуги — куда целишься, то и по центру экрана. + // без клампов и исключений: боец в кадре держится автозумом (отъезжает сам), а не сдвигом камеры + const canAim = F.phase === 'move' && (!NET.on || NET.myTeam == null || NET.myTeam < 0 || myTurn()); + const pts = canAim ? aimPreview() : null; + if (pts && pts.length) { + const end = pts[pts.length - 1]; + tx = end[0]; ty = end[1]; + F.camAim = true; // жёсткое слежение за бегущим кончиком (пока заряжаешь — камера не отстаёт) + } else { tx = F.activeWorm.x + 17; ty = F.activeWorm.y + 17 - 60 / z; F.camAim = false; } + } + if (!(tx > 0)) { tx = (F.xMinPx + F.xMaxPx) / 2; ty = SEA_GY * TILE - 200; } + const hw = vw / 2 / z, hh = vh / 2 / z; + tx = Math.max(F.xMinPx - 100 + hw, Math.min(F.xMaxPx + 100 - hw, tx)); + F.camX += (tx - F.camX) * (F.camAim ? 0.2 : 0.12); + F.camY += (ty - F.camY) * (F.camAim ? 0.2 : 0.12); + // тряска экрана после взрыва + let shx = 0, shy = 0; + if (F.shakeT > 0) { + F.shakeT -= 1 / 60; + const m = F.shakeR * F.shakeT * 3; + shx = (Math.random() - 0.5) * m; shy = (Math.random() - 0.5) * m; + } + + ctx.fillStyle = F.night ? '#3a4670' : '#87CEEB'; + ctx.fillRect(0, 0, vw, vh); + ctx.save(); + ctx.translate(vw / 2, vh / 2); + ctx.scale(z, z); + ctx.translate(-F.camX + shx, -F.camY + shy); + + const minGX = Math.floor((F.camX - hw) / TILE) - 2, maxGX = Math.floor((F.camX + hw) / TILE) + 2; + const minGY = Math.floor((F.camY - hh) / TILE) - 4, maxGY = Math.floor((F.camY + hh) / TILE) + 4; + const ui = Math.min(4, 1 / z); // экранный масштаб мелочей: при отдалении не мельчают + for (const b of blocks) { + if (b.dead) continue; + if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; + const def = BLOCKS[b.t]; + if (def.alpha) { ctx.save(); ctx.globalAlpha = def.alpha; ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); ctx.restore(); } + else ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); + } + // (воду рисуют сами блоки water; общей подложки нет — иначе камень «тонет» визуально) + + // мины / динамит + for (const m of s.mines) { + ctx.fillStyle = m.fuse > 0 ? (Math.sin(performance.now() / 60) > 0 ? '#ff0000' : '#7f8c8d') : (m.armed >= 1 ? '#2d3436' : '#95a5a6'); + ctx.beginPath(); ctx.arc(m.x, m.y, 9, 0, 7); ctx.fill(); + } + for (const d of s.dyn) { + ctx.fillStyle = '#c0392b'; ctx.fillRect(d.x - 10, d.y - 6, 20, 12); + ctx.fillStyle = '#f1c40f'; ctx.fillRect(d.x + 6, d.y - 10, 4, 4); + } + // снаряды + for (const p of s.proj) { + if (p.k === 'missile' || p.k === 'homing') { + ctx.fillStyle = p.k === 'homing' ? '#c0392b' : '#2d3436'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill(); + if (p.k === 'homing') { ctx.fillStyle = '#ffe66d'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, 7); ctx.fill(); } // жёлтый глазок самонавода + } + else if (p.k === 'grenade' || p.k === 'cluster') { ctx.fillStyle = p.k === 'cluster' ? '#8e44ad' : '#27ae60'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill(); } + else if (p.k === 'striker') { ctx.fillStyle = '#e74c3c'; ctx.fillRect(p.x - 3, p.y - 8, 6, 16); } + else if (p.k === 'sheep') { // овца: большая, с тёмным контуром (видно на небе), ноги скачут + const sd = (p.vx || 0) >= 0 ? 1 : -1; + const gal = Math.floor(performance.now() / 140) % 2; // галоп: ноги по очереди + ctx.fillStyle = '#2d3436'; + if (gal) { ctx.fillRect(p.x - 9, p.y + 3, 6, 7); ctx.fillRect(p.x + 3, p.y + 6, 6, 4); } + else { ctx.fillRect(p.x - 9, p.y + 6, 6, 4); ctx.fillRect(p.x + 3, p.y + 3, 6, 7); } + ctx.fillStyle = '#dfe6e9'; // шерсть + ctx.beginPath(); ctx.arc(p.x - 8, p.y - 3, 8, 0, 7); ctx.arc(p.x, p.y - 7, 9, 0, 7); ctx.arc(p.x + 8, p.y - 3, 8, 0, 7); ctx.fill(); + ctx.lineWidth = 2.5; ctx.strokeStyle = '#2d3436'; ctx.stroke(); // контур — не сливается с небом + ctx.fillStyle = '#2d3436'; // морда по ходу + ctx.beginPath(); ctx.arc(p.x + sd * 12, p.y - 1, 6, 0, 7); ctx.fill(); + ctx.fillStyle = '#dfe6e9'; // ухо + ctx.beginPath(); ctx.arc(p.x + sd * 6, p.y - 9, 3, 0, 7); ctx.fill(); + ctx.fillStyle = '#ffffff'; // глаз + ctx.fillRect(p.x + sd * 11, p.y - 3, 2.5, 2.5); + } + else { + // дробь/пули: светящиеся с трассером — видно издалека + if ((p.vx || p.vy) && (Math.abs(p.vx) + Math.abs(p.vy) > 1)) { + ctx.strokeStyle = 'rgba(255,225,77,0.7)'; ctx.lineWidth = Math.max(2, 3 * ui); + ctx.beginPath(); ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - p.vx * 0.035, p.y - p.vy * 0.035); ctx.stroke(); + } + ctx.save(); ctx.shadowColor = '#f1c40f'; ctx.shadowBlur = 14 * ui; + ctx.fillStyle = '#ffe14d'; + const ps = 3 + 3 * ui; + ctx.fillRect(p.x - ps / 2, p.y - ps / 2, ps, ps); ctx.restore(); + } + } + // самолёты авиаудара (сеть — позиции с сервера, локально — из сима) + for (const pl of (NET.on ? (F.planes || []) : s.strikes.filter((x) => x.started))) { + drawPlane(pl.px != null ? pl.px : pl.x, pl.py != null ? pl.py : pl.y, pl.dir || pl.d || 1); + } +// пиксельный самолётик авиаудара (носом по курсу) +function drawPlane(x, y, dir) { + ctx.save(); ctx.translate(x, y); ctx.scale(dir >= 0 ? 1 : -1, 1); + ctx.fillStyle = '#5f6f35'; ctx.fillRect(-22, -4, 44, 8); // фюзеляж + ctx.fillStyle = '#c0392b'; ctx.fillRect(18, -3, 6, 6); // нос + ctx.fillStyle = '#4a5627'; ctx.fillRect(-4, -3, 12, 22); // крыло + ctx.fillStyle = '#4a5627'; ctx.fillRect(-24, -14, 9, 10); // хвост + ctx.fillStyle = '#bfe3ef'; ctx.fillRect(2, -10, 10, 7); // кабина + ctx.fillStyle = '#ecf0f1'; ctx.fillRect(-14, -2, 6, 4); // звезда + ctx.restore(); +} + // червяки-Гречки: с базукой — только активный с базукой/самонаводом, остальные — обычный; + // спрайты нарисованы мордой влево: dir>=0 (вправо) = зеркалить + const iHoldHeavy = (!NET.on || myTurn()) && (F.weapon === 'bazooka' || F.weapon === 'homing'); + for (const w of s.worms) { + if (!w.alive) continue; + const img = F.heroImg; + const heavy = (w === F.activeWorm) && iHoldHeavy; + const picked = w.team === 0 + ? (heavy ? F.wormImgRed : (F.wormPlainRed && F.wormPlainRed.naturalWidth ? F.wormPlainRed : F.wormImgRed)) + : (heavy ? F.wormImgBlue : (F.wormPlainBlue && F.wormPlainBlue.naturalWidth ? F.wormPlainBlue : F.wormImgBlue)); + const showImg = (picked && picked.complete && picked.naturalWidth) ? picked + : (img.complete && img.naturalWidth) ? img : null; + if (showImg) { + ctx.save(); + ctx.translate(w.x - 3 + TILE / 2, w.y - 3 + TILE / 2); + ctx.scale(w.dir >= 0 ? -1 : 1, 1); + const sz = TILE * 2; + ctx.drawImage(showImg, -sz / 2, -sz / 2 - TILE / 2, sz, sz); + ctx.restore(); + } + // имя + хп — над головой спрайта (спрайт 2 блока, макушка на TILE выше) + if (w === F.activeWorm && !F.over) { + ctx.fillStyle = '#f1c40f'; + const ay = w.y - TILE - 40 * ui; + ctx.beginPath(); + ctx.moveTo(w.x + 17, ay); ctx.lineTo(w.x + 17 - 8 * ui, ay + 12 * ui); ctx.lineTo(w.x + 17 + 8 * ui, ay + 12 * ui); + ctx.fill(); + } + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE, 6 * ui); + ctx.fillStyle = w.hp > 50 ? '#2ecc71' : w.hp > 25 ? '#f39c12' : '#e74c3c'; + ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE * Math.max(0, w.hp) / 100, 6 * ui); + if (w.inWater) { // кислород под хп + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE, 4 * ui); + ctx.fillStyle = '#00a1ff'; + ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE * Math.max(0, w.o2) / 100, 4 * ui); + } + ctx.fillStyle = '#fff'; ctx.font = (11 * ui) + 'px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(w.name + ' ' + Math.max(0, Math.round(w.hp)), w.x + 17, w.y - TILE - 20 * ui); + // прицел активного + // прицел активного (в сети — только свой боец в свой ход) + if (w === F.activeWorm && !F.over && F.phase === 'move' && (!NET.on || myTurn())) { + const def = WEAPONS[F.weapon]; + if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray' || F.weapon === 'sheep') { + const cx = w.x + 17, cy = w.y + 11; + const straight = def.kind === 'hitscan' || def.kind === 'spray'; + const aimLen = straight ? 130 : (F.weapon === 'sheep' ? 60 : 34); // овце — короткий указатель направления + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 * ui; + ctx.beginPath(); ctx.moveTo(cx, cy); + ctx.lineTo(cx + Math.cos(F.aim) * aimLen, cy + Math.sin(F.aim) * aimLen); ctx.stroke(); + // превью траектории (экранный размер точек) + const pts = aimPreview(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + const dp = 2 * ui; + if (pts) for (const pt of pts) ctx.fillRect(pt[0] - dp / 2, pt[1] - dp / 2, dp, dp); + } else if (def.kind === 'dig') { + // лопата: линия прицела + кружок на земле, куда копнём + const dp0 = digPoint(w.x + 17, w.y + 17, F.aim); + ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 2 * ui; + ctx.beginPath(); ctx.moveTo(w.x + 17, w.y + 17); + ctx.lineTo(w.x + 17 + Math.cos(F.aim) * 95, w.y + 17 + Math.sin(F.aim) * 95); ctx.stroke(); + if (dp0) { + ctx.setLineDash([6 * ui, 4 * ui]); + ctx.beginPath(); ctx.arc(dp0[0], dp0[1], 52 * F.zoom, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + } + } + } + } + // зрители на балкончиках (сеть): те же гречки, других цветов + имена + drawSpecs(ctx, ui); + // частицы + for (const pt of F.particles) { + if (pt.ring) { // расширяющееся кольцо взрыва + const k = 1 - Math.max(0, pt.t) / (pt.t0 || 0.35); + ctx.globalAlpha = Math.max(0, pt.t) * 2; + ctx.strokeStyle = pt.c; ctx.lineWidth = Math.max(1, 5 * (1 - k)); + ctx.beginPath(); ctx.arc(pt.x, pt.y, 8 + (pt.r1 || 80) * k, 0, 7); ctx.stroke(); + ctx.globalAlpha = 1; + } else { + ctx.fillStyle = pt.c; ctx.globalAlpha = Math.max(0, Math.min(1, pt.t * 1.5)); + const s2 = (pt.sz || 6) / 2; + ctx.fillRect(pt.x - s2, pt.y - s2, pt.sz || 6, pt.sz || 6); ctx.globalAlpha = 1; + } + } + // метка цели (авиаудар/телепорт) + if (F.pendingAim && !F.over) { + const px = F.pendingAim.x, py = F.pendingAim.y, r = 14 * ui; + ctx.strokeStyle = '#e74c3c'; ctx.lineWidth = 3 * ui; + ctx.beginPath(); ctx.arc(px, py, r, 0, 7); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(px - r - 8 * ui, py); ctx.lineTo(px - r + 4 * ui, py); + ctx.moveTo(px + r - 4 * ui, py); ctx.lineTo(px + r + 8 * ui, py); + ctx.moveTo(px, py - r - 8 * ui); ctx.lineTo(px, py - r + 4 * ui); + ctx.moveTo(px, py + r - 4 * ui); ctx.lineTo(px, py + r + 8 * ui); + ctx.stroke(); + } + // цифры урона + ctx.fillStyle = '#ff4757'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; + for (const fl of F.floaters) { + const w = F.sim.wormById(fl.worm); + if (w) ctx.fillText(fl.text, w.x + 17, w.y - 34 - (1.2 - fl.t) * 30); + } + ctx.restore(); + // ночь: просто темнее фон и темнее земля + тёплое свечение у факелов + if (F.night) drawNightShade(vw, vh); + + // полоса силы + if (F.charging) { + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(vw / 2 - 110, vh - 120, 220, 18); + ctx.fillStyle = F.power > 0.85 ? '#e74c3c' : '#f1c40f'; + ctx.fillRect(vw / 2 - 108, vh - 118, 216 * F.power, 14); + } + // таймер хода (левее кнопки оружия) + if (!F.over && F.activeWorm) { + ctx.fillStyle = F.timeLeft < 6 ? '#e74c3c' : '#fff'; + ctx.font = 'bold 22px sans-serif'; ctx.textAlign = 'right'; + ctx.fillText(Math.ceil(F.timeLeft) + 'с', vw - 76, 30); + } +} + +// ---------- ночь: затемнение мира + свечение факелов ---------- +function drawNightShade(vw, vh) { + ctx.save(); + ctx.fillStyle = 'rgba(8,10,34,0.32)'; + ctx.fillRect(0, 0, vw, vh); + // тёплые ореолы у уцелевших факелов + ctx.globalCompositeOperation = 'lighter'; + const now = performance.now(); + for (const t of F.torches) { + const b = getBlock(Math.floor(t.x / TILE), Math.floor(t.y / TILE)); + if (!b || b.dead || b.t !== 'torch') continue; + const sx = (t.x - F.camX) * F.zoom + vw / 2; + const sy = (t.y - F.camY) * F.zoom + vh / 2; + const rr = 130 * F.zoom * (0.92 + Math.sin(now / 110 + t.x * 0.05) * 0.08); + if (sx < -rr || sy < -rr || sx > vw + rr || sy > vh + rr) continue; + const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, rr); + grad.addColorStop(0, 'rgba(255,190,90,0.35)'); + grad.addColorStop(1, 'rgba(255,190,90,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(sx, sy, rr, 0, 7); ctx.fill(); + } + ctx.restore(); +} + +// ---------- главный цикл ---------- +let lastT = 0, acc = 0; +function frame(now) { + requestAnimationFrame(frame); + if (!F.sim) return; // режим ещё не выбран + const dt = Math.min(0.05, (now - lastT) / 1000 || 0.016); + lastT = now; + // сеть: физикой владеет сервер — только ввод, эффекты, рендер + if (NET.on) { + if (!isFinite(F.camX) || !isFinite(F.camY) || !isFinite(F.zoom)) { + F.zoom = 0.55; F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150; + } + if (myTurn()) pollKeys(dt); // чужим бойцом управлять нельзя — ввод только в свой ход + netMoveTick(); + // интерполяция снарядов между снапами: летят гладко, сервер поправляет + for (const p of F.sim.proj) { + if (p.vx || p.vy) { p.px = p.x; p.py = p.y; p.x += p.vx * dt; p.y += p.vy * dt; } + } + if (F.panT > 0) F.panT -= dt; + if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt; + pumpEvents(); + for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; } + F.particles = F.particles.filter((p) => p.t > 0); + for (const fl of F.floaters) fl.t -= dt; + F.floaters = F.floaters.filter((f) => f.t > 0); + render(); + if ((now | 0) % 500 < 20) updateHud(); + return; + } + if (F.over) { render(); return; } + pollKeys(dt); + if (F.panT > 0) F.panT -= dt; + // память о взрыве тает, когда ничего не летит + if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt; + // таймеры хода + if (F.phase === 'move') { + F.timeLeft -= dt; + if (F.timeLeft <= 0) { F.timeLeft = 0; endTurn(); } + } else if (F.phase === 'retreat') { + F.retreatT -= dt; + if (F.retreatT <= 0 && settled()) endTurn(); + } + // физика фикс-степом + acc += dt; + const h = 1 / 60; + let n = 0; + while (acc >= h && n < 4) { F.sim.step(h); acc -= h; n++; } + pumpEvents(); + // проверка победы + const teams = F.sim.aliveTeams(); + if (teams.length <= 1 && !F.over) gameOver(teams[0]); + // частицы + for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; } + F.particles = F.particles.filter((p) => p.t > 0); + for (const fl of F.floaters) fl.t -= dt; + F.floaters = F.floaters.filter((f) => f.t > 0); + render(); + if ((now | 0) % 500 < 20) updateHud(); +} + +// ---------- сеть: лобби, комнаты по коду, зрители ---------- +const NET = { + on: false, sock: null, code: null, myTeam: -2, myName: 'Игрок', + specs: [], // [{name,x,y,hopT,colorIdx}] + roster: null, inviteFrom: null, lastMv: 9, +}; +const SPEC_COLORS = ['#ff8c1a', '#a64dff', '#33cc66', '#ffe14d', '#ff6fb5', '#f2f2f2']; +function netMsg(o) { if (NET.sock) NET.sock.emit('input', o); } +function netStatus(t, cls) { + const el = document.getElementById('netStatus'); + if (!el) return; + el.textContent = t; + el.className = cls || ''; +} +function netConnect() { + if (NET.sock) return NET.sock; + if (typeof io === 'undefined') { // сетевой модуль не загрузился (блокировка/кеш) + netStatus('○ Не загрузился сетевой модуль — обнови страницу', 'bad'); + return { connected: false, emit() {}, connect() {}, on() {} }; + } + NET.sock = io({ path: '/wapi/' }); + const s = NET.sock; + s.on('joined', (m) => { + NET.code = m.code; NET.myTeam = m.team; NET.wasJoined = true; + s.emit('top', { name: NET.myName }); // подтянуть топ и свою статистику + setText('roomCode', 'Код: ' + m.code); + document.getElementById('lobbyWait').style.display = 'block'; + document.getElementById('lobbyForm').style.display = 'none'; + document.getElementById('roster').style.display = 'block'; + // настройки и старт — только у создателя; гость просто ждёт автостарта + const host = !!m.host; + for (const id of ['modeRow', 'dnRow', 'bCopyLink']) { + const el = document.getElementById(id); + if (el) el.style.display = host ? '' : 'none'; + } + document.getElementById('lobbyNote').style.display = host ? 'none' : 'block'; + if (NET.myTeam === -1) setText('hint', 'Ты зритель 👀 Жми ⬆️ — твоя гречка прыгает'); + }); + s.on('room', (r) => { NET.roster = r; renderRoster(); }); + s.on('match_start', (m) => netStart(m)); + s.on('snap', (sn) => applySnap(sn)); + s.on('diff', (m) => netApplyCells(m.cells || [])); + s.on('sync', (m) => netApplyCells(m.cells || m.diff || [])); + s.on('ev', (m) => { if (F.sim && m && m.e) { try { F.sim.ev(m.e); } catch (e) {} } }); + s.on('invite', (m) => { + if (m.to === NET.myName) { + NET.inviteFrom = m.from; + setText('inviteText', m.from + ' зовёт играть!'); + document.getElementById('invitePop').style.display = 'flex'; + playSound('click'); + } + }); + s.on('invite_declined', (m) => setText('hint', m.name + ' отказался')); + s.on('game_over', (m) => netGameOver(m.winner, m)); + // кто сейчас играет + войти зрителем + s.on('rooms', (m) => { + const el = document.getElementById('roomsTable'); + if (!el) return; + const list = (m && m.list) || []; + if (!list.length) { el.innerHTML = '👀 Сейчас никто не играет — создай игру!'; return; } + el.innerHTML = '👀 Сейчас играют' + list.map((r) => { + const who = r.players.map((n) => String(n).slice(0, 12).replace(/[<>&]/g, '')).join(' vs ') || '…'; + const st = r.phase === 'play' ? 'бой' : 'лобби'; + return ''; + }).join('') + '
' + who + '' + st + (r.specs ? ', зрителей: ' + r.specs : '') + + '
'; + el.querySelectorAll('[data-watch]').forEach((b) => { + b.onclick = () => { + NET.myName = (document.getElementById('nick').value.trim() || 'Зритель').slice(0, 20); + try { localStorage.setItem('wormsName', NET.myName); } catch (e) {} + netConnect().emit('join', { code: b.dataset.watch, name: NET.myName, as: 'spectate' }); + playSound('click'); + }; + }); + }); + s.on('top', (m) => { + const el = document.getElementById('topTable'); + if (!el) return; + const list = (m && m.list) || []; + let html = ''; + if (!list.length) html = '🏆 Топ-10: пока пусто — стань первым!'; + else html = '🏆 Топ-10' + list.map((r, i) => + '').join('') + '
' + (i + 1) + '.' + String(r.name).slice(0, 20).replace(/[<>&]/g, '') + + '🏆' + r.wins + '' + r.games + ' игр
'; + if (m && m.me) { + const wr = m.me.games ? Math.round(m.me.wins / m.me.games * 100) : 0; + html += '
⭐ Ты (' + String(m.me.name).slice(0, 20).replace(/[<>&]/g, '') + + '): 🏆' + m.me.wins + ' / ' + m.me.games + ' игр (' + wr + '%)
'; + } + el.innerHTML = html; + }); + s.on('error', (m) => { + setText('hint', m.text || 'Ошибка сети'); + // ссылка протухла (комнаты уже нет) — вернуть ручной вход + if (NET.linkCode) { + NET.linkCode = null; + document.getElementById('codeIn').style.display = ''; + document.getElementById('bCreate').style.display = ''; + document.getElementById('bJoin').textContent = 'Присоединиться'; + } + }); + // обрыв сети: сокет сам переподключится — возвращаемся в комнату тем же именем, + // сервер узнает по имени и вернёт слот бойца (или зрителя) + s.on('connect', () => { NET.online = true; netStatus('● Соединение есть', 'ok'); }); + s.on('disconnect', () => { + NET.online = false; netStatus('○ Соединение потеряно, переподключаю…', 'bad'); + if (NET.code) setText('hint', 'Соединение потеряно, переподключаю…'); + }); + s.on('connect_error', () => { NET.online = false; netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); }); + s.on('connect', () => { + if (NET.code && NET.myName && NET.wasJoined && !NET._rej) { + NET._rej = true; + s.emit('join', { code: NET.code, name: NET.myName }); + setTimeout(() => { NET._rej = false; }, 3000); + } + }); + return s; +} +// генератор сервера 1:1 (seed->terrain), чтобы мир совпал кадр в кадр +function nSeededRandom(seed, gx, gy) { + const n = Math.sin(gx * 12.9898 + gy * 78.233 + seed * 0.1) * 43758.5453; + return n - Math.floor(n); +} +function nSurfaceGyAt(seed, gx) { + const n1 = Math.sin(gx * 0.025 + seed * 0.001) * 8; + const n2 = Math.sin(gx * 0.012 + seed * 0.002) * 12; + const n3 = Math.sin(gx * 0.006 + seed * 0.003) * 6; + const n4 = Math.sin(gx * 0.045 + seed * 0.004) * 4; + const n5 = Math.cos(gx * 0.018 + seed * 0.005) * 5; + return Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); +} +function nGenCell(seed, gx, gy) { + const sgy = nSurfaceGyAt(seed, gx); + if (gy < sgy) { + if (gy === sgy - 1) { + if (sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'wood'; + if (sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 1) < 0.10) return 'flower'; + } + if (gy === sgy - 2 && sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'wood'; + if (gy === sgy - 3 && sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'leaves'; + return null; + } + if (gy === sgy) { + if (sgy > SEA_GY) return 'sand'; + return sgy < SEA_GY - 10 ? 'stone' : 'grass'; + } + if (gy > sgy && gy < SEA_GY && sgy > SEA_GY) return 'water'; + if (gy === BEDROCK_GY) return 'bedrock'; + if (gy > BEDROCK_GY) return null; + let t = 'stone'; + if (gy <= sgy + 3) t = 'dirt'; + if (sgy > SEA_GY && gy === sgy + 1 && nSeededRandom(seed, gx, gy) < 0.25) t = 'clay'; + if (gy > sgy + 6 && nSeededRandom(seed, gx, gy) < 0.07) t = 'gravel'; + const depth = gy - sgy, r = nSeededRandom(seed, gx, gy); + if (t === 'stone') { + if (r < 0.06) t = 'coal'; + else if (r < 0.10) t = 'copper_ore'; + else if (r < 0.13) t = 'iron_ore'; + else if (depth > 40 && r < 0.145) t = 'gold_ore'; + else if (depth > 70 && r < 0.152) t = 'diamond_ore'; + } + return t; +} +function nArenaGy(ax0, arenaW, gx) { + const gapC = ax0 + arenaW / 2; + const d = Math.abs(gx - gapC); + if (d < 3) return SEA_GY + 3; + const t = Math.min(1, (d - 3) / 18); + return Math.round((SEA_GY + 3) + ((SEA_GY - 14) - (SEA_GY + 3)) * Math.sin(t * Math.PI / 2)); +} +function nBase(seed, ax0, arenaW, thMap, gx, gy) { + const th = thMap[gx]; + if (th == null) return nGenCell(seed, gx, gy); + if (gy < th) return null; + if (gy === th) { + const nat = nGenCell(seed, gx, gy); + if (nat && (BLOCKS[nat] || {}).solid) return nat; + return th < SEA_GY - 10 ? 'stone' : 'grass'; + } + if (th > SEA_GY && gy >= SEA_GY - 2 && gy <= th) return 'water'; + if (gy === BEDROCK_GY) return 'bedrock'; + if (gy > BEDROCK_GY) return null; + const nat = nGenCell(seed, gx, gy); + if (nat && (BLOCKS[nat] || {}).solid) return nat; + if (nat && (BLOCKS[nat] || {}).fluid) return nat; + return gy <= th + 3 ? 'dirt' : 'stone'; +} +// залить клиентскую землю миром сервера + накатить дифф +function netBuildWorld(m) { + F.seed = m.seed; F.ax0 = m.ax0; F.arenaW = m.arenaW || 100; F.night = !!m.night; + F.xMinPx = F.ax0 * TILE; F.xMaxPx = (F.ax0 + F.arenaW) * TILE; + const thMap = {}; + for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) thMap[gx] = nArenaGy(F.ax0, F.arenaW, gx); + // площадки как у сервера (peakSpots 1:1) + const spots = (gx0, gx1) => { + const out = []; + for (let gx = gx0; gx <= gx1 && out.length < 10; gx += 2) { + const sgy = nArenaGy(F.ax0, F.arenaW, gx); + if (sgy < SEA_GY) out.push({ gx, gy: Math.round((sgy * TILE - 80 + 80) / TILE) }); + } + return out; + }; + const gapC = F.ax0 + F.arenaW / 2; + for (const s of spots(gapC - 28, gapC - 12).concat(spots(gapC + 12, gapC + 28))) { + for (let dx = -2; dx <= 2; dx++) { + const gx = s.gx + dx; + if (thMap[gx] != null && Math.abs(thMap[gx] - s.gy) <= 4) thMap[gx] = s.gy; + } + } + // чистим полосу и заливаем + const x0 = F.ax0 - 14, x1 = F.ax0 + F.arenaW + 14; + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.gx >= x0 && b.gx <= x1) { grid.delete(k(b.gx, b.gy)); blocks.splice(i, 1); } + } + for (let gx = x0; gx <= x1; gx++) + for (let gy = 0; gy <= BEDROCK_GY + 5; gy++) { + const t = nBase(F.seed, F.ax0, F.arenaW, thMap, gx, gy); + if (t && BLOCKS[t]) { const b = { gx, gy, t, dead: false, active: false, fuse: 0 }; grid.set(k(gx, gy), b); blocks.push(b); } + } + netApplyCells(m.diff || []); + // точная карта с сервера (RLE) — поверх локальной сборки: никаких фантомных блоков + if (m.arena) applyArena(m.arena); + // факелы сервера 1:1 + F.torches = []; + for (const tc of m.torches || []) { + const gx = Math.floor((tc.x - TILE / 2) / TILE), gy = Math.floor((tc.y - TILE / 2) / TILE); + if (!getBlock(gx, gy)) { const b = { gx, gy, t: 'torch', dead: false, active: false, fuse: 0 }; grid.set(k(gx, gy), b); blocks.push(b); } + F.torches.push({ x: tc.x, y: tc.y }); + } + F.grid = { + isSolid: (gx, gy) => isSolid(gx, gy), + isFluid: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].fluid); }, + unbreakable: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].unbreakable); }, + remove: (gx, gy) => !!removeBlock(gx, gy), + groundBelowPx: (x, yTop) => { + const gx = Math.floor(x / TILE); + let gy = Math.max(0, Math.floor(yTop / TILE)); + for (; gy <= BEDROCK_GY + 5; gy++) if (isSolid(gx, gy)) return gy * TILE; + return null; + }, + waterYPx: SEA_GY * TILE, xMinPx: F.xMinPx, xMaxPx: F.xMaxPx, + killYPx: (BEDROCK_GY + 8) * TILE, + }; +} +function netApplyCells(cells) { + for (const c of cells) { + const ex = getBlock(c.gx, c.gy); + if (ex) { grid.delete(k(c.gx, c.gy)); const i = blocks.indexOf(ex); if (i >= 0) blocks.splice(i, 1); } + if (c.t && BLOCKS[c.t]) { const b = { gx: c.gx, gy: c.gy, t: c.t, dead: false, active: false, fuse: 0 }; grid.set(k(c.gx, c.gy), b); blocks.push(b); } + } +} +// Точная арена с сервера: legend|rows, раны [count]code ('.' пусто, a/b/c… — legend[1/2/3…]) +function applyArena(s) { + try { + const bar = s.indexOf('|'); + const legend = s.slice(0, bar).split(','); + const rows = s.slice(bar + 1).split(';'); + const x0 = F.ax0 - 8; + for (let ri = 0; ri < rows.length; ri++) { + const gy = ri - 8; // сервер шлёт с неба (gy -8): верхушки деревьев на пиках тоже синхроним + const row = rows[ri]; + if (!row) continue; + let gx = x0, i = 0, num = ''; + const put = (ch) => { + const t = ch === '.' ? null : legend[ch.charCodeAt(0) - 96]; + const cur = getBlock(gx, gy); + if (t == null || t === '.') { if (cur) removeBlock(gx, gy); } + else if (BLOCKS[t]) { + if (!cur || cur.t !== t) { + if (cur) removeBlock(gx, gy); + const b = { gx, gy, t, dead: false, active: false, fuse: 0 }; + grid.set(k(gx, gy), b); blocks.push(b); + } + } + gx++; + }; + while (i < row.length) { + const ch = row[i++]; + if (ch >= '0' && ch <= '9') { num += ch; continue; } + const n = num ? parseInt(num, 10) : 1; num = ''; + for (let r = 0; r < n; r++) put(ch); + } + } + } catch (e) {} +} +// наблюдатель: slim-плашка с номером в очереди, сама гаснет через 8 секунд +let specBadgeTimer = null; +function showSpecBadge() { + const el = document.getElementById('specBadge'); + if (!el) return; + const q = (NET.roster && NET.roster.spectators || []).findIndex((s) => s.name === NET.myName); + el.textContent = '👀 Ты наблюдатель' + (q >= 0 ? ' · в очереди №' + (q + 1) : ''); + el.style.display = 'block'; + if (specBadgeTimer) clearTimeout(specBadgeTimer); + specBadgeTimer = setTimeout(() => { el.style.display = 'none'; }, 8000); +} +function netStart(m) { + document.getElementById('lobby').style.display = 'none'; + document.getElementById('modeOverlay').style.display = 'none'; + document.getElementById('banner').style.display = 'none'; + document.getElementById('pickPop').style.display = 'none'; + document.getElementById('roster').style.display = 'block'; + netBuildWorld(m); + F.sim = new Sim(F.grid, m.seed); + // команда могла смениться (чемпионат/приглашение): пересчитать по нику + const me = (m.teams || []).find((t) => t.name === NET.myName); + NET.myTeam = me ? me.team : -1; + if (NET.myTeam === -1) showSpecBadge(); + F.ammo = [newAmmo(), newAmmo()]; + F.cool = [{}, {}]; F.digsLeft = 10; F.pendingAim = null; + F.mode = 'net'; NET.on = true; + NET.simMode = m.mode === 'sim'; // одновременно: оба действуют сразу + if (NET.simMode) setText('hint', '⚡ Одновременно — оба стреляют сразу!'); + if (m.champ) setText('hint', '🔄 Чемпион: ' + m.champ.stay + ' — дальше играет ' + m.champ.play); + F.over = false; F.winner = -1; F.activeWorm = null; + F.zoom = 0.55; F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150; + F.boomT = 0; F.particles = []; F.floaters = []; + if (NET.myTeam === -1) setText('hint', 'Ты зритель 👀 Жми ⬆️ — твоя гречка прыгает'); + updateHud(); +} +function applySnap(sn) { + if (!F.sim) return; + if (sn.phase === 'lobby') return; + const seen = new Set(); + for (const sw of sn.worms || []) { + seen.add(sw.id); + let w = F.sim.wormById(sw.id); + if (!w) { w = F.sim.addWorm(sw.team, sw.name, sw.x, sw.y); } + w.x = sw.x; w.y = sw.y; w.dir = sw.dir; w.hp = sw.hp; w.alive = sw.alive; + w.inWater = !!sw.swim; w.o2 = sw.o2 != null ? sw.o2 : 100; + w.team = sw.team; w.name = sw.name; + } + // снаряды: старые тянем к серверной правде мягко (без рывков), новые создаём (команду и владельца не теряем — за ними следит камера) + const oldP = new Map(F.sim.proj.map((p) => [p.id, p])); + F.sim.proj = (sn.proj || []).map((p) => { + const o = oldP.get(p.id); + if (o) { + o.vx = p.vx || 0; o.vy = p.vy || 0; + o.x += (p.x - o.x) * 0.4; o.y += (p.y - o.y) * 0.4; + o.t = p.t; o.o = p.o; + return o; + } + return { id: p.id, kind: p.k, k: p.k, x: p.x, y: p.y, vx: p.vx || 0, vy: p.vy || 0, t: p.t, o: p.o }; + }); + F.sim.mines = (sn.mines || []).map((m) => ({ x: m.x, y: m.y, armed: m.armed ? 1 : 0, fuse: m.beep ? 1 : 0 })); + F.sim.dyn = (sn.dyn || []).map((d) => ({ x: d.x, y: d.y })); + F.sim.wind = sn.wind || 0; + F.planes = sn.planes || []; // самолётики авиаудара с сервера + if (sn.turn) { + // одновременно: активный — свой боец, очереди и таймера нет + if (sn.turn.sim) { + F.teamTurn = NET.myTeam; F.phase = 'move'; F.timeLeft = Infinity; + const w = F.sim.wormById((sn.turn.worms || {})[NET.myTeam]); + if (w && (!F.activeWorm || F.activeWorm.id !== w.id)) { F.activeWorm = w; NET.lastMv = 9; F.pendingAim = null; } + } else { + F.teamTurn = sn.turn.team; F.phase = sn.turn.phase; F.timeLeft = sn.turn.timeLeft; + const w = F.sim.wormById(sn.turn.wormId); + if (w) { if (!F.activeWorm || F.activeWorm.id !== w.id) F.pendingAim = null; F.activeWorm = w; NET.lastMv = 9; } + } + } + if (sn.ammo) F.ammo = sn.ammo; + if (sn.cool) F.cool = sn.cool; + if (sn.digs != null) F.digsLeft = sn.digs; + NET.specs = sn.specs || []; + if (sn.winner != null && sn.winner >= 0) netGameOver(sn.winner); + else if (sn.phase === 'play') { F.over = false; } + updateHud(); +} +function myTurn() { + if (!NET.on || F.over || !F.activeWorm) return false; + // одновременно: всегда свой боец, без очереди + if (NET.simMode) return NET.myTeam >= 0 && F.activeWorm.team === NET.myTeam; + return F.phase === 'move' && F.teamTurn === NET.myTeam; +} +// ---------- Голос двоих бойцов (вкл/выкл кнопкой 🎤) ---------- +// Зрители подключаются только слушать (кнопка 🔊), микрофон не трогаем. +const VOICE_URL = 'https://voicegrech.mkn8n.ru'; +let vSock = null, vStream = null, vCtx = null, vProc = null, voiceOn = false, voiceMic = false, vPosTimer = null, vSpeakTimer = null, lastVoiceRole = ''; +function voiceOff(silent) { + voiceOn = false; voiceMic = false; + if (vPosTimer) { clearInterval(vPosTimer); vPosTimer = null; } + if (vStream) { vStream.getTracks().forEach((t) => t.stop()); vStream = null; } + if (vProc) { vProc.disconnect(); vProc = null; } + if (vCtx) { vCtx.close(); vCtx = null; } + if (vSock) { vSock.disconnect(); vSock = null; } + for (const id of ['micBtn', 'earBtn']) { + const b = document.getElementById(id); + if (b) { b.classList.remove('on', 'err'); } + } + const m = document.getElementById('micBtn'); if (m) m.title = 'Голосовой чат (выкл)'; + const e = document.getElementById('earBtn'); if (e) e.title = 'Слушать бойцов (выкл)'; + if (!silent) playSound('click'); +} +// mic=true: боец говорит+слышит; mic=false: зритель только слышит (микрофон не трогаем) +async function voiceStart(mic) { + if (voiceOn) { voiceOff(); return; } + voiceMic = mic; + const b = document.getElementById(mic ? 'micBtn' : 'earBtn'); + try { + if (mic) { + vStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } }); + } + vCtx = new AudioContext({ sampleRate: 24000 }); + if (vCtx.state === 'suspended') await vCtx.resume(); + if (mic) { + const src = vCtx.createMediaStreamSource(vStream); + vProc = vCtx.createScriptProcessor(4096, 1, 1); + vProc.onaudioprocess = (e) => { + if (!voiceOn || !vSock || !vSock.connected) return; + const pcm = e.inputBuffer.getChannelData(0); + const int16 = new Int16Array(pcm.length); + for (let i = 0; i < pcm.length; i++) { + const s = Math.max(-1, Math.min(1, pcm[i])); + int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; + } + vSock.emit('voice_data', { data: int16.buffer, codec: 'pcm' }); + }; + src.connect(vProc); + vProc.connect(vCtx.createGain()); // не в destination — себя не слышим + } + vSock = io(VOICE_URL, { transports: ['websocket', 'polling'] }); + vSock.on('connect', () => { + vSock.emit('voice_join', { world_id: 'worms-' + NET.code, x: 0, y: 0, name: NET.myName || 'Игрок', mode: 'world', codec: 'pcm' }); + }); + vSock.on('voice_in', (p) => { + if (!vCtx || vCtx.state === 'closed') return; + try { + const int16 = new Int16Array(p.data); + const f32 = new Float32Array(int16.length); + for (let i = 0; i < int16.length; i++) f32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF); + const buf = vCtx.createBuffer(1, f32.length, 24000); + buf.getChannelData(0).set(f32); + const s2 = vCtx.createBufferSource(); s2.buffer = buf; + s2.connect(vCtx.destination); s2.start(); + const ind = document.getElementById('speakInd'); + if (ind && p.meta) { + ind.style.display = 'block'; + ind.textContent = '🔊 ' + (p.meta.name || 'соперник'); + if (vSpeakTimer) clearTimeout(vSpeakTimer); + vSpeakTimer = setTimeout(() => { ind.style.display = 'none'; }, 600); + } + } catch (e) {} + }); + // позиция активного бойца (для порядка; в режиме world дальность не важна) + if (mic) vPosTimer = setInterval(() => { + if (vSock && vSock.connected && F.activeWorm) vSock.emit('voice_pos', { x: F.activeWorm.x, y: F.activeWorm.y }); + }, 2000); + voiceOn = true; + if (b) { + b.classList.add('on'); + b.title = mic ? 'Голосовой чат (вкл) — нажать чтобы выключить' : 'Слушаю бойцов — нажать чтобы выключить'; + } + playSound('click'); + } catch (e) { + voiceOff(true); + if (b) { b.classList.add('err'); b.title = 'Нет доступа к микрофону'; } + } +} +function netFire(point) { + const w = F.activeWorm; + if (!myTurn()) return; + const def = WEAPONS[F.weapon]; + if (!def || F.ammo[F.teamTurn][F.weapon] === 0) return; + const f = { weapon: F.weapon, angle: F.aim }; + if (def.power) f.power = (!F.power || F.power <= 0) ? 0.5 : F.power; + if (def.fuse) f.fuse = def.fuse[F.fuseIdx] || def.fuse[0]; + if (def.kind === 'target' || def.kind === 'teleport') { + const tgt = point || F.pendingAim; + if (!tgt) return; + f.x = tgt.x; f.y = tgt.y; + F.pendingAim = null; + } + netMsg({ fire: f }); + if (F.weapon === 'shotgun') playGun('shotgun'); + else if (F.weapon === 'uzi') playGun('uzi'); + else playSound('attack'); + updateHud(); +} +function netSwitch(wx, wy) { + if (!myTurn() || F.firedThisTurn) return false; + let best = null, bd = 60; + for (const w of F.sim.worms) { + if (!w.alive || w.team !== NET.myTeam || (F.activeWorm && w.id === F.activeWorm.id)) continue; + const d = Math.hypot(w.x + 17 - wx, w.y + 17 - wy); + if (d < bd) { bd = d; best = w; } + } + if (best) { netMsg({ switchTo: best.id }); playSound('click'); return true; } + return false; +} +function netJump() { + if (NET.myTeam === -1) { netMsg({ hop: true }); playSound('step'); return; } + if (myTurn()) netMsg({ jump: true }); +} +function netMoveTick() { + if (!NET.on || !F.sim) return; + let mv = 0; + const kk = F.keys || {}; + if (kk['ArrowLeft'] || kk['KeyA']) mv -= 1; + if (kk['ArrowRight'] || kk['KeyD']) mv += 1; + if (F.touchMove) mv = F.touchMove; + if (!myTurn()) mv = 0; + if (mv !== NET.lastMv) { NET.lastMv = mv; netMsg({ move: mv }); } +} +function netGameOver(winner, m) { + F.over = true; F.winner = winner; + m = m || {}; + const iWon = winner === NET.myTeam; + const pp = document.getElementById('pickPop'); + // окно конца боя — ОБОИМ бойцам: реванш и приглашения доступны каждому + if (NET.myTeam >= 0) { + pp.style.display = 'flex'; + document.getElementById('bSame').style.display = m.same === false ? 'none' : 'block'; + if (m.next) { + pp.querySelector('.b-text').textContent = '🔄 Дальше играет ' + m.next; + setText('hint', m.next + ' решает: принять или отказаться (откажется — позовём следующего)'); + } else { + pp.querySelector('.b-text').textContent = iWon ? '🏆 Победа! С кем дальше?' : '😞 Поражение. Реванш?'; + setText('hint', iWon ? 'Победа! Выбери следующего соперника 👇' : 'Жми «Ещё раз» или зови кого-нибудь из комнаты 👇'); + } + const list = document.getElementById('pickList'); + list.innerHTML = ''; + for (const s of NET.rosterSpectators()) { + const b = document.createElement('button'); + b.className = 'big'; b.textContent = '⚔️ ' + s.name; + b.onclick = () => { netMsg({ invite: s.name }); setText('hint', 'Приглашение отправлено: ' + s.name); }; + list.appendChild(b); + } + } else { + pp.style.display = 'none'; + setText('hint', 'Бой окончен'); + } + updateHud(); +} +NET.rosterSpectators = function () { + const r = NET.roster; + if (!r || !r.spectators) return []; + return r.spectators; +}; +function renderRoster() { + const box = document.getElementById('rosterList'); + if (!box) return; + box.innerHTML = ''; + const r = NET.roster; + if (!r) return; + for (const p of r.players || []) { + const d = document.createElement('div'); + d.textContent = (p.team === 0 ? '🔴 ' : '🔵 ') + p.name + (p.name === NET.myName ? ' (ты)' : '') + (p.online === false ? ' 💤' : ''); + box.appendChild(d); + } + let qi = 0; // номера очереди чемпионата + for (const s of r.spectators || []) { + const d = document.createElement('div'); + const dot = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length]; + d.innerHTML = '● ' + s.name + (s.name === NET.myName ? ' (ты)' : '') + ' №' + (++qi) + ' в очереди'; + if (F.over && NET.myTeam >= 0) { + const b = document.createElement('button'); + b.textContent = '⚔️'; + b.onclick = () => { netMsg({ invite: s.name }); setText('hint', 'Приглашение отправлено: ' + s.name); }; + d.appendChild(b); + } + box.appendChild(d); + } +} +// зрители в кадре: спрайт гречки + цвет + имя + прыжок +function drawSpecs(ctx, ui) { + if (!NET.on) return; + for (const s of NET.specs) { + const hop = s.hopT > 0 ? Math.sin(Math.min(0.5, s.hopT) / 0.5 * Math.PI) * 34 : 0; + const x = s.x - 17, y = s.y - hop; + const img = F.heroImg; + if (img && img.complete && img.naturalWidth) ctx.drawImage(img, x - 3, y - 3, TILE, TILE); + else { ctx.fillStyle = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length]; ctx.fillRect(x, y, 34, 34); } + ctx.globalAlpha = 0.45; + ctx.fillStyle = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length]; + ctx.fillRect(x - 3, y - 3, TILE, TILE); + ctx.globalAlpha = 1; + ctx.fillStyle = '#fff'; ctx.font = (11 * ui) + 'px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(s.name, s.x, y - 8 * ui); + } +} + +// ---------- старт ---------- +function start() { + const q = new URLSearchParams(location.search); + if (q.has('seed')) F.seed = (+q.get('seed')) >>> 0; + resize(); + window.addEventListener('resize', resize); + initTextures(); + F.heroImg = new Image(); + F.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png'; + F.wormImgRed = new Image(); + F.wormImgRed.src = './worm-red-bazooka.png'; + F.wormImgBlue = new Image(); + F.wormImgBlue.src = './worm-blue-bazooka.png'; + F.wormPlainRed = new Image(); + F.wormPlainRed.src = './worm-plain-red.png'; + F.wormPlainBlue = new Image(); + F.wormPlainBlue.src = './worm-plain-blue.png'; + bindInput(); + buildWeaponPopup(); + // день/ночь + const dDay = document.getElementById('dDay'), dNight = document.getElementById('dNight'); + const markDN = () => { + if (dDay) dDay.style.outline = !F.night ? '3px solid #fff' : 'none'; + if (dNight) dNight.style.outline = F.night ? '3px solid #fff' : 'none'; + }; + if (dDay) dDay.onclick = () => { F.night = false; markDN(); playSound('click'); }; + if (dNight) dNight.onclick = () => { F.night = true; markDN(); playSound('click'); }; + markDN(); + const pickMode = (mode) => { + F.mode = mode; + document.getElementById('modeOverlay').style.display = 'none'; + document.getElementById('banner').style.display = 'none'; + F.seed = q.has('seed') ? (+q.get('seed')) >>> 0 : (Math.random() * 1000000) | 0; + setupArena(); + }; + document.getElementById('mAi').onclick = () => pickMode('ai'); + document.getElementById('mHot').onclick = () => pickMode('hotseat'); + // сеть: лобби + document.getElementById('mNet').onclick = () => { + document.getElementById('modeOverlay').style.display = 'none'; + document.getElementById('lobby').style.display = 'flex'; + netConnect().emit('top', { name: NET.myName }); // подтянуть топ-10 + netConnect().emit('rooms'); // кто сейчас играет + playSound('click'); + }; + const netName = () => (document.getElementById('nick').value.trim() || 'Игрок').slice(0, 20); + document.getElementById('bCreate').onclick = () => { + NET.myName = netName(); + try { localStorage.setItem('wormsName', NET.myName); } catch (e) {} + const sk = netConnect(); + if (!sk.connected) { netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); try { sk.connect(); } catch (e) {} return; } + sk.emit('create', { name: NET.myName }); + }; + document.getElementById('bJoin').onclick = () => { + NET.myName = netName(); + try { localStorage.setItem('wormsName', NET.myName); } catch (e) {} + const code = (NET.linkCode && /^\d{5}$/.test(NET.linkCode)) + ? NET.linkCode + : document.getElementById('codeIn').value.trim(); + if (!/^\d{5}$/.test(code)) { setText('hint', 'Код — 5 цифр'); return; } + const sk = netConnect(); + if (!sk.connected) { netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); try { sk.connect(); } catch (e) {} return; } + sk.emit('join', { code, name: NET.myName }); + }; + // бой начинается сам, когда заходит второй, — кнопки старта нет + // режим и время суток выбирает создатель — сразу уходят на сервер (для автостарта) + NET.mode = 'turns'; + const paintMode = () => { + const set = (id, on, label) => { + const el = document.getElementById(id); + el.classList.toggle('on', on); + el.textContent = (on ? '✓ ' : '') + label; + }; + set('bModeTurns', NET.mode !== 'sim', '🚶 По очереди'); + set('bModeSim', NET.mode === 'sim', '⚡ Одновременно'); + set('bDay', !F.night, '☀️ День'); + set('bNight', !!F.night, '🌙 Ночь'); + }; + const sendPref = () => { try { netConnect().emit('pref', { mode: NET.mode, night: !!F.night }); } catch (e) {} }; + document.getElementById('bModeTurns').onclick = () => { NET.mode = 'turns'; paintMode(); sendPref(); playSound('click'); }; + document.getElementById('bModeSim').onclick = () => { NET.mode = 'sim'; paintMode(); sendPref(); playSound('click'); }; + document.getElementById('bDay').onclick = () => { F.night = false; paintMode(); sendPref(); playSound('click'); }; + document.getElementById('bNight').onclick = () => { F.night = true; paintMode(); sendPref(); playSound('click'); }; + paintMode(); + document.getElementById('bCopyLink').onclick = () => { + const link = location.origin + location.pathname + '?room=' + NET.code; + const done = () => setText('hint', 'Ссылка скопирована — кидай другу'); + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(link).then(done, done); + else { window.prompt('Ссылка на бой:', link); done(); } + }; + // вход по ссылке ?room=12345: код уже есть, нужны только имя и кнопка + try { + const rq = new URLSearchParams(location.search).get('room'); + if (rq && /^\d{5}$/.test(rq)) { + NET.linkCode = rq; + document.getElementById('codeIn').style.display = 'none'; + document.getElementById('bCreate').style.display = 'none'; + document.getElementById('bJoin').textContent = '⚔️ В бой!'; + } + } catch (e) {} + // автовход по ссылке: открыл — сразу в комнате (первый — боец, остальные — зрители). + // имя — сохранённое или ГостьN; сервер при повторе имени допишет (2) + if (NET.linkCode) { + let nm = ''; + try { nm = (localStorage.getItem('wormsName') || '').trim(); } catch (e) {} + if (!nm) nm = 'Гость' + ((Math.random() * 9000 + 1000) | 0); + NET.myName = nm.slice(0, 20); + document.getElementById('nick').value = NET.myName; + document.getElementById('modeOverlay').style.display = 'none'; + document.getElementById('lobby').style.display = 'flex'; + netConnect().emit('join', { code: NET.linkCode, name: NET.myName }); + netConnect().emit('top', { name: NET.myName }); + } + document.getElementById('bAccept').onclick = () => { + document.getElementById('invitePop').style.display = 'none'; + netMsg({ accept: true }); + }; + document.getElementById('bDecline').onclick = () => { + document.getElementById('invitePop').style.display = 'none'; + netMsg({ accept: false }); + }; + document.getElementById('bSame').onclick = () => { + document.getElementById('pickPop').style.display = 'none'; + netMsg({ rematch_same: true }); + }; + document.getElementById('micBtn').onclick = () => voiceStart(true); + document.getElementById('earBtn').onclick = () => voiceStart(false); + // автостарт для тестов (?seed= + ?auto=ai|hotseat + ?night=1) + if (q.has('auto')) { + if (q.has('night')) { F.night = true; markDN(); } + pickMode(q.get('auto') === 'hotseat' ? 'hotseat' : 'ai'); + } + const again = document.getElementById('again'); + if (again) again.onclick = () => { + document.getElementById('banner').style.display = 'none'; + F.seed = (Math.random() * 1000000) | 0; + setupArena(); + }; + requestAnimationFrame(frame); +} + +start(); diff --git a/style.css b/style.css index 7d9a764..9b27608 100644 --- a/style.css +++ b/style.css @@ -2,17 +2,17 @@ html, body { margin:0; padding:0; width:100%; height:100%; overflow:hidden; background:#111; font-family: system-ui, sans-serif; user-select:none; -webkit-user-select:none; touch-action:none; } #game { position:absolute; top:0; left:0; right:0; bottom:0; background:#87CEEB; overflow:hidden; } canvas { display:block; width:100%; height:100%; image-rendering:pixelated; } -#controls { position:absolute; left:0; right:0; bottom:0; height:140px; background:#222; border-top:4px solid #444; z-index:100; display:none; } +#controls { position:absolute; left:0; right:0; bottom:0; height:190px; background:#222; border-top:4px solid #444; z-index:100; display:none; } /* Стили для кнопок управления (.btn вместо .cbtn для совместимости с index.html) */ -.btn { position:absolute; top:50%; transform:translateY(-50%); width:74px; height:74px; border-radius:14px; +.btn { position:absolute; width:74px; height:74px; border-radius:14px; background:#333; border:3px solid #555; color:#fff; font-size:32px; display:flex; align-items:center; justify-content:center; box-shadow:0 7px 0 #111; pointer-events:auto; z-index:101; } -.btn:active { transform:translateY(-46%); box-shadow:0 3px 0 #111; background:#444; } -#left { left:18px; } -#right { left:102px; } -#down { left:186px; background:#2980b9; border-color:#3498db; } -#jump { right:18px; background:#d35400; border-color:#e67e22; } +.btn:active { box-shadow:0 3px 0 #111; background:#444; } +#left { left:18px; top:14px; } +#right { left:102px; top:14px; } +#down { left:60px; top:100px; background:#2980b9; border-color:#3498db; } +#jump { right:18px; top:14px; background:#d35400; border-color:#e67e22; } /* Скрываем панель на десктопе (широкие экраны) */ @media (min-width: 769px) { diff --git a/worms-pub/audio/CC0.txt b/worms-pub/audio/CC0.txt new file mode 100644 index 0000000..e1f75c3 --- /dev/null +++ b/worms-pub/audio/CC0.txt @@ -0,0 +1,2 @@ +Дробовик: Wikimedia Commons — «Dragout - War sounds (cc0) (freesound).ogg», CC0. +УЗИ: нарезано из того же CC0-файла (5 ударов пика через 55 мс). diff --git a/worms-pub/audio/shotgun.mp3 b/worms-pub/audio/shotgun.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ab9694ebdf485dd1d740699b0885285ca13fd70a GIT binary patch literal 14778 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 Y9d#(L=m!9R7Y+cZ{|CYUo{B=pAVS0&3`0=~Yyu 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 literal 0 HcmV?d00001 diff --git a/worms-pub/index.html b/worms-pub/index.html new file mode 100644 index 0000000..4902d0d --- /dev/null +++ b/worms-pub/index.html @@ -0,0 +1,169 @@ + + + + + +Гречка: Червяки + + + +
+
+ + +
+
+
+
+
⬅️
+ +
+
+
+ + + + + + +
+
🌾 Гречка: Червяки
+
Две горы. Между ними — вода.
5 на 5. Побеждает команда.
+
+ + +

+ +
+ +
+
+
🌐 Игра по сети
+
+
Подключение…
+
+
Новая игра — код выдадим сами:
+
+
— или войти по коду друга —
+
+ +
🏆 Топ-10: загрузка…
+
👀 Сейчас играют: загрузка…
+
+ +
+
Комната
+
+
Зовут играть!
+ + +
+
+
🏆 Победа! С кем дальше?
+
+ +
+ + + + + + diff --git a/worms-pub/vendor/socket.io.min.js b/worms-pub/vendor/socket.io.min.js new file mode 100644 index 0000000..c72110d --- /dev/null +++ b/worms-pub/vendor/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); +//# sourceMappingURL=socket.io.min.js.map diff --git a/worms-pub/worm-blue-bazooka.png b/worms-pub/worm-blue-bazooka.png new file mode 100644 index 0000000000000000000000000000000000000000..fde898684487b6040788eff895f1cc3aee9e0b4b GIT binary patch literal 34694 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

c_{s4-E|P0PuAY^Yxyd@yAyXLj5>v3aMTz0XRTL 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 WN-yMwSG~^w00000004aX+uL$Nkc;* zP;zf(X>4Tx0C=2rkvmHRK@^2g9ukQODYQ_q*dkgbAjZPNLSqq&AR$IDYO_1pO@!Ue zGP^;7t)OMVLKMMQ;Xlw;u(1&YK@r3sU?qw+64pD}K!TVVX70zCdxp7p0RMu{3_Car z7^cJXnY6T0T$Ms^2qS>x0kk-n)Gguq=CLy*Zmv=LVG~7;krMkuG>5V0#y68+3{CTfax#N8yziv z48*p9%bSj->;qRjRN}KGbGao2X!$h___zR~MPTqA2(Nj4YM=Mw-z`qZ|=JAhJ!y zfC%;)j1A6S$F|9dD>Q z=l%UrRkv^V^n{-2p6QYL`#kr#ef!>9b*k=pa}w6Ej&+v%fDqz?ecX$btr8wp27y1G<#E3nyGQwf6Buk78sx5B(ea8Y5M z0j%TciW3NS*RIaya+$5(n?6$q_;c|MI zi;4-XGk|qG9U*}B^mN=}j64U-6{>m#=r$4AC?ZA{QHE;vtWakX7$ZRx`R5%xIQC`0 zl4uea4T1~3fOV|nJi`L6x3}{Z#+bVjNv^0S6t4=Z>Q(V|-;uiK?2!?>4+8Btf6HH4 ztTTX%3z^MYZ?B$Ai(CAEImLU+g)Z^;;^(!B^xEawBH)}aix8y$&-?-PzJb%hcrWpa zukt)$odH}ts7ajkwILz4lM&p7BE*c4GA5&o9%}^pB#5TOf7W!M3dXE6fOV`c1aLh) z?NIAg6$J6D?8xN&Ow{`-kI zxgA_DS+lU(`DO}<5wG%n)df=joR4`iV%-V2wvpMJzOS$I*Fb*XT2pY&?E%>jw1MVP zDF6jQa|j_SA)-O@Odx`h^rv3z+{#X)r`W@?K$xe0X^oNqEh)?>92e!73Wrb`M0CIR zX0NL56DbT24-c1?IDEzw&R4A60KT`kbBhR1fHbD(JR4eYB4R;bHZU;ySuR2+;5q|X zyQnArKe5*SiaL*1VvGb(uU9A3xLD852sHiQ32)bXw(6hjv4nvy>E9wC21F!b5mvw; z7^zE@Q}C*h!eNvr6&?}cLEwR6QTIIl_}KIk*KJvShG2SoyB;=1u25Ca*=YjK7_rXz zR}Bq~-Ni-70M<=_wTYTU?CtCND{JIuo%3-;)rKNLpz1BC1p*PV2!R2MNI*h}mc+OX zMF>H1B9ap%Le#~A(Y;Na+Cpx&mo>GMi50}D72_KI+Y zo=W{W1BjS>_GkUS7k_N2ub#tcSH$_c&H&aP49R8>dV9P6E->bYWAEcwHF$nL$5laq z1rw&;D+^eWnjW2b-vm;EmXk{OrCj+-q22(<1ACm~tJR2S=R$T^D`iz2Pz5QgGONm@ zSB|S^+$*Er>xB0@t*Rxj>JrVt1|nNSYqlEX2UQg3ne6N9dkDrfoi(&98eIU!w9>q;7CNdQ})<~Z6$9^kQZ?|=Yqgm7kaDK`oMbYtttjp2}tgl zh$=x_{^#)U z=!aP$CZH-Kr~lOHA;PBYke6K2-s+tB6IHoQb+(~Wm3$CJ1;K>ibE;yE#6~@c15b>` zkX=q@`(*is3Xp1Y$q$*BYN}u&bybO{plhp3PD}sPo)fI_Xozo&km`l}t544EErV)gU$>c=1t6bspl`1;|%TF~F zvFYbMP)&1cg@mE>8pAegc_twxRT=Z%4m!_X7jqzX{g)x zBB-K1eK@b)do=c{&Wm~#Rke6+SMS@6G24vM7xwgY{Pghf`1?7-`?JmfE4Fo`y zt+juMsQ2nr)u9^LVoq{R1#B+B<^xPFz=l@Dn)&d_d~Ehq*OV{>WuB?{DY!QXN>$9@P5`cfQteK@S8VpZU} z{)jO$2hzaV+L2Pm~F|FIW37hiZH6?V#J59o~HD}HlUP>JomB1i~ z7%MbvXp!>ROsJ}=%B~$dI$HMb9iQS1Oo0onRX&|z0Nt@;M?MUrPlQ%pu$cJgE)els z;6UtoD2jP9jyd8zQ<{VVBh{K)vDpX>BIJ`$BNiBT9#3K}^jlkF5NGO|uFoTOz?An~ zYAp>SCibev=x=P>*4CdM@@)670$A&2f!U>#qt0}>iimb^-n_Xj`Ii~fgq0*~Q^m&) z7dfK23YEzc>ih{uIittoD^SJ99vcSOLMYxT#+Z$z5?7?3I_>>g*8naIYP`HsJ^rE4 z$crl(@i!E?#M)Cz&DDYjiXwbBiuhoujEG>NmQTbVUM*&tY7ovRLMdxyiM0iVE zE?4OW#*?~$M6!!X?0cIFnK?MY%nC z&OoDCX8`9PwK#EKU+4P*V}7XOTpXr0|0VXqsr}jFW>qNxMOCUKYB{PL2ZF46Mudmr zm~H4oi5>{m}st2^L0cs6~d-Ma>^CT z#SI(tK$0?{s<#4v!5NrI7b63x_a|M5Vv=)(1>(NG&Of!ryftz@7Lnjh#R5Aoki##7 z0m`b}7=}1t(0Ots3}H6S_M1^9VxDA@uwTL`$|R1!%vbQGN;TznLPT=hHx%-|5~YPvoSVu@+ZLUbxU9TwbQ-U#>N3ZJ>6NBbFcvbNeJ8guioCS68LSZ ziFZ`JGId}y`H+BEgT|g{wsJ1#TXc3nm7pPy35-d#itCwYjHmPiTnr50WW^#{_x`f7 zpDrK*65{_2Ys@b@uW^h9Z!8qJG7a@zDB`b&0ME(gYB$)|5&ehDWxf~16h(MpK8FRy z)ibNgVVC#{EJ-{&`(b>VN*tXfug)0Lv%(zTjbc0Iyvtj2TW@d2n}>$R|L$zB^U+74 zx0l@6em_MLofmnAF`z1$q)+~DQ{I$0`UJdlDuMJxn$e6ww?nA zjt_8=GJuTeW9CE10QB_q6bps8X>P7kP%V@WA3i*Kp%%GJ1VG%=)A_?j%sai;xT0!* zs8Ha>FkBOH{(3H2pLA4<@dK4~D_Ie42?M%}p#l^I{$HuWSE^NVBD^4%W1}^UdrwuN zMT9GZ0MFU&dj~X`Br5QsQdv|{5%Y(=z1?3A0-14+M%AbYrYK@^B0le2@~kQi#?T19 zL6s&EX-=NmpekEaf<33(pLP+-%m|aX0W|UcllTM98FeC0MCQYXJ~?qv3?-VYxFEE7 z=j=7W0H+aiBFwSehVk`>bTC)1Fh(o-`qI{11&f!4? zX%N;-R?^C1YG-$-JtIXA0IK%G+TYNF85loVeD2C;$|g9$9I8Zq|hp(MS} z2)+{It1&ho)~{u0D2Nr*E9J2n$`i91%~iaQoT^tNV8n=s2*yZK3W}iWHcn4hbDSo> z;X*Nh6Qq5Ae}4g{U+TTQRfHFbkWVf|@m}M)@6|^|V^uXEHvnH?CHB|ZV{z0rD+-Bk zypizOG5g`r%BC6T-Q_`GU!BiWInxA?RYVs0do+&uc)7B`AWnqYd??ULQW6}BaNK)7 zUartALbs8cqfmiO#?Y450Az*zO5)AWd%6?^8x#aMd_r0L~LvVr(&&+|X0p z5V1luQ(<~&jOuK~i(rg3)>;cz@m{BZ0fhtVby(F8S!)l9#9uvhXnKq_<^woE+V}Ky z?=VJxSas${jIozlo79Rsr*T^4nrW3wK}h!4=99d?8dxelBWu#R_(t2Fo{mk&j>+$a zR-RdMKHgwV@cKf&_N!b-js^2!wssM=7{l?DDY?{!)l`aCN&5$o{D4!ce7%}_`BT#& z%NH1Sp7+j`Xfi^J2yMpDDngqG-6>-@m-SXuxg(e3kvL{bRjh)|1q8)hE!W-zHhIqe z&WPSfVn3Tia*sWF5cP3=I_|O;q^kJHqt2sFPw*y)HJH%iDlvkf>T%W6-vtpNE=L^S zdlVl#4MJa41K1^hQu{XHV~;emmXBd0jbDY@JEU;y=Erz-|%kH)0wi zuRah7?kpB)HHOj}QT>G^*=B<={A5Fs5$}meRQ_3pt`=3wKeCuI%?@j_pk3L$nx)wOkPllCd0E-D`ffv8GxBW^mch1bn>y42sAd&Xo z`z&g-ESjjRp;(Df&Qj`oCe>>o_o$8nqsQQYk zc}*eDrD>wiS|I)m853v{p)CkbP;WfRMtNVU#Ap=LAj0bk1&Sh!dB=nZ_eC+~I3^IG zRiu_c_(g(qk;WW}90`YGEz3 zBDYj3_J{iWTR$~0FnPTGaF0G(^L?FCuRcd8SjJz?3S-y)vmuN$f3QxFTjxA|Qnq=JNbasRYJQ8kwTd(MWJ6 z&NY(XEo{gUZtFh%$(C4jMrmnf-YE}Z^I_c=1s}V#K^1ZwaV<_xmLSAUoYjChxu5-={~5~LI5W1zAWG)hkAcn--CLkxu=t$m}BthU=9whKelzH0 z#w04arfO75^w}|1!S5gi=9@<@Da`@f-{0Nsoa+N~sfb=KVy;m94v=1BwM)ceZJN!M za&-?Xf){JlFgPs(Qtv5tHIr{`kn;G9txlDET#nQ`uXq(z zya=H&(j3RqxSXpx9WiD^RU`V%n>Tj_LG@W8@?zlGz;0v2!UBTN)auiD@y`Xd$gb-A z`gZN=Y_3$;631qnF>a@-xlDwez;;!(SZkXzJ5E)>CpS2~NQW_MLPJo@o1hR%P{@fb zgx2N*Y#1c*U6I;}Gdocuj|7Ifk!hxf$1yhXcE6!eV6!!ACf>hXWFFb+RB{8frjkA5 zv{~f9iqLLk!4JR6v_+Lf>$oWh_;M6cbe_`aG+}?&%B0(_1XQu1CCCR998UVpVw!M9($Gbf?#KGJ-!ZDeWQ{RYindy}ez3FT&-e61_lo(mdDJoH>c=?&l@C zF=|3fkPD2>2Vx7M1o_a~T++40SVJ~WLm^4G)OiyiSwOlF1qlpsDPnqX6e~$w{0H)R zt`FBnJFjzrA&CHF61_dWFPU%PxX!oLX%xYq$NUB%$uN9IF67&BOk@nzi8@-Y}mOa!m<5scU)MsTGn<%wBu0&7(D`*G}EeCW`` z5c9MrciaJA`AW^twImiZfJ{wK3BT(OW8}@CFH6QICu_Y>1iu8VDi}k*AV2E8uXS0e z+340_a)H=fAT}S0%?D!hiA2xh!wU#KmFh{TNSC(r7XMgja>wL>5hV7^8R5Dh;Du`_ z)9+m1fsQomufrI21;K*eew&QtJH%|arWWTvUxK$jjCo~XxhIM#L@|}gIT|*$qG#v} zI3EZal3FvkC;h}^M==t)men3<1&TPhbolf;5F>LgMdi&yc0jnn*n{+ve_68(}`wk1U$?gOG2DsLo$RY*Hn(;LO({|w}LbZiPB zkI1E&Z_0y;kyw%vy8ZdQKX#HUu1%MU!!UZjwemdYe3hiJ%UV#LP7h6PSX`>&BUdY# zT=jF>#73JQ9K)u?FfYsJx%w&K^g9_&wIMFVW~s~3OMC&UTonX-ts3EsP@YKq0Ad&I z3E@PO5au<-Ravaoijg#SiEpp!@XoWKBT*_?iXBZ%4UDGMR7%)XAjm?SsY%h&*Gay$ zfy(5Z`q-(lMoeI_`B2K^Gd7y37)0jw?VFffbjxa}`2a+4>HLDVD)mne5q#uum54Ab zqPofrf=p7=J+*%Xaf#FK<}l=WPZ6izT19<=hmf`(6R2ukNYGk#1pyDnG5JbFd3=_} zEp17WPO5#Y^TefyxKt%BRg)?`5eglRIW&JiypnHgAm7$di?~Y4Kzucp>Qo7_W=DU2_olR9=mbANKxz|2k$c!$ zQxTEE;t#Ds$l_P(lxQv@Y%ZQOb#ui|E--mu6kn=Rv4(zQcy%F9^b~OVtraXL_Ke`O zL8w(#j(Sg7p~srqXBD_540t$>!AO!5(3HnVE+zN~SB~)2IElq;ZG1ZA@mbn0+eFy3 zI(`EY!31drg&4f|NhF{Uo=9;Dja%C1UwXe}NK@m5!Y;EYOfC>WMWhkjHsAA>Ipte7tJaN zJ&BKD)GNolW5_!Oo#&`?Osn$pLZ0X5a+Fl5sB&o#u)|s&j~&Ho%<(6V%tw6yFM<>B zMld8Djg1jBa+HtH5;m`F$KKinN^09@%81WGflKECEFkWatz}6+mtyk)h&T}f=eP{` z)>22wOaMcYx%q8lxLWm`hAx~h%rmL<=Eif?)WNbkXG@%Z)65<@j$}^1VxEoW;?4BC z5U7{dS>j11-60|NaaBf~Xc=!`^Yoc_z~0qbwY?IP6#uq1_m+3<92=3m1f3E2`{F3$VT$$Fz#D z(HJ(QiAwDvG>Kr-rW41!ClsM7f{$YGt0P2&BotV`Gkv@Y3`Z4XEjF-;siW-7-dX(7 zV9^_oruX~e+s^_%PCBjFe0b_o%jF_<*i0Q5!FXj(lCsu2a_dgN#WF`-Sa!LYTvySr&*WaNYn!3!8b5F9_^T-Om1 z>Ay%EfhVjno~y3t(IqfU4~-EWpTh-)7KGOq3j}HS>FFr_GnIZZ!AGi0t8&aahMi}~ zJC3EpLMjT1E-BrcMA%>qTdkqTST@Ria=W`)wiS)zS|g@MF=GsHy$rR?Ah~~-t)zpNqtnIs>*~ahhxW} zbL?}D5$`D&!Ns~d1%#=Fnsg&hy#A7A^{HEVlS4^DfePLj3Dx-?<{3Z@^~FFIh*jl) zF+6(-vue#_#TwF(TEm=IUQ@_(`P0PdS5Nd=w_hP4%EQhx;2is%@A4o70gfKQm! zQJ;)6^MF+dRMFUxYbug&Z8&9rzq5vtWF?v`>?gHO!X`+&1+5@#Xc6#&7-Mu>`tOr6 zfNYif*S_XoLndG(d*x$~tDJ))F!lJma>V3;Q4GqgCbROMwT658tpw^-`;6!nnDm~( z*s;gO3^>o2_b2#2GG|(wNu+vjO7Pt#QSpORy1TSssI-K1KZ~F0q^8J}Do34TDAi7m zc+YgIS(udIa~adJ2HTJ)-`+q&XLFLSy9#Q6qL_4IkdKmj0QDa499Dvr(*RO!!-O_r zHpyMuw$w*YU;wJ-zzQwn{3CM&Rw8n8mX5$Qt8jAv2td4fWi5Pq5+xGG3B z0I?RWI>g9nlY!3VTuB*xmJ1;LOMNU@_!Y3$A5b-L5&O4Xd5JR1GlUpna{mbNOqn<^ zY!uSIGby#Uu#D2O6q&?3BeCI&mrfS zP^FswE`bO^O7tRv$p_em9J$6kK|`LPm`hY*SV-{oH)^$adg`b)!MwV!B-wxqT=Odg zF$NP_iK{V)Y$mzFY(R4o`CGI+%4I#$(7>26vhgAcb0_;Ry}`a3*BX7E>9-{?RF2Ov zduRe<4OI_!77O&Br$HX4Bk0A_)TYly5@MAlKI;LmT1&zysdCWIJMa!JaNx~{#6l6; zMYtpgxXcFhSwpKy5?G=-huvxsJS1AgQSTXYo&(Nt(0e9;YU((%>anRtLK3~h=0l3z z&E%Sk*j&=UHB+rsF95-x1T{t^DMdQB@*elpmt{jMQQ~jx+OeafdGS=C#SB2k#>S=_ z8aj`FY)p)ji%SC#Ns>916I#ifep3UZ7+@CV#rYgJtYB@wE_#NA#AE87vsrufQXwPe zn568rno8a>31OGh_cEKitja{1aI?)?X4JFadB(l3v&W?tn=c4#G=@t8%ca(`)moZT z=^LpoaN3 zc%?dBuGQV!q{sVwH>kz+{}%|hrir9at6&0T$l8u8(iTOo1DN6@8UW0f3l52J?Zp@h zOs1}QeC!CaOsLa96{ZHq&}vM{8m_jM7oA0I|58M5>K=9xVP{_POrlmuh&h|IJChRd z90`G!m0HDH#sp&K$8Rk@e{x>oi8xM)E;+M8ZG=~k4Y-O9DqyPTL z+;`u%X>M*Jj$+2g$I0b#6srZ|sWP+H5H=T*TKc9!l7nAk23%0Fwjdi2@ZPUV){Our zw4$I^B=3FD1stI6>X+&Y49J1>^~JydPB5R;E3WDY8cyR`%~b!1S)$1~oHcAQhSwDG zoGLl1ep6=)$g<0_diBiMic;x3lg6T_eDXZ4iIZ)XWbkGsCG3b~sEw;5=mn~F-SKPZ zm5jI-N@oYDR*+~FjRG4@K!57M+iVQY#w6pKQ)V(-r`n$@(btC)NsoQddG^PN335c0 z8A%3=;vBA0A+A=j#?sN*!H#RM;l`V8;O1Ly=E^ItWb>BIgkeZPk-y#acept5_5AUl z{1GEZNBQI@KgpwyJW9D-rlD8_=ZPlgFu8zSYmt0gL()^vO07KjLNJ2`*?>ZlxW>xo z9Vj((Lq!l()3x|BCz}hfZ)KNq9+8Fu{h173OyOP9X|)RM3M}o$Fs*t*Yox3bY0`;i zemF*)O79s}rkrO&6IFZMD--IO@`*I9rk`EtFq71dpEmWLU#${XDhSZp*2eZ- zmvQ~|*K^Y?H*w81SJT_qOGBXnkEdKNQ!ba8o}Q+ot%D8S-G~Ttb8~cbbn?bKe}ez; zmLK7P?>xXifBG|g>5E@tXmAJ-p;##5D>3Df8EiwCbkS*TNODCa5g0X&znaXTEwTw0ZwE9nN{*+`kA$1G%XWK5(zl1XH@{NgbjHlums-oWbByB zok;wU5L8K(W8O37l`-!bSI#D;mT!v z>?k%iuzmY>uDkv^Zn^aquDkwPwr|@`b5k>lQmIy`R4PnVCX(}*B!$*mi#3*>-X27R zIF5O8?_Q$k9>;sn?a#iQ=ic#L4jw+lSHApZ{`s!YaNoW6B_=|cqu>j;*(!5KCJ7qz z6xtfdwG@*fB9J(bYm+XQd1KD-<{4{}=AfFS1ACV+0I{<2$v}aHlmV!k&cz>Fk^!jt z{oadmarFnRDRV+PeDO@aq)Jc-u?;zb=`u&X=dkzmS+k(ggaBWtRCzFpm`o*S+OxIh zxU(!&d9y`j&!;2olVP!xPt1hGKhbOqms-mu*0L=~Qf(VkUqhzWFD{@t8-mZCmvY`6 z^PWNH+2@k{f#c~K@g85P5LYT#Yw6n1&F*L3%B|15m1o>~3%f4gMMpCmw%-&;Q%!`P^s!g-0HK zgi@(Qp->2}kM+1NC~ zqyEeofcNns5j$xNZHsa`E)GaqXMHA_RSH-k*HR>!Dp5u_;2f7&OC|j|8btVZ6!Gaw zg@%;S^D?ibuLO}VaS1gICX@1-o_CWm^xMQ+-(wAJX{v3E5-TTo?3b(7FDCe<(rgc< zxdQ{5V0EZ4uPxSWY4}-K_B%#20Z9ny_Xzb3+u1Zy2|i1+J(?!q z3@+uoD_4l4D!DLZF@bE#Vr>6^;o2rVGheU8;7`bz5k&!qhJRJHF(>*E8FTIJkIsP#({JW2R5DWvfXddCs3Op|CB>K8lj zTDkQ4Yq|NBo4NIww{rD0SFw5XWO|?>&Wlfn3!Q9WOy(2pV(bTN}t_%s}d*3Cp?hA}59d&%|eSHV_b# z5rMQXetRhmAZx_BKYe}CF#sVcvQY04dj=C?H7T4h`2bUmnM$QzrwCarVI@;F>ec(I zCPddD!uG(h(+2DaESu`2e6#{mzMkNdLYn!mJu}{OER*owanvhQBoT#qg0F&l8k?Hv zy<|JrTzd_-JmVIwzu|heU$UL%#^##0es*@YRy!cFv|8`Ir>U`#5B&W<@W0>v*Sz^H zKg>`4^iMHcn#IVeug-f+bOr7wJmZ+`Pz3=Iz9oTH(kfv_4=8K1?3mawTnzNJXm zTu26YW*Wg6VzFRy)p%71O_0RTprA%u(tp>s^U1=1fbL!Un*Rbw{a@-8b&4#}a{UCP zegK;fk*QLeMd#@b=9$Mz{5aXRj!B$np$L7}aAg>9S*qISQd>UKbCdFmLvxv@-jfh~ z*0Comm7M}21oaMIjd0Zp>OIAV2KxGXx$>$jx$)+kxc-Lg*|Fo&)Oly9M3obqcl8o? z$+fAH&*vE$9_Af?@ZULp{5Ze++rP)|D|hpp=RAj**_qQG!(!?0?*pJ(sWLb?#B-i~ zW<4NkQ>0TwQA8Za1VKPgPY>IAuHf~reJeBNquhJXeSH4kKF^oF_$3Y64F_Q zUS$hG;yni@yw>9UnWR1&WQ6NToO4PtV*Bzi;F=($-zM32S%~gz6LFRzc$;bvB~AMB z4LQewv=Qedb@BKp!c{7G=V@pt($lk*E3UkP8*jXU8*aRTojZ5Z+0}^^OXMOdl}b%| zW{$VhIq&M>y(gE;v3Jjt%*@Qt)z!tw$O!-MV;|>v&wJjP5iy>={(h{rIOiBRFtB9q z#DaU2Y9&ogO;}?w7(AXzwZhEI49AX*Fgh~Akt0Vqdh{3*;}guy%`rJSNiLUL5c@PH zxnA|k+{hHg?&dQdL@Iz-S+Fg=^DArr*Pl<4lz25X?cPo9(MPGK%h075K$2dq=0JMI zYpDqCbf_@_)r_m2xnsrV!kYRxo{nM85Tj@G+bj;*Wp;fsxH91CFkq*(v>AgZ(FD$h z=oiQ0b1WqIW8QNxEu=Z5o(YA@5}x`(p}>|co4I25Zf;5?{LagE(ACvNU;<(vQ7%`Q zo}R8#>z5ACEeBRC$BrE%iXxnI1`Zq` z){`fHsVYGb5XUiJ|JvOgK75Gbp@R$#3@|)=kfTSBa(v=AGcz+(Diz|`p*rtJDi(_5 za=99zW~&n;G;C-gDCU-x%p>(?P8teaaT_4>H&_u(G=QdZxxEMzHAl%(3?T8NiyTt* zvmlKumac0P^Rhnm?E5qA4VZr3=8_I7qDe!HtZoucewccDKFh>&=>{o?u+18FhXI#c z%LZ#}8FF*WB>Ke!Utc*_(xjGuIJMyq`GnwWg*0)DD_3xFOg^7u)8PzT3U$XBu1VU@zg@D zDPxGDh)tU}(bCdPsZ?TUaEMZ=bW+A7B6#Qdj%d`NK zoI(QtU|?Wmw6Cx8h$Lf&F9rs10+v%{rJAHo8f%c)F{yq*#$5eRvp8Ir^}}nBWa3Ph z5&BaP{zfye;#U`B2%cs=oSNW|de3lL+I!F^DYpv8y zopf*5z|o_}IDF&?V`F1+c=5si({{Ub7{1<6%X->2Zmz+n8ByF?$u^@8`*v3 zZnkXMOfgp^)`Z|^XJ?r?&jg=AL~xp@@L4+StT2RNAqkmUmG0iM6IOFBOg(!K!8`jUW6Gs#GL;APD;Hd#XyCARh@fn|3PustoI zEvBEz+F74Tfz4uquW!#))rF<9LusjOsXnK^QpLp)femQyXy>vUuII*^Zsf+BZsdyH zm$PNdW(v7t;^e9%p88U$gdnx7y9)(l470Pd+;Yn;y!2%+gpYQr-N~ID{eE$hI>%WBrEjoO6NiYm8-VUmFd^jn*R>rRoBv()}L zY4S~)UVFH%spTd*L@pZggu+qIL%rY3w8@yPHX-#<8%*yJKK6&q;C<*2%t zsku26*mL9%xrQQF?7o6942hzM2Osz@`eREIk1>YX@+?32(jVln{^H$?kB_4hvqUp3 z*u~?y&x5g>n%#F8St&S7u0@h&YF$%aQzL}bJHz1bJf*Xv9)Il&4p%4QHg*1*Uxj!HCNNw*-163Vy#_s1iu(rMqdyF z-13Z@nJbm>am=2hhxz`YA%egX7|WGC{cP>%B(N5*o{5znweVixLYjZ0S}qf7nmEJX7Zu=0WATS;$u}fbb6I+=LQ_jK{ktyX+Uu_4mS^0| zwbxxs|F(V_8yXV^RIM^MH%B2X@SbFeRhv19Dt ze}Dt~4siMI%PGy3PB3#sgi2K5hkxWveDuQ~C5~gt<1;jLH)HeR>a_!3>e^GStOmcD z$v{$11MT&1oou&wfP)9e5BK(V9T1VrFSba4kc54_bG5!`H7H16aFv+pp)slxvxpH& z*3gRZ>SCT7f}oaqo8eSMpE>TbQs68!H5Mm%sdFUhB_1i9GQ+qoTGt)eN=peI|GMn1k*}h>DEe#E9->{Jf z4?KwpLw4-Gf(O3+9j2zHxc}StbL}jU3nF+eDy2&yZ8ScZ7r?L z9h;)Lzbk2-bUt=?P58)srfGdNVWlap?pPwyHouBf`vB5ZaeX*p05y~N;y{+=7JFARN!N-n5u|V&ZEnIo^mE3gm&D?O~_3YTWgO1J)ES74V)W#oAi)ciILZQHsBS&dy zD6*+*BQxb$<|=bM|Ao)vJ%9BcrlzL&@CQG_5B}f}o-!AHtw8~64AZ4K#%E^$=ys@5!c*!9d~{BW6YMyeB}%6|l9`q(Zv&+FB1=7N zsekWO3?RFM_XDq6(G{I1BvV&nAenY9fx(p{W)2^xGB$$j)w1nwy7@+SUA~L%?rs7T)Y5CGC#T5< zab%J)DVk~G-~R3M{OT|ND&1XOyy8`_@cMOh>(B0h1hSpZBG0c@p z?7s3!Hf`R_{sRL%_~3&aIdX)SmKL0ICzt`o80JcI?7m_*uYbeqdC$AwLtASbvqz_B z-QK-w>9~upS&XSABT15~e;$~RqDph^1*c{JnLcuVs-;{k(m!Y@-9h%{-2aYFKqHFXz}IEB3T1UBHwy?c4$@nOct#(C_Wf6V*;=KVb9xzFLp z-tty{=I4HzyT5)nlarJD+5h}=o^k6lE@+rZ>HQko^ItCTgF2 z*F3E1=U9~h<*GpI|8P}@de8B_M-wlw$3F5gTAN#O?hM1A7Z5@s>6;2gin$y{3uP-V?i+<8$*R$Bl&o&w9@7wby_5{=eg%`@Y7^%nXf<4Ro}2(AwHcKA*3RIh&c8 z;gap!dHoyiWO{lUL6|!>mD=U2e9=mhRvg*|zNuAaJc%8UIP|ksoL1QpfWE%Y2Sm)Z zs_M8{`UFan3S8)HqH$9jV~-9eS$+!Fhau0&g>)IiTwT&m*3s55UqSa|?AYrZhtfnG zbq~p%%{A9>&9zsvW9JSwZrn(7b2Xi1SXAHl_9=-kjVMSe zErN765)uLuBHbVkNP{#;cS)BZIWQm~=zxTDr*wBS^w9I){GRLK1ux*5GiT21So>a| zbuT^zf|P(|##c8j`2O*$54-T%;_LbZVNH+YMe~ZoI@82>Bk(w1I`bo4hkwF3(;xjN zlhX6bIldrBQl~p%`PnJv>-yotDZA$Sxhv220wn?;-IqBs&+w8orLlA)cmQJ=CYPM{ zN8k@P#VA{AG`r_Um+8<&L6sFR7uU9%?xWJ$#z=OnNJ2s|Hh4uu+;()hbA#?$Fo}8j zgj}3gaGYgY*5+t%6k?q%oxi}9L#7%sls?MgB7Jff4ha9OC39^GRQd`m)^AEm9-$=v zQZyevz{bYz2@%N`W=O@a(nIf-Mt z7SdJl7q^I*3S8gpB=k+N`PO1n45Xqlw9@ws^0TgP#k_0zRTw2D<&p_%eG#`U{=81G z&ThW-e1G=$O=RD+=d@Y{{EEu>E;99G0d;NM`qc7ZiwTx$C(PA^M*Whh`NZ5S{INTN zMOEdCdL~|)Y%}2>&qtHBi6yso47T2mjBEPTj*6@z6}=v-X%y=x7q0nx(lj-!Z+b=4 zjCj>bRcYk;1qGa^5BfYe?1$D0xxAk*i20p{E&Ay9>@>3G4xOwmuueBSNHtVU)mr+9 za))*i)2~$pz0zJyi*}Bo#DILHUhLxWCZb(y8DTV>ZO%5xb;AE8x%z}g+%=xdZ&}6X z2B;!ciu9|Ses?yu_Met7Nw;c# z$T6-P$mF?DRScl@?J08f!$x5iIe>_Xl=l*B_g{ZZE-_SS9GzI`eDrIfwFdE7@YmuU zN5z!y0~jX^w{`h@*!I?zsikEwx40XI5($>7s;VRE7U?oDKFJZTUdC=laySLzeri$a z517%o`!NG{cJ|+4RS7;MD}QXD{9S*Kcsi|%j#_>p5ru{^zwP?o&CJYRRtYtSO#V3! z6ZZ2-tf){cvgS%;Yhe&Zz+G2a>fBvugRV$vW?-o)!t%sLrKg%g2`^xA%?lW^OW%i#FIgg}Z4wFVYlP5>U z%OuFliX1Q}OWwqP&7ItleHXD@Ak)G`|6V|sBc*Mxaf}Cz*c2wd+?Y$6!1A&vTAIwk z>+*slxs=SD-~mLqq&H9)qf#l>7am=k5|b(DG*38rvbQ-m$d_|T@Urn#lM0_u+ei*> zSkbX=U|=vkQfj61M^n`v3TW$ zziOfu1`n9{GkCX!UF;^NzjsphuJfiExj)f0bP`#-9K?JNaU4%GocTpuQoHD$oOVww zDqehyjx*#{9|ibEtf5NVlfrl|{HR~yA2kC%Y^>guCFA}6VUo9|T-f@A5A6Y%vm!G9 z_cM{JXnM_y0fCT{T8FZ7cm69Hz>*1wVsO3`PqkYd#{ul+2}m@mVV{5(SMCDs?`jbGK|@ICFNaMcHEK)CZ$e zLOuq`rG^wnM(~apcyqd;9^1X_!6_lVV=rWwggj=T!j=(8Z4`e$`JtG{fhFqhcFq;#){&P(>pPH&qOzJ7BjmmKV#`_Oli7d4_8rTY zC0-5`Ln0#jwZLoorTcX!Nh>Ms=WnYb{xDs2m8u9rnEh91G812g;mYjwIY~!sU8lm! z*|}HV7K|?A|4C#UMXRbGx90_^rYc7dsn+Bb#&ma3NE}UCh)gmt$Vsk~H zWyPXK`6Itx&cUD`1hr^3@Q*QGkxc2|bzp3*P`JU*63Kr5^WbFQ;TesPk{lP;aC~dn zr{OmKIj|_fvaI6E^xG~pZM^6RK5Rz?wU&POit_G-`?FFH zY83gnj`pE=B&nJ6r`WcK57)i~lpfP4#;H5bR?LH~gG8(=H09knon-JBlfl7rIiw9x zRSGpDUR2g6(u%nsUaZDhD5v};W43SD1;!b6v#NPIZ)W_g5N+abCshviAd=%W42m=z z7tl5x3%6uk6Sp0P;sud$SIH3Qe@eD6CACa`w5mW@pSS^Yu|+nqhMa7|)7(Zk7J{d5 zKJXE9iqFj`kYE9gJHBuI;#TU$*KcKCO08DY>Z5a{Y+gVVtqjTV=v=FzZS2U%+nJe} zZi-`VwGji!Ox3YwSnkG#^~%Mm<|N3LykE-n@J~uOdK($SP!)y8{0*+L7M|ALBgE6o zOfC3OOi^8t_1Av=7mK8>C+QglWb)#o;w}3P747d;1Y(a$?P;S@p{F8C@fB4t831kXJ?4M+Rep?v7!InI}_^@EI1g$!$(xPuDe_Qe_ z{XrB{i7k%azCX*-+YYK%8n_X?fH92_(N~H zHifHe?8}VW<+`I7`h7%XUQ`um^~pLmFYg&P5ka-b5M9n^b7F7g-g3(wx|f&tMNRXK z$?5rd&cB2d?f~{oL3?wcjnYi{2ly=i+yADM3=W5Px7VJBcQ~GR__;&>{*fC?_!`ac zNx5SM;1KUwn7fC*f$gug#-Dex^p3N4v9MNkavPMAz=NF#<6`puyX5{>I2CYm^;Hu! zgv}sZ9xImPjf3z;T$4$)YDaXwu{R|s=~9gJAkvLF8mH8a2rsMZDy^TwgR)SwYb6~+ z@v~wv!Uf)GTAO1g6&-&J?#A7ErW5)=VoO3Xz=FZ=pLdab|MlDXk0*h3h#3xxI9eul zorj&413n>3&VIcecVW>z>6RD?agS|+CpZH5Y|uhvcQp!~Z{m4Ie@)N^LVMNk`e!d; zsAGe6r~YQYWi8Mb2Ajnfjre5``E9t|9CO`~t@HE# zpj2YSSA;-b%+}dk46S;zJ&qDq%XWXkp#4Ip=^}*dIG>^sH(*LcG$R8J^`Isu+G{I2 zZeM;S?s-Cga^%^VO?kaCh+A~- zYuGrZvDI(Gm96gXt^S^gG|{nNS5uy6A3M^5;ldKI?R1VGcVBjKC6W-(i>ZgY-+)M>hb7~*V7PI~MNpuz3&`)sD+U%P^(Re6dTg%d= z68^gk9zw`zPf5z|)ZXle6jM#zBpFRKBaOTi*F8HQo1Jb{yNQY)zh3m|8?c*gidkMZ z1L=OS1sxm^E+cOLB0`_p=U?a-n;|^W4~sD|85eig<fajR64|5(H z5K^Y;(7t)AZ(z`~p6C_ts+@^2=zZJpG~PgsG=UGgBj2BVduzl+M@f0hk;Sd#_uNuZ zJRfQt9sWX56z(7Vqx74k`wnAq1yxjKXQ!eX|M|(=?ypli98fQU&!hO>9oNrvo{*04 z=&;E5r-jr15~PgFpZj-sc-UiI@gfV3_YJM4MqH0=E1ah~uOiYNmR~3NU_~=H?$&2h zC*6Niktla&3uN1BRU4C@Wm^3zh42~F*_jPJ^nOl`Up!7z{h_6a6?u|<-Q0$6eE8+3 zi@NV+>&;>y&z$qNmG&wngfp)eS9Wh+^e7i=koknjMj0$ohm2q%0!-vSAGI%ac23o{ z4}zY&Dw)P8bdelCqt;=+IT6&NlD&K((~29VKO(l_v2vuhyhIYB{D1c=F^)`u6_*9Am1i76u$!i+a5Ju)oO`>SyXumAn+5nDrtL*D z>8iXmWgqbRhao;W>FMm9Q@kw)^9^l^^uDJb92_PoV9E5y#sv$2(pj=2U7FGu^*$2b=bj3s(CJRss82mnHp@_i!>UF zda!iB_dXxv-WL!90M?Zdms+ZJ80_u6>qb%PU9WJ~>#cOSGq2)m^$$A(V;y?&$)p_J zf~&8ccUaU$!3DM@y5JUye}$P>{g%!U+tknzoe>rGT4QTIbZKsxUAS@wTMNjG8VJ6f zP{gG3S(1qikz+UF=%y(fgg=Nonbz{>0T+P5V!&p-;JTUl`+9fr+xPF76k_1d8f=Gs z%~vYHW@!c9QA_5!oBl^*XfZLEbQ+td_h=$nsb4jIjpVdC7h-#RN?A7AMpZp1=zCZk zjR>!%;!TR&)7ewuCnGk&bj5Me=;gR6ZD`eBmk@=`XZpIR&1Y_p-s;&}D!WvWO;$J7 z+3&K(GYRfBc8MqgK?`mei)=zR{yDa)TzN%*!wsMg^5X|zifHyz?OQk0B!=#S*9%4h z{86K+Rh>B#9*%kO0?Y&9;##>Um*U`1UTf?Pep3L^zB62ORum3UDd$ay$q*v|ZoR$p zJM8KL(PtVpnuUgBOTKkBOoSpS!zK)7q1FKNSsOl9jlk? zW@RX8h~m)C7S*HNR``4bHURTkqL*s%zlALp>BA;=_;#GOmSoIhYH}!RU(3*Rt(}NG zHA6GG{4X9?kc+`1%5m3L)d*c%&KUDgrq82+jVZV2UE?csVd+UfXU!IuCI=l zqsYtr0n|^tk)Z9@wUV}SCQ{d|Zm>OJ(f&F(sCItfn+Ohr4VM>Pk`>9y}rBR-56>*%?{CqZltH!0UTteSSD_&2NM4&h5W=Y zkLEE>igCVtl*-2^(Fg2DQOmvvU;3_CIu`)()oE~GNzT95tNBldp~{-dN^XQ?jVvXP z)XD;SaCbF7=Qq3Gzoj(RrAw&Cx+42*2yhxx$Ti6()D+TluL@D)F=Tun#a~PbgC#?5 zdnwj#-;8o}bW|3QZvRVFXFw|Rv_-2Hmz5dB{A~P38%} zHIv}IbDpVpP<@pTSNo`Av3u1qJK`uJn0w{-1bK(Piezqaz*}lS5>C?LnA@4|E-*x&2$VF+vBC3OJKgu^;mkMOqj-(&jPlU_fRuA?t zZu>QsyiAGSXhgZm(>=_S*Fmumw5@DymD~;bUmDQ|2LhT#e^dOe_H8(BvzV~(`K=bM zb}(z>*Y!ML)*zoy(d2`rS-H@8AGA;(zb#BLi#oPVG z)iDoW(e1U_Ic-KiQY98?&fczW{c4HxZj1Y5<>7JaADV7bd7Ug57Wku?Y2eC)uW>j1 zlQP^Y?bo)=TE^*_{P(-I<0sOhoNRur-kHo8MofNaXjwdDpDPuyaTKM-W@kNi{yT?lWc77HR1UhUsp;B#VC05Q>N1f2DN7ZTlA3z;FAn|M zarWfOTvZ0rtJW+!&yc;w8~Mrar1^#lhlpnBYPWK=x2$3%j$*JM~J%7tJCj}{fN zymv*(x0G$R(O=<$=hw5<&F0~MOC4;+w&4BX(!e76Z3zFRjnkI_+f@9Yf+#rXNp#|(bCn^?glKfs(5`*t*<$}F6erU7CO#}jS8tehbbwP*e zcH*W}=0bJa852mRP~1I$APbM3Puup++@;hAPgLhHLE#e90OyW2EIDVYI{?;Bl~4}OZ9mXVQP zR+Hp$7w2&tncDbeb*$@X!Q?BZOJa+K zRaHk{L-bzA-GISevV+@AT-ffNKM~?n2!f0RY4VB$JkH;Hz+tVT})_72>JqJmSrQm+*W^1qWE+ zltuvwIh&%@{jPPHI&_Pl+q9c|d1tYxUVW`^z|*;uW%21#ta4%Rxk}y9%w$uj*rFq= zn8#iLm9DU4`iIv>`$j(Jxl(=o{jQprKfEkPYMLkmk#`LYSDoN1N4O+R-<}EMJp6X; zZgDZwkwl0KZD+t6a~U-BK3u8_6ZWX(RC$j3|BQ~^OLwMOUVG|jV_CqG?OylXX9FhZ zFZFX!UpA|1i&>TWVm4^qAaBGH0Hr9&=LD>nAw&KNI87&LU2*iq z?>Rv@UK+wldy(X( ztoO3oGJz7598}bBB)=o&QA&E>AyOO@6GIe#eE=W2zpvhj9N64>xs7v|3&Iqsqg(B? ziVd`1+_oR@<`S6?v$*z6od^SKQw(1!O6GQ%KUcIOq8IJ|9w*r=u~;7liX*tx#qY2H z*bpr9`jrJHwV>;2mW{i6iZfhQQ4xpBf4$Fhe%GYS<>0d*XAEcmS8GZ!$#*WwviUr< z{J##ThkRliy^*666Lp%SQjA^?Ff7`Ep))f4a1q!TN@n^UVhS;J*^kr3zV{{R<8VdI}`RAPQ>G%-` zeVc-0<&L3&WNj>4d;8>yikD-Pi+yoT>jQH@2yu0DORRy9g&vRUX5smF<9Z*tJVp_> z1{kK5jg#~b4mg8)06Ns&s`y2Vhv$+Q@sG_2iRjo^GDRL4N-(t`?0)3TLApUtEhj6o z|D3LKtpl#~w{?4Gl1uopx~CBX zQCki*(O7o~4eOFj_InvcVx-N3Ds-%)3IJgB=X*2tlJM#3&VyHVX;<{!OxHTHI@y?h zjLdhB&S!c9Yi`U_zMW1jElr_*dAexnzD#ZI^@h{_#$18O^@sXM+C+5}G&qrW(1?GI z_q{&vGP&NFwCbDSv_>AxJGaG5x(w7#I`%N9{xDm`2CK)9_u@HIUA3a0bBX9A((4T? z&29L|f}XZ0&!nhYfJj=zeU0+ftG2!AByHgDxMcyNW*#2N1paqCJx)JAVdo%oxCw0S zJ{A-fhGc$fib(ylh67)pFxdLA|BWU&JtG6#HL$%h44G5Z$#T$$G;X?#aTxvg&$X@{ zfu2Nk!qZb#n-4{n?ie|7*DcuKU~_P+qnJ?qx_Tl*F`B(E13e5UexEaHMw;YfzOkRpr)n?_a6;^ ze(4HR82z`01?R~fGMX9IAo!!J5F2w?8HDP!kvh>kz7#hyGCDmy2hU^{zBa0|y*Sae zF_5{vx07F3m>wF6?F^@pA<24bRFmdIF0bAD$lBT_Ei22?)$%Jygz`8p1sp7T1pNIg z6H6!3d$igGfbxIh*YTDM&onq5PXmmnp>l2@MrffWmR9To#Nj)`*l!G^7lA6%3s#>(!M$prfMltUj>Nv;MaE zT<*p(_Q+c&z5C8Eu$IH~zZ4c{%-6lO6fNFo3%hTZ0&wG^w%VZLzyKK&GilmOSJXK3 zN`5XLIuP4TQvfTP&aQySJnZ`Ww+C;@U>h?4x({ zSTNE^czwN({?Ra~B?`3oJuEXz~vPyUct=P-Lni{?yex^<_lQG#W z7aH|z11gI|v-ILXWDa=dbbf1Qck3=F*%MyoT}@uKiX~IQ{0>N z1}-7SE(P$FGeH&FU$W#xJs)6pyJIWP_1DNuNY43Lq4)Y_>UTIi9pH0USKiZZm+OX} z>z`iE!@&?V1~mlSlnfMaOSM7eh4GS9$>v<&8&^%8|I7l3Z&$<8)6#@zhjUd%Tj$q+ z2e&bq{`)x*WIn+@Cyq@YT6MO?u5s>mXeH~ps#U62GA(cIeCnUgv~Z%*UP&_9#qZE- zIpN=CZByO1$cNc$;!kiYr8Ym7I{Zh1wfun`{yah7tUFy6O+Z24Ma1I?cdIOMjq}P( z&A>;r28YFVOJLGd4kg11L`flh;{X|RelXwH((*J2Gl)jgH9R5VZLB(tjkPsP!q(eI z6b%o~Yj{GR;&!iZ2!x)Wy9?TFS@)%Io}QnLPA+y$zznu`4)VcM07SL208|t9E2 z%C_9~uf{w1%e6pOP?{|1`7%~Y2@uNyOF`lt%(p#`YAgZaR3F=-)ReE!upE+Zp9-WP z8dnXF0p4hIaws7od2BPmHgxV%KA2Z1&ZMB->#%S`a^LK949I8Wif{lJD+w?fo0gP^q?uVfw96d~P?=4o?SRxs~0!X2N(_XHBRqc@z>Vrvt z6GrT`HcTqopeEI|%=BYBAg;{YyCDa7{9Os;m=iVe{e@qlsde)3eflWJ{p4@4=4~2M zoTJqj>Z3R=sm~@CexwNaom~_52MEM* zajk$J_O6UtK|w*_yh`=WoACk%mYAAz6IxnY0h}dsR@0RJB#GYj|6E*7N*|srikJ+j zRvSlRwqf3L$3_$H>ylbnueBDjee6S0Jl`%GMtzP9P~kj{Ccy+@!H}!Qco6i;ez-E| zvgq|q_tH|L)|(WDE?TV3R<$?) zt*UmEtm`g@Y0PhoXF;7fixbl^pybM*=VWd zh>9Kkd%YI!%;XZDdlvZBU?=X4h*^USbRFV9iY_tU_02c*z$TO?sB%&=Ap&OheNLtfXBAwjJpV0~Cui~0 z`UDiuJA8d~1@gmwJN8l?uNHm7Q&LjaXKv3TH1*1UeIEJV^uRD)n?56TV9?#T)#pv8 z7M1!XzIdEQ_eS&X1)L7&#{R{RqA0QjZ+$9*i-E<9H}^%$K;H3nsxwI`ZvMx3B38JK zZ!XtP#W+EU_gJDlyr6XJtG38eBPus0h8kJ*-*`TRltM*lG3r5R!O7v4$X6wmMCp!! z?Ondd-n6vF&~Z!6?GDm;Dbk3p=1gM0wIH7x)U6ip)r-j8woXA6euJHB+tC^`A3kTf-EFMpi68;-6zR&b^28b|2vARiH{^$g0?|>bys?S?$*bXD{sd13s$?9BAwy|M z{;v{RqB~X_O==|sVX2@z@0xP;gVqJ=Y7RFD*? z>-{$|@ve4ahRwN|7w9ZfyOK@9**CV#rMmdL1g4LGX}pl=Y{^-+Z}naev$ESqR8OJc z4e0z~1`8ZFjoid-ci!fiAnT zt1yA*_vptU+B^qSJqo{Tj9mO(skhdA8vwGOd2SJJ{}@5G=N1e|KtSHS7%17e|3 zrBEyPt@zB$Q^l*yoXVq>KKY40)L~xR&A(ay3~vOS>tN@Az;LhVk4Ggn9UV%X;-~}` zMF??8HT?5BDX_CRV6vtkcr|vvzU{m6!?VGH_@XII#`^f}Th&0>%_w<6wRqfJcc$lQ zLf1`b%)Cr3nZ(O{2P!Hm{{XiYsmk|5ScbHg?oAd=BDV38)gq^7u;x@Rjs}}AE|O)P z1sZJSEmq|me~|o-GB4U$C3`&RFDeiR2fX9T>n49H=kEIzp{dtc(?HO7sakfvHK`nX zMsbQ!U`;T5Aqid1I7MUc9bSsxI2KtoG{m?(;-9uPIyONohoF^3oltq#O@uLEe4(;X zn?<5z^Ylv#p7?%U`K@Wyl;uOa`DB-1sT3XkaAC6Ey_f2M&~$VopJ7Ti?-}((bu4~> z+ry>j!B5|}1)|7hcfP5%=9!Uqn9UpPMlXq57-4???^*0F?uUVMEb{+C%s=M5R%@@2 za9AJ1y}xLrfFa@3%uHgPMrsRX2G^-q&LHT|rT)y2fF&RwFu@FoZ<3Gqw+$eE`|9#w zXcv=c*PeS(EjI zjnqXCR1dkjGwuIe{XB3v(TD^IW>HbGjt-)4nU^xv5W8A+-}?qRapnBN)GhVo3BuBH zYt<$Cg~c{ywzl?4`~d>i7$xk1gj+iYWSbHUu3$0zX}IMkVN?a0G)evhJ+4$dK#A$e z?F0MyYm6VjFI0`x>$|Y^_4$4x@o^cCoQu3Ex82lm_P`}1&`0rVVrJ!?I_CXb;^Plb zPM-0Sp1gh)B>4~MtdRQ-F{XZA_OKyeNqUDzc{)1Nb-tP9%(MQ_T|FGSH|XC(Ru4`W zA>JeEGWS=Avg_`CB=giBYA4kB=S6DN!7c*fBT%aM!vg170CEPCVM6~`t<4KR!s~zH zIR^(X%d55-n4_@3OHZQRuz$EOqXFo{Looq`V-ai_+PL?aCwB|56N=!&69ewHtZ}D?{les8%%e1H?oaHJLyf!lMv&{u}5Gr+m}U`@VPjg8y?<@6YVkp zpx08PLT5KIH6!x;%&nQok)a+0LLj*e`Zx)@LlV)^eoq17MJ7)}dfXy<+ADAJ)D?Wa z?`OMZEv!F$SVLr=2zer;?4pBvv}x<8+PXjv1w}HokyV_RCzFwp$%`|a@hXLIYGOh` zOY7mFh@2(pzxMS0fe>a&_JbqrG0=dCiHTwxd%RKBru<6fZyzoH(q88TvCZ(5X;jBb zh80@8C8nT|(AdNRi&BZEHyG{*`MLRExI?I83$#9E^~2P;L4G4px#N@8cry-zPK>;q zb}|s0NU&^i9rx4G(y@o9rn)<0sF@?pTTTC_53jaosw5Mzo zOCPO=d8neiSJG5Fn6qF**7w38rXEd0$5|$Jp=m)Ca|)0>^Ag}mhKW^KwDW@h^6NXMcVW!g^PUO^6Wh~ZbY_sete2s6?L>=0b?%xWs+$qO;Tq+ z%L0Wm6!FBC-L{Z!;6Te)#ZBk2(!!oSJN-PusHL*s(_DW{!c%*a&7L@0w$Fw(q@K+NITog2R*oy~_1NN4i!f36fEb%N?YV2S2&sG0bbo%zDH zR67wM$2x{9a2YvJq;5a^YvkX*ECr0cvzA4Zty+osLsuKLTJ0Gy_E30NE}fQ%LZosF zVj%x;CO<4iM?-UMK977SG2&-xy0K=~ci3YM1IdU@4V=slEf*MPm7 zu6KI@P?Qu*93lXfI=o&qj>F1QEVq|%Ix|CCoSnUFkTFFu0=mLU%>t2xV;Yk{5tYVu ziuAbch^1W<@h>bpdnKRhMUJ-GhP-#__FUPB4Xw=yB0Tri5x7gy;cn``67B+ zb#8BA_T`EggD-Lhfy?nP+p(fg@g~Q{*~lwj9p4tpo}xW$w1qM)9UuW-vh7^$RTyOt zxOKgM^RG2PMVv-s8{uG&zEHBG6P?Bu2@*+Nkw%RlTP&eNa9Y`9X^aZ^IhE6(C+XO- z?`OBG^D$4899{8jho9)(o8Mo=A0D6&^%Gdmn!)7*S5O6GPZJNF9x28?{_K$Eu}K`A<= zXwk9J6}?I|Sabn_n3((m0>F`{dcm*nOE|y{;dYZ&b~8T#mdW1fsCac=ProF7|1RRv zlrvpAB1BIaDx>n}Ogs_9PZ&iuboTpCD)FiUq0RAcI5?J0JdtG+%Jin%>y^=FEtG8+ z7r#xmV_T|Re*UUEX$))XqDJ^Uc z9n?HquW!_)yV2&u$Kl>yg2v3P{_i@xD=|Ce5E}9^xLvF)jyvzlpOv`84o(o=Z9mLi za+7jpU2HLj+pQ=m>kSc`n6a|Ogenhi{5~q81|_!wopvAW_$Lb- zq_UA4o^4~dEcgLD$GS@GsceK?%GOD55pyFk<=~ zl{hEK>rYQK#}_G)GV%Gi3UYpKrl!ZjTUk{pM@M)u`M5lrgJ@YUpseh065-1wb$gyW z@z2{q`?hJYLXA&AfLRL3Cx!4o)Aa+Xx(}Z|sbwVOpz}(#oCz5MN!_Cdb~@C*Ai3F5 zx%%^SJs%oQT43PFkQYMg#TQD(Aq3bX#99E8whZ+(b7r^HG5r4b<2u~($y&&soZcjgM-dRe-I9Lf_?*K#(hD=KB({#?j}l8E#;9 zI;rX!P|)9AC|(XV-(}9s&6P7Syd_74EiYH0c*MmGf%i_W)y z{xU=C%opJ=1qz!fE!3Vcry((2U4XKkrd;?1SJ!5;um16m9kA_`w%LMb=}5CnTyiDR z)4cwG;VS0tu7bYudhfEoq6gO`@ON?XEi@GSwwh;S9c^Kkk>MB$nxkr#YBPTIl_3Gn zQ3F-qyLNiNq=pH{ZrkmyxuDKP{4S;LA|FRSaDWL6c1Q_=a6T^n^cmI{aLd>1kGu^E zHE(w}Qmo6M$7RhokON)6OI=b>VC<-z)Z(rFOn0f;C{>AEKH$`0b|OUp7ql@e193xq z3+BdfUR770)wSIcAH!1yYU#V8=H^}e+uQ6R<^*Lu$U#67a$CxPaw9WW}|vmFk$g7HDt9od##FRt-7b`)sfbenEJ zD{3-6Hl}4MDVN5KHD0{0zo8PfJL{n`k9zDcX%K`tcfL<82m%_E52eDQVCNq#IsHU( z{afimbCY;dDYp(K=%;3=*Jd^?4W*$wuLe!m&tJ7pLygGehYr5f;~&Q#MTLCRkMXl{ zbi5f#XT9^Xgx1qiQK1ZUeBEif;_Rk=Kf9O^YI@YXp;Ng`ZGyr!vNuv;A&S7QikpVJ z;DpHj^{%8`t$E!7A|)#j?1Xf$41s9rbm&iNrp_6CqpnS~DFK&`KrVmLsVU$HbCG9m zjU3P0W%SbOGS`VLiDBNBD1#f%t81Z8AWrns^7o&V65gca4Q=LDs6f*gmjMN^AcV`* zGLcibDd_5;^e3UDAjQVPuK60mCksAKUET5UtHo8AgWn52z8a@juXsw{t$Gj=$DxEq zB_FU}db!{JZE5KaB9Um&hxyfiuaOXTUMsm?7P%`0(1fFK2Am@8geD?%1+Oa zBO@a+G>QT6y1V*`DzEpOKUl*G71H*HvirH4d7%j{Lc`*-ayVh{I!cOdM=a>!`GxXa8 zcPrZK1l9JloruAHRTJN?lbqGN%y@4RTlSmM;{Q=5<1&xujj2*3&SwI4-1P*a9*f|ASK zn^(ksljPJ?bi$5HQ-oQl*L`C|;7sO=o|H_a^Z zpnzSoW&{?#A$`m+wbu|8_rE$g@5afvPN((NFybLgc%#6IXpjCaz+@z~O23+TZ5N8o z6-%BdL0}T=X^xvTfGDAnaQ-SZcd2kL0q6Pzfe^AM?_7g<K@)8zb}7 z-^zZNM^NxF^6cyZ!e>(K@-GDn?V;Aga9pS|MCFCl^_WyK{qTf_+GPyiC4;VvB^7k%0;86IDQH^!diE zlfHMhHq{jm>2xKOIO0&?hX*kM&8+UP*l1k!Wb4#)tXhqwwKXQN_OcRocp{#Y|EEfq zyLLacK($MZnCMK<$J(nZm#24^_*i1%;#bQ+(Q_zMKy2^q`26ke4)^c#|BR2?7V;ao z8RbHe6*@;a!=vvjzle?&%EEtAC(ROpIik}kiYd(Zj}HU0-8S`GSRfK@Z>p+PEOPGM zFy}(elb@I$9#CoHkv*^EdHvv2xH}CutX!b&tu`GURR)!b>tKIXnmpYgiKr>e%F8-5 z;s{UBVL`Q4UqS}+aN%L-GaxtbOC7n>`5ZPl>FVlglxp9dC8~7#eRg!r3p(P^IKc)r$7``tw>M0VLjJ6YMr+Kd-Yzyt`OOp0_uLZC-P^o@^Oyp8N4Vp7{;qx(yD8|$ zOH)mMNoiinLpUjhN*(XR1{C`j{4Ry19)Yq2MjB+Qo=V^xaY zD;YTdk%YoYP4!7Yp0r;2s>?|ggl_a#-C>@SVvC?0LbP2(i13d&2({3PV|mNT&0Ast z??6Bp(^RBcdgtllqNVXNd2Z-tZD?z$C7@tF^y=zL(3-IgT$>#<2^a79;jlR3q!T;c z!pkjOk`VWC^>;>Q#^qoT@u5NUk-^OH)VBc9u3fM{(VH62*GuhHyn(5O=P%%1LB6V;CtyLFj0X`Iv0fUd$SGdta0k*unsDfLe1r0ez!sZ z_jSAU8T6t@jUFC$H^&!TyqsPBjj^lql#+(VTbEp3>Ju!%@MZBrnM%-OA>fev|1bIl zXjA@txkEhs{N(@zXYPLhAc2!f|6Ah&Af%xDy^)ns8IXHKPb)etuKq;xSm|7|QnE*4n7^ z1X7&-agk1w;E*Ik8nKG=$iL2jg;PHu#nr%wwAa7OG~B0r#) zq~?=;sEe)=785Hpk@7R|mEUp|eeYY-JECg&hd$d@H^619_US?=N1g|+6Fd?w%`T2(yRPXf-WiyJ%Lh~La=r$T7u#yBU`7osQ zHjH`uh!U6~_DS#&_E-pdNdR~)DO%C&?X_YsGTTL1mFRM{*yG~ZlP_-d$?*b$NvS@R z!SFP22q}vG^wtmcH9eQFPq5|>Yldh*ET*nP*~eR9wD0cT?oZ+`xe3HgTTf2#@i3|v zHiu&4a29!TIGY=WJ0umZur#Es1X~^_Fe7f0mL0>nUE4YEA2wYC`lG*Cw=9IlFDza4 zpr>tbZ%m4BPBL65SiZ?QQAjMnFL575_`~UP^1s%{y(_!?`I-p(v{t=Tu}y~Jk7k_l eWk;T-kRis}o{LMar2rlp_)(HqldF(5{qlcA))!a+ literal 0 HcmV?d00001 diff --git a/worms-pub/worms.js b/worms-pub/worms.js new file mode 100644 index 0000000..b92e44c --- /dev/null +++ b/worms-pub/worms.js @@ -0,0 +1,3218 @@ +// GrechkaCraft — auto-bundled from ES modules +(function() { +"use strict"; + +// === core/constants.js === +// ==================== КОНСТАНТЫ ИГРЫ ==================== + +const TILE = 40; + +// Мир +const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов +const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже +const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки + +// Физика +const GRAV = 2200; +const GRAV_WATER = 550; +const MOVE = 320; +const JUMP = 760; + +// День/ночь +const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза + +// Сохранение +const SAVE_KEY = 'minegrechka_save'; + +// Сеть +const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду + +// Погода +const MAX_RAINDROPS = 200; + +// Чат +const MAX_CHAT_MESSAGES = 50; + +// Голосовой чат +const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru'; + +// === core/state.js === +// Все мутабельные переменные игры в одном объекте состояния +const state = { + // Камера + camX: 0, + camY: 0, + + // День/ночь + worldTime: 0, + isNightTime: false, + + // Мультиплеер + isMultiplayer: false, + mySocketId: null, + socket: null, + + // Инвентарь/UI + selected: 0, + showFullInventory: false, + craftOpen: false, + inventoryOpen: false, + chatOpen: false, + modeIdx: 0, + + // Мир + worldSeed: Math.floor(Math.random() * 1000000), + + // Погода + isRaining: false, + rainIntensity: 0, + weatherTimer: 0, + weatherChangeInterval: 60 + Math.random() * 120, + + // Мобы/спавн + spawnT: 0, + + // Цикл + last: 0, + prevJump: false, + + // Сеть — throttle отправки позиции + lastMoveSendTime: 0, + lastSentX: 0, + lastSentY: 0, + + // Игрок + player: { + x: 6 * TILE, + y: 0 * TILE, + w: 34, + h: 34, + vx: 0, + vy: 0, + grounded: false, + inWater: false, + headInWater: false, + hp: 100, + hunger: 100, + o2: 100, + invuln: 0, + fallStartY: 0, + lastStepTime: 0, + sleeping: false, + inBoat: false, + armor: 0, + equippedArmor: null + }, + + // Точка спавна + spawnPoint: { x: 6 * TILE, y: 0 * TILE }, + + // Инвентарь + inv: { + dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0, + wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0, + copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0, + brick: 0, glass: 0, + tnt: 1, campfire: 0, torch: 0, + meat: 0, cooked: 0, arrow: 0, + wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0, + wood_sword: 0, stone_sword: 0, iron_sword: 0, + iron_armor: 0, + bow: 0, furnace: 0, + bed: 0, boat: 0, + iron_ingot: 0, gold_ingot: 0, copper_ingot: 0 + }, + + // Лодка + boat: { + x: 0, y: 0, + w: 34, h: 34, + vx: 0, vy: 0, + active: false, + inWater: false + }, + + // Ввод + inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false }, + + // Мышь + mouse: { x: 0, y: 0 }, + + // Другие игроки (MP) + otherPlayers: new Map(), + + // Серверные мобы (MP) + serverMobs: new Map(), + + // Мобы + mobs: [], + + // Снаряды + projectiles: [], + + // Отслеживание изменений мира + placedBlocks: [], + removedBlocks: [], + + // Серверные изменения + serverOverrides: new Map(), + + // Чат + chatMessages: [], + + // Погода — капли + raindrops: [], + + // Облака + clouds: Array.from({ length: 10 }, () => ({ + x: Math.random() * 2000, + y: -200 - Math.random() * 260, + w: 80 + Math.random() * 120, + s: 12 + Math.random() * 20 + })), + + // Частицы + parts: [], + + // Активный TNT + activeTNT: new Set(), + + // Прочность инструментов + toolDurability: new Map(), + + // Последние выбранные предметы + recentItems: [], + + // Активные печи + activeFurnaces: new Map(), + + // Сгенерированные колонны + generated: new Set(), + + // Изображение героя + heroImg: null +}; + +// === core/canvas.js === +// ==================== ХОЛСТ И РАЗМЕРЫ ==================== + +const gameEl = document.getElementById('game'); +const canvas = document.getElementById('c'); +const ctx = canvas.getContext('2d'); + +// offscreen light map (не вставляем в DOM) +const lightC = document.createElement('canvas'); +const lightCtx = lightC.getContext('2d'); + +const dpr = Math.max(1, window.devicePixelRatio || 1); +let W = 0, H = 0; + +function resize() { + W = gameEl.clientWidth; + H = gameEl.clientHeight; + canvas.width = W * dpr; + canvas.height = H * dpr; + lightC.width = W * dpr; + lightC.height = H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); +} + +function setW(v) { W = v; } +function setH(v) { H = v; } + +// === data/blocks.js === +const BLOCKS = { + air: { n:'Воздух', solid:false }, + grass: { n:'Трава', c:'#7cfc00', solid:true }, + dirt: { n:'Грязь', c:'#8b4513', solid:true }, + stone: { n:'Камень', c:'#7f8c8d', solid:true }, + sand: { n:'Песок', c:'#f4d06f', solid:true }, + gravel: { n:'Гравий', c:'#95a5a6', solid:true }, + clay: { n:'Глина', c:'#74b9ff', solid:true }, + wood: { n:'Дерево', c:'#d35400', solid:true }, + planks: { n:'Доски', c:'#e67e22', solid:true }, + ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true }, + leaves: { n:'Листва', c:'#2ecc71', solid:true }, + glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 }, + water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true }, + coal: { n:'Уголь', c:'#2c3e50', solid:true }, + copper_ore:{ n:'Медь', c:'#e17055', solid:true }, + iron_ore: { n:'Железо', c:'#dcdde1', solid:true }, + iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 }, + gold_ore: { n:'Золото', c:'#f1c40f', solid:true }, + diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true }, + brick: { n:'Кирпич', c:'#c0392b', solid:true }, + tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true }, + campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 }, + torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 }, + bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true }, + flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true }, + bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true }, + boat: { n:'Лодка', c:'#8B4513', solid:false }, + furnace: { n:'Печь', c:'#696969', solid:true, smelting:true } +}; + +// === world/world-storage.js === +// Мир-хранилище +const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...} +const blocks = []; // для рендера/перебора видимых + +function k(gx, gy) { return gx + ',' + gy; } + +function getBlock(gx, gy) { return grid.get(k(gx, gy)); } + +function hasBlock(gx, gy) { return grid.has(k(gx, gy)); } + +function isSolid(gx, gy) { + const b = getBlock(gx, gy); + if (!b || b.dead) return false; + const def = BLOCKS[b.t]; + return !!def.solid && !def.fluid && !def.decor; +} + +function setBlock(gx, gy, t, isPlayerPlaced = false) { + const key = k(gx, gy); + if (grid.has(key)) return false; + const b = { gx, gy, t, dead: false, active: false, fuse: 0 }; + grid.set(key, b); + blocks.push(b); + + // Отслеживаем блоки, установленные игроком + if (isPlayerPlaced) { + state.placedBlocks.push({ gx, gy, t }); + } + + return true; +} + +function removeBlock(gx, gy) { + const key = k(gx, gy); + const b = grid.get(key); + if (!b) return null; + if (BLOCKS[b.t].unbreakable) return null; + grid.delete(key); + b.dead = true; + + // Отслеживаем удалённые блоки + const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy); + if (wasPlayerPlaced) { + // Удаляем из placedBlocks + state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy)); + } else { + // Это природный блок - добавляем в removedBlocks + state.removedBlocks.push({ gx, gy }); + } + + return b; +} + +// === world/generation.js === +// Генерация (по X, на всю глубину до bedrock) +const generated = state.generated; // Set of gx already generated + +function surfaceGyAt(gx) { + // базовая поверхность выше уровня воды с вариациями + "горы" + // Используем seed для детерминированной генерации + // Увеличили амплитуду и добавили больше частот для разнообразия + const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; // крупные горы + const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; // средние горы + const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; // мелкие холмы + const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; // детали + const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; // дополнительные вариации + const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше + return h; +} + +function genColumn(gx) { + if (generated.has(gx)) return; + generated.add(gx); + + const sgy = surfaceGyAt(gx); + + // вода (если поверхность ниже уровня моря => sgy > SEA_GY) + if (sgy > SEA_GY) { + for (let gy = SEA_GY; gy < sgy; gy++) { + setBlock(gx, gy, 'water'); + } + // пляж + setBlock(gx, sgy, 'sand'); + } else { + // верхний блок: снег на высоких точках + if (sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone'); + else setBlock(gx, sgy, 'grass'); + } + + // подповерхностные слои + for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) { + if (gy === BEDROCK_GY) { + setBlock(gx, gy, 'bedrock'); + continue; + } + + let t = 'stone'; + + // ближе к поверхности + if (gy <= sgy + 3) t = 'dirt'; + + // биомы/материалы + if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = 'clay'; + if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = 'gravel'; + + // руды: чем глубже, тем интереснее + const depth = gy - sgy; + const r = seededRandom(gx, gy); + if (t === 'stone') { + if (r < 0.06) t = 'coal'; + else if (r < 0.10) t = 'copper_ore'; + else if (r < 0.13) t = 'iron_ore'; + else if (depth > 40 && r < 0.145) t = 'gold_ore'; + else if (depth > 70 && r < 0.152) t = 'diamond_ore'; + } + + setBlock(gx, gy, t); + } + + // Деревья и цветы (только на траве, и не в воде) + const top = getBlock(gx, sgy); + if (top && top.t === 'grass') { + if (seededRandom(gx, sgy - 1) < 0.10) { + setBlock(gx, sgy - 1, 'flower'); + } + if (seededRandom(gx, sgy - 2) < 0.12) { + // простое дерево + setBlock(gx, sgy - 1, 'wood'); + setBlock(gx, sgy - 2, 'wood'); + setBlock(gx, sgy - 3, 'leaves'); + setBlock(gx - 1, sgy - 3, 'leaves'); + setBlock(gx + 1, sgy - 3, 'leaves'); + } + } + + // Применяем серверные оверрайды для этой колонны + const colPrefix = gx + ','; + for (const [key, ov] of state.serverOverrides) { + if (!key.startsWith(colPrefix)) continue; + if (ov.op === 'remove') { + const b = grid.get(key); + if (b) { grid.delete(key); b.dead = true; } + } else if (ov.op === 'set') { + if (!grid.has(key)) { + const gy = parseInt(key.split(',')[1]); + const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 }; + grid.set(key, nb); + blocks.push(nb); + } + } + } +} + +// Перегенерация видимых чанков (используется при загрузке сохранения) +function regenerateVisibleChunks() { + const gx0 = Math.floor(state.camX / TILE); + for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) { + // Принудительно перегенерируем колонну + generated.delete(gx); + genColumn(gx); + } +} + +function ensureGenAroundCamera() { + const gx0 = Math.floor(state.camX / TILE); + for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) { + genColumn(gx); + } +} + +function seededRandom(gx, gy) { + const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453; + return n - Math.floor(n); +} + +// === render/textures.js === +// Текстуры блоков (простые) + + +const tex = {}; + +function makeTex(type) { + const t = BLOCKS[type]; + const c = document.createElement('canvas'); + c.width = 32; c.height = 32; + const g = c.getContext('2d'); + + if (type === 'tnt') { + g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32); + g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8); + g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20); + return c; + } + if (type === 'campfire') { + g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6); + g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4); + return c; + } + if (type === 'torch') { + g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18); + g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8); + return c; + } + if (type === 'glass') { + g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32); + g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28); + g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke(); + return c; + } + if (type === 'water') { + g.fillStyle = t.c; g.fillRect(0, 0, 32, 32); + g.fillStyle = 'rgba(255,255,255,0.08)'; + g.fillRect(0, 6, 32, 2); + return c; + } + if (type === 'bed') { + // Основание кровати + g.fillStyle = '#e91e63'; + g.fillRect(0, 0, 32, 32); + // Подушка + g.fillStyle = '#f8bbd0'; + g.fillRect(2, 2, 14, 14); + // Одеяло + g.fillStyle = '#c2185b'; + g.fillRect(16, 4, 14, 24); + // Детали одеяла + g.fillStyle = '#e91e63'; + g.fillRect(18, 6, 10, 20); + return c; + } + if (type === 'flower') { + g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18); + g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill(); + return c; + } + if (type === 'boat') { + // Корпус лодки + g.fillStyle = '#8B4513'; + g.fillRect(2, 12, 28, 8); + // Борта + g.fillStyle = '#A0522D'; + g.fillRect(0, 10, 32, 12); + // Внутренность + g.fillStyle = '#DEB887'; + g.fillRect(4, 14, 24, 4); + // Дно + g.fillStyle = '#654321'; + g.fillRect(2, 20, 28, 4); + return c; + } + if (type === 'ladder') { + // Боковые стойки лестницы + g.fillStyle = '#8B4513'; + g.fillRect(4, 0, 4, 32); + g.fillRect(24, 0, 4, 32); + // Ступени + g.fillStyle = '#A0522D'; + g.fillRect(4, 4, 24, 3); + g.fillRect(4, 12, 24, 3); + g.fillRect(4, 20, 24, 3); + g.fillRect(4, 28, 24, 3); + return c; + } + + g.fillStyle = t.c || '#000'; + g.fillRect(0, 0, 32, 32); + + g.fillStyle = 'rgba(0,0,0,0.10)'; + for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4); + + if (type.endsWith('_ore') || type === 'coal') { + g.fillStyle = 'rgba(0,0,0,0.35)'; + for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6); + } + return c; +} + +function initTextures() { + Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k)); +} + +// === audio/sound-engine.js === +// Звуковой движок +const sounds = {}; + + + +function loadSound(id, src) { + const audio = new Audio(); + audio.src = src; + audio.volume = 0.3; + sounds[id] = audio; +} + +// Загрузка звуков +loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3'); +loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3'); +loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3'); +loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3'); +loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3'); +loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3'); +loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3'); +loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3'); +loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3'); +loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3'); +loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3'); +loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3'); +loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3'); +loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3'); +loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3'); +loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3'); +loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3'); + +function playSound(id) { + if (sounds[id]) { + sounds[id].currentTime = 0; + sounds[id].play().catch(e => console.error('Sound error:', e)); + } +} + +// === worms/sim.js === +// Worms-Grechka: абстрактная симуляция боя. Ноль импортов, ноль DOM. +// Один и тот же файл едет в клиент (хотсит) и в node-сервер (авторитет). +// Сетка: адаптер { isSolid(gx,gy), isFluid(gx,gy), unbreakable(gx,gy), +// remove(gx,gy)->bool, waterYPx, xMinPx, xMaxPx, killYPx }. +const STILE = 40; +const SGRAV = 2200; // как в Гречке +const SMOVE = 320; +const SJUMP = 760; +const WORM_W = 34, WORM_H = 34; + +const WEAPONS = { + bazooka: { name: 'Базука', kind: 'aimed', power: true, windK: 1.0, dmg: 45, blast: 2.3, ammo: Infinity }, + homing: { name: 'Самонавод', kind: 'target', power: false, windK: 0, dmg: 50, blast: 2.3, ammo: 2 }, + sheep: { name: 'Овца', kind: 'sheep', power: false, windK: 0, dmg: 55, blast: 2.6, ammo: 1 }, + grenade: { name: 'Граната', kind: 'thrown', power: true, windK: 0.25, dmg: 40, blast: 2.1, fuse: [1, 2, 3, 4, 5], ammo: Infinity }, + cluster: { name: 'Кластер', kind: 'thrown', power: true, windK: 0.5, dmg: 26, blast: 1.5, fuse: [2, 3, 4], ammo: 3 }, + shotgun: { name: 'Дробовик', kind: 'hitscan', shots: 2, windK: 0, dmg: 6, pellets: 5, ammo: 3 }, + uzi: { name: 'Узи', kind: 'spray', windK: 0, dmg: 4, pellets: 12, ammo: 3 }, + dynamite: { name: 'Динамит', kind: 'drop', windK: 0, dmg: 70, blast: 3.6, fuse: 5, ammo: 2 }, + mine: { name: 'Мина', kind: 'drop', windK: 0, dmg: 45, blast: 2.2, ammo: 3 }, + airstrike:{ name: 'Авиаудар', kind: 'target', windK: 0, dmg: 35, blast: 1.7, ammo: 2 }, + teleport: { name: 'Телепорт', kind: 'teleport', windK: 0, ammo: 2 }, + shovel: { name: 'Лопата', kind: 'dig', windK: 0, ammo: Infinity }, + skip: { name: 'Пропуск', kind: 'skip', windK: 0, ammo: Infinity }, +}; + +const TURN_TIME = 30; +const RETREAT_TIME = 3; +const PROJ_GRAV = 950; // снаряды более «плавучие», чем червяки +const WIND_ACCEL = 55; // px/s^2 на единицу ветра +const MAX_WIND = 5; + +function mulberry(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +class Sim { + constructor(grid, seed) { + this.g = grid; + this.rand = mulberry(seed >>> 0); + this.worms = []; + this.proj = []; + this.mines = []; + this.dyn = []; + this.strikes = []; + this.events = []; + this.wind = 0; + this._wid = 1; this._pid = 1; + } + ev(e) { this.events.push(e); } + drainEvents() { const e = this.events; this.events = []; return e; } + + addWorm(team, name, x, y) { + const w = { + id: this._wid++, team, name, x, y, w: WORM_W, h: WORM_H, + vx: 0, vy: 0, dir: team === 0 ? 1 : -1, + hp: 100, alive: true, grounded: false, moveDir: 0, fallStartY: y, + inWater: false, o2: 100, o2acc: 0, + }; + this.worms.push(w); + return w; + } + wormById(id) { return this.worms.find((w) => w.id === id); } + aliveTeams() { const s = new Set(); for (const w of this.worms) if (w.alive) s.add(w.team); return [...s]; } + + // --- тайловая AABB-физика в стиле Гречки --- + solidBox(x, y, w, h) { + const g = this.g; + const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE); + const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE); + for (let gy = gy0; gy <= gy1; gy++) + for (let gx = gx0; gx <= gx1; gx++) + if (g.isSolid(gx, gy)) return true; + return false; + } + fluidBox(x, y, w, h) { + const g = this.g; + const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE); + const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE); + for (let gy = gy0; gy <= gy1; gy++) + for (let gx = gx0; gx <= gx1; gx++) + if (g.isFluid(gx, gy)) return true; + return false; + } + + setMove(id, dir) { const w = this.wormById(id); if (w && w.alive) { w.moveDir = dir; if (dir) w.dir = dir; } } + jump(id) { + const w = this.wormById(id); + if (w && w.alive && (w.grounded || w.inWater)) { + w.vy = w.inWater ? -SJUMP * 0.55 : -SJUMP; // в воде — гребок вверх + w.grounded = false; + } + } + + hurt(w, dmg, cause) { + if (!w.alive) return; + w.hp -= dmg; + this.ev({ t: 'damage', worm: w.id, dmg: Math.round(dmg), hp: Math.max(0, Math.round(w.hp)) }); + if (w.hp <= 0) this.kill(w, cause); + } + kill(w, cause) { + if (!w.alive) return; + w.alive = false; w.hp = 0; w.moveDir = 0; + this.ev({ t: 'die', worm: w.id, team: w.team, cause: cause || 'killed', x: Math.round(w.x), y: Math.round(w.y) }); + } + + explodePx(x, y, dmg, blastTiles, cause) { + const g = this.g; + const R = blastTiles * STILE; + // разрушение: круг по тайлам (жидкости и неразрушаемое не трогаем) + const removed = []; + const gx0 = Math.floor((x - R) / STILE), gx1 = Math.floor((x + R) / STILE); + const gy0 = Math.floor((y - R) / STILE), gy1 = Math.floor((y + R) / STILE); + for (let gy = gy0; gy <= gy1; gy++) + for (let gx = gx0; gx <= gx1; gx++) { + const cx = gx * STILE + STILE / 2, cy = gy * STILE + STILE / 2; + if (Math.hypot(cx - x, cy - y) > R) continue; + // взрыв на суше не роет ниже уровня моря — в воду ничего не падает + if (y < g.waterYPx && gy * STILE > g.waterYPx) continue; + if (g.isFluid(gx, gy) || g.unbreakable(gx, gy)) continue; + if (g.remove(gx, gy)) removed.push([gx, gy]); + } + this.ev({ t: 'carve', x: Math.round(x), y: Math.round(y), r: Math.round(R), tiles: removed }); + this.ev({ t: 'explode', x: Math.round(x), y: Math.round(y), r: Math.round(R), dmg }); + // урон + отброс + for (const w of this.worms) { + if (!w.alive) continue; + const dx = (w.x + w.w / 2) - x, dy = (w.y + w.h / 2) - y; + const dist = Math.hypot(dx, dy); + if (dist < R + 60) { + const dd = Math.max(1, Math.round(dmg * (1 - dist / (R + 60)))); + this.hurt(w, dd, cause || 'boom'); + // отброс с ограничением: серия взрывов не катапультирует через полкарты в воду + w.vx = Math.max(-450, Math.min(450, w.vx + (dx / (dist || 1)) * 620)); + w.vy = Math.max(-450, w.vy - 330); + w.grounded = false; + } + } + // цепные реакции + for (const m of this.mines) + if (m.fuse <= 0 && Math.hypot(m.x - x, m.y - y) < R) { m.fuse = 0.25; m.armed = 1; } + for (const d of this.dyn) + if (Math.hypot(d.x - x, d.y - y) < R) d.fuse = Math.min(d.fuse, 0.4); + for (const p of this.proj) + if ((p.kind === 'grenade' || p.kind === 'cluster') && Math.hypot(p.x - x, p.y - y) < R) + p.fuse = Math.min(p.fuse, 0.05); + } + + fire(wormId, weaponId, p) { + const w = this.wormById(wormId); + const def = WEAPONS[weaponId]; + if (!w || !w.alive || !def) return false; + p = p || {}; + const ang = p.angle || 0; + const pow = Math.max(0, Math.min(1, p.power == null ? 0.5 : p.power)); + const cx = w.x + w.w / 2, cy = w.y + w.h / 2; + const mx = cx + Math.cos(ang) * 26, my = cy - 6 + Math.sin(ang) * 26; + const P = (kind, x, y, vx, vy, extra) => Object.assign({ id: this._pid++, kind, x, y, vx, vy, age: 0 }, extra || {}); + switch (def.kind) { + case 'aimed': { + const sp = 500 + pow * 1000; + this.proj.push(P(weaponId === 'homing' ? 'homing' : 'missile', mx, my, Math.cos(ang) * sp, Math.sin(ang) * sp, + { dmg: def.dmg, blast: def.blast, windK: def.windK, owner: w.id, team: w.team, life: weaponId === 'homing' ? 5 : 0 })); + break; + } + case 'sheep': { + // овцу выпускаем у ног — бежит сама, прыгает через уступы, разворачивается у стен + this.proj.push(P('sheep', cx + w.dir * 24, cy + 10, w.dir * 60, -60, + { dmg: def.dmg, blast: def.blast, fuse: 4, owner: w.id, team: w.team, dir: w.dir })); + break; + } + case 'thrown': { + const sp = 300 + pow * 800; + const fuse = p.fuse || (def.fuse ? def.fuse[2] : 3); + this.proj.push(P(weaponId === 'cluster' ? 'cluster' : 'grenade', cx, cy - 20, + w.dir * 60 + Math.cos(ang) * sp * 0.7, Math.sin(ang) * sp - 140, + { dmg: def.dmg, blast: def.blast, windK: def.windK, fuse, owner: w.id, team: w.team })); + break; + } + case 'hitscan': { + for (let i = 0; i < def.pellets; i++) { + const a = ang + (this.rand() - 0.5) * 0.14; + this.proj.push(P('pellet', mx, my, Math.cos(a) * 1500, Math.sin(a) * 1500, + { dmg: def.dmg, life: 1.6, owner: w.id, team: w.team })); + } + break; + } + case 'spray': { + for (let i = 0; i < def.pellets; i++) { + const a = ang + (this.rand() - 0.5) * 0.26; + const sp = 1250 + this.rand() * 250; + this.proj.push(P('pellet', mx, my, Math.cos(a) * sp, Math.sin(a) * sp, + { dmg: def.dmg, life: 1.4, owner: w.id, team: w.team })); + } + break; + } + case 'drop': { + if (weaponId === 'dynamite') this.dyn.push({ x: cx + w.dir * 30, y: cy, fuse: def.fuse }); + else this.mines.push({ x: cx + w.dir * 30, y: cy, team: w.team, armed: 0, fuse: 0 }); + break; + } + case 'target': { + const tx = Math.max(this.g.xMinPx + 30, Math.min(this.g.xMaxPx - 30, p.x || (cx + w.dir * 200))); + if (weaponId === 'homing') { + // самонавод: ракета вылетает и летит в отмеченную крестиком точку. + // стартуем из первой свободной клетки вдоль прицела — иначе нос в стене = взрыв в упор + const tgtY = p.y == null ? cy : p.y; + const a0 = Math.atan2(tgtY - my, tx - mx); + let hx = mx, hy = my; + for (let d = 8; d <= 80; d += 8) { + const qx = mx + Math.cos(a0) * d, qy = my + Math.sin(a0) * d; + if (!this.pointSolid(qx, qy)) { hx = qx; hy = qy; break; } + } + this.proj.push(P('homing', hx, hy, Math.cos(a0) * 950, Math.sin(a0) * 950, + { dmg: def.dmg, blast: def.blast, windK: 0, owner: w.id, team: w.team, life: 6, tx, ty: tgtY })); + } else { + this.strikes.push({ x: tx, t: 0.8, left: 5, next: 0, dir: this.rand() < 0.5 ? -1 : 1, px: 0, py: 0, started: false, team: w.team }); + } + this.ev({ t: 'strike_incoming', x: Math.round(tx) }); + break; + } + case 'teleport': { + const tx = Math.max(this.g.xMinPx + 20, Math.min(this.g.xMaxPx - 20, p.x || cx)); + // ищем землю под точкой + const gy = this.g.groundBelowPx(tx, p.y == null ? 0 : p.y); + if (gy !== null) { + w.x = tx - w.w / 2; w.y = gy - w.h - 1; w.vx = 0; w.vy = 0; + this.ev({ t: 'teleport', worm: w.id, x: Math.round(w.x), y: Math.round(w.y) }); + } + break; + } + case 'dig': { + // лопата: рейкаст вдоль прицела — копаем первую твёрдую землю (вперёд/назад/вниз) + let hx = null, hy = null; + const dx = Math.cos(ang), dy = Math.sin(ang); + for (let d = 8; d <= 95 && hx == null; d += 8) { + const gx = Math.floor((cx + dx * d) / STILE), gy = Math.floor((cy + dy * d) / STILE); + if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue; + if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { hx = gx; hy = gy; } + } + // не зацепили — опускаемся от точки прицела вниз до земли + let px = cx + dx * 52, py = cy + dy * 52; + if (hx == null) { + for (let dd = 0; dd <= 120; dd += 10) { + const gx = Math.floor(px / STILE), gy = Math.floor((py + dd) / STILE); + if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue; + if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { px = gx * STILE + STILE / 2; py = gy * STILE + STILE / 2; hx = gx; hy = gy; break; } + } + if (hx == null) return false; // рядом нет земли — пустой взмах + } else { px = hx * STILE + STILE / 2; py = hy * STILE + STILE / 2; } + const R = STILE * 1.6; // тоннели просторные — внутри можно ходить и прыгать + let dug = 0; + const gx0 = Math.floor((px - R) / STILE), gx1 = Math.floor((px + R) / STILE); + const gy0 = Math.floor((py - R) / STILE), gy1 = Math.floor((py + R) / STILE); + for (let gy = gy0; gy <= gy1; gy++) + for (let gx = gx0; gx <= gx1; gx++) { + const bx = gx * STILE + STILE / 2, by = gy * STILE + STILE / 2; + if (Math.hypot(bx - px, by - py) > R) continue; + if (this.g.isFluid(gx, gy) || this.g.unbreakable(gx, gy)) continue; + if (this.g.remove(gx, gy)) dug++; + } + if (dug > 0) this.ev({ t: 'dig', x: Math.round(px), y: Math.round(py) }); + return dug > 0; + } + case 'skip': break; + } + w.moveDir = 0; + this.ev({ t: 'fired', worm: w.id, weapon: weaponId }); + return true; + } + + step(dt) { + const g = this.g; + // --- червяки --- + for (const w of this.worms) { + if (!w.alive) continue; + const cx = w.x + w.w / 2, cy = w.y + w.h / 2; + // вода: можно плыть (как в Гречке), смерть только от нехватки кислорода + const wasWater = w.inWater; + w.inWater = this.fluidBox(w.x + 4, w.y + 4, w.w - 8, w.h - 8); + if (w.inWater && !wasWater) this.ev({ t: 'splash', x: Math.round(cx) }); + const headWet = this.fluidBox(w.x + 8, w.y, w.w - 16, 14); + if (headWet) { + w.o2 = Math.max(0, w.o2 - 14 * dt); + if (w.o2 <= 0) { + w.o2acc += 12 * dt; + if (w.o2acc >= 5) { w.o2acc -= 5; this.hurt(w, 5, 'drown'); } + } + } else w.o2 = Math.min(100, w.o2 + 50 * dt); + if (w.y > g.killYPx) { this.kill(w, 'out'); continue; } + // горизонталь (в воде медленнее) + const spd = w.inWater ? SMOVE * 0.5 : SMOVE; + w.vx = w.moveDir * spd; + if (w.vx !== 0) { + let nx = w.x + w.vx * dt; + nx = Math.max(g.xMinPx, Math.min(g.xMaxPx - w.w, nx)); + if (!this.solidBox(nx, w.y + 2, w.w, w.h - 2)) w.x = nx; + else { + // подъём по склону до 1 тайла + let stepped = false; + for (let k = 8; k <= STILE; k += 8) { + if (!this.solidBox(nx, w.y - k, w.w, w.h)) { w.x = nx; w.y -= k; stepped = true; break; } + } + if (!stepped) w.vx = 0; + } + } + // вертикаль (в воде — плавучесть как в Гречке) + w.vy += (w.inWater ? 550 : SGRAV) * dt; + if (w.inWater && w.vy > 260) w.vy = 260; + if (w.vy > 1600) w.vy = 1600; + let ny = w.y + w.vy * dt; + if (w.vy >= 0) { + // опора под ногами + if (this.solidBox(w.x + 2, ny + w.h - 6, w.w - 4, 8)) { + const gy = Math.floor((ny + w.h) / STILE); + w.y = gy * STILE - w.h; + const fallTiles = (w.y - w.fallStartY) / STILE; + if (fallTiles > 6 && !this.fluidBox(w.x, w.y, w.w, w.h)) { console.log('[DBG] fall worm=' + w.id + ' tiles=' + fallTiles.toFixed(1)); this.hurt(w, (fallTiles - 6) * 10, 'fall'); } + w.vy = 0; w.grounded = true; w.fallStartY = w.y; + } else { w.y = ny; w.grounded = false; } + } else { + if (this.solidBox(w.x + 2, ny, w.w - 4, 8)) { w.vy = 0; } + else { w.y = ny; w.grounded = false; } + } + if (!w.grounded && this.solidBox(w.x + 2, w.y + w.h, w.w - 4, 3)) { + w.grounded = true; w.vy = 0; w.fallStartY = w.y; + } + } + // --- мины --- + for (const m of this.mines) { + if (m.armed < 1 && m.fuse <= 0) m.armed = Math.min(1, m.armed + dt / 2); + else if (m.fuse > 0) { + m.fuse -= dt; + if (m.fuse <= 0) { this.explodePx(m.x, m.y, WEAPONS.mine.dmg, WEAPONS.mine.blast, 'mine'); m.dead = true; } + } else { + for (const w of this.worms) { + if (w.alive && w.team !== m.team && + Math.hypot(w.x + w.w / 2 - m.x, w.y + w.h / 2 - m.y) < 46) { + m.fuse = 0.8; this.ev({ t: 'mine_beep', x: Math.round(m.x), y: Math.round(m.y) }); break; + } + } + } + } + this.mines = this.mines.filter((m) => !m.dead); + // --- динамит --- + for (const d of this.dyn) { + d.fuse -= dt; + if (d.fuse <= 0) { this.explodePx(d.x, d.y, WEAPONS.dynamite.dmg, WEAPONS.dynamite.blast, 'dynamite'); d.dead = true; } + } + this.dyn = this.dyn.filter((d) => !d.dead); + // --- авиаудары: пиксельный самолёт пролетает и сбрасывает бомбы --- + for (const s of this.strikes) { + if (!s.started) { + s.t -= dt; + if (s.t <= 0) { + s.started = true; + s.px = s.dir > 0 ? this.g.xMinPx - 300 : this.g.xMaxPx + 300; + s.py = -170; // выше самых высоких деревьев на пиках (пик 0 + дерево 80 + запас) + } + continue; + } + s.px += s.dir * 560 * dt; + const inZone = Math.abs(s.px - s.x) < 260; + const passed = s.dir > 0 ? s.px > s.x + 200 : s.px < s.x - 200; + if (s.left > 0 && (inZone || passed)) { + s.next -= dt; + if (s.next <= 0) { + s.next = 0.16; s.left--; + this.proj.push({ id: this._pid++, kind: 'striker', x: s.px, y: s.py + 10, vx: s.dir * 120, vy: 200, age: 0, dmg: WEAPONS.airstrike.dmg, blast: WEAPONS.airstrike.blast, windK: 0, team: s.team }); + } + } + if (s.left <= 0 && Math.abs(s.px - s.x) > 600) s.dead = true; + } + this.strikes = this.strikes.filter((s) => !s.dead); + // --- снаряды --- + for (const p of this.proj) { + p.age += dt; + if (p.kind === 'pellet') { + p.life -= dt; + // субстепы ≤10px: иначе быстрая пуля пролетает червяка насквозь + const dist = Math.hypot(p.vx * dt, p.vy * dt); + const n = Math.max(1, Math.ceil(dist / 10)); + for (let k = 0; k < n && !p.dead; k++) { + if (p.life <= 0 || this.advance(p, dt / n)) { p.dead = true; break; } + for (const w of this.worms) { + if (!w.alive || (w.id === p.owner && p.age < 0.05)) continue; + if (p.x > w.x && p.x < w.x + w.w && p.y > w.y && p.y < w.y + w.h) { + this.hurt(w, p.dmg, 'shot'); + w.vx = 0; w.vy = -120; w.grounded = false; + p.dead = true; break; + } + } + } + if (p.dead) continue; + } else if (p.kind === 'striker') { + p.vy += PROJ_GRAV * 0.5 * dt; + if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'air'); p.dead = true; } + } else if (p.kind === 'missile') { + p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt; + p.vy += PROJ_GRAV * dt; + if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; continue; } + // вода — только настоящая (клетка), а не «всё ниже уровня моря»: сухие катакомбы под морем — не вода + if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) { + // упала в воду — взрыв на поверхности, а не тихое бульк + this.ev({ t: 'splash', x: Math.round(p.x) }); + this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); + p.dead = true; continue; + } + for (const w of this.worms) { + if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue; + if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) { + this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; break; + } + } + } else if (p.kind === 'homing') { + // самонаводящаяся ракета: летит в отмеченную крестиком точку (или доворачивает к врагу рядом с ней) + p.life -= dt; + let tgt = null, bd = 1e9; + for (const w of this.worms) { + if (!w.alive || w.team === p.team) continue; + const d = Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y); + if (d < bd) { bd = d; tgt = w; } + } + // враг далеко от точки — летим строго в точку; рядом с точкой — доворачиваем к нему + const tdx = (p.tx == null ? p.x : p.tx), tdy = (p.ty == null ? p.y : p.ty); + let gx = tdx, gy = tdy; + if (tgt && Math.hypot(tgt.x - tdx, tgt.y - tdy) < 120) { gx = tgt.x + tgt.w / 2; gy = tgt.y + tgt.h / 2; } + const SPD = 950; + { + const cur = Math.atan2(p.vy, p.vx); + const want = Math.atan2(gy - p.y, gx - p.x); + let d = want - cur; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + const turn = 3.4 * dt, na = cur + Math.max(-turn, Math.min(turn, d)); + p.vx = Math.cos(na) * SPD; p.vy = Math.sin(na) * SPD; + } + if (Math.hypot(gx - p.x, gy - p.y) < 30) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; } + if (this.advance(p, dt)) { + if (p.age < 0.12) continue; // только вылетела — нос ещё выходит из-за укрытия, не взрываем + this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; + } + if (p.life <= 0) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; } + if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) { + this.ev({ t: 'splash', x: Math.round(p.x) }); + this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); + p.dead = true; continue; + } + for (const w of this.worms) { + if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue; + if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) { + this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; break; + } + } + } else if (p.kind === 'sheep') { + // супер-овца: бежит по земле, вскарабкивается на уступы, у стен разворачивается, у врага — бум + p.fuse -= dt; + p.vy = Math.min(900, p.vy + SGRAV * dt); + const dir = p.dir || 1, nx = p.x + dir * 150 * dt; + if (!this.pointSolid(nx, p.y)) p.x = nx; + else if (!this.pointSolid(nx, p.y - 34)) { p.x = nx; p.y -= 34; p.vy = Math.min(0, p.vy); } + else p.dir = -dir; + p.y += p.vy * dt; + if (this.pointSolid(p.x, p.y + 10)) { + let n = 0; + while (n++ < 20 && this.pointSolid(p.x, p.y + 8)) p.y -= 2; + p.vy = 0; + } + if (p.y > this.g.killYPx) { p.dead = true; continue; } + if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) { + this.ev({ t: 'splash', x: Math.round(p.x) }); + this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep'); + p.dead = true; continue; + } + let hit = p.fuse <= 0; + if (!hit) for (const w of this.worms) { + if (!w.alive || w.team === p.team) continue; // своих (включая хозяина) не задевает — только враги + if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 30) { hit = true; break; } + } + if (hit) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep'); p.dead = true; } + } else { // grenade / cluster + p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt; + p.vy += PROJ_GRAV * dt; + this.bounce(p, dt); + p.fuse -= dt; + // вода — только настоящая клетка: в сухих катакомбах под морем граната летит дальше + if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) { + // граната/кластер в воде — взрыв на месте + осколки, а не тихое бульк + this.ev({ t: 'splash', x: Math.round(p.x) }); + const cy = p.y; + if (p.kind === 'cluster') { + this.explodePx(p.x, cy, 10, 1.1, 'cluster'); + for (let i = 0; i < 5; i++) { + const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6; + this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: cy - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner }); + } + } else this.explodePx(p.x, cy, p.dmg, p.blast, 'grenade'); + p.dead = true; continue; + } + if (p.fuse <= 0) { + if (p.kind === 'cluster') { + this.explodePx(p.x, p.y, 10, 1.1, 'cluster'); + for (let i = 0; i < 5; i++) { + const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6; + this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: p.y - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner }); + } + } else this.explodePx(p.x, p.y, p.dmg, p.blast, 'grenade'); + p.dead = true; + } + } + } + this.proj = this.proj.filter((p) => !p.dead); + } + + pointSolid(x, y) { + return this.g.isSolid(Math.floor(x / STILE), Math.floor(y / STILE)); + } + advance(p, dt) { + // Возвращает true при ударе о землю (dead НЕ ставит — решает вызывающий: + // пуля гаснет, базука взрывается). + const dist = Math.hypot(p.vx * dt, p.vy * dt); + const n = Math.max(1, Math.ceil(dist / 6)); + for (let i = 0; i < n; i++) { + p.x += (p.vx * dt) / n; p.y += (p.vy * dt) / n; + if (this.pointSolid(p.x, p.y)) { p.hitX = p.x; p.hitY = p.y; return true; } + if (p.x < this.g.xMinPx + 2 || p.x > this.g.xMaxPx - 2) { p.vx = -p.vx; p.x = Math.max(this.g.xMinPx + 2, Math.min(this.g.xMaxPx - 2, p.x)); } + } + return false; + } + bounce(p, dt) { + const dist = Math.hypot(p.vx * dt, p.vy * dt); + const n = Math.max(1, Math.ceil(dist / 5)); + let hitX = false, hitY = false; + for (let i = 0; i < n; i++) { + const sx = (p.vx * dt) / n, sy = (p.vy * dt) / n; + if (!this.pointSolid(p.x + sx, p.y)) p.x += sx; else hitX = true; + if (!this.pointSolid(p.x, p.y + sy)) p.y += sy; else hitY = true; + } + // отклик — один раз за шаг, а не за каждый подшаг: иначе касание склона + // на скорости гасит скорость в ноль и граната падает сильно ближе прицела + if (hitX) p.vx = -p.vx * 0.45; + if (hitY) { p.vy = -p.vy * 0.42; p.vx *= 0.82; if (Math.abs(p.vy) < 70) p.vy = 0; } + if (p.vy === 0 && Math.abs(p.vx) < 30) p.vx = 0; + if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy) * 0.5; } + } + + snapshot() { + return { + worms: this.worms.map((w) => ({ id: w.id, team: w.team, name: w.name, x: Math.round(w.x * 10) / 10, y: Math.round(w.y * 10) / 10, dir: w.dir, hp: Math.round(w.hp), alive: w.alive, swim: w.inWater, o2: Math.round(w.o2) })), + proj: this.proj.map((p) => ({ id: p.id, k: p.kind, x: Math.round(p.x), y: Math.round(p.y), vx: Math.round(p.vx || 0), vy: Math.round(p.vy || 0), t: p.team, o: p.owner })), + mines: this.mines.map((m) => ({ x: Math.round(m.x), y: Math.round(m.y), armed: m.armed >= 1, beep: m.fuse > 0 })), + dyn: this.dyn.map((d) => ({ x: Math.round(d.x), y: Math.round(d.y) })), + strikes: this.strikes.length, + planes: this.strikes.filter((s) => s.started && !s.dead).map((s) => ({ x: Math.round(s.px), y: Math.round(s.py), d: s.dir, t: s.team })), + wind: this.wind, + }; + } +} + +// Перебор углов/сил через превью: куда упадёт снаряд ближе к цели. +// Чистая функция — используется и ИИ в клиенте, и тестами. +function solveShot(grid, mx, my, tx, ty, thrown, wind, dir) { + let best = { ang: -1.1, pow: 0.6, err: 1e12 }; + const angs = thrown ? [-2.4, -2.0, -1.6, -1.2, -0.8] : [-2.5, -2.1, -1.7, -1.3, -0.9, -0.5]; + const pows = [0.3, 0.5, 0.7, 0.9, 1.0]; + for (const a of angs) { + for (const p of pows) { + const sx = mx + Math.cos(a) * 26, sy = my + Math.sin(a) * 26; + const pts = previewShot(grid, sx, sy, a, p, + { thrown, wind, windK: thrown ? 0.25 : 1.0, fuse: 4, dir: dir || 1 }); + if (!pts.length) continue; + const land = pts[pts.length - 1]; + const err = Math.hypot(land[0] - tx, land[1] - ty) + (thrown ? 0 : Math.abs(a + 1.2) * 30); + if (err < best.err) best = { ang: a, pow: p, err }; + } + } + return best; +} + +// Превью траектории для прицела (только aimed/thrown, без взрывов по пути). +function previewStraight(grid, sx, sy, angle, maxLen) { + // прямой прицел дроби/УЗИ: луч без гравитации до земли или лимита + const pts = []; + const dx = Math.cos(angle), dy = Math.sin(angle); + for (let d = 24; d <= (maxLen || 520); d += 24) { + const x = sx + dx * d, y = sy + dy * d; + pts.push([Math.round(x), Math.round(y)]); + if (grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE))) break; + } + return pts; +} +function previewShot(grid, sx, sy, angle, power, opts) { + opts = opts || {}; + const pts = []; + let x = sx, y = sy; + let vx, vy; + if (opts.thrown) { + const sp = 300 + power * 800; + vx = (opts.dir || 1) * 60 + Math.cos(angle) * sp * 0.7; + vy = Math.sin(angle) * sp - 140; + } else { + vx = Math.cos(angle) * (500 + power * 1000); + vy = Math.sin(angle) * (500 + power * 1000); + } + const dt = 1 / 60; + const windAcc = (opts.wind || 0) * WIND_ACCEL * (opts.windK || 0); + const maxT = opts.fuse || 4; + const solid = (x, y) => grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE)); + const fluid = (x, y) => grid.isFluid(Math.floor(x / STILE), Math.floor(y / STILE)); + for (let t = 0; t < maxT; t += dt) { + vx += windAcc * dt; vy += PROJ_GRAV * dt; + // подшаги как в bounce/advance сима: иначе на скорости превью проваливается + // в склон и рисует точку падения дальше, чем долетит граната + const dist = Math.hypot(vx * dt, vy * dt); + const n = Math.max(1, Math.ceil(dist / 5)); + let hit = false; + for (let i = 0; i < n; i++) { + x += (vx * dt) / n; y += (vy * dt) / n; + if (solid(x, y) || fluid(x, y)) { hit = true; break; } + } + const last = pts[pts.length - 1]; + if (!last || Math.abs(x - last[0]) + Math.abs(y - last[1]) > 16) pts.push([Math.round(x), Math.round(y)]); + if (hit || pts.length > 90) break; + } + return pts; +} + +// API для node (require) и для бандлера (общая область IIFE — без export). +const __api = { STILE, SGRAV, SMOVE, SJUMP, WORM_W, WORM_H, WEAPONS, TURN_TIME, RETREAT_TIME, MAX_WIND, Sim, previewShot, solveShot }; +if (typeof module !== 'undefined' && module.exports) module.exports = __api; + +// === worms/worms-main.js === +// Worms-Grechka: хотсит-клиент (2 игрока на одном экране). +// Мир, земля, вода, спрайт — настоящая Гречка. Бой — пошаговый, как Worms. + + + + + + + + + + +const WEAPON_ORDER = ['bazooka', 'homing', 'grenade', 'cluster', 'sheep', 'shotgun', 'uzi', 'dynamite', 'mine', 'shovel', 'airstrike', 'teleport', 'skip']; +const TEAM_COLORS = ['#e74c3c', '#3498db']; +const TEAM_NAMES = ['Красные', 'Синие']; +const WORMS_PER_TEAM = 5; +const ARENA_TILES = 100; +const GAP_HALF = 3; // полуширина пропасти с водой (в тайлах) +const HILL_RUN = 18; // длина склона горы в тайлах (узкие пики — над бойцами открытое небо) +const HILL_TOP = 14; // вершина горы: SEA_GY - 14 + +// ---------- состояние боя ---------- +const F = { + sim: null, grid: null, + seed: (Math.random() * 1000000) | 0, + mode: 'ai', // 'ai' | 'hotseat' + ax0: 0, arenaW: ARENA_TILES, + xMinPx: 0, xMaxPx: 0, + order: [], turnPos: 0, lastTeam: 1, + activeWorm: null, teamTurn: 0, + phase: 'move', timeLeft: TURN_TIME, retreatT: 0, shotsLeft: 0, + weapon: 'bazooka', fuseIdx: 2, + ammo: [{}, {}], + aim: -0.7, power: 0, charging: false, chargeT: 0, + camX: 0, camY: 0, zoom: 1, + zoomManual: false, panT: 0, // panT обязан быть числом: undefined глушит ветку камеры (undefined <= 0 === false) + boomX: 0, boomY: 0, boomT: 0, // память о взрыве: камера держится 2 с + night: false, torches: [], + particles: [], floaters: [], + over: false, winner: -1, + keys: {}, + stickId: null, stickOX: 0, stickOY: 0, + pinchD: 0, +}; + +function newAmmo() { + const a = {}; + for (const id of WEAPON_ORDER) a[id] = WEAPONS[id].ammo; + return a; +} + +// Целевая поверхность арены: две горы с пропастью посередине. +function arenaSurfaceGy(gx) { + const gapC = F.ax0 + F.arenaW / 2; + const d = Math.abs(gx - gapC); + if (d < GAP_HALF) return SEA_GY + 3; // мелкое дно пропасти — под водой + const t = Math.min(1, (d - GAP_HALF) / HILL_RUN); + const peak = SEA_GY - HILL_TOP, floor = SEA_GY + 3; + return Math.round(floor + (peak - floor) * Math.sin(t * Math.PI / 2)); +} + +function sculptArena(flats) { + // th-карта + выравнивание площадок под спавн + const thMap = {}; + for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) thMap[gx] = arenaSurfaceGy(gx); + for (const f of flats || []) { + for (let dx = -2; dx <= 2; dx++) { + const gx = f.gx + dx; + if (thMap[gx] != null && Math.abs(thMap[gx] - f.gy) <= 4) thMap[gx] = f.gy; + } + } + for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) { + const th = thMap[gx]; + for (let gy = 0; gy < th; gy++) removeBlock(gx, gy); + const cap = th < SEA_GY - 10 ? 'stone' : 'grass'; + if (!getBlock(gx, th)) setBlock(gx, th, cap); + for (let gy = th + 1; gy < BEDROCK_GY; gy++) { + if (!getBlock(gx, gy)) setBlock(gx, gy, gy <= th + 3 ? 'dirt' : 'stone'); + } + if (th > SEA_GY) { + for (let gy = SEA_GY - 2; gy <= th; gy++) { + const b = getBlock(gx, gy); + if (b && BLOCKS[b.t].fluid) continue; + if (b) removeBlock(gx, gy); + setBlock(gx, gy, 'water'); + } + } + } +} + +function setupArena() { + state.worldSeed = F.seed; + state.placedBlocks = []; + state.removedBlocks = []; + grid.clear(); blocks.length = 0; + state.generated.clear(); + F.ax0 = 5000 + (F.seed % 1000); + F.xMinPx = F.ax0 * TILE; + F.xMaxPx = (F.ax0 + F.arenaW) * TILE; + for (let gx = F.ax0 - 4; gx <= F.ax0 + F.arenaW + 4; gx++) genColumn(gx); + + // --- арена «две горы + пропасть»: лепим рельеф поверх генерации --- + // точки спавна считаем ДО лепки, площадки выравниваем в sculpt + const gapC = F.ax0 + F.arenaW / 2; + const peakSpots = (gx0, gx1) => { + const out = []; + for (let gx = gx0; gx <= gx1 && out.length < 10; gx += 2) { + const sgy = arenaSurfaceGy(gx); + if (sgy < SEA_GY) out.push({ gx, x: gx * TILE + TILE / 2, y: sgy * TILE - 80 }); + } + return out; + }; + const left = peakSpots(gapC - 28, gapC - 12); + const right = peakSpots(gapC + 12, gapC + 28); + const flats = left.concat(right).map((s) => ({ gx: s.gx, gy: Math.round((s.y + 80) / TILE) })); + sculptArena(flats); + + F.grid = { + isSolid: (gx, gy) => isSolid(gx, gy), + isFluid: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].fluid); }, + unbreakable: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].unbreakable); }, + remove: (gx, gy) => !!removeBlock(gx, gy), + groundBelowPx: (x, yTop) => { + const gx = Math.floor(x / TILE); + let gy = Math.max(0, Math.floor(yTop / TILE)); + for (; gy <= BEDROCK_GY + 5; gy++) if (isSolid(gx, gy)) return gy * TILE; + return null; + }, + waterYPx: SEA_GY * TILE, + xMinPx: F.xMinPx, xMaxPx: F.xMaxPx, + killYPx: (BEDROCK_GY + 8) * TILE, + }; + + F.sim = new Sim(F.grid, F.seed); + F.ammo = [newAmmo(), newAmmo()]; + F.cool = [{}, {}]; // кулдауны: {airstrike: ходы} + F.digsLeft = 10; // копок лопатой за ход + // спавн: команды на вершинах гор (точки уже посчитаны выше) + const mk = (list, team, tag) => { + const offs = [-80, -40, 0, 40, 80]; // каждый боец — на своей позиции, без кучи + for (let i = 0; i < WORMS_PER_TEAM; i++) { + const s = list.length ? list[(i * 2) % list.length] : { x: (F.xMinPx + F.xMaxPx) / 2, y: 0 }; + F.sim.addWorm(team, tag + (i + 1), s.x + offs[i % offs.length], s.y - (i * 30)); + } + }; + mk(left.length ? left : peakSpots(F.ax0 + 8, gapC - GAP_HALF - 2), 0, 'Гречка R'); + mk(right.length ? right : peakSpots(gapC + GAP_HALF + 2, F.ax0 + F.arenaW - 8), 1, 'Гречка B'); + // отстой физики + чистый старт: никакого урона от спавна + for (let i = 0; i < 300; i++) F.sim.step(1 / 60); + // вытолкнуть закопанных: вокруг каждого должно быть свободное место + for (const w of F.sim.worms) { + let n = 0; + while (n++ < 15 && F.sim.solidBox(w.x + 2, w.y + 2, w.w - 4, w.h - 4)) w.y -= 20; + } + for (let i = 0; i < 120; i++) F.sim.step(1 / 60); + for (const w of F.sim.worms) { w.hp = 100; w.fallStartY = w.y; w.vx = 0; w.vy = 0; } + F.sim.drainEvents(); + // ночью — факелы рядом с персонажами + F.torches = []; + if (F.night) placeTorches(); + F.lastTeam = 1; F.order = []; F.turnPos = 0; + F.over = false; F.winner = -1; + F.zoom = 0.55; // старт с общим планом: видны оба острова + F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150; + F.boomT = 0; + nextTurn(); + console.log('[DBG] spawn hp=' + F.sim.worms.map((w) => w.name + ':' + Math.round(w.hp)).join(' ')); + updateHud(); +} + +// Ночь: факел в 2–3 тайлах от каждого червяка, на поверхности. +function placeTorches() { + for (const w of F.sim.worms) { + const side = (w.id % 2 === 0) ? 1 : -1; + for (let dx = 2; dx <= 4; dx++) { + const gx = Math.floor((w.x + 17) / TILE) + side * dx; + if (gx * TILE < F.xMinPx || gx * TILE > F.xMaxPx) continue; + const gyTop = F.grid.groundBelowPx(gx * TILE + TILE / 2, 0); + if (gyTop == null) continue; + const gy = Math.floor(gyTop / TILE) - 1; + if (gy <= 0 || gyTop > SEA_GY * TILE + TILE) continue; // не в воду + if (getBlock(gx, gy)) continue; + if (setBlock(gx, gy, 'torch')) { + F.torches.push({ x: gx * TILE + TILE / 2, y: gy * TILE + TILE / 2 }); + break; + } + } + } +} +function aliveWorms(team) { return F.sim.worms.filter((w) => w.alive && (team == null || w.team === team)); } + +function nextTurn() { + const teams = F.sim.aliveTeams(); + if (teams.length <= 1) return gameOver(teams[0]); + // следующий червяк другой команды (round-robin внутри команды) + const prevTeam = F.lastTeam; + const nt = teams.find((t) => t !== prevTeam) ?? teams[0]; + const cands = aliveWorms(nt); + const prev = F.activeWorm; + let w = cands.find((c) => !prev || c.id !== prev.id) || cands[0]; + if (cands.length > 1 && prev && prev.team === nt) { + const i = cands.findIndex((c) => c.id === prev.id); + w = cands[(i + 1) % cands.length]; + } + F.activeWorm = w; F.teamTurn = nt; F.lastTeam = nt; + if (F.cool[nt].airstrike > 0) F.cool[nt].airstrike--; + F.digsLeft = 10; + F.phase = 'move'; F.timeLeft = TURN_TIME; F.shotsLeft = 0; + F.sim.wind = Math.round((Math.random() * 2 - 1) * MAX_WIND); + F.aim = -0.7 * 1; // вверх; направление по dir червяка при отрисовке + F.aimLocked = false; + F.power = 0; F.charging = false; F.firedThisTurn = false; F.zoomManual = false; + F.pendingAim = null; + if (F.ammo[nt][F.weapon] === 0) F.weapon = 'bazooka'; + console.log('[DBG] turn team=' + nt + ' worm=' + w.name); + updateHud(); + if (F.mode === 'ai' && nt === 1 && !F.over) aiTakeTurn(); +} + +// ---------- комп (синие) ---------- +function aiTakeTurn() { + setTimeout(() => { + if (F.over || !F.activeWorm || F.activeWorm.team !== 1 || F.phase !== 'move') return; + aiAct(); + }, 1400); +} + +function aiAct() { + const w = F.activeWorm; + const foes = aliveWorms(0); + console.log('[DBG] ai acts worm=' + w.name); + if (!foes.length) return; + let tgt = foes[0], bd = 1e12; + for (const e of foes) { + const d = Math.hypot(e.x - w.x, e.y - w.y); + if (d < bd) { bd = d; tgt = e; } + } + const tx = tgt.x + 17, ty = tgt.y + 11; + const ammo = F.ammo[1]; + const tryWeapon = (id, fn) => { if (ammo[id] !== 0) { F.weapon = id; fn(); return true; } return false; }; + // вплотную — очередь + if (bd < 130) { + if (tryWeapon('uzi', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); F.power = 1; doFire(); })) return; + if (tryWeapon('shotgun', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); doFire(); })) return; + } + // средняя — граната с подбором таймера + if (bd < 320) { + if (tryWeapon('grenade', () => { + const a = solveShotLocal(w, tx, ty, true, F.sim.wind); + F.aim = a.ang; F.power = a.pow; + F.fuseIdx = bd < 150 ? 0 : bd < 230 ? 1 : 2; + doFire(); + })) return; + } + // далеко — базука с пристрелкой + if (tryWeapon('bazooka', () => { + const a = solveShotLocal(w, tx, ty, false, F.sim.wind); + F.aim = a.ang + (Math.random() - 0.5) * 0.05; + F.power = Math.min(1, Math.max(0.25, a.pow)); + doFire(); + })) return; + // запасной вариант + tryWeapon('skip', () => doFire()); +} + +// Перебор углов/сил — общий код в sim.js (там же тесты). +function solveShotLocal(w, tx, ty, thrown, wind) { + return solveShot(F.grid, w.x + 17, w.y + 11, tx, ty, thrown, wind, w.dir); +} + +function endTurn() { + F.sim.setMove(F.activeWorm.id, 0); + F.charging = false; F.power = 0; + nextTurn(); +} + +function settled() { + const s = F.sim; + if (s.proj.length || s.strikes.length || s.dyn.length) return false; + if (s.mines.some((m) => m.fuse > 0)) return false; + return s.worms.every((w) => !w.alive || w.grounded); +} + +// выстрелы: настоящие сэмплы (CC0, audio/CC0.txt), запасной — синтез +const gunAudio = {}; +function gunSample(kind) { + let a = gunAudio[kind]; + if (!a) { + a = new Audio('audio/' + kind + '.mp3'); + a.preload = 'auto'; gunAudio[kind] = a; + } + return a; +} +let gunCtx = null; +// овца бекует: «бе-е-е» — пила с вибрато, два слога +function playBaa() { + try { + gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)(); + const ctx = gunCtx, t = ctx.currentTime; + for (let s = 0; s < 2; s++) { + const tt = t + s * 0.22; + const o = ctx.createOscillator(), g = ctx.createGain(), lfo = ctx.createOscillator(), lg = ctx.createGain(); + o.type = 'sawtooth'; o.frequency.value = 520 + Math.random() * 60; + lfo.type = 'sine'; lfo.frequency.value = 28; lg.gain.value = 90; + lfo.connect(lg); lg.connect(o.frequency); + g.gain.setValueAtTime(0.0001, tt); + g.gain.exponentialRampToValueAtTime(0.35, tt + 0.03); + g.gain.exponentialRampToValueAtTime(0.01, tt + 0.2); + o.connect(g); g.connect(ctx.destination); + o.start(tt); o.stop(tt + 0.22); lfo.start(tt); lfo.stop(tt + 0.22); + } + } catch (e) {} +} +function playGun(kind) { + try { + const a = gunSample(kind); + a.currentTime = 0; + const p = a.play(); + if (p && p.catch) p.catch(() => playGunSynth(kind)); + } catch (e) { playGunSynth(kind); } +} +function playGunSynth(kind) { + try { + gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)(); + const ctx = gunCtx, t = ctx.currentTime; + if (kind === 'shotgun') { + const len = Math.floor(ctx.sampleRate * 0.35); + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = ctx.createBufferSource(); src.buffer = buf; + const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 480; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.01, t + 0.35); + src.connect(f); f.connect(g); g.connect(ctx.destination); src.start(t); + const o = ctx.createOscillator(), og = ctx.createGain(); + o.type = 'sine'; o.frequency.setValueAtTime(160, t); + o.frequency.exponentialRampToValueAtTime(45, t + 0.3); + og.gain.setValueAtTime(0.7, t); og.gain.exponentialRampToValueAtTime(0.01, t + 0.3); + o.connect(og); og.connect(ctx.destination); o.start(t); o.stop(t + 0.32); + } else { + for (let i = 0; i < 5; i++) { + const tt = t + i * 0.055; + const o = ctx.createOscillator(), g = ctx.createGain(); + o.type = 'square'; o.frequency.value = 900 + Math.random() * 300; + g.gain.setValueAtTime(0.22, tt); g.gain.exponentialRampToValueAtTime(0.01, tt + 0.04); + o.connect(g); g.connect(ctx.destination); o.start(tt); o.stop(tt + 0.05); + } + } + } catch (e) {} +} +function doFire(point) { + if (NET.on) return netFire(point); + const w = F.activeWorm; + if (!w || F.phase !== 'move' || F.over) return; + const def = WEAPONS[F.weapon]; + if (F.ammo[F.teamTurn][F.weapon] === 0) return; + const ang = F.aim; + let ok = false; + if (def.kind === 'target' || def.kind === 'teleport') { + const tgt = point || F.pendingAim; + if (!tgt) return; // нет метки — нет удара + if (F.weapon === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун + ok = F.sim.fire(w.id, F.weapon, { x: tgt.x, y: tgt.y }); + F.pendingAim = null; + if (ok && F.weapon === 'airstrike') F.cool[F.teamTurn].airstrike = 2; // 2 своих хода ждать + } else if (def.kind === 'hitscan') { + if (F.shotsLeft <= 0) F.shotsLeft = def.shots; + ok = F.sim.fire(w.id, F.weapon, { angle: ang }); + if (ok) { + F.shotsLeft--; + if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--; + if (F.shotsLeft <= 0) { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; } + updateHud(); + return; + } + } else if (def.kind === 'dig') { + // лопата: копает туда, куда наведён прицел (как дробовик), ход не тратится (10 копок за ход) + if (F.digsLeft <= 0) return; + ok = F.sim.fire(w.id, F.weapon, { angle: ang }); + if (ok) { + F.digsLeft--; + playSound('sand1'); + const dpt = digPoint(w.x + 17, w.y + 17, ang); + burst(dpt ? dpt[0] : w.x + 17 + Math.cos(ang) * 52, dpt ? dpt[1] : w.y + 17 + Math.sin(ang) * 52, 14, ['#8d6e63', '#a1887f', '#6d4c41']); + updateHud(); + } + return; + } else { + const params = { angle: ang }; + if (def.power) params.power = F.power; + if (def.fuse) params.fuse = def.fuse[F.fuseIdx] || def.fuse[0]; + ok = F.sim.fire(w.id, F.weapon, params); + } + if (!ok) return; + if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--; + // звук выстрела — через событие fired (там же услышат и сетевые выстрелы) + console.log('[DBG] fired team=' + F.teamTurn + ' weapon=' + F.weapon); + F.firedThisTurn = true; + if (def.kind === 'skip' || def.kind === 'teleport') endTurn(); + else { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; } + updateHud(); +} + +function gameOver(team) { + F.over = true; + F.winner = team == null ? -1 : team; + const el = document.getElementById('banner'); + el.style.display = 'flex'; + el.querySelector('.b-text').textContent = team == null || team === undefined + ? 'Ничья! Все утонули.' + : 'Победа: ' + TEAM_NAMES[team] + '!'; +} + +// ---------- события сима -> эффекты ---------- +function pumpEvents() { + for (const e of F.sim.drainEvents()) { + if (e.t === 'explode') { + playSound('explode1'); + boomFx(e.x, e.y, e.r || 2); + F.boomX = e.x; F.boomY = e.y; F.boomT = 2.0; // камера смотрит взрыв ещё 2 с + } else if (e.t === 'splash') playSound('splash'); + else if (e.t === 'dig') burst(e.x, e.y, 10, ['#8d6e63', '#a1887f']); + else if (e.t === 'damage') { + if (e.dmg > 0) { + console.log('[DBG] damage worm=' + e.worm + ' dmg=' + e.dmg); + F.floaters.push({ x: 0, y: 0, worm: e.worm, text: '-' + e.dmg, t: 1.2 }); + playSound('hit1'); + } + } else if (e.t === 'die') { + const w = F.sim.wormById(e.worm); + if (w) burst(w.x + 17, w.y + 17, 14, ['#ffffff', '#00a1ff']); + } else if (e.t === 'fired') { + // выстрел услышали все: дробовик/узи — сэмплами, остальное — штатным звуком атаки (и сеть, и локалка) + if (e.weapon === 'shotgun') playGun('shotgun'); + else if (e.weapon === 'uzi') playGun('uzi'); + else if (e.weapon === 'sheep') { playBaa(); F.baaNext = performance.now() + 900; } + else playSound('attack'); + } else if (e.t === 'mine_beep') playSound('click'); + else if (e.t === 'teleport') playSound('click'); + else if (e.t === 'strike_incoming') playSound('click'); + } + // овца скачет — периодически бекует, пока жива (и сеть, и локалка). Вне цикла событий: иначе молчит между событиями + try { + if (F.sim && F.sim.proj.some((p) => p.k === 'sheep') && performance.now() > (F.baaNext || 0)) { + playBaa(); F.baaNext = performance.now() + 1000; + } + } catch (e) {} +} + +function burst(x, y, n, colors, sz) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2, sp = 60 + Math.random() * 320; + F.particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 120, t: 0.5 + Math.random() * 0.5, c: colors[(Math.random() * colors.length) | 0], sz: sz || 6 }); + } +} +// красивый взрыв: вспышка + огонь + дым + искры + комья + кольцо + тряска экрана. R — радиус взрыва с сервера +function boomFx(x, y, R) { + const P = (o) => F.particles.push(Object.assign({ x, y, vx: 0, vy: 0, t: 0.5, c: '#fff', sz: 6 }, o)); + P({ c: '#fff7d6', sz: R * 44, t: 0.14 }); // вспышка + for (let i = 0; i < 14; i++) { const a = Math.random() * Math.PI * 2, sp = 40 + Math.random() * 200; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 160, t: 0.4 + Math.random() * 0.3, c: ['#f39c12', '#e67e22', '#e74c3c'][(Math.random() * 3) | 0], sz: 8 + Math.random() * 8 }); } // огонь + for (let i = 0; i < 10; i++) { const a = Math.random() * Math.PI * 2, sp = 20 + Math.random() * 80; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 90, t: 1.2 + Math.random() * 0.8, c: ['#7f8c8d', '#95a5a6', '#57606f'][(Math.random() * 3) | 0], sz: 10 + Math.random() * 10 }); } // дым + for (let i = 0; i < 18; i++) { const a = Math.random() * Math.PI * 2, sp = 400 + Math.random() * 500; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 100, t: 0.5 + Math.random() * 0.4, c: ['#ffe66d', '#ffffff', '#ffd76e'][(Math.random() * 3) | 0], sz: 4 }); } // искры + for (let i = 0; i < 12; i++) { const a = Math.random() * Math.PI * 2, sp = 120 + Math.random() * 300; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 200, t: 0.8 + Math.random() * 0.5, c: ['#8d6e63', '#6d4c41', '#5d4037'][(Math.random() * 3) | 0], sz: 6 + Math.random() * 5 }); } // комья земли + F.particles.push({ x, y, vx: 0, vy: 0, t: 0.35, t0: 0.35, c: '#ffd76e', ring: 1, r1: R * 40 }); // кольцо + F.shakeT = 0.3; F.shakeR = Math.min(16, 5 + R * 3); // тряска экрана +} + +// ---------- ввод ---------- +function screenToWorld(sx, sy) { + const vw = canvas.clientWidth, vh = canvas.clientHeight; + return { x: (sx - vw / 2) / F.zoom + F.camX, y: (sy - vh / 2) / F.zoom + F.camY }; +} + +// hold-кнопки как в оригинале Гречки (bindHold) +function bindHold(id, down, up) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('pointerdown', (e) => { e.preventDefault(); down(); }); + el.addEventListener('pointerup', (e) => { e.preventDefault(); if (up) up(); }); + el.addEventListener('pointerleave', () => { if (up) up(); }); + el.addEventListener('pointercancel', () => { if (up) up(); }); +} + +function showStick(sx, sy) { + const st = document.getElementById('stick'); + st.style.display = 'block'; + st.style.left = (sx - 60) + 'px'; + st.style.top = (sy - 60) + 'px'; + moveStickKnob(0, 0); +} +function hideStick() { document.getElementById('stick').style.display = 'none'; } +function moveStickKnob(dx, dy) { + const k = document.getElementById('stickKnob'); + k.style.left = (34 + dx) + 'px'; + k.style.top = (34 + dy) + 'px'; +} + +function bindInput() { + window.addEventListener('keydown', (e) => { + if (e.repeat) { F.keys[e.code] = true; return; } + F.keys[e.code] = true; + if (e.code === 'Tab') { e.preventDefault(); cycleWeapon(1); } + if (e.code === 'KeyC') cycleWorm(); + if (e.code === 'KeyF') { + const def = WEAPONS[F.weapon]; + if (def.fuse) { F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length; updateHud(); } + } + if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') jump(); + if (e.code === 'Space') { e.preventDefault(); startCharge(); } + }); + window.addEventListener('keyup', (e) => { + F.keys[e.code] = false; + if (e.code === 'Space') { e.preventDefault(); releaseFire(); } + }); + // кнопки движения как в оригинале + bindHold('left', () => (F.touchMove = -1), () => { if (F.touchMove < 0) F.touchMove = 0; }); + bindHold('right', () => (F.touchMove = 1), () => { if (F.touchMove > 0) F.touchMove = 0; }); + bindHold('jumpBtn', () => jump()); + // огонь: нажал — начинается заряд (для оружия с силой), отпустил — выстрел. + // держишь дольше — летит дальше; быстрое нажатие — короткий выстрел. Как пробел на компе. + bindHold('fireBtn', () => { + if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); // пробел после клика — выстрел, а не повтор кнопки + if (F.phase !== 'move' || F.over) return; + if (F.mode === 'ai' && F.teamTurn === 1) return; + startCharge(); + }, () => { releaseFire(); }); + // таймер гранаты/кластера + bindHold('fuseBtn', () => { + const def = WEAPONS[F.weapon]; + if (def.fuse) { + F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length; + playSound('click'); + updateHud(); + } + }); + // навёл курсор на кнопку — прицел замирает, вернулся на карту — снова следует за мышью + for (const bid of ['fireBtn', 'jumpBtn', 'wOpen', 'fuseBtn', 'zIn', 'zOut']) { + const bel = document.getElementById(bid); + if (!bel) continue; + bel.addEventListener('pointerenter', () => { F.uiHover = true; }); + bel.addEventListener('pointerleave', () => { F.uiHover = false; }); + } + // зум: колесо + кнопки + щипок + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + setZoom(F.zoom * (e.deltaY > 0 ? 0.88 : 1.14)); + }, { passive: false }); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // ПКМ — пан карты, не меню + bindHold('zIn', () => setZoom(F.zoom * 1.25)); + bindHold('zOut', () => setZoom(F.zoom * 0.8)); + const pts = new Map(); + canvas.addEventListener('pointerdown', (e) => { + // правая кнопка мыши — тянуть карту (пан), как два пальца на телефоне + if (e.button === 2) { F.rpan = { x: e.clientX, y: e.clientY, id: e.pointerId }; return; } + pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (pts.size === 2) { + const p = [...pts.values()]; + F.pinchD = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + F.pinchMX = (p[0].x + p[1].x) / 2; F.pinchMY = (p[0].y + p[1].y) / 2; + if (F.stickId != null) { F.stickId = null; hideStick(); } + return; + } + const def = WEAPONS[F.weapon]; + const r = canvas.getBoundingClientRect(); + const sx = e.clientX - r.left, sy = e.clientY - r.top; + if (def.kind === 'target' || def.kind === 'teleport') { + // клик — только метка цели, удар — строго с кнопки ОГОНЬ + const wpt = screenToWorld(sx, sy); + F.pendingAim = wpt; + playSound('click'); + updateHud(); + return; + } + if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray') { + // в сети чужим прицелом не водим + if (NET.on && !myTurn()) return; + // стик прицела появится под пальцем после сдвига; быстрый тап — смена бойца + F.stickId = e.pointerId; F.stickOX = sx; F.stickOY = sy; + F.stickDX = 0; F.stickDY = 0; F.stickShown = false; F.stickT = performance.now(); + } + }); + canvas.addEventListener('pointermove', (e) => { + if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + // десктоп: прицел следует за мышью без зажатия (тач — только стиком) + // во время зарядки прицел заморожен — иначе уползёт за курсором вниз + if (e.pointerType === 'mouse' && e.buttons === 0 && F.stickId == null && !F.rpan && !F.aimLocked && !F.uiHover + && F.activeWorm && F.phase === 'move' && !F.over && (!NET.on || myTurn())) { + const dk = WEAPONS[F.weapon].kind; + if (dk === 'aimed' || dk === 'thrown' || dk === 'hitscan' || dk === 'spray' || dk === 'dig') { + const r = canvas.getBoundingClientRect(); + const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top); + F.aim = Math.atan2(wpt.y - (F.activeWorm.y + 11), wpt.x - (F.activeWorm.x + 17)); + } + } + // пан правой кнопкой мыши + if (F.rpan && e.pointerId === F.rpan.id) { + F.camX -= (e.clientX - F.rpan.x) / F.zoom; + F.camY -= (e.clientY - F.rpan.y) / F.zoom; + F.rpan.x = e.clientX; F.rpan.y = e.clientY; + F.zoomManual = true; F.panT = 4; + return; + } + if (pts.size === 2) { + const p = [...pts.values()]; + const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); + // два пальца: сдвиг середины — двигает карту, развод — зум + const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2; + if (F.pinchMX != null) { + F.camX -= (mx - F.pinchMX) / F.zoom; + F.camY -= (my - F.pinchMY) / F.zoom; + F.zoomManual = true; + F.panT = 4; // карту подержать: камера не убегает 4 с + } + F.pinchMX = mx; F.pinchMY = my; + if (F.pinchD > 0) setZoom(F.zoom * (d / F.pinchD)); + F.pinchD = d; + return; + } + if (e.pointerId !== F.stickId) return; + const r = canvas.getBoundingClientRect(); + let dx = (e.clientX - r.left) - F.stickOX, dy = (e.clientY - r.top) - F.stickOY; + const len = Math.hypot(dx, dy), max = 60; + if (!F.stickShown) { + if (len < 12) return; // ещё тап, не тянем + F.stickShown = true; + showStick(F.stickOX, F.stickOY); + } + if (len > max) { dx = (dx / len) * max; dy = (dy / len) * max; } + F.stickDX = dx; F.stickDY = dy; + moveStickKnob(dx, dy); + F.aimLocked = false; // явное перетаскивание стика — главный жест, разморозить + F.aim = Math.atan2(dy, dx); + F.power = Math.min(1, len / max); + const w = F.activeWorm; + if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; + }); + const endPt = (e) => { + pts.delete(e.pointerId); + if (F.rpan && e.pointerId === F.rpan.id) { F.rpan = null; return; } + if (pts.size < 2) { F.pinchD = 0; F.pinchMX = null; } + if (e.pointerId !== F.stickId) return; + const wasTap = !F.stickShown; + F.stickId = null; F.stickShown = false; hideStick(); + if (wasTap) { + // тап по своему бойцу — смена активного (кроме оружия с меткой: там тап = цель) + const def2 = WEAPONS[F.weapon]; + if (def2.kind !== 'target' && def2.kind !== 'teleport') { + const r = canvas.getBoundingClientRect(); + const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top); + if (!selectWormAt(wpt.x, wpt.y)) { + // по бойцу не попали — тап наводит прицел в точку (как мышь на компе): удобно копать/целиться с телефона + const w = F.activeWorm; + if (w && F.phase === 'move' && !F.over && (!NET.on || myTurn())) { + F.aim = Math.atan2(wpt.y - (w.y + 17), wpt.x - (w.x + 17)); + w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; + } + } + } + return; + } + // отпустил стик — только конец наведения, огонь СТРОГО с кнопки ОГОНЬ + hideStick(); + }; + canvas.addEventListener('pointerup', endPt); + canvas.addEventListener('pointercancel', endPt); +} + +function setZoom(z) { + if (!isFinite(z)) return; // планшетный развал: битый зум не пускаем + F.zoomManual = true; // ручной зум отменяет автозум до конца хода + F.zoom = Math.max(0.1, Math.min(1.6, z)); +} + +// Смена бойца: клик по своему живому червяку до выстрела. +function selectWormAt(wx, wy) { + if (NET.on) return netSwitch(wx, wy); + if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return false; + if (F.mode === 'ai' && F.teamTurn === 1) return false; + let best = null, bd = 40; + for (const w of F.sim.worms) { + if (!w.alive || w.team !== F.teamTurn || w.id === F.activeWorm.id) continue; + const d = Math.hypot(w.x + 17 - wx, w.y + 17 - wy); + if (d < bd) { bd = d; best = w; } + } + if (best) { + F.sim.setMove(F.activeWorm.id, 0); + F.activeWorm = best; + F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false; + playSound('click'); + updateHud(); + return true; + } + return false; +} + +function jump() { + if (NET.on) { netJump(); return; } + if (F.mode === 'ai' && F.teamTurn === 1) return; // комп думает — его бойцом не прыгаем + if (F.activeWorm && F.phase !== 'over') { F.sim.jump(F.activeWorm.id); playSound('step'); } +} +function cycleWeapon(d) { + let i = WEAPON_ORDER.indexOf(F.weapon); + for (let k = 0; k < WEAPON_ORDER.length; k++) { + i = (i + d + WEAPON_ORDER.length) % WEAPON_ORDER.length; + if (F.ammo[F.teamTurn][WEAPON_ORDER[i]] !== 0) break; + } + F.weapon = WEAPON_ORDER[i]; + F.pendingAim = null; // смена оружия снимает старую метку (иначе телепорт/удар уйдёт по ней) + updateHud(); +} +// Смена бойца клавиатурой: следующий живой свой. +function cycleWorm() { + if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return; + if (F.mode === 'ai' && F.teamTurn === 1) return; + const mine = F.sim.worms.filter((w) => w.alive && w.team === F.teamTurn); + if (mine.length < 2) return; + const i = mine.findIndex((w) => w.id === F.activeWorm.id); + F.sim.setMove(F.activeWorm.id, 0); + F.activeWorm = mine[(i + 1) % mine.length]; + F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false; + playSound('click'); + updateHud(); +} +function startCharge() { + const def = WEAPONS[F.weapon]; + if (F.phase !== 'move' || F.over) return; + F.aimLocked = true; // прицел замер: навёл мышью — жми пробел, курсор больше не уведёт его + if (def.kind === 'hitscan' || def.kind === 'spray') { F.power = 1; doFire(); F.aimLocked = false; return; } + if (!def.power) { doFire(); F.aimLocked = false; return; } + F.charging = true; F.chargeT = 0; +} +function releaseFire() { + if (!F.charging) { F.aimLocked = false; return; } + F.charging = false; F.aimLocked = false; + // быстрое нажатие пробела — выстрел сразу с рабочей силой, держать — добрать силу вручную + const def = WEAPONS[F.weapon]; + if (def.power && (F.chargeT || 0) < 0.25) F.power = 0.65; + doFire(); +} + +function pollKeys(dt) { + const w = F.activeWorm; + if (!w || F.over) return; + const k = F.keys; + let mv = 0; + if (k['ArrowLeft'] || k['KeyA']) mv -= 1; + if (k['ArrowRight'] || k['KeyD']) mv += 1; + if (F.touchMove) mv = F.touchMove; + // движение — только своим червяком в свой ход + if (F.mode === 'ai' && F.teamTurn === 1) mv = 0; + if (F.phase !== 'over') F.sim.setMove(w.id, mv); + if (k['ArrowUp'] || k['KeyW']) { F.aim -= 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; } + if (k['ArrowDown'] || k['KeyS']) { F.aim += 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; } + if (F.charging) { + F.chargeT += dt; + F.power = Math.min(1, F.chargeT / 1.5); + if (F.power >= 1) { F.charging = false; doFire(); } + } +} + +// ---------- HUD: оружие справа сверху, пиксельные иконки ---------- +// иконки рисуются кодом, пиксель-арт 12x12 +function drawIcon(cv, id) { + const g = cv.getContext('2d'); + const P = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); }; + g.clearRect(0, 0, 48, 48); + g.save(); g.scale(4, 4); // 12x12 пикселей + const Q = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); }; + switch (id) { + case 'bazooka': Q(1, 5, 9, 2, '#2d3436'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#e74c3c'); break; + case 'grenade': Q(4, 3, 4, 5, '#27ae60'); Q(5, 1, 2, 2, '#7f8c8d'); Q(7, 0, 2, 1, '#95a5a6'); break; + case 'homing': Q(1, 5, 9, 2, '#c0392b'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#ffe66d'); Q(2, 3, 2, 2, '#c0392b'); break; + case 'sheep': Q(2, 4, 8, 6, '#f5f6fa'); Q(0, 6, 2, 4, '#f5f6fa'); Q(8, 2, 4, 4, '#2d3436'); Q(9, 3, 1, 1, '#fff'); break; + case 'cluster': Q(3, 4, 4, 4, '#8e44ad'); Q(1, 6, 2, 2, '#8e44ad'); Q(8, 2, 2, 2, '#8e44ad'); Q(8, 8, 2, 2, '#8e44ad'); break; + case 'shotgun': Q(1, 4, 8, 1, '#6d4c41'); Q(1, 6, 8, 1, '#6d4c41'); Q(8, 3, 3, 5, '#a1887f'); Q(0, 4, 1, 3, '#e74c3c'); break; + case 'uzi': Q(2, 4, 6, 3, '#2d3436'); Q(8, 5, 3, 1, '#2d3436'); Q(4, 7, 1, 3, '#7f8c8d'); Q(0, 2, 2, 1, '#f1c40f'); Q(0, 0, 1, 1, '#f1c40f'); break; + case 'dynamite': Q(3, 3, 2, 7, '#c0392b'); Q(6, 3, 2, 7, '#c0392b'); Q(3, 5, 5, 1, '#7f8c8d'); Q(8, 1, 2, 1, '#f1c40f'); break; + case 'mine': Q(3, 6, 6, 4, '#2d3436'); Q(4, 4, 4, 2, '#7f8c8d'); Q(5, 2, 2, 2, '#e74c3c'); break; + case 'airstrike': Q(1, 2, 8, 2, '#95a5a6'); Q(4, 0, 3, 2, '#e74c3c'); Q(2, 6, 1, 2, '#e74c3c'); Q(5, 6, 1, 3, '#e74c3c'); Q(8, 6, 1, 2, '#e74c3c'); break; + case 'teleport': Q(4, 0, 4, 2, '#00a1ff'); Q(2, 3, 8, 2, '#00a1ff'); Q(4, 6, 4, 2, '#00a1ff'); Q(0, 4, 3, 4, '#00a1ff'); Q(9, 4, 3, 4, '#00a1ff'); Q(4, 9, 4, 2, '#00a1ff'); break; + case 'shovel': Q(5, 0, 2, 6, '#8d6e63'); Q(4, 0, 4, 1, '#5d4037'); Q(3, 6, 6, 5, '#b0bec5'); Q(4, 7, 4, 3, '#78909c'); Q(5, 11, 2, 1, '#546e7a'); break; + case 'skip': Q(1, 2, 4, 8, '#f1c40f'); Q(6, 2, 4, 8, '#f1c40f'); Q(1, 2, 9, 2, '#f1c40f'); Q(1, 8, 9, 2, '#f1c40f'); break; + } + g.restore(); + P(0, 0, 0, 0, '#000'); // no-op, держим P используемой +} + +function buildWeaponPopup() { + const pop = document.getElementById('wPop'); + pop.innerHTML = ''; + for (const id of WEAPON_ORDER) { + const row = document.createElement('div'); + row.className = 'wrow'; row.dataset.w = id; + const cv = document.createElement('canvas'); + cv.width = 48; cv.height = 48; + drawIcon(cv, id); + const nm = document.createElement('span'); + nm.textContent = WEAPONS[id].name; + const am = document.createElement('span'); + am.className = 'ammo'; + row.appendChild(cv); row.appendChild(nm); row.appendChild(am); + row.onclick = () => { + if (F.ammo[F.teamTurn][id] === 0) return; + if (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун + if (F.weapon !== id) F.pendingAim = null; // смена оружия снимает метку + F.weapon = id; + pop.classList.remove('on'); + playSound('click'); + updateHud(); + }; + pop.appendChild(row); + } + const btn = document.getElementById('wOpen'); + btn.onclick = (e) => { e.stopPropagation(); pop.classList.toggle('on'); updateHud(); }; + document.addEventListener('pointerdown', (e) => { + if (pop.classList.contains('on') && !pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) + pop.classList.remove('on'); + }); +} + +function updateHud() { + const s = F.sim; + if (!s) return; + const t0 = aliveWorms(0).reduce((sm, w) => sm + Math.max(0, w.hp), 0); + const t1 = aliveWorms(1).reduce((sm, w) => sm + Math.max(0, w.hp), 0); + setText('hudTeams', '🔴 ' + TEAM_NAMES[0] + ' ' + Math.round(t0) + ' vs 🔵 ' + TEAM_NAMES[1] + ' ' + Math.round(t1)); + setText('hudTurn', F.activeWorm && !F.over + ? (NET.simMode ? '⚡ Одновременно — ' + F.activeWorm.name + : 'Ходит: ' + F.activeWorm.name + (F.mode === 'ai' && F.teamTurn === 1 ? ' (комп думает…)' : '') + ' — ' + Math.ceil(F.timeLeft) + 'с') + : ''); + const wd = s.wind, wa = Math.min(5, Math.abs(wd)); + setText('hudWind', 'Ветер ' + (wd === 0 ? '· штиль' : (wd > 0 ? '▶' : '◀') + ' ' + '■'.repeat(wa) + '□'.repeat(5 - wa))); + // голос: бойцам — микрофон 🎤, зрителям — слушать 🔊; смена роли — голос гаснет, включить заново одним нажатием + const vRole = !NET.on ? '' : (NET.myTeam >= 0 ? 'p' : 's'); + if (vRole !== lastVoiceRole) { lastVoiceRole = vRole; if (voiceOn) voiceOff(true); } + document.getElementById('micBtn').style.display = vRole === 'p' ? 'block' : 'none'; + document.getElementById('earBtn').style.display = vRole === 's' ? 'block' : 'none'; + // зрителям — только смотреть: кнопки действий прячем, зум и осмотр карты остаются + const showCtl = vRole !== 's' ? '' : 'none'; + for (const id of ['controls', 'jumpBtn', 'fireBtn', 'wOpen', 'fuseBtn']) { + const el = document.getElementById(id); + if (el && (id !== 'fuseBtn' || showCtl === 'none')) el.style.display = showCtl; + } + // кнопка текущего оружия + const btn = document.getElementById('wOpen'); + btn.innerHTML = ''; + const cv = document.createElement('canvas'); + cv.width = 48; cv.height = 48; + drawIcon(cv, F.weapon); + btn.appendChild(cv); + const def = WEAPONS[F.weapon]; + btn.title = def.name + (def.fuse ? ' ⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с' : ''); + // кнопка таймера — только для оружия с таймером (зрителям не показываем вовсе) + const fuseBtn = document.getElementById('fuseBtn'); + if (fuseBtn) { + if (def.fuse && vRole !== 's') { + fuseBtn.style.display = 'block'; + fuseBtn.textContent = '⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с'; + } else fuseBtn.style.display = 'none'; + } + // подсказки убраны — вверху только важные сообщения + setText('hint', ''); + canvas.style.cursor = (def.kind === 'target' || def.kind === 'teleport') ? 'crosshair' : 'default'; + // строки попапа + const pop = document.getElementById('wPop'); + for (const row of pop.children) { + const id = row.dataset.w; + const left = F.ammo[F.teamTurn][id]; + const cd = (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) ? F.cool[F.teamTurn].airstrike : 0; + row.classList.toggle('sel', id === F.weapon); + row.classList.toggle('empty', left === 0 || cd > 0); + row.querySelector('.ammo').textContent = cd > 0 ? '⏳' + cd : (left === Infinity ? '∞' : '×' + left); + } +} +function setText(id, s) { const el = document.getElementById(id); if (el) el.textContent = s; } + +// ---------- рендер ---------- +// точка копки лопаты (клиентский рейкаст 1:1 с симом) — для кружка-прицела +function digPoint(cx, cy, ang) { + const dx = Math.cos(ang), dy = Math.sin(ang); + for (let d = 8; d <= 95; d += 8) { + const gx = Math.floor((cx + dx * d) / TILE), gy = Math.floor((cy + dy * d) / TILE); + if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue; + if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2]; + } + const px = cx + dx * 52, py = cy + dy * 52; + for (let dd = 0; dd <= 120; dd += 10) { + const gx = Math.floor(px / TILE), gy = Math.floor((py + dd) / TILE); + if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue; + if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2]; + } + return null; +} +// Точки превью траектории активного оружия (для отрисовки и автозума). +function aimPreview() { + const w = F.activeWorm; + if (!w || F.phase !== 'move' || F.over) return null; + const def = WEAPONS[F.weapon]; + if (!def || (def.kind !== 'aimed' && def.kind !== 'thrown' && def.kind !== 'hitscan' && def.kind !== 'spray')) return null; + const cx = w.x + 17, cy = w.y + 11; + if (def.kind === 'hitscan' || def.kind === 'spray') + return previewStraight(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26, F.aim, 520); + return previewShot(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26, + F.aim, def.power ? F.power : 0.6, + { thrown: def.kind === 'thrown', wind: F.sim.wind, windK: def.windK || 0, fuse: def.fuse ? def.fuse[F.fuseIdx] : 3, dir: w.dir }); +} +// Автозум: держит в кадре червяка и конец прицела. Ручной зум отменяет до конца хода. +function autoZoom(vw, vh) { + if (F.zoomManual || F.panT > 0) return; + if (F.sim.proj.length || F.boomT > 0 || !F.activeWorm) return; + const pts = aimPreview(); + if (!pts || !pts.length) return; + const w = F.activeWorm; + const end = pts[pts.length - 1]; + // широкий кадр: не налезаем на стреляющего, держим контекст вокруг + const needW = Math.abs(end[0] - (w.x + 17)) + 520; + const needH = Math.abs(end[1] - (w.y + 17)) + 480; + const zt = Math.max(0.15, Math.min(0.7, vw / needW, vh / needH)); + F.zoom += (zt - F.zoom) * 0.08; +} + +function render() { + const vw = canvas.clientWidth, vh = canvas.clientHeight; + // отладка камеры/прицела для тестов (window.WDBG()) + try { + window.WDBG = () => { + const s = F.sim, pts = (typeof aimPreview === 'function') ? aimPreview() : null; + return { + v: 51, weapon: F.weapon, phase: F.phase, over: !!F.over, + active: F.activeWorm ? { n: F.activeWorm.name, x: Math.round(F.activeWorm.x), y: Math.round(F.activeWorm.y) } : null, + aim: +((typeof F.aim === 'number') ? F.aim.toFixed(2) : -99), power: F.power, + cam: { x: Math.round(F.camX), y: Math.round(F.camY) }, zoom: +F.zoom.toFixed(2), + pts: pts ? pts.length : null, + end: pts && pts.length ? pts[pts.length - 1].map(Math.round) : null, + proj: s ? s.proj.length : -1, boomT: +(F.boomT || 0).toFixed(1), panT: +(F.panT || 0).toFixed(1), + }; + }; + } catch (e) {} + // автозум за прицелом: червяк + конец траектории в кадре + try { autoZoom(vw, vh); } catch (e) { try { window.WERR = 'autoZoom: ' + e.message; } catch (_) {} } + const z = F.zoom; + // камера за активным червяком / снарядом (мировые координаты центра экрана) + let tx = F.camX, ty = F.camY; + const s = F.sim; + if (!s) return; + // следим только за СВОИМ выстрелом: чужой снаряд камеру не дёргает. + // в локалке и у зрителя — за последним снарядом, как раньше. + // команда не опознана — лучше стоять на бойце, чем следить за чужим + let fp = null; + if (s.proj.length) { + if (!NET.on || NET.myTeam == null || NET.myTeam < 0) { + fp = s.proj[s.proj.length - 1]; + } else { + for (let i = s.proj.length - 1; i >= 0; i--) { + const p0 = s.proj[i]; + const pt = p0.t != null ? p0.t : (((s.wormById && s.wormById(p0.o)) || {}).team); + if (pt === NET.myTeam) { fp = p0; break; } + } + } + } + if (fp) { + // следим за полётом + tx = fp.x; ty = fp.y; + F.boomT = 0; + } else if (F.boomT > 0) { + // взрыв был только что — держим камеру на нём + tx = F.boomX; ty = F.boomY; + } else if (F.activeWorm && F.panT <= 0) { + // прицел: камера всегда на КОНЧИКЕ дуги — куда целишься, то и по центру экрана. + // без клампов и исключений: боец в кадре держится автозумом (отъезжает сам), а не сдвигом камеры + const canAim = F.phase === 'move' && (!NET.on || NET.myTeam == null || NET.myTeam < 0 || myTurn()); + const pts = canAim ? aimPreview() : null; + if (pts && pts.length) { + const end = pts[pts.length - 1]; + tx = end[0]; ty = end[1]; + F.camAim = true; // жёсткое слежение за бегущим кончиком (пока заряжаешь — камера не отстаёт) + } else { tx = F.activeWorm.x + 17; ty = F.activeWorm.y + 17 - 60 / z; F.camAim = false; } + } + if (!(tx > 0)) { tx = (F.xMinPx + F.xMaxPx) / 2; ty = SEA_GY * TILE - 200; } + const hw = vw / 2 / z, hh = vh / 2 / z; + tx = Math.max(F.xMinPx - 100 + hw, Math.min(F.xMaxPx + 100 - hw, tx)); + F.camX += (tx - F.camX) * (F.camAim ? 0.2 : 0.12); + F.camY += (ty - F.camY) * (F.camAim ? 0.2 : 0.12); + // тряска экрана после взрыва + let shx = 0, shy = 0; + if (F.shakeT > 0) { + F.shakeT -= 1 / 60; + const m = F.shakeR * F.shakeT * 3; + shx = (Math.random() - 0.5) * m; shy = (Math.random() - 0.5) * m; + } + + ctx.fillStyle = F.night ? '#3a4670' : '#87CEEB'; + ctx.fillRect(0, 0, vw, vh); + ctx.save(); + ctx.translate(vw / 2, vh / 2); + ctx.scale(z, z); + ctx.translate(-F.camX + shx, -F.camY + shy); + + const minGX = Math.floor((F.camX - hw) / TILE) - 2, maxGX = Math.floor((F.camX + hw) / TILE) + 2; + const minGY = Math.floor((F.camY - hh) / TILE) - 4, maxGY = Math.floor((F.camY + hh) / TILE) + 4; + const ui = Math.min(4, 1 / z); // экранный масштаб мелочей: при отдалении не мельчают + for (const b of blocks) { + if (b.dead) continue; + if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue; + const def = BLOCKS[b.t]; + if (def.alpha) { ctx.save(); ctx.globalAlpha = def.alpha; ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); ctx.restore(); } + else ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); + } + // (воду рисуют сами блоки water; общей подложки нет — иначе камень «тонет» визуально) + + // мины / динамит + for (const m of s.mines) { + ctx.fillStyle = m.fuse > 0 ? (Math.sin(performance.now() / 60) > 0 ? '#ff0000' : '#7f8c8d') : (m.armed >= 1 ? '#2d3436' : '#95a5a6'); + ctx.beginPath(); ctx.arc(m.x, m.y, 9, 0, 7); ctx.fill(); + } + for (const d of s.dyn) { + ctx.fillStyle = '#c0392b'; ctx.fillRect(d.x - 10, d.y - 6, 20, 12); + ctx.fillStyle = '#f1c40f'; ctx.fillRect(d.x + 6, d.y - 10, 4, 4); + } + // снаряды + for (const p of s.proj) { + if (p.k === 'missile' || p.k === 'homing') { + ctx.fillStyle = p.k === 'homing' ? '#c0392b' : '#2d3436'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill(); + if (p.k === 'homing') { ctx.fillStyle = '#ffe66d'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, 7); ctx.fill(); } // жёлтый глазок самонавода + } + else if (p.k === 'grenade' || p.k === 'cluster') { ctx.fillStyle = p.k === 'cluster' ? '#8e44ad' : '#27ae60'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill(); } + else if (p.k === 'striker') { ctx.fillStyle = '#e74c3c'; ctx.fillRect(p.x - 3, p.y - 8, 6, 16); } + else if (p.k === 'sheep') { // овца: большая, с тёмным контуром (видно на небе), ноги скачут + const sd = (p.vx || 0) >= 0 ? 1 : -1; + const gal = Math.floor(performance.now() / 140) % 2; // галоп: ноги по очереди + ctx.fillStyle = '#2d3436'; + if (gal) { ctx.fillRect(p.x - 9, p.y + 3, 6, 7); ctx.fillRect(p.x + 3, p.y + 6, 6, 4); } + else { ctx.fillRect(p.x - 9, p.y + 6, 6, 4); ctx.fillRect(p.x + 3, p.y + 3, 6, 7); } + ctx.fillStyle = '#dfe6e9'; // шерсть + ctx.beginPath(); ctx.arc(p.x - 8, p.y - 3, 8, 0, 7); ctx.arc(p.x, p.y - 7, 9, 0, 7); ctx.arc(p.x + 8, p.y - 3, 8, 0, 7); ctx.fill(); + ctx.lineWidth = 2.5; ctx.strokeStyle = '#2d3436'; ctx.stroke(); // контур — не сливается с небом + ctx.fillStyle = '#2d3436'; // морда по ходу + ctx.beginPath(); ctx.arc(p.x + sd * 12, p.y - 1, 6, 0, 7); ctx.fill(); + ctx.fillStyle = '#dfe6e9'; // ухо + ctx.beginPath(); ctx.arc(p.x + sd * 6, p.y - 9, 3, 0, 7); ctx.fill(); + ctx.fillStyle = '#ffffff'; // глаз + ctx.fillRect(p.x + sd * 11, p.y - 3, 2.5, 2.5); + } + else { + // дробь/пули: светящиеся с трассером — видно издалека + if ((p.vx || p.vy) && (Math.abs(p.vx) + Math.abs(p.vy) > 1)) { + ctx.strokeStyle = 'rgba(255,225,77,0.7)'; ctx.lineWidth = Math.max(2, 3 * ui); + ctx.beginPath(); ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - p.vx * 0.035, p.y - p.vy * 0.035); ctx.stroke(); + } + ctx.save(); ctx.shadowColor = '#f1c40f'; ctx.shadowBlur = 14 * ui; + ctx.fillStyle = '#ffe14d'; + const ps = 3 + 3 * ui; + ctx.fillRect(p.x - ps / 2, p.y - ps / 2, ps, ps); ctx.restore(); + } + } + // самолёты авиаудара (сеть — позиции с сервера, локально — из сима) + for (const pl of (NET.on ? (F.planes || []) : s.strikes.filter((x) => x.started))) { + drawPlane(pl.px != null ? pl.px : pl.x, pl.py != null ? pl.py : pl.y, pl.dir || pl.d || 1); + } +// пиксельный самолётик авиаудара (носом по курсу) +function drawPlane(x, y, dir) { + ctx.save(); ctx.translate(x, y); ctx.scale(dir >= 0 ? 1 : -1, 1); + ctx.fillStyle = '#5f6f35'; ctx.fillRect(-22, -4, 44, 8); // фюзеляж + ctx.fillStyle = '#c0392b'; ctx.fillRect(18, -3, 6, 6); // нос + ctx.fillStyle = '#4a5627'; ctx.fillRect(-4, -3, 12, 22); // крыло + ctx.fillStyle = '#4a5627'; ctx.fillRect(-24, -14, 9, 10); // хвост + ctx.fillStyle = '#bfe3ef'; ctx.fillRect(2, -10, 10, 7); // кабина + ctx.fillStyle = '#ecf0f1'; ctx.fillRect(-14, -2, 6, 4); // звезда + ctx.restore(); +} + // червяки-Гречки: с базукой — только активный с базукой/самонаводом, остальные — обычный; + // спрайты нарисованы мордой влево: dir>=0 (вправо) = зеркалить + const iHoldHeavy = (!NET.on || myTurn()) && (F.weapon === 'bazooka' || F.weapon === 'homing'); + for (const w of s.worms) { + if (!w.alive) continue; + const img = F.heroImg; + const heavy = (w === F.activeWorm) && iHoldHeavy; + const picked = w.team === 0 + ? (heavy ? F.wormImgRed : (F.wormPlainRed && F.wormPlainRed.naturalWidth ? F.wormPlainRed : F.wormImgRed)) + : (heavy ? F.wormImgBlue : (F.wormPlainBlue && F.wormPlainBlue.naturalWidth ? F.wormPlainBlue : F.wormImgBlue)); + const showImg = (picked && picked.complete && picked.naturalWidth) ? picked + : (img.complete && img.naturalWidth) ? img : null; + if (showImg) { + ctx.save(); + ctx.translate(w.x - 3 + TILE / 2, w.y - 3 + TILE / 2); + ctx.scale(w.dir >= 0 ? -1 : 1, 1); + const sz = TILE * 2; + ctx.drawImage(showImg, -sz / 2, -sz / 2 - TILE / 2, sz, sz); + ctx.restore(); + } + // имя + хп — над головой спрайта (спрайт 2 блока, макушка на TILE выше) + if (w === F.activeWorm && !F.over) { + ctx.fillStyle = '#f1c40f'; + const ay = w.y - TILE - 40 * ui; + ctx.beginPath(); + ctx.moveTo(w.x + 17, ay); ctx.lineTo(w.x + 17 - 8 * ui, ay + 12 * ui); ctx.lineTo(w.x + 17 + 8 * ui, ay + 12 * ui); + ctx.fill(); + } + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE, 6 * ui); + ctx.fillStyle = w.hp > 50 ? '#2ecc71' : w.hp > 25 ? '#f39c12' : '#e74c3c'; + ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE * Math.max(0, w.hp) / 100, 6 * ui); + if (w.inWater) { // кислород под хп + ctx.fillStyle = 'rgba(0,0,0,0.55)'; + ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE, 4 * ui); + ctx.fillStyle = '#00a1ff'; + ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE * Math.max(0, w.o2) / 100, 4 * ui); + } + ctx.fillStyle = '#fff'; ctx.font = (11 * ui) + 'px sans-serif'; ctx.textAlign = 'center'; + ctx.fillText(w.name + ' ' + Math.max(0, Math.round(w.hp)), w.x + 17, w.y - TILE - 20 * ui); + // прицел активного + // прицел активного (в сети — только свой боец в свой ход) + if (w === F.activeWorm && !F.over && F.phase === 'move' && (!NET.on || myTurn())) { + const def = WEAPONS[F.weapon]; + if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray' || F.weapon === 'sheep') { + const cx = w.x + 17, cy = w.y + 11; + const straight = def.kind === 'hitscan' || def.kind === 'spray'; + const aimLen = straight ? 130 : (F.weapon === 'sheep' ? 60 : 34); // овце — короткий указатель направления + ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 * ui; + ctx.beginPath(); ctx.moveTo(cx, cy); + ctx.lineTo(cx + Math.cos(F.aim) * aimLen, cy + Math.sin(F.aim) * aimLen); ctx.stroke(); + // превью траектории (экранный размер точек) + const pts = aimPreview(); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + const dp = 2 * ui; + if (pts) for (const pt of pts) ctx.fillRect(pt[0] - dp / 2, pt[1] - dp / 2, dp, dp); + } else if (def.kind === 'dig') { + // лопата: линия прицела + кружок на земле, куда копнём + const dp0 = digPoint(w.x + 17, w.y + 17, F.aim); + ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 2 * ui; + ctx.beginPath(); ctx.moveTo(w.x + 17, w.y + 17); + ctx.lineTo(w.x + 17 + Math.cos(F.aim) * 95, w.y + 17 + Math.sin(F.aim) * 95); ctx.stroke(); + if (dp0) { + ctx.setLineDash([6 * ui, 4 * ui]); + ctx.beginPath(); ctx.arc(dp0[0], dp0[1], 52 * F.zoom, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + } + } + } + } + // зрители на балкончиках (сеть): те же гречки, других цветов + имена + drawSpecs(ctx, ui); + // частицы + for (const pt of F.particles) { + if (pt.ring) { // расширяющееся кольцо взрыва + const k = 1 - Math.max(0, pt.t) / (pt.t0 || 0.35); + ctx.globalAlpha = Math.max(0, pt.t) * 2; + ctx.strokeStyle = pt.c; ctx.lineWidth = Math.max(1, 5 * (1 - k)); + ctx.beginPath(); ctx.arc(pt.x, pt.y, 8 + (pt.r1 || 80) * k, 0, 7); ctx.stroke(); + ctx.globalAlpha = 1; + } else { + ctx.fillStyle = pt.c; ctx.globalAlpha = Math.max(0, Math.min(1, pt.t * 1.5)); + const s2 = (pt.sz || 6) / 2; + ctx.fillRect(pt.x - s2, pt.y - s2, pt.sz || 6, pt.sz || 6); ctx.globalAlpha = 1; + } + } + // метка цели (авиаудар/телепорт) + if (F.pendingAim && !F.over) { + const px = F.pendingAim.x, py = F.pendingAim.y, r = 14 * ui; + ctx.strokeStyle = '#e74c3c'; ctx.lineWidth = 3 * ui; + ctx.beginPath(); ctx.arc(px, py, r, 0, 7); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(px - r - 8 * ui, py); ctx.lineTo(px - r + 4 * ui, py); + ctx.moveTo(px + r - 4 * ui, py); ctx.lineTo(px + r + 8 * ui, py); + ctx.moveTo(px, py - r - 8 * ui); ctx.lineTo(px, py - r + 4 * ui); + ctx.moveTo(px, py + r - 4 * ui); ctx.lineTo(px, py + r + 8 * ui); + ctx.stroke(); + } + // цифры урона + ctx.fillStyle = '#ff4757'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center'; + for (const fl of F.floaters) { + const w = F.sim.wormById(fl.worm); + if (w) ctx.fillText(fl.text, w.x + 17, w.y - 34 - (1.2 - fl.t) * 30); + } + ctx.restore(); + // ночь: просто темнее фон и темнее земля + тёплое свечение у факелов + if (F.night) drawNightShade(vw, vh); + + // полоса силы + if (F.charging) { + ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(vw / 2 - 110, vh - 120, 220, 18); + ctx.fillStyle = F.power > 0.85 ? '#e74c3c' : '#f1c40f'; + ctx.fillRect(vw / 2 - 108, vh - 118, 216 * F.power, 14); + } + // таймер хода (левее кнопки оружия) + if (!F.over && F.activeWorm) { + ctx.fillStyle = F.timeLeft < 6 ? '#e74c3c' : '#fff'; + ctx.font = 'bold 22px sans-serif'; ctx.textAlign = 'right'; + ctx.fillText(Math.ceil(F.timeLeft) + 'с', vw - 76, 30); + } +} + +// ---------- ночь: затемнение мира + свечение факелов ---------- +function drawNightShade(vw, vh) { + ctx.save(); + ctx.fillStyle = 'rgba(8,10,34,0.32)'; + ctx.fillRect(0, 0, vw, vh); + // тёплые ореолы у уцелевших факелов + ctx.globalCompositeOperation = 'lighter'; + const now = performance.now(); + for (const t of F.torches) { + const b = getBlock(Math.floor(t.x / TILE), Math.floor(t.y / TILE)); + if (!b || b.dead || b.t !== 'torch') continue; + const sx = (t.x - F.camX) * F.zoom + vw / 2; + const sy = (t.y - F.camY) * F.zoom + vh / 2; + const rr = 130 * F.zoom * (0.92 + Math.sin(now / 110 + t.x * 0.05) * 0.08); + if (sx < -rr || sy < -rr || sx > vw + rr || sy > vh + rr) continue; + const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, rr); + grad.addColorStop(0, 'rgba(255,190,90,0.35)'); + grad.addColorStop(1, 'rgba(255,190,90,0)'); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.arc(sx, sy, rr, 0, 7); ctx.fill(); + } + ctx.restore(); +} + +// ---------- главный цикл ---------- +let lastT = 0, acc = 0; +function frame(now) { + requestAnimationFrame(frame); + if (!F.sim) return; // режим ещё не выбран + const dt = Math.min(0.05, (now - lastT) / 1000 || 0.016); + lastT = now; + // сеть: физикой владеет сервер — только ввод, эффекты, рендер + if (NET.on) { + if (!isFinite(F.camX) || !isFinite(F.camY) || !isFinite(F.zoom)) { + F.zoom = 0.55; F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150; + } + if (myTurn()) pollKeys(dt); // чужим бойцом управлять нельзя — ввод только в свой ход + netMoveTick(); + // интерполяция снарядов между снапами: летят гладко, сервер поправляет + for (const p of F.sim.proj) { + if (p.vx || p.vy) { p.px = p.x; p.py = p.y; p.x += p.vx * dt; p.y += p.vy * dt; } + } + if (F.panT > 0) F.panT -= dt; + if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt; + pumpEvents(); + for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; } + F.particles = F.particles.filter((p) => p.t > 0); + for (const fl of F.floaters) fl.t -= dt; + F.floaters = F.floaters.filter((f) => f.t > 0); + render(); + if ((now | 0) % 500 < 20) updateHud(); + return; + } + if (F.over) { render(); return; } + pollKeys(dt); + if (F.panT > 0) F.panT -= dt; + // память о взрыве тает, когда ничего не летит + if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt; + // таймеры хода + if (F.phase === 'move') { + F.timeLeft -= dt; + if (F.timeLeft <= 0) { F.timeLeft = 0; endTurn(); } + } else if (F.phase === 'retreat') { + F.retreatT -= dt; + if (F.retreatT <= 0 && settled()) endTurn(); + } + // физика фикс-степом + acc += dt; + const h = 1 / 60; + let n = 0; + while (acc >= h && n < 4) { F.sim.step(h); acc -= h; n++; } + pumpEvents(); + // проверка победы + const teams = F.sim.aliveTeams(); + if (teams.length <= 1 && !F.over) gameOver(teams[0]); + // частицы + for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; } + F.particles = F.particles.filter((p) => p.t > 0); + for (const fl of F.floaters) fl.t -= dt; + F.floaters = F.floaters.filter((f) => f.t > 0); + render(); + if ((now | 0) % 500 < 20) updateHud(); +} + +// ---------- сеть: лобби, комнаты по коду, зрители ---------- +const NET = { + on: false, sock: null, code: null, myTeam: -2, myName: 'Игрок', + specs: [], // [{name,x,y,hopT,colorIdx}] + roster: null, inviteFrom: null, lastMv: 9, +}; +const SPEC_COLORS = ['#ff8c1a', '#a64dff', '#33cc66', '#ffe14d', '#ff6fb5', '#f2f2f2']; +function netMsg(o) { if (NET.sock) NET.sock.emit('input', o); } +function netStatus(t, cls) { + const el = document.getElementById('netStatus'); + if (!el) return; + el.textContent = t; + el.className = cls || ''; +} +function netConnect() { + if (NET.sock) return NET.sock; + if (typeof io === 'undefined') { // сетевой модуль не загрузился (блокировка/кеш) + netStatus('○ Не загрузился сетевой модуль — обнови страницу', 'bad'); + return { connected: false, emit() {}, connect() {}, on() {} }; + } + NET.sock = io({ path: '/wapi/' }); + const s = NET.sock; + s.on('joined', (m) => { + NET.code = m.code; NET.myTeam = m.team; NET.wasJoined = true; + s.emit('top', { name: NET.myName }); // подтянуть топ и свою статистику + setText('roomCode', 'Код: ' + m.code); + document.getElementById('lobbyWait').style.display = 'block'; + document.getElementById('lobbyForm').style.display = 'none'; + document.getElementById('roster').style.display = 'block'; + // настройки и старт — только у создателя; гость просто ждёт автостарта + const host = !!m.host; + for (const id of ['modeRow', 'dnRow', 'bCopyLink']) { + const el = document.getElementById(id); + if (el) el.style.display = host ? '' : 'none'; + } + document.getElementById('lobbyNote').style.display = host ? 'none' : 'block'; + if (NET.myTeam === -1) setText('hint', 'Ты зритель 👀 Жми ⬆️ — твоя гречка прыгает'); + }); + s.on('room', (r) => { NET.roster = r; renderRoster(); }); + s.on('match_start', (m) => netStart(m)); + s.on('snap', (sn) => applySnap(sn)); + s.on('diff', (m) => netApplyCells(m.cells || [])); + s.on('sync', (m) => netApplyCells(m.cells || m.diff || [])); + s.on('ev', (m) => { if (F.sim && m && m.e) { try { F.sim.ev(m.e); } catch (e) {} } }); + s.on('invite', (m) => { + if (m.to === NET.myName) { + NET.inviteFrom = m.from; + setText('inviteText', m.from + ' зовёт играть!'); + document.getElementById('invitePop').style.display = 'flex'; + playSound('click'); + } + }); + s.on('invite_declined', (m) => setText('hint', m.name + ' отказался')); + s.on('game_over', (m) => netGameOver(m.winner, m)); + // кто сейчас играет + войти зрителем + s.on('rooms', (m) => { + const el = document.getElementById('roomsTable'); + if (!el) return; + const list = (m && m.list) || []; + if (!list.length) { el.innerHTML = '👀 Сейчас никто не играет — создай игру!'; return; } + el.innerHTML = '👀 Сейчас играют' + list.map((r) => { + const who = r.players.map((n) => String(n).slice(0, 12).replace(/[<>&]/g, '')).join(' vs ') || '…'; + const st = r.phase === 'play' ? 'бой' : 'лобби'; + return ''; + }).join('') + '
' + who + '' + st + (r.specs ? ', зрителей: ' + r.specs : '') + + '
'; + el.querySelectorAll('[data-watch]').forEach((b) => { + b.onclick = () => { + NET.myName = (document.getElementById('nick').value.trim() || 'Зритель').slice(0, 20); + try { localStorage.setItem('wormsName', NET.myName); } catch (e) {} + netConnect().emit('join', { code: b.dataset.watch, name: NET.myName, as: 'spectate' }); + playSound('click'); + }; + }); + }); + s.on('top', (m) => { + const el = document.getElementById('topTable'); + if (!el) return; + const list = (m && m.list) || []; + let html = ''; + if (!list.length) html = '🏆 Топ-10: пока пусто — стань первым!'; + else html = '🏆 Топ-10' + list.map((r, i) => + '').join('') + '
' + (i + 1) + '.' + String(r.name).slice(0, 20).replace(/[<>&]/g, '') + + '🏆' + r.wins + '' + r.games + ' игр
'; + if (m && m.me) { + const wr = m.me.games ? Math.round(m.me.wins / m.me.games * 100) : 0; + html += '

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