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 0000000..ab9694e Binary files /dev/null and b/worms-pub/audio/shotgun.mp3 differ diff --git a/worms-pub/audio/uzi.mp3 b/worms-pub/audio/uzi.mp3 new file mode 100644 index 0000000..ddc127c Binary files /dev/null and b/worms-pub/audio/uzi.mp3 differ 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 0000000..fde8986 Binary files /dev/null and b/worms-pub/worm-blue-bazooka.png differ diff --git a/worms-pub/worm-red-bazooka.png b/worms-pub/worm-red-bazooka.png new file mode 100644 index 0000000..dc9e61c Binary files /dev/null and b/worms-pub/worm-red-bazooka.png differ 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(); + +})();