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.
This commit is contained in:
parent
2f60136b36
commit
99468cd379
10 changed files with 143 additions and 48 deletions
2
MAP.md
2
MAP.md
|
|
@ -11,7 +11,7 @@ Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically
|
||||||
- `js/characters.js` — `CHARS` data (stats + visual params) and `updateCharacter()`: per-dog special movement (air dash / charge+pound / grapple constraint / blink / jetpack).
|
- `js/characters.js` — `CHARS` data (stats + visual params) and `updateCharacter()`: per-dog special movement (air dash / charge+pound / grapple constraint / blink / jetpack).
|
||||||
- `js/sim.js` — **authoritative, DOM-free sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay.
|
- `js/sim.js` — **authoritative, DOM-free sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay.
|
||||||
- `js/input.js` — keyboard → bitmasks; slot 0 = WASD+FGH, slot 1 = arrows+KL;.
|
- `js/input.js` — keyboard → bitmasks; slot 0 = WASD+FGH, slot 1 = arrows+KL;.
|
||||||
- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P.
|
- `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P. Runs a 2s heartbeat + 8s watchdog (WebRTC close events are unreliable); broker `network` errors are ignored once the data channel is up.
|
||||||
- `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners.
|
- `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners.
|
||||||
- `js/audio.js` — WebAudio-synthesized sfx; `playEvents` maps sim events → sounds.
|
- `js/audio.js` — WebAudio-synthesized sfx; `playEvents` maps sim events → sounds.
|
||||||
- `js/main.js` — app state machine: screens, char select, three match drivers (local / host / client) sharing one rAF fixed-timestep loop.
|
- `js/main.js` — app state machine: screens, char select, three match drivers (local / host / client) sharing one rAF fixed-timestep loop.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>BARKOUR — dogfights × parkour</title>
|
<title>BARKOUR — dogfights × parkour</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🐕</text></svg>">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<!-- PeerJS: signaling broker only; game traffic is pure P2P WebRTC.
|
<!-- PeerJS: signaling broker only; game traffic is pure P2P WebRTC.
|
||||||
If offline, local 2-player still works. -->
|
If offline, local 2-player still works. -->
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,11 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
|
||||||
const dx = p.x - st.anchor.x, dy = p.y - st.anchor.y;
|
const dx = p.x - st.anchor.x, dy = p.y - st.anchor.y;
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
const d = Math.hypot(dx, dy) || 1;
|
||||||
if (d > st.len) {
|
if (d > st.len) {
|
||||||
p.x = st.anchor.x + dx / d * st.len;
|
|
||||||
p.y = st.anchor.y + dy / d * st.len;
|
|
||||||
const nx = dx / d, ny = dy / d;
|
const nx = dx / d, ny = dy / d;
|
||||||
|
const cx = st.anchor.x + nx * st.len, cy = st.anchor.y + ny * st.len;
|
||||||
|
// the radial projection ignores terrain between here and the taut-rope
|
||||||
|
// position — committing it blindly drags the box through ledge lips
|
||||||
|
if (H.boxFree(game.map, cx, cy, p.w, p.h)) { p.x = cx; p.y = cy; }
|
||||||
const vr = p.vx * nx + p.vy * ny; // strip outward radial velocity → pendulum
|
const vr = p.vx * nx + p.vy * ny; // strip outward radial velocity → pendulum
|
||||||
if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; }
|
if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const PREVENT = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Spa
|
||||||
|
|
||||||
export function initInput() {
|
export function initInput() {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.target && e.target.tagName === 'INPUT') return; // leave text fields alone
|
||||||
if (PREVENT.has(e.code)) e.preventDefault();
|
if (PREVENT.has(e.code)) e.preventDefault();
|
||||||
if (down.has(e.code)) return;
|
if (down.has(e.code)) return;
|
||||||
down.add(e.code);
|
down.add(e.code);
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,10 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
startLoop();
|
startLoop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pointerdown', () => initAudio()); // any gesture unlocks audio
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
initAudio(); // browsers require a gesture before audio
|
initAudio(); // browsers require a gesture before audio
|
||||||
|
if (e.target && e.target.tagName === 'INPUT') return; // don't eat typing in the room-key field
|
||||||
if (e.code === 'KeyM') {
|
if (e.code === 'KeyM') {
|
||||||
const m = toggleMute();
|
const m = toggleMute();
|
||||||
$('mute').textContent = m ? '🔇' : '🔊';
|
$('mute').textContent = m ? '🔇' : '🔊';
|
||||||
|
|
@ -204,6 +206,7 @@ function tryStart() {
|
||||||
|
|
||||||
// ---- networking ----
|
// ---- networking ----
|
||||||
function startHost() {
|
function startHost() {
|
||||||
|
if (net) return; // a connection attempt is already in flight
|
||||||
if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
||||||
mode = 'host';
|
mode = 'host';
|
||||||
myPick = null; remotePick = null; peerJoined = false;
|
myPick = null; remotePick = null; peerJoined = false;
|
||||||
|
|
@ -226,6 +229,7 @@ function startHost() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function doJoin() {
|
function doJoin() {
|
||||||
|
if (net) return; // a connection attempt is already in flight
|
||||||
if (!peerAvailable()) { setStatus($('join-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
if (!peerAvailable()) { setStatus($('join-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
||||||
const key = $('join-key').value.toUpperCase().trim();
|
const key = $('join-key').value.toUpperCase().trim();
|
||||||
if (key.length < 4) { setStatus($('join-status'), 'Enter the 5-letter room key.', true); return; }
|
if (key.length < 4) { setStatus($('join-status'), 'Enter the 5-letter room key.', true); return; }
|
||||||
|
|
@ -267,7 +271,7 @@ function onNetData(msg) {
|
||||||
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
|
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
|
||||||
break;
|
break;
|
||||||
case 'tochars':
|
case 'tochars':
|
||||||
if (mode === 'client') { myPick = null; remotePick = null; show('s-chars'); refreshCharUI(); }
|
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
|
||||||
break;
|
break;
|
||||||
case 'bye':
|
case 'bye':
|
||||||
netClosed('Other player left.');
|
netClosed('Other player left.');
|
||||||
|
|
@ -302,6 +306,7 @@ function doRematch() {
|
||||||
|
|
||||||
function doChangeDogs() {
|
function doChangeDogs() {
|
||||||
sfx('ui');
|
sfx('ui');
|
||||||
|
resultShown = false; // lets the host tick() guard idle the finished match
|
||||||
if (mode === 'local') {
|
if (mode === 'local') {
|
||||||
picks = [null, null];
|
picks = [null, null];
|
||||||
show('s-chars'); refreshCharUI();
|
show('s-chars'); refreshCharUI();
|
||||||
|
|
|
||||||
55
js/mapgen.js
55
js/mapgen.js
|
|
@ -38,16 +38,26 @@ export function generateMap(seed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- spawn columns (picked early so hazards can avoid them) ---
|
// --- spawn columns (picked early so hazards can avoid them) ---
|
||||||
|
// The player box is 18px wide (~1.5 tiles), so a valid spawn column needs
|
||||||
|
// its neighbours' ground no higher than its own, or the dog spawns embedded.
|
||||||
const spawnCols = [];
|
const spawnCols = [];
|
||||||
|
const colOk = (x) =>
|
||||||
|
x >= 1 && x < GW - 1 && !spawnCols.includes(x) &&
|
||||||
|
hs[x] <= GH - 2 && hs[x - 1] <= GH - 2 && hs[x + 1] <= GH - 2;
|
||||||
const wanted = [0.12, 0.38, 0.62, 0.88].map(f => Math.floor(GW * f) + r.int(-3, 3));
|
const wanted = [0.12, 0.38, 0.62, 0.88].map(f => Math.floor(GW * f) + r.int(-3, 3));
|
||||||
for (let sx of wanted) {
|
for (let sx of wanted) {
|
||||||
let x = Math.max(2, Math.min(GW - 3, sx));
|
let x = Math.max(2, Math.min(GW - 3, sx));
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
while (hs[x] > GH - 2 && tries++ < 20) x = (x + 1) % (GW - 4) + 2; // skip pits
|
while (!colOk(x) && tries++ < 90) x = (x + 1) % (GW - 4) + 2; // skip pits/steps
|
||||||
if (hs[x] <= GH - 2) spawnCols.push(x);
|
if (colOk(x)) spawnCols.push(x);
|
||||||
}
|
}
|
||||||
if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback
|
if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback
|
||||||
const nearSpawn = (x) => spawnCols.some(sx => Math.abs(sx - x) < 5);
|
const nearSpawn = (x) => spawnCols.some(sx => Math.abs(sx - x) < 5);
|
||||||
|
// pixel spawn points sit above the highest ground of the 3-column footprint
|
||||||
|
const spawns = spawnCols.map(x => {
|
||||||
|
const surf = Math.min(hs[x - 1] ?? GH, hs[x] <= GH ? hs[x] : GH - 4, hs[x + 1] ?? GH);
|
||||||
|
return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
|
||||||
|
});
|
||||||
|
|
||||||
// --- wall-jump towers rising from the ground ---
|
// --- wall-jump towers rising from the ground ---
|
||||||
const nTowers = r.int(2, 3);
|
const nTowers = r.int(2, 3);
|
||||||
|
|
@ -55,8 +65,10 @@ export function generateMap(seed) {
|
||||||
const tx = r.int(8, GW - 9);
|
const tx = r.int(8, GW - 9);
|
||||||
if (hs[tx] > GH || nearSpawn(tx)) continue;
|
if (hs[tx] > GH || nearSpawn(tx)) continue;
|
||||||
const th = r.int(5, 9), tw = r.int(1, 2);
|
const th = r.int(5, 9), tw = r.int(1, 2);
|
||||||
for (let x = tx; x < tx + tw; x++)
|
for (let x = tx; x < tx + tw; x++) {
|
||||||
for (let y = hs[tx] - th; y < hs[tx]; y++) set(x, y, T.SOLID);
|
if (hs[x] > GH) continue; // don't hang tower slabs over pit columns
|
||||||
|
for (let y = hs[x] - th; y < hs[x]; y++) set(x, y, T.SOLID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- floating islands ---
|
// --- floating islands ---
|
||||||
|
|
@ -126,25 +138,44 @@ export function generateMap(seed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- rail saws: hazards moving along fixed paths through open air ---
|
// --- rail saws: hazards moving along fixed paths through open air ---
|
||||||
|
// A path is rejected if it grinds through terrain (invisible through-wall
|
||||||
|
// hits) or sweeps within kill radius of a spawn point.
|
||||||
|
const solidT = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PAD;
|
||||||
|
const sawPathOk = (pts, loop) => {
|
||||||
|
const nSegs = loop ? pts.length : pts.length - 1;
|
||||||
|
for (let s = 0; s < nSegs; s++) {
|
||||||
|
const a = pts[s], b = pts[(s + 1) % pts.length];
|
||||||
|
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
||||||
|
for (let d = 0; d <= len; d += 6) {
|
||||||
|
const px = a.x + (b.x - a.x) * d / len, py = a.y + (b.y - a.y) * d / len;
|
||||||
|
for (const [ox, oy] of [[0, 0], [12, 0], [-12, 0], [0, 12], [0, -12]]) {
|
||||||
|
if (solidT(get(Math.floor((px + ox) / TILE), Math.floor((py + oy) / TILE)))) return false;
|
||||||
|
}
|
||||||
|
for (const sp of spawns) if (Math.hypot(sp.x - px, sp.y - py) < 30) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
const saws = [];
|
const saws = [];
|
||||||
const nSaws = r.int(1, 3);
|
const nSaws = r.int(1, 3);
|
||||||
for (let i = 0; i < nSaws; i++) {
|
for (let i = 0; i < nSaws; i++) {
|
||||||
|
for (let t = 0; t < 30; t++) {
|
||||||
const y = (r.int(10, 26)) * TILE;
|
const y = (r.int(10, 26)) * TILE;
|
||||||
const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE;
|
const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE;
|
||||||
|
let pts, loop = false;
|
||||||
if (r.chance(0.5)) {
|
if (r.chance(0.5)) {
|
||||||
saws.push({ pts: [{ x: x0, y }, { x: x1, y }], speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y });
|
pts = [{ x: x0, y }, { x: x1, y }];
|
||||||
} else {
|
} else {
|
||||||
const y2 = y + r.int(4, 8) * TILE; // rectangle loop
|
const y2 = y + r.int(4, 8) * TILE; // rectangle loop
|
||||||
saws.push({ pts: [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }], speed: 1.2 + r.f(), prog: r.f(), x: x0, y, loop: true });
|
pts = [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }];
|
||||||
|
loop = true;
|
||||||
|
}
|
||||||
|
if (!sawPathOk(pts, loop)) continue;
|
||||||
|
saws.push({ pts, speed: 1.1 + r.f() * 1.1, prog: r.f(), x: x0, y, loop });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- spawn points (pixel coords, centered above surface) ---
|
|
||||||
const spawns = spawnCols.map(x => {
|
|
||||||
let surf = hs[x] <= GH ? hs[x] : GH - 4;
|
|
||||||
return { x: x * TILE + TILE / 2, y: surf * TILE - 10 };
|
|
||||||
});
|
|
||||||
|
|
||||||
return { seed, tiles, hp, spawns, saws, pal: r.int(0, PALETTES.length - 1) };
|
return { seed, tiles, hp, spawns, saws, pal: r.int(0, PALETTES.length - 1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
js/net.js
54
js/net.js
|
|
@ -25,27 +25,48 @@ function errMsg(e) {
|
||||||
return 'Connection error: ' + (e && e.message || t || 'unknown');
|
return 'Connection error: ' + (e && e.message || t || 'unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebRTC close events are unreliable (a vanished tab often never fires
|
||||||
|
// 'close'), so both sides run a heartbeat + watchdog over the data channel.
|
||||||
|
const HB_INTERVAL = 2000, HB_TIMEOUT = 8000;
|
||||||
|
|
||||||
|
function wireConn(c, cb, die, who) {
|
||||||
|
let lastRecv = performance.now();
|
||||||
|
c.on('data', (d) => {
|
||||||
|
lastRecv = performance.now();
|
||||||
|
if (d && d.t === '_hb') return;
|
||||||
|
cb.data(d);
|
||||||
|
});
|
||||||
|
c.on('close', () => die(`${who} disconnected.`));
|
||||||
|
c.on('error', () => die('Connection lost.'));
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (c.open) {
|
||||||
|
try { c.send({ t: '_hb' }); } catch (_) {}
|
||||||
|
if (performance.now() - lastRecv > HB_TIMEOUT) die(`${who} stopped responding.`);
|
||||||
|
}
|
||||||
|
}, HB_INTERVAL);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
|
||||||
// cb: { open(key), conn(), data(msg), close(reason) }
|
// cb: { open(key), conn(), data(msg), close(reason) }
|
||||||
export function hostRoom(key, cb) {
|
export function hostRoom(key, cb) {
|
||||||
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
|
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
|
||||||
const peer = new window.Peer(PRE + key);
|
const peer = new window.Peer(PRE + key);
|
||||||
let conn = null;
|
let conn = null;
|
||||||
let dead = false;
|
let dead = false;
|
||||||
const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
|
let linked = false; // data channel established — broker hiccups no longer matter
|
||||||
|
let unwire = null;
|
||||||
|
const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
|
||||||
peer.on('open', () => cb.open(key));
|
peer.on('open', () => cb.open(key));
|
||||||
peer.on('error', (e) => die(errMsg(e)));
|
peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
|
||||||
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
|
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
|
||||||
peer.on('connection', (c) => {
|
peer.on('connection', (c) => {
|
||||||
if (conn) { try { c.close(); } catch (_) {} return; } // room is full (2 players)
|
if (conn) { try { c.close(); } catch (_) {} return; } // room is full (2 players)
|
||||||
conn = c;
|
conn = c;
|
||||||
c.on('open', () => cb.conn());
|
c.on('open', () => { linked = true; unwire = wireConn(c, cb, die, 'Other player'); cb.conn(); });
|
||||||
c.on('data', (d) => cb.data(d));
|
|
||||||
c.on('close', () => die('Other player disconnected.'));
|
|
||||||
c.on('error', () => die('Connection lost.'));
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
send: (o) => { if (conn && conn.open) conn.send(o); },
|
send: (o) => { if (conn && conn.open) conn.send(o); },
|
||||||
destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
|
destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,18 +74,23 @@ export function joinRoom(key, cb) {
|
||||||
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
|
if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; }
|
||||||
const peer = new window.Peer();
|
const peer = new window.Peer();
|
||||||
let dead = false;
|
let dead = false;
|
||||||
const die = (msg) => { if (!dead) { dead = true; cb.close(msg); } };
|
let linked = false;
|
||||||
|
let unwire = null;
|
||||||
|
const die = (msg) => { if (!dead) { dead = true; if (unwire) unwire(); cb.close(msg); } };
|
||||||
const api = {
|
const api = {
|
||||||
send: () => {},
|
send: () => {},
|
||||||
destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} },
|
destroy: () => { dead = true; if (unwire) unwire(); try { peer.destroy(); } catch (_) {} },
|
||||||
};
|
};
|
||||||
peer.on('error', (e) => die(errMsg(e)));
|
peer.on('error', (e) => { if (linked && e && e.type === 'network') return; die(errMsg(e)); });
|
||||||
|
peer.on('disconnected', () => { try { peer.reconnect(); } catch (_) { /* broker gone; game traffic is p2p anyway */ } });
|
||||||
peer.on('open', () => {
|
peer.on('open', () => {
|
||||||
const c = peer.connect(PRE + key.toUpperCase().trim(), { reliable: true });
|
const c = peer.connect(PRE + key.toUpperCase().trim(), { reliable: true });
|
||||||
c.on('open', () => { api.send = (o) => { if (c.open) c.send(o); }; cb.conn(); });
|
c.on('open', () => {
|
||||||
c.on('data', (d) => cb.data(d));
|
linked = true;
|
||||||
c.on('close', () => die('Host disconnected.'));
|
api.send = (o) => { if (c.open) c.send(o); };
|
||||||
c.on('error', () => die('Connection lost.'));
|
unwire = wireConn(c, cb, die, 'Host');
|
||||||
|
cb.conn();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,7 @@ function drawItemIcon(x, y, item, r = 5) {
|
||||||
const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?';
|
const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?';
|
||||||
ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088';
|
ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088';
|
||||||
ctx.fillText(ch, x, y + 0.5);
|
ctx.fillText(ch, x, y + 0.5);
|
||||||
|
ctx.textBaseline = 'alphabetic'; // don't leak 'middle' into HUD/banner text
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawEnts(game) {
|
function drawEnts(game) {
|
||||||
|
|
|
||||||
26
js/sim.js
26
js/sim.js
|
|
@ -77,7 +77,7 @@ function stepPlayer(g, p, inp, pressed) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const c = CHARS[p.charId], s = c.stats;
|
const c = CHARS[p.charId], s = c.stats;
|
||||||
if (p.invuln > 0) p.invuln--;
|
if (p.invuln > 0 && g.freeze <= 0) p.invuln--; // spawn protection shouldn't burn during countdown
|
||||||
if (p.atkCd > 0) p.atkCd--;
|
if (p.atkCd > 0) p.atkCd--;
|
||||||
if (p.shield > 0) p.shield--;
|
if (p.shield > 0) p.shield--;
|
||||||
if (p.boost > 0) p.boost--;
|
if (p.boost > 0) p.boost--;
|
||||||
|
|
@ -217,16 +217,20 @@ function breakTile(g, tx, ty, drop) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hurt(g, v, dmg, nx, ny, base) {
|
export function hurt(g, v, dmg, nx, ny, base) {
|
||||||
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return;
|
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; }
|
if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return false; }
|
||||||
v.dmg += dmg;
|
v.dmg += dmg;
|
||||||
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
|
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
|
||||||
v.vx = nx * m;
|
// collision is single-pass AABB: velocities >= TILE/tick tunnel through walls
|
||||||
v.vy = ny * m - 1.4;
|
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.hitstun = Math.min(40, 9 + m * 1.6);
|
||||||
v.invuln = 14;
|
v.invuln = 14;
|
||||||
v.st.charge = 0; v.st.anchor = null; v.st.pound = false;
|
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 });
|
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function senseTiles(g, p) {
|
function senseTiles(g, p) {
|
||||||
|
|
@ -236,10 +240,11 @@ function senseTiles(g, p) {
|
||||||
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
|
||||||
if (tileAt(g.map, tx, ty) === T.SPIKE) {
|
if (tileAt(g.map, tx, ty) === T.SPIKE) {
|
||||||
const cx = tx * TILE + 6;
|
const cx = tx * TILE + 6;
|
||||||
hurt(g, p, 9, Math.sign(p.x - cx) * 0.4 || 0.2, -1, 5.5);
|
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 });
|
g.events.push({ t: 'spike', x: p.x, y: p.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// bounce pad: landed on top of a PAD tile
|
// bounce pad: landed on top of a PAD tile
|
||||||
if (p.justLanded) {
|
if (p.justLanded) {
|
||||||
const ty = Math.floor((p.y + hh + 2) / TILE);
|
const ty = Math.floor((p.y + hh + 2) / TILE);
|
||||||
|
|
@ -308,6 +313,7 @@ function stepEnts(g) {
|
||||||
} else if (e.k === 'ball') {
|
} else if (e.k === 'ball') {
|
||||||
e.age++; e.ttl--;
|
e.age++; e.ttl--;
|
||||||
e.vy += GRAV * 0.45;
|
e.vy += GRAV * 0.45;
|
||||||
|
if (e.vy > MAXFALL) e.vy = MAXFALL;
|
||||||
const pvx = e.vx, pvy = e.vy;
|
const pvx = e.vx, pvy = e.vy;
|
||||||
const r = moveEntity(e, g.map, {});
|
const r = moveEntity(e, g.map, {});
|
||||||
if (r.ground && pvy > 1) e.vy = -pvy * 0.72;
|
if (r.ground && pvy > 1) e.vy = -pvy * 0.72;
|
||||||
|
|
@ -327,6 +333,7 @@ function stepEnts(g) {
|
||||||
} else if (e.k === 'bomb') {
|
} else if (e.k === 'bomb') {
|
||||||
e.age++; e.fuse--;
|
e.age++; e.fuse--;
|
||||||
e.vy += GRAV;
|
e.vy += GRAV;
|
||||||
|
if (e.vy > MAXFALL) e.vy = MAXFALL;
|
||||||
const pvx = e.vx, pvy = e.vy;
|
const pvx = e.vx, pvy = e.vy;
|
||||||
const r = moveEntity(e, g.map, {});
|
const r = moveEntity(e, g.map, {});
|
||||||
if (r.ground && pvy > 1) { e.vy = -pvy * 0.45; e.vx *= 0.7; }
|
if (r.ground && pvy > 1) { e.vy = -pvy * 0.45; e.vx *= 0.7; }
|
||||||
|
|
@ -378,12 +385,13 @@ function stepSaws(g) {
|
||||||
const d = Math.hypot(dx, dy);
|
const d = Math.hypot(dx, dy);
|
||||||
if (d < 16) {
|
if (d < 16) {
|
||||||
const n = d || 1;
|
const n = d || 1;
|
||||||
hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5);
|
if (hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5)) {
|
||||||
g.events.push({ t: 'saw', x: p.x, y: p.y });
|
g.events.push({ t: 'saw', x: p.x, y: p.y });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function kill(g, p) {
|
function kill(g, p) {
|
||||||
p.stocks--;
|
p.stocks--;
|
||||||
|
|
@ -392,7 +400,7 @@ function kill(g, p) {
|
||||||
x: Math.max(20, Math.min(W - 20, p.x)),
|
x: Math.max(20, Math.min(W - 20, p.x)),
|
||||||
y: Math.max(20, Math.min(H - 20, p.y)),
|
y: Math.max(20, Math.min(H - 20, p.y)),
|
||||||
});
|
});
|
||||||
p.st = {}; p.item = null; p.ammo = 0;
|
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.vx = 0; p.vy = 0; p.hitstun = 0; p.atkT = 0; p.dmg = 0;
|
||||||
p.x = -1000; p.y = -1000;
|
p.x = -1000; p.y = -1000;
|
||||||
p.respawn = p.stocks > 0 ? 80 : 0;
|
p.respawn = p.stocks > 0 ? 80 : 0;
|
||||||
|
|
@ -434,6 +442,7 @@ export function snapshot(g, events) {
|
||||||
x: +p.x.toFixed(1), y: +p.y.toFixed(1),
|
x: +p.x.toFixed(1), y: +p.y.toFixed(1),
|
||||||
vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
|
vx: +p.vx.toFixed(2), vy: +p.vy.toFixed(2),
|
||||||
f: p.face, d: Math.round(p.dmg), s: p.stocks,
|
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,
|
r: p.respawn, iv: p.invuln, hs: p.hitstun, a: p.atkT,
|
||||||
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
|
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
|
||||||
st: {
|
st: {
|
||||||
|
|
@ -456,6 +465,7 @@ export function applySnapshot(g, s) {
|
||||||
if (!p) return;
|
if (!p) return;
|
||||||
p.x = sp.x; p.y = sp.y; p.vx = sp.vx; p.vy = sp.vy;
|
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.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.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.item = sp.it; p.ammo = sp.am; p.shield = sp.sh; p.boost = sp.bo;
|
||||||
p.st = {
|
p.st = {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
|
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
|
||||||
import { generateMap, sawPos } from '../js/mapgen.js';
|
import { generateMap, sawPos } from '../js/mapgen.js';
|
||||||
import { mulberry32 } from '../js/rng.js';
|
import { mulberry32 } from '../js/rng.js';
|
||||||
|
import { boxFree, solidAtPx } from '../js/physics.js';
|
||||||
import { GW, GH, T, B } from '../js/consts.js';
|
import { GW, GH, T, B } from '../js/consts.js';
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
|
@ -32,8 +33,22 @@ for (let s = 1; s <= 40; s++) {
|
||||||
const p = sawPos(saw, 0.37);
|
const p = sawPos(saw, 0.37);
|
||||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`);
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`);
|
||||||
}
|
}
|
||||||
|
// spawn footprints must be clear of solids (review finding: 58% of seeds embedded players)
|
||||||
|
for (const sp of map.spawns) {
|
||||||
|
if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`seed ${seed}: spawn embedded in terrain at (${sp.x},${sp.y})`);
|
||||||
}
|
}
|
||||||
ok('mapgen: 40 seeds, deterministic, sane geometry');
|
// saw paths must not grind through terrain or sweep spawn points
|
||||||
|
for (const saw of map.saws) {
|
||||||
|
for (let f = 0; f < 1; f += 0.02) {
|
||||||
|
const p = sawPos(saw, f);
|
||||||
|
if (solidAtPx(map, p.x, p.y)) { fail(`seed ${seed}: saw path inside solid at (${p.x | 0},${p.y | 0})`); break; }
|
||||||
|
for (const sp of map.spawns) {
|
||||||
|
if (Math.hypot(sp.x - p.x, sp.y - p.y) < 25) { fail(`seed ${seed}: saw sweeps spawn point`); f = 2; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok('mapgen: 40 seeds, deterministic, sane geometry, clear spawns, clean saw paths');
|
||||||
|
|
||||||
// --- full-match simulation with chaotic inputs ---
|
// --- full-match simulation with chaotic inputs ---
|
||||||
const ALL_BITS = [0, B.L, B.R, B.L | B.JUMP | B.U, B.R | B.JUMP | B.U, B.ATK, B.SPC, B.R | B.SPC,
|
const ALL_BITS = [0, B.L, B.R, B.L | B.JUMP | B.U, B.R | B.JUMP | B.U, B.ATK, B.SPC, B.R | B.SPC,
|
||||||
|
|
@ -84,13 +99,16 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
|
||||||
const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]);
|
const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]);
|
||||||
g.freeze = 0;
|
g.freeze = 0;
|
||||||
const p = g.players[0];
|
const p = g.players[0];
|
||||||
|
const sx = p.x, sy = p.y;
|
||||||
const tx = Math.floor(p.x / 12) + 2, ty = Math.floor(p.y / 12);
|
const tx = Math.floor(p.x / 12) + 2, ty = Math.floor(p.y / 12);
|
||||||
g.map.tiles[ty * GW + tx] = T.CRYSTAL;
|
g.map.tiles[ty * GW + tx] = T.CRYSTAL;
|
||||||
g.map.hp[ty * GW + tx] = 3;
|
g.map.hp[ty * GW + tx] = 3;
|
||||||
p.face = 1;
|
|
||||||
let dropped = false;
|
let dropped = false;
|
||||||
for (let i = 0; i < 200 && !dropped; i++) {
|
for (let i = 0; i < 200 && !dropped; i++) {
|
||||||
const evs = step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
|
// pin the dog next to the crystal — the bite's forward lunge would otherwise
|
||||||
|
// shove it off terrain steps and out of reach
|
||||||
|
p.x = sx; p.y = sy; p.vx = 0; p.vy = 0; p.face = 1;
|
||||||
|
step(g, [i % 30 === 0 ? B.ATK : 0, 0]);
|
||||||
if (g.ents.some(e => e.k === 'pickup')) dropped = true;
|
if (g.ents.some(e => e.k === 'pickup')) dropped = true;
|
||||||
}
|
}
|
||||||
if (!dropped) fail('mining a crystal never dropped a pickup');
|
if (!dropped) fail('mining a crystal never dropped a pickup');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue