602 lines
20 KiB
JavaScript
602 lines
20 KiB
JavaScript
|
|
// Top-down "Dogpit" arena simulation. Same authoritative/DOM-free contract and
|
||
|
|
// export surface as sim.js — main.js picks the module per match.
|
||
|
|
//
|
||
|
|
// The plane is x/y; z is hop height (a third axis for dodging ground hazards
|
||
|
|
// and crossing pits). KOs come from being knocked into pits, not blast zones.
|
||
|
|
import { TILE, GW, GH, W, H, T, B, STOCKS, ITEMS } from './consts.js';
|
||
|
|
import { makeRng } from './rng.js';
|
||
|
|
import { generateArena } from './mapgen_arena.js';
|
||
|
|
import { sawPos } from './mapgen.js';
|
||
|
|
import { moveEntity, tileAt, raycast, boxFree } from './physics.js';
|
||
|
|
import { CHARS } from './characters.js';
|
||
|
|
|
||
|
|
const PW = 18, PH = 14;
|
||
|
|
const ZGRAV = 0.34;
|
||
|
|
const VCAP = TILE - 2.5; // collision is single-pass AABB; stay under tile size
|
||
|
|
|
||
|
|
export function createGame(seed, defs) {
|
||
|
|
const map = generateArena(seed);
|
||
|
|
const g = {
|
||
|
|
kind: 'arena', seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
|
||
|
|
rng: makeRng((seed ^ 0xD06600D) >>> 0),
|
||
|
|
events: [], over: null, overT: 0, freeze: 170, dropTimer: 550,
|
||
|
|
};
|
||
|
|
defs.forEach((d, i) => g.players.push(makePlayer(d, i, map)));
|
||
|
|
return g;
|
||
|
|
}
|
||
|
|
|
||
|
|
function makePlayer(d, i, map) {
|
||
|
|
const sp = map.spawns[i % map.spawns.length];
|
||
|
|
return {
|
||
|
|
idx: i, charId: d.charId, name: d.name || CHARS[d.charId].name,
|
||
|
|
x: sp.x, y: sp.y, vx: 0, vy: 0, z: 0, vz: 0, ang: i % 2 ? Math.PI : 0,
|
||
|
|
w: PW, h: PH, face: i % 2 ? -1 : 1, falling: 0,
|
||
|
|
onGround: true, landT: 0,
|
||
|
|
dmg: 0, stocks: STOCKS, respawn: 0, invuln: 90,
|
||
|
|
atkCd: 0, atkT: 0, hitMask: 0, minedSet: {},
|
||
|
|
hitstun: 0, item: null, ammo: 0, shield: 0, boost: 0,
|
||
|
|
prev: 0, st: {}, wallHit: 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const alive = (p) => p.stocks > 0 && p.respawn <= 0 && p.falling <= 0;
|
||
|
|
|
||
|
|
export function step(g, inputs) {
|
||
|
|
g.tick++;
|
||
|
|
g.events.length = 0;
|
||
|
|
if (g.freeze > 0) {
|
||
|
|
g.freeze--;
|
||
|
|
if (g.freeze === 0) g.events.push({ t: 'go' });
|
||
|
|
}
|
||
|
|
const frozen = g.freeze > 0 || !!g.over;
|
||
|
|
|
||
|
|
for (const p of g.players) {
|
||
|
|
const inp = frozen ? 0 : (inputs[p.idx] || 0);
|
||
|
|
stepPlayer(g, p, inp, inp & ~p.prev);
|
||
|
|
p.prev = inp;
|
||
|
|
}
|
||
|
|
stepEnts(g);
|
||
|
|
stepSaws(g);
|
||
|
|
|
||
|
|
if (!g.over && --g.dropTimer <= 0) {
|
||
|
|
g.dropTimer = 520 + g.rng.int(0, 300);
|
||
|
|
// care package lands on a random clear floor tile
|
||
|
|
for (let t = 0; t < 40; t++) {
|
||
|
|
const tx = g.rng.int(3, GW - 4), ty = g.rng.int(3, GH - 4);
|
||
|
|
if (tileAt(g.map, tx, ty) === T.EMPTY) {
|
||
|
|
spawnPickup(g, tx * TILE + 6, ty * TILE + 6, g.rng.pick(ITEMS));
|
||
|
|
g.events.push({ t: 'drop' });
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
checkEnd(g);
|
||
|
|
return g.events;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stepPlayer(g, p, inp, pressed) {
|
||
|
|
if (p.stocks <= 0) return;
|
||
|
|
if (p.respawn > 0) {
|
||
|
|
p.respawn--;
|
||
|
|
if (p.respawn === 0) respawn(g, p);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// mid-fall into a pit: no control, shrink, then KO
|
||
|
|
if (p.falling > 0) {
|
||
|
|
p.falling--;
|
||
|
|
p.vx *= 0.88; p.vy *= 0.88;
|
||
|
|
p.x += p.vx; p.y += p.vy;
|
||
|
|
if (p.falling === 0) kill(g, p);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const c = CHARS[p.charId], s = c.stats, st = p.st;
|
||
|
|
if (p.invuln > 0 && g.freeze <= 0) p.invuln--;
|
||
|
|
if (p.atkCd > 0) p.atkCd--;
|
||
|
|
if (p.shield > 0) p.shield--;
|
||
|
|
if (p.boost > 0) p.boost--;
|
||
|
|
if (p.landT > 0) p.landT--;
|
||
|
|
|
||
|
|
const bm = p.boost > 0 ? 1.45 : 1;
|
||
|
|
const stunned = p.hitstun > 0;
|
||
|
|
if (stunned) p.hitstun--;
|
||
|
|
|
||
|
|
// --- 8-way movement ---
|
||
|
|
const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
|
||
|
|
const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
|
||
|
|
if (!stunned && !(st.charge > 0) && !st.anchor) {
|
||
|
|
if (dx || dy) {
|
||
|
|
const n = Math.hypot(dx, dy), ux = dx / n, uy = dy / n;
|
||
|
|
const acc = s.accel * (p.z > 0 ? 0.55 : 1) * bm;
|
||
|
|
p.vx += ux * acc;
|
||
|
|
p.vy += uy * acc;
|
||
|
|
const cap = s.run * bm, spd = Math.hypot(p.vx, p.vy);
|
||
|
|
if (spd > cap) { // let dash overspeed decay instead of hard-clamping
|
||
|
|
const k = Math.max(cap, spd - s.accel * 2) / spd;
|
||
|
|
p.vx *= k; p.vy *= k;
|
||
|
|
}
|
||
|
|
p.ang = Math.atan2(uy, ux);
|
||
|
|
if (dx) p.face = dx > 0 ? 1 : -1;
|
||
|
|
} else if (p.z <= 0) {
|
||
|
|
p.vx *= s.fric; p.vy *= s.fric;
|
||
|
|
if (Math.hypot(p.vx, p.vy) < 0.1) { p.vx = 0; p.vy = 0; }
|
||
|
|
} else {
|
||
|
|
p.vx *= 0.99; p.vy *= 0.99;
|
||
|
|
}
|
||
|
|
} else if (stunned && p.z <= 0) {
|
||
|
|
p.vx *= 0.9; p.vy *= 0.9;
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- hop ---
|
||
|
|
if (!stunned && (pressed & B.HOP) && p.z <= 0) {
|
||
|
|
p.vz = 3.0;
|
||
|
|
g.events.push({ t: 'jump', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- character specials ---
|
||
|
|
updateCharArena(g, p, c, inp, pressed);
|
||
|
|
|
||
|
|
// --- z physics ---
|
||
|
|
const wasAir = p.z > 0;
|
||
|
|
p.z += p.vz;
|
||
|
|
if (!st.hover) p.vz -= ZGRAV;
|
||
|
|
let justLanded = false;
|
||
|
|
if (p.z <= 0) {
|
||
|
|
if (wasAir) {
|
||
|
|
justLanded = true;
|
||
|
|
p.landT = 6;
|
||
|
|
if (-p.vz > 2) g.events.push({ t: 'land', x: p.x, y: p.y + 5, v: 4 });
|
||
|
|
}
|
||
|
|
p.z = 0;
|
||
|
|
if (p.vz < 0) p.vz = 0;
|
||
|
|
}
|
||
|
|
p.onGround = p.z <= 0;
|
||
|
|
|
||
|
|
if (justLanded && st.pound) {
|
||
|
|
st.pound = false;
|
||
|
|
g.events.push({ t: 'pound', x: p.x, y: p.y });
|
||
|
|
for (const o of g.players) {
|
||
|
|
if (o === p || !alive(o) || o.z > 6) continue;
|
||
|
|
const ox = o.x - p.x, oy = o.y - p.y, d = Math.hypot(ox, oy);
|
||
|
|
if (d < 55) { const n = d || 1; hurt(g, o, 9, ox / n, oy / n, 6); }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- integrate & wall-collide (y-axis hits count as walls here too) ---
|
||
|
|
const res = moveEntity(p, g.map, {});
|
||
|
|
p.wallHit = res.wall || ((res.ground || res.ceil) ? 2 : 0);
|
||
|
|
|
||
|
|
// --- ground tile sensors ---
|
||
|
|
if (p.z <= 0) {
|
||
|
|
const t = tileAt(g.map, Math.floor(p.x / TILE), Math.floor(p.y / TILE));
|
||
|
|
if (t === T.PIT) {
|
||
|
|
p.falling = 36;
|
||
|
|
p.atkT = 0; p.st.charge = 0; p.st.anchor = null;
|
||
|
|
g.events.push({ t: 'fall', x: p.x, y: p.y });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (t === T.PADTOP && p.vz <= 0) {
|
||
|
|
p.vz = 4.6;
|
||
|
|
g.events.push({ t: 'pad', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
// spikes under any part of the footprint
|
||
|
|
const hw = p.w / 2, hh = p.h / 2;
|
||
|
|
const x0 = Math.floor((p.x - hw) / TILE), x1 = Math.floor((p.x + hw) / TILE);
|
||
|
|
const y0 = Math.floor((p.y - hh) / TILE), y1 = Math.floor((p.y + hh) / TILE);
|
||
|
|
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||
|
|
if (tileAt(g.map, tx, ty) === T.SPIKE) {
|
||
|
|
const sx = tx * TILE + 6, sy = ty * TILE + 6;
|
||
|
|
const n = Math.hypot(p.x - sx, p.y - sy) || 1;
|
||
|
|
if (hurt(g, p, 9, (p.x - sx) / n, (p.y - sy) / n, 5.5)) {
|
||
|
|
g.events.push({ t: 'spike', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- attack ---
|
||
|
|
if (!stunned && (pressed & B.ATK) && p.atkCd <= 0) {
|
||
|
|
p.atkCd = 24; p.atkT = 7;
|
||
|
|
p.hitMask = 0; p.minedSet = {};
|
||
|
|
p.vx += Math.cos(p.ang) * 1.4;
|
||
|
|
p.vy += Math.sin(p.ang) * 1.4;
|
||
|
|
g.events.push({ t: 'bark', x: p.x, y: p.y, f: p.face });
|
||
|
|
}
|
||
|
|
if (p.atkT > 0) { p.atkT--; doAttack(g, p); }
|
||
|
|
|
||
|
|
if (!stunned && (pressed & B.ITM) && p.item) useItem(g, p);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateCharArena(g, p, c, inp, pressed) {
|
||
|
|
const st = p.st;
|
||
|
|
const inputDir = () => {
|
||
|
|
const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
|
||
|
|
const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
|
||
|
|
if (dx || dy) { const n = Math.hypot(dx, dy); return [dx / n, dy / n]; }
|
||
|
|
return [Math.cos(p.ang), Math.sin(p.ang)];
|
||
|
|
};
|
||
|
|
switch (c.key) {
|
||
|
|
|
||
|
|
case 'bolt': { // zoom dash, on a cooldown instead of landing-reset
|
||
|
|
if (st.dashCd > 0) st.dashCd--;
|
||
|
|
if ((pressed & B.SPC) && st.dashCd <= 0 && p.hitstun <= 0) {
|
||
|
|
const [ux, uy] = inputDir();
|
||
|
|
p.vx = ux * 8.6; p.vy = uy * 8.6;
|
||
|
|
p.ang = Math.atan2(uy, ux);
|
||
|
|
st.dashCd = 50;
|
||
|
|
g.events.push({ t: 'dash', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'tank': {
|
||
|
|
if (st.chargeCd > 0) st.chargeCd--;
|
||
|
|
if (st.charge > 0) {
|
||
|
|
st.charge--;
|
||
|
|
p.vx = Math.cos(st.chargeAng) * 6.4;
|
||
|
|
p.vy = Math.sin(st.chargeAng) * 6.4;
|
||
|
|
for (const o of g.players) {
|
||
|
|
if (o !== p && alive(o) && o.z < 6 &&
|
||
|
|
Math.abs(o.x - p.x) < 18 && Math.abs(o.y - p.y) < 16) {
|
||
|
|
hurt(g, o, 10, Math.cos(st.chargeAng), Math.sin(st.chargeAng), 6.5);
|
||
|
|
st.charge = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (p.wallHit) { // x or y wall — stepPlayer encodes both
|
||
|
|
st.charge = 0;
|
||
|
|
g.events.push({ t: 'thud', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
} else if ((pressed & B.SPC) && p.hitstun <= 0) {
|
||
|
|
if (p.z <= 0 && st.chargeCd <= 0) {
|
||
|
|
st.charge = 34; st.chargeCd = 80; st.chargeAng = p.ang;
|
||
|
|
g.events.push({ t: 'charge', x: p.x, y: p.y });
|
||
|
|
} else if (p.z > 0 && !st.pound) {
|
||
|
|
st.pound = true;
|
||
|
|
p.vz = -4.5; // slam down
|
||
|
|
}
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'swing': { // zip-leash: latch a wall, get reeled in, slingshot out
|
||
|
|
if (st.anchor) {
|
||
|
|
if (pressed & B.SPC) { st.anchor = null; break; }
|
||
|
|
const dx = st.anchor.x - p.x, dy = st.anchor.y - p.y;
|
||
|
|
const d = Math.hypot(dx, dy) || 1;
|
||
|
|
if (d < 16 || (pressed & B.HOP)) { // arrived, or slingshot release
|
||
|
|
st.anchor = null;
|
||
|
|
p.vx *= 1.18; p.vy *= 1.18;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
const ux = dx / d, uy = dy / d;
|
||
|
|
p.vx += (ux * 7 - p.vx) * 0.25;
|
||
|
|
p.vy += (uy * 7 - p.vy) * 0.25;
|
||
|
|
p.ang = Math.atan2(uy, ux);
|
||
|
|
} else if ((pressed & B.SPC) && p.hitstun <= 0) {
|
||
|
|
const hit = raycast(g.map, p.x, p.y, Math.cos(p.ang), Math.sin(p.ang), 175);
|
||
|
|
if (hit) {
|
||
|
|
st.anchor = { x: hit.x, y: hit.y };
|
||
|
|
g.events.push({ t: 'grapple', x: hit.x, y: hit.y });
|
||
|
|
} else {
|
||
|
|
g.events.push({ t: 'whiff', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'wisp': { // blink works exactly as in side view — through walls, over pits
|
||
|
|
if (st.blinkCd > 0) st.blinkCd--;
|
||
|
|
if ((pressed & B.SPC) && st.blinkCd <= 0 && p.hitstun <= 0) {
|
||
|
|
const [ux, uy] = inputDir();
|
||
|
|
for (let dist = 58; dist >= 12; dist -= 6) {
|
||
|
|
const tx = p.x + ux * dist, ty = p.y + uy * dist;
|
||
|
|
if (boxFree(g.map, tx, ty, p.w, p.h)) {
|
||
|
|
g.events.push({ t: 'blink', x: p.x, y: p.y, x2: tx, y2: ty });
|
||
|
|
p.x = tx; p.y = ty;
|
||
|
|
p.vx = ux * 3.5; p.vy = uy * 3.5;
|
||
|
|
p.ang = Math.atan2(uy, ux);
|
||
|
|
st.blinkCd = 48;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
case 'rocket': { // hover: floats over pits/spikes/saws while fuel lasts
|
||
|
|
st.hover = false;
|
||
|
|
if (p.z <= 0) st.fuel = Math.min(70, (st.fuel ?? 70) + 2.4);
|
||
|
|
if (st.fuel === undefined) st.fuel = 70;
|
||
|
|
if ((inp & B.SPC) && st.fuel > 0 && p.hitstun <= 0) {
|
||
|
|
st.fuel -= 1;
|
||
|
|
st.hover = true;
|
||
|
|
if (p.z < 5) p.vz = Math.max(p.vz, 0.9);
|
||
|
|
else { p.z = 5; p.vz = 0; }
|
||
|
|
if (g.tick % 5 === 0) g.events.push({ t: 'jet', x: p.x, y: p.y + 4 });
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function doAttack(g, p) {
|
||
|
|
const ax = p.x + Math.cos(p.ang) * 17, ay = p.y + Math.sin(p.ang) * 17;
|
||
|
|
const aw = 22, ah = 20;
|
||
|
|
for (const o of g.players) {
|
||
|
|
if (o === p || !alive(o)) continue;
|
||
|
|
if (p.hitMask & (1 << o.idx)) continue;
|
||
|
|
if (Math.abs(o.z - p.z) > 6) continue; // can't bite over/under a hopping dog
|
||
|
|
if (Math.abs(o.x - ax) < (aw + o.w) / 2 && Math.abs(o.y - ay) < (ah + o.h) / 2) {
|
||
|
|
p.hitMask |= 1 << o.idx;
|
||
|
|
const n = Math.hypot(o.x - p.x, o.y - p.y) || 1;
|
||
|
|
hurt(g, o, 8, (o.x - p.x) / n, (o.y - p.y) / n, 3.2);
|
||
|
|
p.vx *= 0.6; p.vy *= 0.6;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// crystals are full-height — mineable regardless of z
|
||
|
|
const x0 = Math.floor((ax - aw / 2) / TILE), x1 = Math.floor((ax + aw / 2) / TILE);
|
||
|
|
const y0 = Math.floor((ay - ah / 2) / TILE), y1 = Math.floor((ay + ah / 2) / TILE);
|
||
|
|
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||
|
|
if (tileAt(g.map, tx, ty) !== T.CRYSTAL) continue;
|
||
|
|
const i = ty * GW + tx;
|
||
|
|
if (p.minedSet[i]) continue;
|
||
|
|
p.minedSet[i] = 1;
|
||
|
|
const hp = (g.map.hp[i] ?? 3) - 1;
|
||
|
|
if (hp <= 0) breakTile(g, tx, ty, true);
|
||
|
|
else {
|
||
|
|
g.map.hp[i] = hp;
|
||
|
|
g.events.push({ t: 'crack', i, hp, x: tx * TILE + 6, y: ty * TILE + 6 });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function breakTile(g, tx, ty, drop) {
|
||
|
|
const i = ty * GW + tx;
|
||
|
|
g.map.tiles[i] = T.EMPTY;
|
||
|
|
delete g.map.hp[i];
|
||
|
|
g.events.push({ t: 'tile', i, v: T.EMPTY, x: tx * TILE + 6, y: ty * TILE + 6 });
|
||
|
|
if (drop) spawnPickup(g, tx * TILE + 6, ty * TILE + 6, g.rng.pick(ITEMS));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function hurt(g, v, dmg, nx, ny, base) {
|
||
|
|
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0 || v.falling > 0) return false;
|
||
|
|
if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return false; }
|
||
|
|
v.dmg += dmg;
|
||
|
|
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
|
||
|
|
v.vx = Math.max(-VCAP, Math.min(VCAP, nx * m));
|
||
|
|
v.vy = Math.max(-VCAP, Math.min(VCAP, ny * m));
|
||
|
|
v.vz = Math.max(v.vz, 1.4); // little pop for juice
|
||
|
|
if (v.z <= 0) v.z = 0.01;
|
||
|
|
v.hitstun = Math.min(40, 9 + m * 1.6);
|
||
|
|
v.invuln = 14;
|
||
|
|
v.st.charge = 0; v.st.anchor = null; v.st.pound = false;
|
||
|
|
v.atkT = 0;
|
||
|
|
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function useItem(g, p) {
|
||
|
|
const it = p.item;
|
||
|
|
const cx = Math.cos(p.ang), cy = Math.sin(p.ang);
|
||
|
|
if (it === 'ball') {
|
||
|
|
spawnEnt(g, { k: 'ball', x: p.x + cx * 12, y: p.y + cy * 12, vx: cx * 7 + p.vx * 0.3, vy: cy * 7 + p.vy * 0.3, w: 8, h: 8, own: p.idx, ttl: 240, age: 0 });
|
||
|
|
g.events.push({ t: 'throw', x: p.x, y: p.y });
|
||
|
|
if (--p.ammo <= 0) p.item = null;
|
||
|
|
} else if (it === 'bomb') {
|
||
|
|
spawnEnt(g, { k: 'bomb', x: p.x + cx * 10, y: p.y + cy * 10, vx: cx * 4.5 + p.vx * 0.4, vy: cy * 4.5 + p.vy * 0.4, w: 9, h: 9, own: p.idx, fuse: 75, age: 0 });
|
||
|
|
g.events.push({ t: 'throw', x: p.x, y: p.y });
|
||
|
|
p.item = null;
|
||
|
|
} else if (it === 'shield') {
|
||
|
|
p.shield = 260; p.item = null;
|
||
|
|
g.events.push({ t: 'shield', x: p.x, y: p.y });
|
||
|
|
} else if (it === 'boost') {
|
||
|
|
p.boost = 320; p.item = null;
|
||
|
|
g.events.push({ t: 'boost', x: p.x, y: p.y });
|
||
|
|
} else if (it === 'heal') {
|
||
|
|
p.dmg = Math.max(0, p.dmg - 35); p.item = null;
|
||
|
|
g.events.push({ t: 'heal', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function spawnEnt(g, e) {
|
||
|
|
e.id = g.nextEnt++;
|
||
|
|
g.ents.push(e);
|
||
|
|
return e;
|
||
|
|
}
|
||
|
|
|
||
|
|
function spawnPickup(g, x, y, item) {
|
||
|
|
spawnEnt(g, { k: 'pickup', x, y, vx: 0, vy: 0, w: 10, h: 10, item, ttl: 1400 });
|
||
|
|
}
|
||
|
|
|
||
|
|
function stepEnts(g) {
|
||
|
|
for (const e of g.ents) {
|
||
|
|
if (e.k === 'pickup') {
|
||
|
|
e.ttl--;
|
||
|
|
for (const p of g.players) {
|
||
|
|
if (alive(p) && !p.item && Math.abs(p.x - e.x) < 14 && Math.abs(p.y - e.y) < 14) {
|
||
|
|
p.item = e.item;
|
||
|
|
if (e.item === 'ball') p.ammo = 3;
|
||
|
|
e.dead = true;
|
||
|
|
g.events.push({ t: 'pickup', x: e.x, y: e.y, item: e.item });
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (e.ttl <= 0) e.dead = true;
|
||
|
|
|
||
|
|
} else if (e.k === 'ball') {
|
||
|
|
e.age++; e.ttl--;
|
||
|
|
const pvx = e.vx, pvy = e.vy;
|
||
|
|
const r = moveEntity(e, g.map, {});
|
||
|
|
if (r.wall) e.vx = -pvx * 0.85;
|
||
|
|
if (r.ground || r.ceil) e.vy = -pvy * 0.85; // top-down: ±y walls bounce too
|
||
|
|
for (const p of g.players) {
|
||
|
|
if (!alive(p) || p.z > 6) continue;
|
||
|
|
if (p.idx === e.own && e.age < 30) continue;
|
||
|
|
if (Math.abs(p.x - e.x) < (p.w + e.w) / 2 && Math.abs(p.y - e.y) < (p.h + e.h) / 2) {
|
||
|
|
const n = Math.hypot(pvx, pvy) || 1;
|
||
|
|
hurt(g, p, 6, pvx / n, pvy / n, 3.5);
|
||
|
|
e.dead = true;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// balls sail over pits (they're light); they just expire
|
||
|
|
if (e.ttl <= 0) e.dead = true;
|
||
|
|
|
||
|
|
} else if (e.k === 'bomb') {
|
||
|
|
e.age++; e.fuse--;
|
||
|
|
e.vx *= 0.96; e.vy *= 0.96; // slides to a stop
|
||
|
|
const pvx = e.vx, pvy = e.vy;
|
||
|
|
const r = moveEntity(e, g.map, {});
|
||
|
|
if (r.wall) e.vx = -pvx * 0.6;
|
||
|
|
if (r.ground || r.ceil) e.vy = -pvy * 0.6;
|
||
|
|
// a bomb sliding onto a pit drops in
|
||
|
|
if (tileAt(g.map, Math.floor(e.x / TILE), Math.floor(e.y / TILE)) === T.PIT) { e.dead = true; continue; }
|
||
|
|
let contact = false;
|
||
|
|
for (const p of g.players) {
|
||
|
|
if (!alive(p) || p.z > 8) continue;
|
||
|
|
if (p.idx === e.own && e.age < 15) continue;
|
||
|
|
if (Math.abs(p.x - e.x) < 12 && Math.abs(p.y - e.y) < 12) { contact = true; break; }
|
||
|
|
}
|
||
|
|
if (e.fuse <= 0 || contact) { explode(g, e.x, e.y); e.dead = true; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
g.ents = g.ents.filter(e => !e.dead);
|
||
|
|
}
|
||
|
|
|
||
|
|
function explode(g, x, y) {
|
||
|
|
g.events.push({ t: 'boom', x, y });
|
||
|
|
for (const p of g.players) {
|
||
|
|
if (!alive(p) || p.z > 8) continue;
|
||
|
|
const dx = p.x - x, dy = p.y - y;
|
||
|
|
const d = Math.hypot(dx, dy);
|
||
|
|
if (d < 48) {
|
||
|
|
const n = d || 1;
|
||
|
|
hurt(g, p, 14, dx / n, dy / n, 7);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const tx0 = Math.floor((x - 48) / TILE), tx1 = Math.floor((x + 48) / TILE);
|
||
|
|
const ty0 = Math.floor((y - 48) / TILE), ty1 = Math.floor((y + 48) / TILE);
|
||
|
|
for (let ty = ty0; ty <= ty1; ty++) for (let tx = tx0; tx <= tx1; tx++) {
|
||
|
|
if (tileAt(g.map, tx, ty) === T.CRYSTAL &&
|
||
|
|
Math.hypot(tx * TILE + 6 - x, ty * TILE + 6 - y) < 48) {
|
||
|
|
breakTile(g, tx, ty, true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function stepSaws(g) {
|
||
|
|
for (const s of g.map.saws) {
|
||
|
|
const pos = sawPosOf(s);
|
||
|
|
s.prog += s.speed / (pos.total || 1);
|
||
|
|
s.x = pos.x; s.y = pos.y;
|
||
|
|
if (g.freeze > 0 || g.over) continue;
|
||
|
|
for (const p of g.players) {
|
||
|
|
if (!alive(p) || p.z > 5) continue; // hop over the blade
|
||
|
|
const dx = p.x - s.x, dy = p.y - s.y;
|
||
|
|
const d = Math.hypot(dx, dy);
|
||
|
|
if (d < 16) {
|
||
|
|
const n = d || 1;
|
||
|
|
if (hurt(g, p, 12, dx / n, dy / n, 6.5)) {
|
||
|
|
g.events.push({ t: 'saw', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function sawPosOf(s) { return sawPos(s, s.prog); }
|
||
|
|
|
||
|
|
function kill(g, p) {
|
||
|
|
p.stocks--;
|
||
|
|
g.events.push({
|
||
|
|
t: 'ko', idx: p.idx,
|
||
|
|
x: Math.max(20, Math.min(W - 20, p.x)),
|
||
|
|
y: Math.max(20, Math.min(H - 20, p.y)),
|
||
|
|
});
|
||
|
|
p.st = {}; p.item = null; p.ammo = 0; p.shield = 0; p.boost = 0;
|
||
|
|
p.vx = 0; p.vy = 0; p.vz = 0; p.z = 0; p.falling = 0;
|
||
|
|
p.hitstun = 0; p.atkT = 0; p.dmg = 0;
|
||
|
|
p.x = -1000; p.y = -1000;
|
||
|
|
p.respawn = p.stocks > 0 ? 80 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function respawn(g, p) {
|
||
|
|
let best = g.map.spawns[0], bd = -1;
|
||
|
|
for (const sp of g.map.spawns) {
|
||
|
|
let d = 1e9;
|
||
|
|
for (const o of g.players) {
|
||
|
|
if (o !== p && alive(o)) d = Math.min(d, Math.hypot(o.x - sp.x, o.y - sp.y));
|
||
|
|
}
|
||
|
|
if (d > bd) { bd = d; best = sp; }
|
||
|
|
}
|
||
|
|
p.x = best.x; p.y = best.y;
|
||
|
|
p.vx = 0; p.vy = 0; p.z = 0; p.vz = 0;
|
||
|
|
p.invuln = 90; p.dmg = 0;
|
||
|
|
g.events.push({ t: 'respawn', x: p.x, y: p.y });
|
||
|
|
}
|
||
|
|
|
||
|
|
function checkEnd(g) {
|
||
|
|
if (g.over) { g.overT++; return; }
|
||
|
|
const standing = g.players.filter(p => p.stocks > 0);
|
||
|
|
if (standing.length <= 1 && g.players.length > 1) {
|
||
|
|
g.over = { winner: standing[0] ? standing[0].idx : -1 };
|
||
|
|
g.overT = 0;
|
||
|
|
g.events.push({ t: 'over', winner: g.over.winner });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- snapshots: same contract as sim.js, plus z / facing-angle / falling ---
|
||
|
|
export function snapshot(g, events) {
|
||
|
|
return {
|
||
|
|
tick: g.tick, freeze: g.freeze, over: g.over, overT: g.overT,
|
||
|
|
p: g.players.map(p => ({
|
||
|
|
x: +p.x.toFixed(1), y: +p.y.toFixed(1),
|
||
|
|
vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
|
||
|
|
z: +p.z.toFixed(1), fa: +p.ang.toFixed(2), fl: p.falling,
|
||
|
|
f: p.face, d: Math.round(p.dmg), s: p.stocks,
|
||
|
|
og: p.z <= 0 ? 1 : 0, lt: p.landT | 0,
|
||
|
|
r: p.respawn, iv: p.invuln, hs: p.hitstun, a: p.atkT,
|
||
|
|
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
|
||
|
|
st: {
|
||
|
|
an: p.st.anchor ? [Math.round(p.st.anchor.x), Math.round(p.st.anchor.y)] : 0,
|
||
|
|
ch: p.st.charge > 0 ? 1 : 0, cc: p.st.chargeCd | 0,
|
||
|
|
jet: p.st.hover ? 1 : 0, fu: p.st.fuel | 0,
|
||
|
|
po: p.st.pound ? 1 : 0, da: (p.st.dashCd | 0) <= 0 ? 1 : 0, bc: p.st.blinkCd | 0,
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
e: g.ents.map(e => ({ id: e.id, k: e.k, x: Math.round(e.x), y: Math.round(e.y), item: e.item, f: e.fuse })),
|
||
|
|
sw: g.map.saws.map(s => [Math.round(s.x || 0), Math.round(s.y || 0)]),
|
||
|
|
ev: events,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function applySnapshot(g, s) {
|
||
|
|
g.tick = s.tick; g.freeze = s.freeze; g.over = s.over; g.overT = s.overT;
|
||
|
|
s.p.forEach((sp, i) => {
|
||
|
|
const p = g.players[i];
|
||
|
|
if (!p) return;
|
||
|
|
p.x = sp.x; p.y = sp.y; p.vx = sp.vx; p.vy = sp.vy;
|
||
|
|
p.z = sp.z; p.ang = sp.fa; p.falling = sp.fl;
|
||
|
|
p.face = sp.f; p.dmg = sp.d; p.stocks = sp.s;
|
||
|
|
p.onGround = !!sp.og; p.landT = sp.lt;
|
||
|
|
p.respawn = sp.r; p.invuln = sp.iv; p.hitstun = sp.hs; p.atkT = sp.a;
|
||
|
|
p.item = sp.it; p.ammo = sp.am; p.shield = sp.sh; p.boost = sp.bo;
|
||
|
|
p.st = {
|
||
|
|
anchor: sp.st.an ? { x: sp.st.an[0], y: sp.st.an[1] } : null,
|
||
|
|
charge: sp.st.ch, chargeCd: sp.st.cc, hover: sp.st.jet, jet: sp.st.jet,
|
||
|
|
fuel: sp.st.fu, pound: sp.st.po, dash: sp.st.da, blinkCd: sp.st.bc,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
g.ents = s.e.map(e => ({ ...e, fuse: e.f, vx: 0, vy: 0, w: 8, h: 8 }));
|
||
|
|
s.sw.forEach((xy, i) => {
|
||
|
|
const saw = g.map.saws[i];
|
||
|
|
if (saw) { saw.x = xy[0]; saw.y = xy[1]; }
|
||
|
|
});
|
||
|
|
for (const ev of s.ev) {
|
||
|
|
if (ev.t === 'tile') { g.map.tiles[ev.i] = ev.v; delete g.map.hp[ev.i]; }
|
||
|
|
else if (ev.t === 'crack') g.map.hp[ev.i] = ev.hp;
|
||
|
|
}
|
||
|
|
return s.ev;
|
||
|
|
}
|