barkour/js/sim.js
w1n5t0n 99468cd379 fix: address all 21 confirmed findings from multi-agent review + live P2P testing
Sim: clamp knockback under tile size (wall tunneling), bomb/ball terminal
velocity, grapple constraint no longer drags players into terrain, bite
window ends on getting hit, shield/boost cleared on death, spawn invuln
preserved through countdown, spike/saw events only on real damage.
Mapgen: spawn footprint validation (was embedding dogs in 58% of seeds),
saw paths rejected if they grind terrain or sweep spawns, towers no longer
hang slabs over pits. Net: heartbeat+watchdog for dead peers, broker-error
tolerance once P2P link is up, join/host reentrancy guards. UI: text-field
keystrokes no longer hit game hotkeys, audio unlocks on mouse, textBaseline
leak, snapshot carries onGround/landT so client dogs animate.
2026-06-10 14:47:40 +02:00

488 lines
16 KiB
JavaScript

// Authoritative game simulation. DOM-free so it runs headless (node tests) and
// only on the host in netplay — clients just render snapshots.
import { TILE, GW, GH, W, H, T, B, GRAV, MAXFALL, STOCKS, ITEMS } from './consts.js';
import { makeRng } from './rng.js';
import { generateMap, sawPos } from './mapgen.js';
import { moveEntity, tileAt, raycast, boxFree, solidAtPx, standingOnPlat } from './physics.js';
import { CHARS, updateCharacter } from './characters.js';
const PW = 18, PH = 14;
export function createGame(seed, defs) {
const map = generateMap(seed);
const g = {
seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
rng: makeRng((seed ^ 0xC0FFEE) >>> 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, w: PW, h: PH, face: i % 2 ? -1 : 1,
onGround: false, jumpsLeft: 0, coyote: 0, drop: 0, touchWall: 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, justLanded: false, impact: 0, landT: 0,
};
}
function helpers(g) {
return {
hurt: (v, dmg, nx, ny, base) => hurt(g, v, dmg, nx, ny, base),
event: (e) => g.events.push(e),
raycast, boxFree, solidAtPx,
};
}
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);
// periodic care package from the sky
if (!g.over && --g.dropTimer <= 0) {
g.dropTimer = 520 + g.rng.int(0, 300);
spawnPickup(g, 40 + g.rng.f() * (W - 80), -20, g.rng.pick(ITEMS));
g.events.push({ t: 'drop' });
}
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;
}
const c = CHARS[p.charId], s = c.stats;
if (p.invuln > 0 && g.freeze <= 0) p.invuln--; // spawn protection shouldn't burn during countdown
if (p.atkCd > 0) p.atkCd--;
if (p.shield > 0) p.shield--;
if (p.boost > 0) p.boost--;
if (p.landT > 0) p.landT--;
if (p.coyote > 0) p.coyote--;
if (p.drop > 0) p.drop--;
const bm = p.boost > 0 ? 1.45 : 1;
const stunned = p.hitstun > 0;
if (stunned) p.hitstun--;
// --- horizontal control ---
if (!stunned && !(p.st.charge > 0)) {
const dir = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
if (dir !== 0) {
const acc = p.onGround ? s.accel : s.air;
p.vx += dir * acc * bm;
const cap = s.run * bm;
if (p.vx > cap) p.vx = Math.max(cap, p.vx - acc * 2); // let dashes decay, don't hard-clamp
if (p.vx < -cap) p.vx = Math.min(-cap, p.vx + acc * 2);
if (!p.st.anchor) p.face = dir;
} else if (p.onGround) {
p.vx *= s.fric;
if (Math.abs(p.vx) < 0.1) p.vx = 0;
} else {
p.vx *= 0.985;
}
} else if (p.onGround && stunned) {
p.vx *= 0.85;
}
// --- wall contact (used by jump + slide) ---
p.touchWall = 0;
if (c.wallJump && !p.onGround) {
const hw = p.w / 2;
if ((inp & B.L) && solidAtPx(g.map, p.x - hw - 2, p.y)) p.touchWall = -1;
else if ((inp & B.R) && solidAtPx(g.map, p.x + hw + 2, p.y)) p.touchWall = 1;
if (p.touchWall && p.vy > 1.4) p.vy = 1.4; // wall slide
}
// --- jumping ---
if (!stunned) {
if ((pressed & B.D) && p.onGround && standingOnPlat(g.map, p)) p.drop = 10;
if (pressed & B.JUMP) {
if (p.onGround || p.coyote > 0) {
p.vy = -s.jump * Math.sqrt(bm);
p.coyote = 0;
g.events.push({ t: 'jump', x: p.x, y: p.y });
} else if (p.touchWall) {
p.vy = -s.jump * 0.95;
p.vx = -p.touchWall * s.run * 1.05;
p.face = -p.touchWall;
g.events.push({ t: 'jump', x: p.x, y: p.y, wall: 1 });
} else if (p.jumpsLeft > 0) {
p.jumpsLeft--;
p.vy = -s.jump * 0.9;
g.events.push({ t: 'jump', x: p.x, y: p.y, air: 1 });
}
}
if (p.vy < 0 && !(inp & B.JUMP)) p.vy *= 0.92; // variable jump height
}
// --- gravity ---
p.vy += GRAV * s.grav * (p.st.anchor ? 0.9 : 1);
if (p.vy > MAXFALL) p.vy = MAXFALL;
// --- character special mechanics ---
updateCharacter(p, c, inp, pressed, g, helpers(g));
// --- integrate & collide ---
const wasGround = p.onGround;
const preVy = p.vy;
const res = moveEntity(p, g.map, { drop: p.drop > 0 });
p.onGround = res.ground;
p.wallHit = res.wall;
p.justLanded = res.ground && !wasGround;
if (p.justLanded) {
p.impact = preVy;
p.landT = 8;
if (preVy > 4) g.events.push({ t: 'land', x: p.x, y: p.y + p.h / 2, v: preVy });
}
if (p.onGround) { p.coyote = 6; p.jumpsLeft = s.jumps - 1; }
senseTiles(g, p);
// --- attack (bite doubles as mining pick) ---
if (!stunned && (pressed & B.ATK) && p.atkCd <= 0) {
p.atkCd = 24; p.atkT = 7;
p.hitMask = 0; p.minedSet = {};
p.vx += p.face * 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); }
// --- items ---
if (!stunned && (pressed & B.ITM) && p.item) useItem(g, p);
// --- blast zones ---
if (p.x < -50 || p.x > W + 50 || p.y > H + 40 || p.y < -220) kill(g, p);
}
function doAttack(g, p) {
const ax = p.x + p.face * (p.w / 2 + 9), ay = p.y;
const aw = 22, ah = 18;
for (const o of g.players) {
if (o === p || o.stocks <= 0 || o.respawn > 0) continue;
if (p.hitMask & (1 << o.idx)) continue;
if (Math.abs(o.x - ax) < (aw + o.w) / 2 && Math.abs(o.y - ay) < (ah + o.h) / 2) {
p.hitMask |= 1 << o.idx;
hurt(g, o, 8, p.face * 0.9, -0.45, 3.2);
p.vx *= 0.6;
}
}
// mine crystals in the swing arc (one hit per crystal per swing)
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 + 4, g.rng.pick(ITEMS));
}
export function hurt(g, v, dmg, nx, ny, base) {
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 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;
// collision is single-pass AABB: velocities >= TILE/tick tunnel through walls
const cap = TILE - 2.5;
v.vx = Math.max(-cap, Math.min(cap, nx * m));
v.vy = Math.max(-cap, Math.min(cap, ny * m - 1.4));
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; // a launched dog's bite window ends with it
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
return true;
}
function senseTiles(g, p) {
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 cx = tx * TILE + 6;
if (hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5)) {
g.events.push({ t: 'spike', x: p.x, y: p.y });
}
}
}
// bounce pad: landed on top of a PAD tile
if (p.justLanded) {
const ty = Math.floor((p.y + hh + 2) / TILE);
for (let tx = x0; tx <= x1; tx++) {
if (tileAt(g.map, tx, ty) === T.PAD) {
p.vy = -12.8;
p.onGround = false;
g.events.push({ t: 'pad', x: tx * TILE + 6, y: ty * TILE });
break;
}
}
}
}
function useItem(g, p) {
const it = p.item;
if (it === 'ball') {
spawnEnt(g, { k: 'ball', x: p.x + p.face * 12, y: p.y - 2, vx: p.face * 7 + p.vx * 0.3, vy: -0.5, 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 + p.face * 10, y: p.y - 4, vx: p.face * 3.5 + p.vx * 0.4, vy: -4.5, 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.vy = Math.min(e.vy + GRAV * 0.55, 5.5);
const r = moveEntity(e, g.map, {});
if (r.ground) e.vx *= 0.8;
e.ttl--;
for (const p of g.players) {
if (p.stocks > 0 && p.respawn <= 0 && !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.y > H + 40 || e.ttl <= 0) e.dead = true;
} else if (e.k === 'ball') {
e.age++; e.ttl--;
e.vy += GRAV * 0.45;
if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) e.vy = -pvy * 0.72;
if (r.ceil) e.vy = Math.abs(pvy) * 0.5;
if (r.wall) e.vx = -pvx * 0.8;
for (const p of g.players) {
if (p.stocks <= 0 || p.respawn > 0) 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) {
hurt(g, p, 6, (Math.sign(pvx) || 1) * 0.8, -0.5, 3.5);
e.dead = true;
break;
}
}
if (e.ttl <= 0 || e.y > H + 40) e.dead = true;
} else if (e.k === 'bomb') {
e.age++; e.fuse--;
e.vy += GRAV;
if (e.vy > MAXFALL) e.vy = MAXFALL;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.ground && pvy > 1) { e.vy = -pvy * 0.45; e.vx *= 0.7; }
if (r.wall) e.vx = -pvx * 0.6;
let contact = false;
for (const p of g.players) {
if (p.stocks <= 0 || p.respawn > 0) 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; }
if (e.y > H + 60) 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 (p.stocks <= 0 || p.respawn > 0) 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 * 0.6 - 0.6, 7);
}
}
// blast nearby crystals open
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 = sawPos(s, s.prog);
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 (p.stocks <= 0 || p.respawn > 0) continue;
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 - 0.5, 6.5)) {
g.events.push({ t: 'saw', x: p.x, y: p.y });
}
}
}
}
}
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.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 && o.stocks > 0 && o.respawn <= 0) 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.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 alive = g.players.filter(p => p.stocks > 0);
if (alive.length <= 1 && g.players.length > 1) {
g.over = { winner: alive[0] ? alive[0].idx : -1 };
g.overT = 0;
g.events.push({ t: 'over', winner: g.over.winner });
}
}
// ---------------------------------------------------------------------------
// Snapshots (host → client). Client regenerates the map from the seed; tile
// changes ride along as 'tile'/'crack' events which main.js applies.
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),
f: p.face, d: Math.round(p.dmg), s: p.stocks,
og: p.onGround ? 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.jet ? 1 : 0, fu: p.st.fuel | 0,
po: p.st.pound ? 1 : 0, da: p.st.dash | 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.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, 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]; }
});
// tile mutations arrive as events
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;
}