From 99468cd379aa4ccd4e14ea1dc5bd99cad80e028d Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 10 Jun 2026 14:47:40 +0200 Subject: [PATCH] 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. --- MAP.md | 2 +- index.html | 1 + js/characters.js | 6 +++-- js/input.js | 1 + js/main.js | 7 +++++- js/mapgen.js | 65 +++++++++++++++++++++++++++++++++++------------- js/net.js | 54 +++++++++++++++++++++++++++++----------- js/render.js | 1 + js/sim.js | 30 ++++++++++++++-------- test/smoke.mjs | 24 +++++++++++++++--- 10 files changed, 143 insertions(+), 48 deletions(-) diff --git a/MAP.md b/MAP.md index b81a51d..a88b89c 100644 --- a/MAP.md +++ b/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/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/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/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. diff --git a/index.html b/index.html index e430322..a7291f1 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ BARKOUR — dogfights × parkour + diff --git a/js/characters.js b/js/characters.js index 72b0162..74c6690 100644 --- a/js/characters.js +++ b/js/characters.js @@ -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 d = Math.hypot(dx, dy) || 1; 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 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 if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; } } diff --git a/js/input.js b/js/input.js index a0a0c2f..3ba66b9 100644 --- a/js/input.js +++ b/js/input.js @@ -14,6 +14,7 @@ const PREVENT = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Spa export function initInput() { window.addEventListener('keydown', (e) => { + if (e.target && e.target.tagName === 'INPUT') return; // leave text fields alone if (PREVENT.has(e.code)) e.preventDefault(); if (down.has(e.code)) return; down.add(e.code); diff --git a/js/main.js b/js/main.js index 1c1c657..230259e 100644 --- a/js/main.js +++ b/js/main.js @@ -49,8 +49,10 @@ window.addEventListener('DOMContentLoaded', () => { startLoop(); }); +window.addEventListener('pointerdown', () => initAudio()); // any gesture unlocks audio window.addEventListener('keydown', (e) => { 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') { const m = toggleMute(); $('mute').textContent = m ? '🔇' : '🔊'; @@ -204,6 +206,7 @@ function tryStart() { // ---- networking ---- 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; } mode = 'host'; myPick = null; remotePick = null; peerJoined = false; @@ -226,6 +229,7 @@ function startHost() { } 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; } const key = $('join-key').value.toUpperCase().trim(); 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] }]); break; 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; case 'bye': netClosed('Other player left.'); @@ -302,6 +306,7 @@ function doRematch() { function doChangeDogs() { sfx('ui'); + resultShown = false; // lets the host tick() guard idle the finished match if (mode === 'local') { picks = [null, null]; show('s-chars'); refreshCharUI(); diff --git a/js/mapgen.js b/js/mapgen.js index a427c77..96966b1 100644 --- a/js/mapgen.js +++ b/js/mapgen.js @@ -38,16 +38,26 @@ export function generateMap(seed) { } // --- 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 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)); for (let sx of wanted) { let x = Math.max(2, Math.min(GW - 3, sx)); let tries = 0; - while (hs[x] > GH - 2 && tries++ < 20) x = (x + 1) % (GW - 4) + 2; // skip pits - if (hs[x] <= GH - 2) spawnCols.push(x); + while (!colOk(x) && tries++ < 90) x = (x + 1) % (GW - 4) + 2; // skip pits/steps + if (colOk(x)) spawnCols.push(x); } if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback 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 --- const nTowers = r.int(2, 3); @@ -55,8 +65,10 @@ export function generateMap(seed) { const tx = r.int(8, GW - 9); if (hs[tx] > GH || nearSpawn(tx)) continue; const th = r.int(5, 9), tw = r.int(1, 2); - for (let x = tx; x < tx + tw; x++) - for (let y = hs[tx] - th; y < hs[tx]; y++) set(x, y, T.SOLID); + for (let x = tx; x < tx + tw; x++) { + 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 --- @@ -126,25 +138,44 @@ export function generateMap(seed) { } // --- 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 nSaws = r.int(1, 3); for (let i = 0; i < nSaws; i++) { - const y = (r.int(10, 26)) * TILE; - const x0 = r.int(8, GW - 30) * TILE, x1 = x0 + r.int(12, 20) * TILE; - 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 }); - } else { - 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 }); + for (let t = 0; t < 30; t++) { + const y = (r.int(10, 26)) * 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)) { + pts = [{ x: x0, y }, { x: x1, y }]; + } else { + const y2 = y + r.int(4, 8) * TILE; // rectangle loop + 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) }; } diff --git a/js/net.js b/js/net.js index bda4346..51cf294 100644 --- a/js/net.js +++ b/js/net.js @@ -25,27 +25,48 @@ function errMsg(e) { 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) } export function hostRoom(key, cb) { if (!peerAvailable()) { cb.close('PeerJS failed to load — check your internet connection.'); return null; } const peer = new window.Peer(PRE + key); let conn = null; 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('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('connection', (c) => { if (conn) { try { c.close(); } catch (_) {} return; } // room is full (2 players) conn = c; - c.on('open', () => cb.conn()); - c.on('data', (d) => cb.data(d)); - c.on('close', () => die('Other player disconnected.')); - c.on('error', () => die('Connection lost.')); + c.on('open', () => { linked = true; unwire = wireConn(c, cb, die, 'Other player'); cb.conn(); }); }); return { 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; } const peer = new window.Peer(); 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 = { 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', () => { 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('data', (d) => cb.data(d)); - c.on('close', () => die('Host disconnected.')); - c.on('error', () => die('Connection lost.')); + c.on('open', () => { + linked = true; + api.send = (o) => { if (c.open) c.send(o); }; + unwire = wireConn(c, cb, die, 'Host'); + cb.conn(); + }); }); return api; } diff --git a/js/render.js b/js/render.js index 5eddf93..83374b5 100644 --- a/js/render.js +++ b/js/render.js @@ -237,6 +237,7 @@ function drawItemIcon(x, y, item, r = 5) { const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?'; ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088'; ctx.fillText(ch, x, y + 0.5); + ctx.textBaseline = 'alphabetic'; // don't leak 'middle' into HUD/banner text } function drawEnts(game) { diff --git a/js/sim.js b/js/sim.js index 50f1b33..0496106 100644 --- a/js/sim.js +++ b/js/sim.js @@ -77,7 +77,7 @@ function stepPlayer(g, p, inp, pressed) { } 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.shield > 0) p.shield--; 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) { - if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0) return; - if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); 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 false; } v.dmg += dmg; const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight; - v.vx = nx * m; - v.vy = ny * m - 1.4; + // 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) { @@ -236,8 +240,9 @@ function senseTiles(g, p) { 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; - 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 }); + 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 @@ -308,6 +313,7 @@ function stepEnts(g) { } 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; @@ -327,6 +333,7 @@ function stepEnts(g) { } 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; } @@ -378,8 +385,9 @@ function stepSaws(g) { const d = Math.hypot(dx, dy); if (d < 16) { const n = d || 1; - hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5); - g.events.push({ t: 'saw', x: p.x, y: p.y }); + if (hurt(g, p, 12, dx / n, dy / n - 0.5, 6.5)) { + g.events.push({ t: 'saw', x: p.x, y: p.y }); + } } } } @@ -392,7 +400,7 @@ function kill(g, p) { 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.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; @@ -434,6 +442,7 @@ export function snapshot(g, events) { 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: { @@ -456,6 +465,7 @@ export function applySnapshot(g, s) { 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 = { diff --git a/test/smoke.mjs b/test/smoke.mjs index b44bb66..85a2a35 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -3,6 +3,7 @@ import { createGame, step, snapshot, applySnapshot } from '../js/sim.js'; import { generateMap, sawPos } from '../js/mapgen.js'; import { mulberry32 } from '../js/rng.js'; +import { boxFree, solidAtPx } from '../js/physics.js'; import { GW, GH, T, B } from '../js/consts.js'; let failures = 0; @@ -32,8 +33,22 @@ for (let s = 1; s <= 40; s++) { const p = sawPos(saw, 0.37); 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})`); + } + // 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'); +ok('mapgen: 40 seeds, deterministic, sane geometry, clear spawns, clean saw paths'); // --- 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, @@ -84,13 +99,16 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout'); const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]); g.freeze = 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); g.map.tiles[ty * GW + tx] = T.CRYSTAL; g.map.hp[ty * GW + tx] = 3; - p.face = 1; let dropped = false; 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 (!dropped) fail('mining a crystal never dropped a pickup');