// Procedural arena generation. Fully seeded — both peers generate the same map // from the match seed; only the host simulates, the seed just saves bandwidth. import { TILE, GW, GH, T } from './consts.js'; import { makeRng } from './rng.js'; export const PALETTES = [ { name: 'midnight', sky: ['#0b1026', '#1b2845'], hill: '#141d3d', solid: '#3a4466', top: '#5a6e9e', plat: '#c0863f', spike: '#aab4cc', crystal: '#7df9ff', pad: '#ffd166', star: true }, { name: 'sunset', sky: ['#2d1b4e', '#b4496b'], hill: '#451f55', solid: '#5d4157', top: '#ec9a5e', plat: '#7b4b94', spike: '#f3d9c9', crystal: '#ffe066', pad: '#9ef01a', star: false }, { name: 'forest', sky: ['#0e2a1f', '#1f5840'], hill: '#123524', solid: '#4a3b2a', top: '#69a14a', plat: '#8a6b3d', spike: '#d8e0c0', crystal: '#ff7edb', pad: '#ffe45e', star: false }, { name: 'candy', sky: ['#341a4f', '#7b2d8b'], hill: '#4b2266', solid: '#9b5d9e', top: '#f5b0cb', plat: '#54d6d6', spike: '#fff0f5', crystal: '#a6ff4d', pad: '#ff9f1c', star: true }, ]; export function generateMap(seed) { const r = makeRng(seed); const tiles = new Uint8Array(GW * GH); const hp = {}; // crystal tile hp, keyed by tile index const set = (x, y, v) => { if (x >= 0 && x < GW && y >= 0 && y < GH) tiles[y * GW + x] = v; }; const get = (x, y) => (x < 0 || x >= GW || y < 0 || y >= GH) ? T.SOLID : tiles[y * GW + x]; // --- ground heightmap (stepped terrain) --- let h = GH - 5 + r.int(-1, 1); const hs = []; // surface row per column; > GH means pit for (let x = 0; x < GW; x++) { if (x % 4 === 0) h += r.int(-2, 2); h = Math.max(GH - 12, Math.min(GH - 3, h)); hs.push(h); for (let y = h; y < GH; y++) set(x, y, T.SOLID); } // --- pits into the abyss --- const nPits = r.int(1, 2); for (let i = 0; i < nPits; i++) { const gx = r.int(12, GW - 18), gw = r.int(3, 4); for (let x = gx; x < gx + gw; x++) { for (let y = 0; y < GH; y++) if (tiles[y * GW + x] === T.SOLID) set(x, y, T.EMPTY); hs[x] = GH + 9; } } // --- 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 (!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); for (let i = 0; i < nTowers; i++) { 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++) { 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 --- const islands = []; const nIsl = r.int(3, 5); for (let i = 0; i < nIsl; i++) { const iw = r.int(4, 8), ih = r.int(2, 3); const ix = r.int(4, GW - iw - 4), iy = r.int(8, 22); let clear = true; for (let x = ix - 1; x < ix + iw + 1 && clear; x++) for (let y = iy - 3; y < iy + ih + 3; y++) if (get(x, y) !== T.EMPTY) { clear = false; break; } if (!clear) continue; islands.push({ ix, iy, iw, ih }); for (let x = ix; x < ix + iw; x++) for (let y = iy; y < iy + ih; y++) set(x, y, T.SOLID); } // --- one-way platforms --- const nPlat = r.int(6, 9); for (let i = 0; i < nPlat; i++) { const pw = r.int(4, 7), px = r.int(3, GW - pw - 3), py = r.int(7, GH - 16); let ok = true; for (let x = px - 1; x < px + pw + 1 && ok; x++) for (let y = py - 2; y < py + 3; y++) if (get(x, y) !== T.EMPTY) { ok = false; break; } if (ok) for (let x = px; x < px + pw; x++) set(x, py, T.PLAT); } // --- crystals: outcrops sitting on surfaces (mine them for powerups) --- let placed = 0, tries = 0; while (placed < 10 && tries++ < 300) { const onIsland = islands.length > 0 && r.chance(0.4); let x, surf; if (onIsland) { const isl = r.pick(islands); x = isl.ix + r.int(0, isl.iw - 1); surf = isl.iy; } else { x = r.int(2, GW - 3); surf = hs[x]; if (surf > GH - 2) continue; } if (nearSpawn(x)) continue; if (get(x, surf - 1) !== T.EMPTY || get(x, surf - 2) !== T.EMPTY) continue; set(x, surf - 1, T.CRYSTAL); hp[(surf - 1) * GW + x] = 3; if (r.chance(0.3) && get(x, surf - 3) === T.EMPTY) { set(x, surf - 2, T.CRYSTAL); hp[(surf - 2) * GW + x] = 3; } placed++; } // --- bounce pads (solid bumps on the ground) --- let pads = 0; tries = 0; while (pads < r.int(2, 3) + 1 && tries++ < 100) { const x = r.int(3, GW - 4), surf = hs[x]; if (surf > GH - 2 || nearSpawn(x)) continue; if (get(x, surf - 1) !== T.EMPTY || get(x, surf - 2) !== T.EMPTY) continue; set(x, surf - 1, T.PAD); pads++; } // --- spike patches on the ground surface --- let patches = 0; tries = 0; while (patches < r.int(2, 4) && tries++ < 100) { const x0 = r.int(3, GW - 8), w = r.int(2, 3); let ok = true; for (let x = x0; x < x0 + w; x++) if (hs[x] > GH - 2 || nearSpawn(x) || get(x, hs[x] - 1) !== T.EMPTY) ok = false; if (!ok) continue; for (let x = x0; x < x0 + w; x++) set(x, hs[x] - 1, T.SPIKE); patches++; } // --- 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++) { 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; } } return { seed, tiles, hp, spawns, saws, pal: r.int(0, PALETTES.length - 1) }; } // Position of a saw along its (possibly looping) polyline at progress t in [0,1). export function sawPos(saw, prog) { const pts = saw.pts; const segs = []; let total = 0; const n = saw.loop ? pts.length : pts.length * 2 - 2; // ping-pong for open paths const at = (i) => { if (saw.loop) return pts[i % pts.length]; const m = (pts.length - 1) * 2; const j = i % m; return j < pts.length ? pts[j] : pts[m - j]; }; for (let i = 0; i < n; i++) { const a = at(i), b = at(i + 1); const len = Math.hypot(b.x - a.x, b.y - a.y); segs.push({ a, b, len }); total += len; } let d = ((prog % 1) + 1) % 1 * total; for (const s of segs) { if (d <= s.len) { const f = s.len ? d / s.len : 0; return { x: s.a.x + (s.b.x - s.a.x) * f, y: s.a.y + (s.b.y - s.a.y) * f, total }; } d -= s.len; } return { x: pts[0].x, y: pts[0].y, total }; }