бэкап-снимок: незакоммиченные правки grechka-game

This commit is contained in:
Mikhail 2026-10-09 23:05:52 +02:00
parent afb5b8a7c6
commit ea0694898e
16 changed files with 9350 additions and 8 deletions

0
build.err Normal file
View File

View File

@ -8,7 +8,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const SRC = path.join(__dirname, 'src'); const SRC = path.join(__dirname, 'src');
const ENTRY = 'main.js'; const ENTRY = process.argv[2] || 'main.js';
const visited = new Set(); const visited = new Set();
const chunks = []; const chunks = [];

View File

@ -115,5 +115,40 @@
</div> </div>
<script src="game.js?v=44"></script> <script src="game.js?v=44"></script>
<!-- Выбор игры: Grechka Craft / Grechka Armageddon -->
<style>
#gamePick{position:fixed;inset:0;z-index:2147483647;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.6);font-family:sans-serif}
#gamePick .box{background:#fff;border-radius:12px;padding:24px 32px;text-align:center;max-width:86vw}
#gamePick h1{font-size:22px;margin:0 0 16px;color:#111}
#gamePick .pick{display:block;width:270px;margin:10px auto;padding:12px 24px;font-size:18px;border:none;border-radius:10px;cursor:pointer;color:#fff}
#gamePick .craft{background:#27ae60}#gamePick .arma{background:#2980b9}
</style>
<div id="gamePick"><div class="box">
<h1>Во что играем?</h1>
<button class="pick craft" id="pickCraft">⛏️ Grechka Craft</button>
<button class="pick arma" id="pickArma">💥 Grechka Armageddon</button>
</div></div>
<script>
(function(){
try {
var q = new URLSearchParams(location.search).get('play');
var ov = document.getElementById('gamePick');
// выбор — всегда вручную, никаких автопереходов
if (q === 'craft') { try { localStorage.setItem('grechka-game-choice','craft'); } catch(e){} return; }
if (q === 'armageddon') { location.replace('/worms/'); return; }
ov.style.display = 'flex';
document.getElementById('pickCraft').onclick = function(){
try { localStorage.setItem('grechka-game-choice','craft'); } catch(e){}
ov.style.display = 'none';
};
document.getElementById('pickArma').onclick = function(){
try { localStorage.setItem('grechka-game-choice','armageddon'); } catch(e){}
location.href = '/worms/';
};
} catch(e){}
})();
</script>
</body> </body>
</html> </html>

636
src/worms/sim.js Normal file
View File

@ -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;

2002
src/worms/worms-main.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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; } 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; } #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; } 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 вместо .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; 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; } 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; } .btn:active { box-shadow:0 3px 0 #111; background:#444; }
#left { left:18px; } #left { left:18px; top:14px; }
#right { left:102px; } #right { left:102px; top:14px; }
#down { left:186px; background:#2980b9; border-color:#3498db; } #down { left:60px; top:100px; background:#2980b9; border-color:#3498db; }
#jump { right:18px; background:#d35400; border-color:#e67e22; } #jump { right:18px; top:14px; background:#d35400; border-color:#e67e22; }
/* Скрываем панель на десктопе (широкие экраны) */ /* Скрываем панель на десктопе (широкие экраны) */
@media (min-width: 769px) { @media (min-width: 769px) {

2
worms-pub/audio/CC0.txt Normal file
View File

@ -0,0 +1,2 @@
Дробовик: Wikimedia Commons — «Dragout - War sounds (cc0) (freesound).ogg», CC0.
УЗИ: нарезано из того же CC0-файла (5 ударов пика через 55 мс).

BIN
worms-pub/audio/shotgun.mp3 Normal file

Binary file not shown.

BIN
worms-pub/audio/uzi.mp3 Normal file

Binary file not shown.

169
worms-pub/index.html Normal file
View File

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>Гречка: Червяки</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #87CEEB; font-family: sans-serif; }
#game { position: fixed; inset: 0; }
#c { width: 100%; height: 100%; display: block; touch-action: none; }
#hud { position: fixed; top: 0; left: 0; right: 64px; padding: 6px 8px; color: #fff; font-size: 14px;
text-shadow: 0 1px 3px #000; pointer-events: none; display: flex; gap: 12px; flex-wrap: wrap; }
#hudWind { font-family: monospace; font-weight: bold; letter-spacing: 1px;
background: rgba(0,0,0,0.45); border-radius: 6px; padding: 1px 7px; }
/* оружие справа сверху */
#wOpen { position: fixed; top: 8px; right: 8px; width: 52px; height: 52px; border-radius: 10px;
background: rgba(0,0,0,0.55); border: 2px solid #f1c40f; display: flex; align-items: center; justify-content: center; z-index: 5; }
#wOpen canvas { width: 40px; height: 40px; }
#wPop { position: fixed; top: 68px; right: 8px; width: 248px; max-height: 60vh; overflow-y: auto;
background: rgba(20,20,20,0.92); border-radius: 12px; padding: 8px; display: none; z-index: 6; }
#wPop.on { display: block; }
.wrow { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 8px; color: #fff; font-size: 14px; }
.wrow.sel { background: #3d484b; outline: 2px solid #f1c40f; }
.wrow.empty { opacity: 0.35; }
.wrow canvas { width: 36px; height: 36px; background: #111; border-radius: 6px; }
.wrow .ammo { margin-left: auto; color: #f1c40f; }
/* зум */
#zoom { position: fixed; right: 8px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 5; }
.zbtn { width: 44px; height: 44px; border-radius: 50%; border: none; font-size: 22px; background: rgba(0,0,0,0.5); color: #fff; }
/* движение как в оригинале */
#controls { position: fixed; left: 8px; bottom: 12px; display: none; gap: 10px; z-index: 5; }
@media (pointer: coarse) { #controls { display: flex; } }
.btn { width: 64px; height: 64px; border-radius: 50%; background: rgba(0,0,0,0.5); color: #fff;
font-size: 28px; display: flex; align-items: center; justify-content: center; user-select: none; -webkit-user-select: none; }
.btn:active { background: rgba(241,196,15,0.6); }
/* джойстик прицела */
#stick { position: fixed; width: 120px; height: 120px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.7);
background: rgba(0,0,0,0.25); display: none; z-index: 5; pointer-events: none; }
#stickKnob { position: absolute; width: 52px; height: 52px; border-radius: 50%; background: rgba(241,196,15,0.8);
left: 34px; top: 34px; }
#banner, #modeOverlay { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); z-index: 10; }
/* правая колонка: прыжок, над ним огонь */
#jumpBtn { position: fixed; right: 12px; bottom: 12px; width: 84px; height: 64px; border-radius: 14px;
border: 3px solid #00a1ff; background: rgba(0,0,0,0.5); color: #fff; font-size: 26px;
display: block; z-index: 5; user-select: none; -webkit-user-select: none; }
#jumpBtn:active { background: rgba(0,161,255,0.6); }
/* кнопка огня */
#fireBtn { position: fixed; right: 12px; bottom: 84px; width: 84px; height: 84px; border-radius: 50%;
border: 3px solid #f1c40f; background: rgba(192,57,43,0.85); color: #fff; font-size: 15px; font-weight: bold;
display: block; z-index: 5; user-select: none; -webkit-user-select: none; }
#fireBtn:active { background: rgba(241,196,15,0.9); }
#fuseBtn { position: fixed; right: 12px; bottom: 176px; min-width: 84px; height: 40px; border-radius: 10px;
border: 2px solid #00a1ff; background: rgba(0,0,0,0.55); color: #fff; font-size: 15px; z-index: 5; display: none; }
#hint { position: fixed; top: 66px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 14px;
background: rgba(0,0,0,0.55); border-radius: 10px; padding: 6px 12px; pointer-events: none; z-index: 4;
white-space: nowrap; max-width: 94vw; overflow: hidden; text-overflow: ellipsis; }
#modeOverlay { display: flex; }
#lobby, #invitePop, #pickPop { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); z-index: 11; }
#lobby .box input { font-size: 18px; padding: 10px; border-radius: 8px; border: 1px solid #ccc; margin: 6px; width: 200px; text-align: center; }
#roomCode { font-size: 26px; font-weight: bold; letter-spacing: 4px; margin: 8px; }
#modeRow { display: flex; gap: 8px; justify-content: center; margin: 8px 0; }
#modeRow .big, #dnRow .big { background: #95a5a6; opacity: 0.7; }
#modeRow .big.on { background: #27ae60; opacity: 1; }
#dnRow { display: flex; gap: 8px; justify-content: center; margin: 8px 0; }
#dnRow .big.on { background: #2980b9; opacity: 1; }
#topTable { margin-top: 10px; font-size: 14px; color: #333; }
#topTable table { margin: 4px auto; border-collapse: collapse; }
#topTable td { padding: 1px 8px; text-align: left; }
#netStatus { font-size: 14px; margin: 4px; color: #e67e22; }
#netStatus.ok { color: #27ae60; }
#netStatus.bad { color: #c0392b; }
.ltext { font-size: 13px; color: #555; margin: 6px 0 2px; }
#specBadge { position: fixed; bottom: 8px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.6); color: #ffe14d; font-size: 13px; border-radius: 8px;
padding: 4px 12px; z-index: 5; pointer-events: none; }
#rosterList { max-height: 38vh; overflow-y: auto; }
#micBtn { position: fixed; right: 12px; top: 116px; width: 52px; height: 52px; border-radius: 50%;
font-size: 24px; background: rgba(0,0,0,0.55); border: 2px solid #fff; z-index: 5; }
#micBtn.on { background: rgba(46,204,113,0.9); border-color: #2ecc71; }
#micBtn.err { background: rgba(231,76,60,0.9); }
#earBtn { position: fixed; right: 12px; top: 116px; width: 52px; height: 52px; border-radius: 50%;
font-size: 24px; background: rgba(0,0,0,0.55); border: 2px solid #fff; z-index: 5; }
#earBtn.on { background: rgba(52,152,219,0.9); border-color: #3498db; }
#speakInd { position: fixed; right: 72px; top: 128px; background: rgba(0,0,0,0.7); color: #2ecc71;
padding: 4px 10px; border-radius: 8px; font-size: 13px; z-index: 5; pointer-events: none; }
#roster { position: fixed; left: 8px; top: 64px; z-index: 4; background: rgba(0,0,0,0.55); color: #fff;
border-radius: 10px; padding: 8px 10px; font-size: 13px; display: none; max-width: 44vw; }
#roster button { margin-left: 6px; font-size: 14px; }
.box { background: #fff; border-radius: 12px; padding: 24px 32px; text-align: center; max-width: 86vw; }
.box .b-text { font-size: 22px; margin-bottom: 16px; }
.big { font-size: 18px; padding: 12px 24px; border-radius: 10px; border: none; background: #27ae60; color: #fff; margin: 6px; }
.big.blue { background: #2980b9; }
#help { position: fixed; left: 8px; top: 44px; color: #fff; font-size: 12px; text-shadow: 0 1px 3px #000;
pointer-events: none; opacity: 0.85; }
@media (pointer: coarse) { #help { display: none; } }
</style>
</head>
<body>
<div id="game"><canvas id="c"></canvas></div>
<div id="hud"><span id="hudTeams"></span><span id="hudTurn"></span><span id="hudWind"></span></div>
<div id="specBadge" style="display:none">👀 Ты наблюдатель — болеешь с балкона (⬆️ — прыгать)</div>
<div id="help" style="display:none"></div>
<div id="wOpen" title="Оружие"></div>
<div id="wPop"></div>
<div id="zoom"><button class="zbtn" id="zIn">+</button><button class="zbtn" id="zOut">−</button></div>
<div id="controls">
<div id="left" class="btn">⬅️</div>
<div id="right" class="btn">➡️</div>
</div>
<div id="stick"><div id="stickKnob"></div></div>
<div id="hint"></div>
<button id="fuseBtn">⏱3с</button>
<button id="jumpBtn">⬆️</button>
<button id="fireBtn">ОГОНЬ</button>
<button id="micBtn" style="display:none" title="Голосовой чат (выкл)">🎤</button>
<button id="earBtn" style="display:none" title="Слушать бойцов (выкл)">🔊</button>
<div id="speakInd" style="display:none"></div>
<div id="modeOverlay"><div class="box">
<div class="b-text">🌾 Гречка: Червяки</div>
<div>Две горы. Между ними — вода.<br>5 на 5. Побеждает команда.</div>
<div style="margin:10px 0" id="dnRow">
<button class="big" id="dDay" style="background:#f39c12">☀️ День</button>
<button class="big" id="dNight" style="background:#34495e">🌙 Ночь</button>
</div><br>
<button class="big blue" id="mAi">🤖 С компом</button>
<button class="big" id="mHot">🎮 Вдвоём</button><br>
<button class="big" id="mNet" style="background:#8e44ad">🌐 По сети</button>
</div></div>
<div id="lobby"><div class="box">
<div class="b-text">🌐 Игра по сети</div>
<div id="lobbyForm">
<div id="netStatus">Подключение…</div>
<input id="nick" maxlength="20" placeholder="Твоё имя"><br>
<div class="ltext">Новая игра — код выдадим сами:</div>
<button class="big" id="bCreate">Создать игру</button><br>
<div class="ltext">— или войти по коду друга —</div>
<input id="codeIn" maxlength="5" inputmode="numeric" placeholder="Код: 5 цифр"><br>
<button class="big blue" id="bJoin">Присоединиться</button>
<div id="topTable">🏆 Топ-10: загрузка…</div>
<div id="roomsTable" style="margin-top:8px">👀 Сейчас играют: загрузка…</div>
</div>
<div id="lobbyWait" style="display:none">
<div id="roomCode">Код: …</div>
<div>Друзья вводят код и появляются в твоём мире.<br>Третий и дальше — зрители на балкончиках.<br>Второй игрок зашёл — бой начинается сам.</div>
<div id="modeRow"><button class="big on" id="bModeTurns">🚶 По очереди</button><button class="big" id="bModeSim">⚡ Одновременно</button></div>
<div id="dnRow"><button class="big on" id="bDay">☀️ День</button><button class="big" id="bNight">🌙 Ночь</button></div>
<div id="lobbyNote" style="display:none">Ждём создателя… Бой начнётся сам, как только оба в комнате.</div>
<button class="big blue" id="bCopyLink">📋 Ссылка на бой</button><br>
</div>
</div></div>
<div id="roster"><div class="b-text">Комната</div><div id="rosterList"></div></div>
<div id="invitePop"><div class="box">
<div class="b-text" id="inviteText">Зовут играть!</div>
<button class="big" id="bAccept">Играю!</button>
<button class="big" id="bDecline" style="background:#7f8c8d">Откажусь</button>
</div></div>
<div id="pickPop"><div class="box">
<div class="b-text">🏆 Победа! С кем дальше?</div>
<div id="pickList"></div>
<button class="big blue" id="bSame">Ещё раз с тем же</button>
</div></div>
<div id="banner"><div class="box"><div class="b-text"></div><button class="big" id="again">Ещё бой</button></div></div>
<script src="vendor/socket.io.min.js"></script>
<script src="worms.js?v=62"></script>
</body>
</html>

7
worms-pub/vendor/socket.io.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

3218
worms-pub/worms.js Normal file

File diff suppressed because it is too large Load Diff

55
worms.html Normal file
View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>Гречка: Червяки — хотсит</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #87CEEB; font-family: sans-serif; }
#game { position: fixed; inset: 0; }
#c { width: 100%; height: 100%; display: block; touch-action: none; }
#hud { position: fixed; top: 0; left: 0; right: 0; padding: 6px 8px; color: #fff; font-size: 14px;
text-shadow: 0 1px 3px #000; pointer-events: none; display: flex; gap: 12px; flex-wrap: wrap; }
#wbar { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 4px; padding: 6px;
overflow-x: auto; background: rgba(0,0,0,0.45); }
.wbtn { flex: 1 0 auto; min-width: 86px; padding: 10px 6px; font-size: 13px; border-radius: 8px;
border: 2px solid transparent; background: #2d3436; color: #fff; }
.wbtn.sel { border-color: #f1c40f; background: #3d484b; }
.wbtn.empty { opacity: 0.35; }
#touch { position: fixed; right: 8px; bottom: 64px; display: none; flex-direction: column; gap: 8px; }
@media (pointer: coarse) { #touch { display: flex; } #wbar { bottom: 0; } }
.tbtn { width: 60px; height: 60px; border-radius: 50%; border: none; font-size: 24px;
background: rgba(0,0,0,0.5); color: #fff; }
#tAim { width: 60px; writing-mode: vertical-lr; direction: rtl; height: 140px; }
#move { position: fixed; left: 8px; bottom: 64px; display: none; gap: 8px; }
@media (pointer: coarse) { #move { display: flex; } }
#banner { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); z-index: 10; }
#banner .box { background: #fff; border-radius: 12px; padding: 24px 32px; text-align: center; }
#banner .b-text { font-size: 22px; margin-bottom: 16px; }
#again { font-size: 18px; padding: 10px 24px; border-radius: 8px; border: none; background: #27ae60; color: #fff; }
#help { position: fixed; left: 8px; top: 44px; color: #fff; font-size: 12px; text-shadow: 0 1px 3px #000;
pointer-events: none; opacity: 0.85; }
@media (pointer: coarse) { #help { display: none; } }
</style>
</head>
<body>
<div id="game"><canvas id="c"></canvas></div>
<div id="hud"><span id="hudTeams"></span><span id="hudTurn"></span><span id="hudWind"></span><span id="hudWeapon"></span></div>
<div id="help">A/D — идти · W/S — прицел · Пробел (держать) — сила/выстрел · Shift — прыжок · Tab — оружие · F — таймер</div>
<div id="move">
<button class="tbtn" id="tLeft">◀</button>
<button class="tbtn" id="tRight">▶</button>
<button class="tbtn" id="tJump">⤒</button>
</div>
<div id="touch">
<input type="range" id="tAim" min="0" max="100" value="30">
<button class="tbtn" id="tFuse">⏱</button>
<button class="tbtn" id="tWeapon">🌀</button>
<button class="tbtn" id="tFire">🔥</button>
</div>
<div id="wbar"></div>
<div id="banner"><div class="box"><div class="b-text"></div><button id="again">Ещё бой</button></div></div>
<script src="worms.js?v=1"></script>
</body>
</html>

3218
worms.js Normal file

File diff suppressed because it is too large Load Diff