From 2f60136b36df3642758e8584873162395d7917f8 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 10 Jun 2026 14:26:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20Barkour=20=E2=80=94=20movemen?= =?UTF-8?q?t-PvP=20dog=20fighting=20game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 dogs with distinct movement physics, seeded procedural arenas with traps and mineable powerups, smash-style knockback/stocks combat, local 2P and P2P netplay (PeerJS room keys), procedural art and sfx, headless sim smoke tests. --- .gitignore | 3 + MAP.md | 26 +++ README.md | 65 ++++++ index.html | 82 +++++++ js/audio.js | 105 +++++++++ js/characters.js | 180 ++++++++++++++++ js/consts.js | 15 ++ js/input.js | 30 +++ js/main.js | 375 ++++++++++++++++++++++++++++++++ js/mapgen.js | 178 +++++++++++++++ js/net.js | 70 ++++++ js/physics.js | 94 ++++++++ js/render.js | 548 +++++++++++++++++++++++++++++++++++++++++++++++ js/rng.js | 26 +++ js/sim.js | 478 +++++++++++++++++++++++++++++++++++++++++ package.json | 10 + style.css | 148 +++++++++++++ test/smoke.mjs | 118 ++++++++++ 18 files changed, 2551 insertions(+) create mode 100644 .gitignore create mode 100644 MAP.md create mode 100644 README.md create mode 100644 index.html create mode 100644 js/audio.js create mode 100644 js/characters.js create mode 100644 js/consts.js create mode 100644 js/input.js create mode 100644 js/main.js create mode 100644 js/mapgen.js create mode 100644 js/net.js create mode 100644 js/physics.js create mode 100644 js/render.js create mode 100644 js/rng.js create mode 100644 js/sim.js create mode 100644 package.json create mode 100644 style.css create mode 100644 test/smoke.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6a2308 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +node_modules/ +.DS_Store diff --git a/MAP.md b/MAP.md new file mode 100644 index 0000000..b81a51d --- /dev/null +++ b/MAP.md @@ -0,0 +1,26 @@ +# MAP.md — Barkour code index + +Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically. + +- `index.html` — DOM shell: all screens (menu/host/join/charselect/game/result) as overlay divs; loads PeerJS from CDN (online play only), then `js/main.js`. +- `style.css` — overlay/menu styling; canvas letterboxed to 16:9 via CSS. +- `js/consts.js` — grid (TILE=12, 80×45 → 960×540), tile enum `T`, input bitmask `B`, item ids, physics constants, snapshot rate. +- `js/rng.js` — seeded mulberry32 (`makeRng`); `randomSeed()` is UI-side only. +- `js/mapgen.js` — `generateMap(seed)`: ground heightmap, pits, towers, islands, one-way platforms, crystals (hp 3), pads, spikes, rail saws, spawns; `PALETTES`; `sawPos()` polyline eval (ping-pong or loop). +- `js/physics.js` — center-based AABB vs tile grid: `moveEntity` (per-axis, one-way platform aware), `raycast`, `boxFree`, `standingOnPlat`. +- `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/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. +- `test/smoke.mjs` — `npm test`: headless mapgen determinism + chaotic full matches + targeted mining/stocks scenarios. + +## Conventions & gotchas + +- **Client never simulates.** In netplay the joiner only sends input bits and renders `applySnapshot` state; anything render reads must survive the snapshot round-trip (player `st` is re-shaped there). +- **Sim events are the only side-channel**: fx, audio, *and* client tile mutations (`tile`/`crack` events) ride on them. Host accumulates `pendingEvs` between snapshots. +- All sim/mapgen randomness must use the seeded RNG — never `Math.random()` inside `js/sim.js`/`js/mapgen.js`/`js/characters.js` paths. +- Velocities must stay < TILE (12 px/tick) or AABB collision tunnels. +- 7 as an arc end-angle in render means "> 2π" (full circle), a deliberate shorthand. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c67052 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# BARKOUR 🐕💨 + +**Dogfights × parkour.** A 2-player movement-PvP fighting game: five dogs with +wildly different movement physics brawl across procedurally generated arenas +full of traps and mineable powerups. Last dog standing wins. + +No build step, no game server, no asset files — everything (art, sound, maps) +is generated procedurally in the browser. + +## Run it + +```bash +npm run dev # python http.server on :8137 +# → http://localhost:8137 +``` + +Any static file server works (ES modules just need http://, not file://). + +## Play + +| | Move | Bite | Special | Item | +|---|---|---|---|---| +| **P1** | WASD | F | G | H | +| **P2** | arrows | K | L | ; | + +- **↑/W** jumps, **↓** drops through one-way platforms +- **Bite** is also your mining pick — crack open crystals for powerups +- Damage % makes knockback grow (Smash-style); die by being launched past + the arena edges or into the abyss. 3 stocks each. +- **M** mute, **Esc** pause/leave, **Enter** rematch + +### Online play (P2P) + +One player clicks **Host online room** and shares the 5-letter key; the other +clicks **Join with room key**. The PeerJS public cloud is used for signaling +only — all game traffic flows directly peer-to-peer over a WebRTC data channel. +No server of ours is involved. (Host is authoritative; the joiner streams +inputs and renders snapshots.) + +## The dogs + +| Dog | Breed | Movement identity | +|---|---|---| +| **Bolt** | Greyhound | Fastest runner, wall-slide/wall-jump, 8-way air dash | +| **Brick** | Bulldog | Massive knockback resistance, charging ram, ground-pound shockwave | +| **Lasso** | Lurcher | Leash grapple — anchor, reel, pendulum-swing, jump-release launch | +| **Wisp** | Saluki | Low gravity, double jump, blink-teleports through walls | +| **Stubbs** | Corgi | Jetpack (fuel refills on ground), butt-bounce off hard landings | + +## Arenas + +Every match generates a fresh seeded arena: stepped terrain, abyss pits, +wall-jump towers, floating islands, one-way platforms, spike patches, bounce +pads, rail saws on patrol routes, and crystal outcrops. Mine crystals (or bomb +them) for: tennis-ball launcher, bomb, shield, speed boost, heal. A care +package also parachutes in periodically. + +## Tests + +```bash +npm test # headless sim smoke test (node, no browser needed) +``` + +The sim core (`js/sim.js` + deps) is DOM-free by design: maps are seeded and +deterministic, matches run headlessly. See `MAP.md` for the code layout. diff --git a/index.html b/index.html new file mode 100644 index 0000000..e430322 --- /dev/null +++ b/index.html @@ -0,0 +1,82 @@ + + + + + +BARKOUR — dogfights × parkour + + + + + +
+ + +
+
🔊
+ + + + +
+

BARKOUR

+

dogfights × parkour

+ + + + +
+
P1 WASD move · F bite · G special · H item
+
P2 arrows move · K bite · L special · ; item
+
↓ drops through platforms · bite crystals to mine powerups · last dog standing wins
+
+
+ + + + + + + + + + + + + + + +
+
+ + + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..6832a69 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,105 @@ +// Procedural sound effects — pure WebAudio synthesis, no asset files. +let ac = null, master = null, muted = false; + +export function initAudio() { + if (!ac) { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + ac = new AC(); + master = ac.createGain(); + master.gain.value = 0.35; + master.connect(ac.destination); + } + if (ac.state === 'suspended') ac.resume(); +} + +export function toggleMute() { muted = !muted; return muted; } + +function tone(freq, dur, type = 'square', vol = 0.18, slide = 0) { + const t = ac.currentTime; + const o = ac.createOscillator(), g = ac.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t); + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur); + g.gain.setValueAtTime(vol, t); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + o.connect(g); g.connect(master); + o.start(t); o.stop(t + dur + 0.02); +} + +function noise(dur, vol = 0.25, filterFreq = 1200, q = 1) { + const t = ac.currentTime; + const len = Math.max(1, Math.floor(ac.sampleRate * dur)); + const buf = ac.createBuffer(1, len, ac.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = ac.createBufferSource(); + src.buffer = buf; + const f = ac.createBiquadFilter(); + f.type = 'lowpass'; f.frequency.value = filterFreq; f.Q.value = q; + const g = ac.createGain(); + g.gain.setValueAtTime(vol, t); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + src.connect(f); f.connect(g); g.connect(master); + src.start(t); +} + +export function sfx(name) { + if (!ac || muted) return; + switch (name) { + case 'jump': tone(300, 0.12, 'square', 0.12, 250); break; + case 'airjump': tone(380, 0.1, 'square', 0.1, 280); break; + case 'land': noise(0.08, 0.12, 500); break; + case 'bark': tone(170, 0.07, 'sawtooth', 0.2, -60); tone(340, 0.05, 'square', 0.08, -100); break; + case 'hit': noise(0.12, 0.3, 900); tone(110, 0.15, 'triangle', 0.25, -50); break; + case 'block': tone(520, 0.1, 'sine', 0.15, 100); break; + case 'dash': noise(0.1, 0.12, 2500); tone(600, 0.08, 'sine', 0.06, 400); break; + case 'spike': tone(200, 0.2, 'sawtooth', 0.2, -140); break; + case 'saw': noise(0.15, 0.25, 1800, 4); break; + case 'pad': tone(180, 0.22, 'sine', 0.22, 380); break; + case 'mine': tone(900 + Math.random() * 300, 0.05, 'square', 0.1); break; + case 'break': tone(700, 0.07, 'square', 0.12, 300); tone(1050, 0.12, 'square', 0.1, 400); break; + case 'pickup': tone(660, 0.07, 'square', 0.12); tone(990, 0.1, 'square', 0.1); break; + case 'throw': noise(0.06, 0.1, 3000); break; + case 'boom': noise(0.5, 0.4, 350, 0.5); tone(60, 0.4, 'sine', 0.3, -25); break; + case 'shield': tone(440, 0.25, 'sine', 0.15, 220); break; + case 'boost': tone(330, 0.1, 'square', 0.12, 200); tone(500, 0.12, 'square', 0.1, 300); break; + case 'heal': tone(520, 0.12, 'sine', 0.12, 160); tone(780, 0.15, 'sine', 0.1, 100); break; + case 'grapple': tone(800, 0.06, 'square', 0.12, -300); break; + case 'whiff': tone(250, 0.08, 'sine', 0.08, -100); break; + case 'blink': tone(1000, 0.12, 'sine', 0.1, 500); break; + case 'jet': noise(0.07, 0.07, 700); break; + case 'boing': tone(140, 0.25, 'sine', 0.22, 320); break; + case 'charge': tone(120, 0.2, 'sawtooth', 0.18, 80); break; + case 'thud': noise(0.1, 0.25, 400); break; + case 'pound': noise(0.25, 0.35, 500); tone(70, 0.3, 'sine', 0.3, -20); break; + case 'ko': tone(500, 0.5, 'sawtooth', 0.25, -420); noise(0.4, 0.3, 800); break; + case 'respawn': tone(400, 0.15, 'sine', 0.12, 300); break; + case 'go': tone(523, 0.12, 'square', 0.15); tone(784, 0.25, 'square', 0.15); break; + case 'count': tone(440, 0.1, 'square', 0.12); break; + case 'over': tone(523, 0.15, 'square', 0.15); tone(659, 0.15, 'square', 0.15); tone(784, 0.3, 'square', 0.15); break; + case 'drop': tone(880, 0.1, 'sine', 0.08, -200); break; + case 'ui': tone(600, 0.05, 'square', 0.08); break; + } +} + +// Map sim events → sfx, with a per-frame cap so explosions don't clip. +const EVENT_SFX = { + jump: 'jump', land: 'land', bark: 'bark', hit: 'hit', block: 'block', + dash: 'dash', spike: 'spike', saw: 'saw', pad: 'pad', crack: 'mine', + tile: 'break', pickup: 'pickup', throw: 'throw', boom: 'boom', + shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple', + whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing', + charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko', + respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', +}; + +export function playEvents(events) { + let n = 0; + for (const ev of events) { + if (n >= 8) break; + let name = EVENT_SFX[ev.t]; + if (ev.t === 'jump' && ev.air) name = 'airjump'; + if (name) { sfx(name); n++; } + } +} diff --git a/js/characters.js b/js/characters.js new file mode 100644 index 0000000..72b0162 --- /dev/null +++ b/js/characters.js @@ -0,0 +1,180 @@ +// The dogs. Each has shared stats plus a `special` with genuinely different +// movement physics, dispatched from updateCharacter() every sim tick. +import { B } from './consts.js'; + +export const CHARS = [ + { + id: 0, key: 'bolt', name: 'Bolt', breed: 'Greyhound', color: '#4fc3f7', accent: '#01579b', + desc: 'Pure speed. The map is a racetrack and everyone else is standing still.', + moves: ['Fastest runner — momentum is king', 'Wall-slide & wall-jump', 'SPECIAL: air dash, 8-way (resets on landing)'], + stats: { run: 4.3, accel: 0.45, air: 0.32, fric: 0.62, jump: 7.4, weight: 0.85, grav: 1, jumps: 1 }, + wallJump: true, ears: 'fold', tail: 'whip', snout: 1.4, body: 1.1, + }, + { + id: 1, key: 'tank', name: 'Brick', breed: 'Bulldog', color: '#ef9a5a', accent: '#7a3b10', + desc: 'A wrecking ball with legs. Barely launches, hits like a truck.', + moves: ['Heavy: takes far less knockback', 'SPECIAL on ground: charging ram', 'SPECIAL in air: ground pound + shockwave'], + stats: { run: 2.5, accel: 0.35, air: 0.2, fric: 0.7, jump: 7.9, weight: 1.45, grav: 1.05, jumps: 1 }, + wallJump: false, ears: 'nub', tail: 'stub', snout: 0.7, body: 1.25, + }, + { + id: 2, key: 'swing', name: 'Lasso', breed: 'Lurcher', color: '#aed581', accent: '#33691e', + desc: 'A leash-grapple acrobat. Slow on foot, lethal on the swing.', + moves: ['SPECIAL: fire leash grapple (45° up, or straight up with ↑)', 'Hold ↑/↓ to reel in / let out', 'Jump out of a swing for a launch'], + stats: { run: 2.9, accel: 0.4, air: 0.26, fric: 0.65, jump: 7.0, weight: 1, grav: 1, jumps: 1 }, + wallJump: false, ears: 'point', tail: 'long', snout: 1.2, body: 1, + }, + { + id: 3, key: 'wisp', name: 'Wisp', breed: 'Saluki', color: '#ce93d8', accent: '#4a148c', + desc: 'Barely touches the ground. Half ghost, all hair.', + moves: ['Low gravity + floaty double jump', 'SPECIAL: blink 8-way — phases through walls', 'Fragile: launches easily'], + stats: { run: 3.3, accel: 0.4, air: 0.34, fric: 0.6, jump: 6.6, weight: 0.75, grav: 0.72, jumps: 2 }, + wallJump: false, ears: 'silk', tail: 'plume', snout: 1.1, body: 0.95, + }, + { + id: 4, key: 'rocket', name: 'Stubbs', breed: 'Corgi', color: '#fff176', accent: '#bf6f00', + desc: 'Strapped a jetpack to a corgi. No regrets so far.', + moves: ['SPECIAL (hold): jetpack — fuel refills on the ground', 'Hold ↓ on a hard landing: butt-bounce', 'Short legs, big heart'], + stats: { run: 3.0, accel: 0.5, air: 0.3, fric: 0.68, jump: 6.4, weight: 0.95, grav: 0.95, jumps: 1 }, + wallJump: false, ears: 'point', tail: 'fluff', snout: 0.9, body: 0.9, + }, +]; + +// H: helpers from sim — { hurt(victim,dmg,nx,ny,base), event(e), raycast, boxFree, solidAtPx } +export function updateCharacter(p, c, inp, pressed, game, H) { + const st = p.st; + switch (c.key) { + + case 'bolt': { + if (p.onGround) st.dash = 1; + if ((pressed & B.SPC) && !p.onGround && st.dash > 0 && p.hitstun <= 0) { + st.dash = 0; + let dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0); + let dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0); + if (!dx && !dy) dx = p.face; + const n = Math.hypot(dx, dy) || 1; + p.vx = dx / n * 8.6; + p.vy = dy ? dy / n * 7 : Math.min(p.vy, 0) - 1.2; + if (dx) p.face = dx > 0 ? 1 : -1; + H.event({ 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 = p.face * 6.4; + p.vy = Math.min(p.vy, 2); + for (const o of game.players) { + if (o !== p && o.stocks > 0 && o.respawn <= 0 && + Math.abs(o.x - p.x) < 18 && Math.abs(o.y - p.y) < 16) { + H.hurt(o, 10, p.face, -0.5, 6.5); + st.charge = 0; + } + } + if (p.wallHit) { st.charge = 0; H.event({ t: 'thud', x: p.x, y: p.y }); } + } else if ((pressed & B.SPC) && p.hitstun <= 0) { + if (p.onGround && st.chargeCd <= 0) { + st.charge = 34; st.chargeCd = 80; + H.event({ t: 'charge', x: p.x, y: p.y }); + } else if (!p.onGround && !st.pound) { + st.pound = true; p.vy = 10.5; p.vx *= 0.25; + } + } + if (st.pound && p.onGround) { + st.pound = false; + H.event({ t: 'pound', x: p.x, y: p.y }); + for (const o of game.players) { + if (o !== p && o.stocks > 0 && o.respawn <= 0 && + Math.abs(o.x - p.x) < 55 && Math.abs(o.y - p.y) < 30) { + const d = Math.sign(o.x - p.x) || 1; + H.hurt(o, 9, d * 0.6, -0.8, 6); + } + } + } + break; + } + + case 'swing': { + if (st.anchor) { + if (pressed & B.SPC) { st.anchor = null; break; } + if (pressed & B.JUMP) { + st.anchor = null; + p.vy -= 2.4; p.vx *= 1.15; + H.event({ t: 'jump', x: p.x, y: p.y }); + break; + } + if (!H.solidAtPx(game.map, st.anchor.x, st.anchor.y)) { st.anchor = null; break; } // anchor mined away + if (inp & B.U) st.len = Math.max(20, st.len - 2.2); + if (inp & B.D) st.len = Math.min(200, st.len + 2.2); + if (inp & B.L) p.vx -= 0.28; + if (inp & B.R) p.vx += 0.28; + 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 vr = p.vx * nx + p.vy * ny; // strip outward radial velocity → pendulum + if (vr > 0) { p.vx -= vr * nx; p.vy -= vr * ny; } + } + } else if ((pressed & B.SPC) && p.hitstun <= 0) { + let dx, dy; + if ((inp & B.U) && !(inp & (B.L | B.R))) { dx = 0; dy = -1; } + else { dx = p.face * 0.6; dy = -0.8; } + const hit = H.raycast(game.map, p.x, p.y, dx, dy, 175); + if (hit) { + st.anchor = { x: hit.x, y: hit.y }; + st.len = Math.max(20, hit.d - 2); + H.event({ t: 'grapple', x: hit.x, y: hit.y }); + } else { + H.event({ t: 'whiff', x: p.x, y: p.y }); + } + } + break; + } + + case 'wisp': { + if (st.blinkCd > 0) st.blinkCd--; + if ((pressed & B.SPC) && st.blinkCd <= 0 && p.hitstun <= 0) { + let dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0); + let dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0); + if (!dx && !dy) dx = p.face; + const n = Math.hypot(dx, dy); + const ux = dx / n, uy = dy / n; + // walk back from max range until the landing box is clear of solids + for (let dist = 58; dist >= 12; dist -= 6) { + const tx = p.x + ux * dist, ty = p.y + uy * dist; + if (H.boxFree(game.map, tx, ty, p.w, p.h)) { + H.event({ 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 * 2.5; + st.blinkCd = 48; + if (dx) p.face = dx > 0 ? 1 : -1; + break; + } + } + } + break; + } + + case 'rocket': { + if (p.onGround) 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; + p.vy = Math.max(p.vy - 0.85, -5.2); + st.jet = true; + if (game.tick % 5 === 0) H.event({ t: 'jet', x: p.x, y: p.y + p.h / 2 }); + } else st.jet = false; + if (p.justLanded && p.impact > 6.2 && (inp & B.D)) { + p.vy = -p.impact * 0.72; + p.onGround = false; + H.event({ t: 'boing', x: p.x, y: p.y }); + } + break; + } + } +} diff --git a/js/consts.js b/js/consts.js new file mode 100644 index 0000000..0b68335 --- /dev/null +++ b/js/consts.js @@ -0,0 +1,15 @@ +// Shared constants for sim + render. Logical resolution is exactly the tile grid. +export const TILE = 12, GW = 80, GH = 45; +export const W = GW * TILE, H = GH * TILE; // 960 x 540 + +// Tile types +export const T = { EMPTY: 0, SOLID: 1, PLAT: 2, SPIKE: 3, PAD: 4, CRYSTAL: 5 }; + +// Input bitmask +export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128 }; + +export const ITEMS = ['ball', 'bomb', 'shield', 'boost', 'heal']; + +export const GRAV = 0.42, MAXFALL = 9.5; +export const STOCKS = 3; +export const SNAP_EVERY = 2; // host sends a snapshot every N sim ticks (30/s) diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..a0a0c2f --- /dev/null +++ b/js/input.js @@ -0,0 +1,30 @@ +// Keyboard → input bitmasks. Two local slots; netplay merges both slots so the +// online player can use either WASD or arrows. +import { B } from './consts.js'; + +const MAPS = [ + { KeyA: B.L, KeyD: B.R, KeyW: B.U | B.JUMP, KeyS: B.D, KeyF: B.ATK, KeyG: B.SPC, KeyH: B.ITM }, + { ArrowLeft: B.L, ArrowRight: B.R, ArrowUp: B.U | B.JUMP, ArrowDown: B.D, KeyK: B.ATK, KeyL: B.SPC, Semicolon: B.ITM }, +]; + +const bits = [0, 0]; +const down = new Set(); + +const PREVENT = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space']); + +export function initInput() { + window.addEventListener('keydown', (e) => { + if (PREVENT.has(e.code)) e.preventDefault(); + if (down.has(e.code)) return; + down.add(e.code); + for (let s = 0; s < 2; s++) if (MAPS[s][e.code]) bits[s] |= MAPS[s][e.code]; + }); + window.addEventListener('keyup', (e) => { + down.delete(e.code); + for (let s = 0; s < 2; s++) if (MAPS[s][e.code]) bits[s] &= ~MAPS[s][e.code]; + }); + window.addEventListener('blur', () => { bits[0] = 0; bits[1] = 0; down.clear(); }); +} + +export function getBits(slot) { return bits[slot]; } +export function getMergedBits() { return bits[0] | bits[1]; } diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..1c1c657 --- /dev/null +++ b/js/main.js @@ -0,0 +1,375 @@ +// App shell: screens, character select, local/host/client match drivers. +import { SNAP_EVERY } from './consts.js'; +import { randomSeed } from './rng.js'; +import { CHARS } from './characters.js'; +import { createGame, step, snapshot, applySnapshot } from './sim.js'; +import { initInput, getBits, getMergedBits } from './input.js'; +import { initRender, resetRender, render, fxEvents } from './render.js'; +import { initAudio, playEvents, sfx, toggleMute } from './audio.js'; +import { randomKey, hostRoom, joinRoom, peerAvailable } from './net.js'; + +const $ = (id) => document.getElementById(id); + +// ---- state ---- +let mode = null; // 'local' | 'host' | 'client' +let net = null; +let game = null; +let picks = [null, null]; // local char select +let myPick = null, remotePick = null, peerJoined = false; +let remoteBits = 0; +let pendingEvs = []; +let clientOverT = 0; +let resultShown = false; +let paused = false; +let rafId = null, acc = 0, last = 0; +const TICK_MS = 1000 / 60; + +// ---- screens ---- +const SCREENS = ['s-menu', 's-host', 's-join', 's-chars', 's-game', 's-result']; +function show(id) { + for (const s of SCREENS) $(s).classList.toggle('hidden', s !== id); + $('cv').classList.toggle('dim', id !== 's-game'); +} + +function setStatus(el, msg, isErr = false) { + el.textContent = msg; + el.classList.toggle('err', isErr); +} + +// ---- boot ---- +window.addEventListener('DOMContentLoaded', () => { + initInput(); + initRender($('cv')); + buildCharCards(); + wireMenus(); + show('s-menu'); + // background demo map behind menus + game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]); + resetRender(game); + startLoop(); +}); + +window.addEventListener('keydown', (e) => { + initAudio(); // browsers require a gesture before audio + if (e.code === 'KeyM') { + const m = toggleMute(); + $('mute').textContent = m ? '🔇' : '🔊'; + } + if (e.code === 'Escape') onEscape(); + if (e.code === 'Enter' && !$('s-result').classList.contains('hidden')) doRematch(); +}); + +function onEscape() { + if (!$('s-game').classList.contains('hidden')) { + if (mode === 'local') { + paused = !paused; + $('pause-tag').classList.toggle('hidden', !paused); + } else { + leaveToMenu('Left the match.'); + } + } else if ($('s-menu').classList.contains('hidden')) { + leaveToMenu(); + } +} + +function leaveToMenu(msg) { + if (net) { try { net.send({ t: 'bye' }); net.destroy(); } catch (_) {} net = null; } + mode = null; paused = false; resultShown = false; + myPick = null; remotePick = null; peerJoined = false; picks = [null, null]; + $('pause-tag').classList.add('hidden'); + if (msg) setStatus($('menu-status'), msg); + game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]); + resetRender(game); + show('s-menu'); + refreshCharUI(); +} + +// ---- menus ---- +function wireMenus() { + $('btn-local').onclick = () => { sfx('ui'); mode = 'local'; picks = [null, null]; show('s-chars'); refreshCharUI(); }; + $('btn-host').onclick = () => { sfx('ui'); startHost(); }; + $('btn-join').onclick = () => { sfx('ui'); show('s-join'); setStatus($('join-status'), ''); $('join-key').focus(); }; + $('btn-join-go').onclick = () => doJoin(); + $('join-key').addEventListener('keydown', (e) => { if (e.key === 'Enter') doJoin(); }); + $('btn-host-cancel').onclick = () => leaveToMenu(); + $('btn-join-cancel').onclick = () => leaveToMenu(); + $('btn-chars-back').onclick = () => leaveToMenu(); + $('btn-start').onclick = () => tryStart(); + $('btn-rematch').onclick = () => doRematch(); + $('btn-newdogs').onclick = () => doChangeDogs(); + $('btn-result-menu').onclick = () => leaveToMenu(); +} + +// ---- char select ---- +function buildCharCards() { + const grid = $('char-grid'); + grid.innerHTML = ''; + for (const c of CHARS) { + const card = document.createElement('div'); + card.className = 'card'; + card.id = `card-${c.id}`; + card.style.borderColor = c.color; + card.innerHTML = ` +
${c.name}
+
${c.breed}
+ +
${c.desc}
+ +
`; + card.onclick = () => pickChar(c.id); + grid.appendChild(card); + drawCardDog(card.querySelector('.card-cv'), c); + } +} + +function drawCardDog(cv2, c) { + const x = cv2.getContext('2d'); + x.translate(45, 32); + x.fillStyle = c.color; + x.beginPath(); x.ellipse(0, 0, 22 * c.body, 13 * c.body, 0, 0, 7); x.fill(); + x.beginPath(); x.arc(17, -12, 11 * c.body, 0, 7); x.fill(); + x.beginPath(); x.ellipse(17 + 9, -10, 9 * c.snout, 5, 0, 0, 7); x.fill(); + x.fillStyle = c.accent; + x.beginPath(); x.moveTo(13, -20); x.lineTo(16, -30); x.lineTo(20, -19); x.fill(); + x.fillStyle = '#1a1a1a'; + x.beginPath(); x.arc(19, -14, 2.5, 0, 7); x.fill(); + x.strokeStyle = c.accent; x.lineWidth = 4; x.lineCap = 'round'; + for (let i = 0; i < 4; i++) { + x.beginPath(); x.moveTo(-14 + i * 9, 10); x.lineTo(-14 + i * 9, 20); x.stroke(); + } + x.lineWidth = 3; + x.beginPath(); x.moveTo(-21, -4); x.quadraticCurveTo(-32, -14, -34, -8); x.stroke(); +} + +function pickChar(id) { + sfx('ui'); + if (mode === 'local') { + if (picks[0] === null) picks[0] = id; + else if (picks[1] === null) picks[1] = id; + else { picks = [id, null]; } // third click restarts picking + } else { + myPick = id; + if (net) net.send({ t: 'char', c: id }); + } + refreshCharUI(); +} + +function refreshCharUI() { + for (const c of CHARS) { + const tags = $(`card-${c.id}`)?.querySelector('.card-tags'); + if (!tags) continue; + const t = []; + if (mode === 'local') { + if (picks[0] === c.id) t.push('P1'); + if (picks[1] === c.id) t.push('P2'); + } else { + if (myPick === c.id) t.push('YOU'); + if (remotePick === c.id) t.push('THEM'); + } + tags.innerHTML = t.join(''); + } + const status = $('chars-status'); + const startBtn = $('btn-start'); + if (mode === 'local') { + startBtn.classList.remove('hidden'); + startBtn.disabled = picks[0] === null || picks[1] === null; + setStatus(status, picks[0] === null ? 'Player 1: pick your dog' + : picks[1] === null ? 'Player 2: pick your dog' : 'Ready! Hit Start.'); + } else if (mode === 'host') { + startBtn.classList.remove('hidden'); + startBtn.disabled = myPick === null || remotePick === null; + const key = $('room-key-tag'); + key.classList.remove('hidden'); + setStatus(status, !peerJoined ? 'Waiting for player 2 to join…' + : myPick === null ? 'Pick your dog' + : remotePick === null ? 'Waiting for them to pick…' : 'Both ready — Start!'); + } else if (mode === 'client') { + startBtn.classList.add('hidden'); + setStatus(status, myPick === null ? 'Pick your dog' : 'Waiting for host to start…'); + } + $('room-key-tag').classList.toggle('hidden', mode !== 'host'); +} + +function tryStart() { + if (mode === 'local') { + if (picks[0] === null || picks[1] === null) return; + startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]); + } else if (mode === 'host') { + if (myPick === null || remotePick === null) return; + const seed = randomSeed(); + net.send({ t: 'start', seed, chars: [myPick, remotePick] }); + startMatch(seed, [{ charId: myPick }, { charId: remotePick }]); + } +} + +// ---- networking ---- +function startHost() { + if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; } + mode = 'host'; + myPick = null; remotePick = null; peerJoined = false; + const key = randomKey(); + show('s-host'); + setStatus($('host-status'), 'Opening room…'); + $('host-key').textContent = key; + $('room-key').textContent = key; + net = hostRoom(key, { + open: () => setStatus($('host-status'), 'Room open! Share the key — waiting for player 2…'), + conn: () => { + peerJoined = true; + net.send({ t: 'welcome' }); + show('s-chars'); + refreshCharUI(); + }, + data: onNetData, + close: (msg) => netClosed(msg), + }); +} + +function doJoin() { + 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; } + mode = 'client'; + myPick = null; remotePick = null; + setStatus($('join-status'), 'Connecting…'); + net = joinRoom(key, { + conn: () => { show('s-chars'); refreshCharUI(); }, + data: onNetData, + close: (msg) => netClosed(msg), + }); +} + +function netClosed(msg) { + if (mode === 'host' || mode === 'client') leaveToMenu(msg || 'Connection closed.'); +} + +function onNetData(msg) { + if (!msg || typeof msg !== 'object') return; + switch (msg.t) { + case 'char': + remotePick = msg.c; + refreshCharUI(); + break; + case 'start': // client: host started the match + if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]); + break; + case 'i': // host: remote inputs + remoteBits = msg.b | 0; + break; + case 's': // client: snapshot + if (mode === 'client' && game) { + const evs = applySnapshot(game, msg.s); + fxEvents(evs, game); + playEvents(evs); + } + break; + case 'rematch': + 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(); } + break; + case 'bye': + netClosed('Other player left.'); + break; + } +} + +// ---- match lifecycle ---- +function startMatch(seed, defs) { + game = createGame(seed, defs); + resetRender(game); + pendingEvs = []; + remoteBits = 0; + clientOverT = 0; + resultShown = false; + paused = false; + acc = 0; + show('s-game'); + sfx('count'); +} + +function doRematch() { + sfx('ui'); + if (mode === 'local') { + startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]); + } else if (mode === 'host') { + const seed = randomSeed(); + net.send({ t: 'rematch', seed, chars: [myPick, remotePick] }); + startMatch(seed, [{ charId: myPick }, { charId: remotePick }]); + } +} + +function doChangeDogs() { + sfx('ui'); + if (mode === 'local') { + picks = [null, null]; + show('s-chars'); refreshCharUI(); + } else if (mode === 'host') { + net.send({ t: 'tochars' }); + myPick = null; remotePick = null; + show('s-chars'); refreshCharUI(); + } +} + +function showResult() { + resultShown = true; + const w = game.over.winner; + $('result-title').textContent = w >= 0 ? `${game.players[w].name} wins!` : 'Double KO!'; + $('result-title').style.color = w >= 0 ? CHARS[game.players[w].charId].color : '#fff'; + const isClient = mode === 'client'; + $('btn-rematch').classList.toggle('hidden', isClient); + $('btn-newdogs').classList.toggle('hidden', isClient); + $('result-wait').classList.toggle('hidden', !isClient); + show('s-result'); +} + +// ---- main loop ---- +function startLoop() { + if (rafId) cancelAnimationFrame(rafId); + last = performance.now(); + const frame = (t) => { + rafId = requestAnimationFrame(frame); + acc += t - last; + last = t; + if (acc > 200) acc = 200; // tab was backgrounded; don't spiral + while (acc >= TICK_MS) { + acc -= TICK_MS; + tick(); + } + if (game) render(game, { smooth: mode === 'client' }); + }; + rafId = requestAnimationFrame(frame); +} + +function tick() { + if (!game || paused) return; + const inGame = !$('s-game').classList.contains('hidden'); + + if (mode === 'local' || mode === null) { + // local match, or attract-mode demo behind menus (no inputs) + const i0 = inGame ? getBits(0) : 0; + const i1 = inGame ? getBits(1) : 0; + const evs = step(game, [i0, i1]); + if (inGame) { fxEvents(evs, game); playEvents(evs); } + if (inGame && game.over && game.overT > 140 && !resultShown) showResult(); + + } else if (mode === 'host') { + if (!inGame && !resultShown) return; // still in lobby/charselect + const evs = step(game, [getMergedBits(), remoteBits]); + pendingEvs.push(...evs); + fxEvents(evs, game); + playEvents(evs); + if (game.tick % SNAP_EVERY === 0 && net) { + net.send({ t: 's', s: snapshot(game, pendingEvs) }); + pendingEvs = []; + } + if (game.over && game.overT > 140 && !resultShown) showResult(); + + } else if (mode === 'client') { + if (!inGame && !resultShown) return; + if (net) net.send({ t: 'i', b: getMergedBits() }); + // sim runs on host; we just count toward the result screen + if (game.over && ++clientOverT > 140 && !resultShown) showResult(); + } +} diff --git a/js/mapgen.js b/js/mapgen.js new file mode 100644 index 0000000..a427c77 --- /dev/null +++ b/js/mapgen.js @@ -0,0 +1,178 @@ +// 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) --- + const spawnCols = []; + 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); + } + if (spawnCols.length < 2) spawnCols.push(4, GW - 5); // paranoid fallback + const nearSpawn = (x) => spawnCols.some(sx => Math.abs(sx - x) < 5); + + // --- 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++) + for (let y = hs[tx] - th; y < hs[tx]; 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 --- + 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 }); + } + } + + // --- 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) }; +} + +// 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 }; +} diff --git a/js/net.js b/js/net.js new file mode 100644 index 0000000..bda4346 --- /dev/null +++ b/js/net.js @@ -0,0 +1,70 @@ +// P2P networking via PeerJS (WebRTC data channels). The PeerJS public cloud +// broker is used only for signaling/room discovery — once connected, all game +// traffic flows peer-to-peer. No game server anywhere. +// +// The room key IS the host's peer id (prefixed). Share the 5 letters, done. + +const PRE = 'barkour-w1-'; +const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export function randomKey() { + let s = ''; + for (let i = 0; i < 5; i++) s += KEY_ALPHABET[Math.floor(Math.random() * KEY_ALPHABET.length)]; + return s; +} + +export function peerAvailable() { + return typeof window !== 'undefined' && typeof window.Peer !== 'undefined'; +} + +function errMsg(e) { + const t = e && e.type; + if (t === 'peer-unavailable') return 'No room with that key. Check the code and try again.'; + if (t === 'unavailable-id') return 'Room key collision — host again for a fresh key.'; + if (t === 'network' || t === 'server-error' || t === 'socket-error') return 'Could not reach the signaling broker (offline?).'; + return 'Connection error: ' + (e && e.message || t || 'unknown'); +} + +// 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); } }; + peer.on('open', () => cb.open(key)); + peer.on('error', (e) => 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.')); + }); + return { + send: (o) => { if (conn && conn.open) conn.send(o); }, + destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} }, + }; +} + +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); } }; + const api = { + send: () => {}, + destroy: () => { dead = true; try { peer.destroy(); } catch (_) {} }, + }; + peer.on('error', (e) => die(errMsg(e))); + 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.')); + }); + return api; +} diff --git a/js/physics.js b/js/physics.js new file mode 100644 index 0000000..12d2457 --- /dev/null +++ b/js/physics.js @@ -0,0 +1,94 @@ +// Tile-grid AABB physics. Entities are {x, y, w, h, vx, vy} with x,y = center. +import { TILE, GW, GH, T } from './consts.js'; + +export const idx = (x, y) => y * GW + x; + +export function tileAt(map, tx, ty) { + if (tx < 0 || tx >= GW || ty < 0 || ty >= GH) return T.EMPTY; + return map.tiles[ty * GW + tx]; +} + +export const isSolid = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PAD; + +export function solidAtPx(map, px, py) { + return isSolid(tileAt(map, Math.floor(px / TILE), Math.floor(py / TILE))); +} + +export function boxFree(map, cx, cy, w, h) { + const x0 = Math.floor((cx - w / 2) / TILE), x1 = Math.floor((cx + w / 2 - 0.01) / TILE); + const y0 = Math.floor((cy - h / 2) / TILE), y1 = Math.floor((cy + h / 2 - 0.01) / TILE); + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) + if (isSolid(tileAt(map, x, y))) return false; + return true; +} + +// Move with per-axis collision resolution. opt.drop: fall through one-way platforms. +// Velocities must stay below TILE per tick (we cap MAXFALL/dashes accordingly). +export function moveEntity(e, map, opt = {}) { + const res = { ground: false, wall: 0, ceil: false }; + const hw = e.w / 2, hh = e.h / 2; + + // X axis + e.x += e.vx; + { + const y0 = Math.floor((e.y - hh) / TILE), y1 = Math.floor((e.y + hh - 0.01) / TILE); + if (e.vx > 0) { + const tx = Math.floor((e.x + hw - 0.01) / TILE); + for (let ty = y0; ty <= y1; ty++) { + if (isSolid(tileAt(map, tx, ty))) { e.x = tx * TILE - hw; e.vx = 0; res.wall = 1; break; } + } + } else if (e.vx < 0) { + const tx = Math.floor((e.x - hw) / TILE); + for (let ty = y0; ty <= y1; ty++) { + if (isSolid(tileAt(map, tx, ty))) { e.x = (tx + 1) * TILE + hw; e.vx = 0; res.wall = -1; break; } + } + } + } + + // Y axis + const oldBottom = e.y + hh; + e.y += e.vy; + { + const x0 = Math.floor((e.x - hw) / TILE), x1 = Math.floor((e.x + hw - 0.01) / TILE); + if (e.vy > 0) { + const ty = Math.floor((e.y + hh - 0.01) / TILE); + for (let tx = x0; tx <= x1; tx++) { + const t = tileAt(map, tx, ty); + const top = ty * TILE; + if (isSolid(t) || (t === T.PLAT && !opt.drop && oldBottom <= top + 0.01)) { + e.y = top - hh; e.vy = 0; res.ground = true; break; + } + } + } else if (e.vy < 0) { + const ty = Math.floor((e.y - hh) / TILE); + for (let tx = x0; tx <= x1; tx++) { + if (isSolid(tileAt(map, tx, ty))) { e.y = (ty + 1) * TILE + hh; e.vy = 0; res.ceil = true; break; } + } + } + } + return res; +} + +// True if the tiles directly under the entity's feet are one-way platform (and nothing solid). +export function standingOnPlat(map, p) { + const ty = Math.floor((p.y + p.h / 2 + 1) / TILE); + const x0 = Math.floor((p.x - p.w / 2) / TILE), x1 = Math.floor((p.x + p.w / 2 - 0.01) / TILE); + let plat = false; + for (let tx = x0; tx <= x1; tx++) { + const t = tileAt(map, tx, ty); + if (isSolid(t)) return false; + if (t === T.PLAT) plat = true; + } + return plat; +} + +// Coarse raycast against solid tiles. Returns {x, y, d} of the hit point or null. +export function raycast(map, x, y, dx, dy, maxDist) { + const step = 4, n = Math.ceil(maxDist / step); + for (let i = 1; i <= n; i++) { + const px = x + dx * step * i, py = y + dy * step * i; + if (solidAtPx(map, px, py)) return { x: px, y: py, d: step * i }; + } + return null; +} diff --git a/js/render.js b/js/render.js new file mode 100644 index 0000000..5eddf93 --- /dev/null +++ b/js/render.js @@ -0,0 +1,548 @@ +// All drawing. Procedural everything: dogs are canvas paths, the background is +// seeded decor, effects are a small particle pool fed by sim events. +import { TILE, GW, GH, W, H, T } from './consts.js'; +import { CHARS } from './characters.js'; +import { PALETTES } from './mapgen.js'; +import { mulberry32 } from './rng.js'; + +let ctx = null, cv = null; +let bgCanvas = null, bgSeed = null; +let particles = []; +let ghosts = []; // afterimages +let shake = 0; +let banners = []; // floating text +let frame = 0; + +export function initRender(canvas) { + cv = canvas; + ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; +} + +export function resetRender(game) { + particles = []; ghosts = []; banners = []; shake = 0; + buildBackground(game.map); +} + +function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; } + +// --- static background, cached to an offscreen canvas --- +function buildBackground(map) { + bgSeed = map.seed; + bgCanvas = document.createElement('canvas'); + bgCanvas.width = W; bgCanvas.height = H; + const b = bgCanvas.getContext('2d'); + const p = PALETTES[map.pal % PALETTES.length]; + const r = mulberry32((map.seed ^ 0xBA9D) >>> 0); + + const grad = b.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]); + b.fillStyle = grad; + b.fillRect(0, 0, W, H); + + if (p.star) { + b.fillStyle = 'rgba(255,255,255,0.7)'; + for (let i = 0; i < 80; i++) { + const x = r() * W, y = r() * H * 0.7, s = r() < 0.15 ? 2 : 1; + b.fillRect(x, y, s, s); + } + } + // moon + b.fillStyle = 'rgba(255,255,240,0.85)'; + b.beginPath(); + b.arc(W * (0.15 + r() * 0.7), 60 + r() * 50, 22, 0, 7); + b.fill(); + // hills silhouette + b.fillStyle = p.hill; + b.beginPath(); + b.moveTo(0, H); + let y = H * 0.65 + r() * 40; + for (let x = 0; x <= W; x += 40) { + y += (r() - 0.5) * 50; + y = Math.max(H * 0.45, Math.min(H * 0.85, y)); + b.lineTo(x, y); + } + b.lineTo(W, H); + b.fill(); +} + +// --- particles & event reactions ---------------------------------------- +function spawn(x, y, vx, vy, ttl, c, r = 2, grav = 0.15) { + if (particles.length > 400) particles.shift(); + particles.push({ x, y, vx, vy, ttl, ttl0: ttl, c, r, grav }); +} + +function burst(x, y, n, c, spd = 3, ttl = 25, grav = 0.1) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2, s = spd * (0.3 + Math.random() * 0.7); + spawn(x, y, Math.cos(a) * s, Math.sin(a) * s, ttl * (0.6 + Math.random() * 0.6), c, 1 + Math.random() * 2, grav); + } +} + +export function fxEvents(events, game) { + const p = pal(game); + for (const ev of events) { + switch (ev.t) { + case 'jump': burst(ev.x, ev.y + 7, 5, '#ffffff55', 1.5, 14, 0.02); break; + case 'land': burst(ev.x, ev.y, Math.min(10, ev.v | 0), '#ffffff44', 2, 16, 0.02); break; + case 'dash': burst(ev.x, ev.y, 8, '#9be7ff', 2.5, 18, 0); break; + case 'hit': burst(ev.x, ev.y, 12, '#ffd54f', 3.5, 22); shake = Math.max(shake, Math.min(8, 2 + ev.m * 0.5)); break; + case 'block': burst(ev.x, ev.y, 8, '#80d8ff', 2, 18, 0); break; + case 'spike': burst(ev.x, ev.y, 10, '#ef5350', 3, 20); shake = Math.max(shake, 3); break; + case 'saw': burst(ev.x, ev.y, 12, '#ef5350', 3.5, 22); shake = Math.max(shake, 4); break; + case 'pad': burst(ev.x, ev.y, 8, p.pad, 2.5, 18, 0.02); break; + case 'crack': burst(ev.x, ev.y, 5, p.crystal, 2, 16); break; + case 'tile': burst(ev.x, ev.y, 14, p.crystal, 3, 26); break; + case 'pickup': burst(ev.x, ev.y, 10, '#fff59d', 2, 20, -0.02); break; + case 'boom': burst(ev.x, ev.y, 30, '#ffab40', 5, 32); burst(ev.x, ev.y, 16, '#eceff1', 3, 40); shake = Math.max(shake, 9); break; + case 'grapple': burst(ev.x, ev.y, 5, '#aed581', 1.5, 12, 0); break; + case 'blink': + burst(ev.x, ev.y, 8, '#ce93d8', 2, 18, 0); + burst(ev.x2, ev.y2, 8, '#ce93d8', 2, 18, 0); + ghosts.push({ x: ev.x, y: ev.y, x2: ev.x2, y2: ev.y2, ttl: 12, kind: 'blinkline' }); + break; + case 'jet': spawn(ev.x, ev.y, (Math.random() - 0.5), 2 + Math.random() * 1.5, 14, '#ffab40', 2.5, -0.02); break; + case 'boing': burst(ev.x, ev.y + 6, 8, '#fff176', 2.5, 18); break; + case 'charge': burst(ev.x, ev.y + 5, 6, '#ffccbc', 2, 14); break; + case 'thud': burst(ev.x, ev.y, 8, '#ffccbc', 2.5, 16); shake = Math.max(shake, 3); break; + case 'pound': burst(ev.x, ev.y + 6, 18, '#ffccbc', 4, 26); shake = Math.max(shake, 6); break; + case 'ko': { + burst(ev.x, ev.y, 26, '#ff5252', 5, 36); + const who = game.players[ev.idx]; + banners.push({ text: `${who ? who.name : '???'} KO'd!`, ttl: 90, y: H * 0.3 }); + shake = Math.max(shake, 8); + break; + } + case 'respawn': burst(ev.x, ev.y, 12, '#b2ff59', 2, 24, -0.02); break; + case 'heal': burst(ev.x, ev.y, 10, '#69f0ae', 1.5, 24, -0.05); break; + case 'shield': burst(ev.x, ev.y, 10, '#80d8ff', 2, 20, 0); break; + case 'boost': burst(ev.x, ev.y, 10, '#ffd740', 2, 20, 0); break; + case 'drop': banners.push({ text: 'Care package!', ttl: 70, y: 60 }); break; + case 'go': banners.push({ text: 'GO!', ttl: 45, y: H * 0.4, big: true }); break; + } + } +} + +// --- main render -------------------------------------------------------- +export function render(game, opts = {}) { + frame++; + const p = pal(game); + if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map); + + ctx.save(); + ctx.clearRect(0, 0, W, H); + if (shake > 0.3) { + ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); + shake *= 0.85; + } else shake = 0; + + ctx.drawImage(bgCanvas, 0, 0); + drawTiles(game, p); + drawSaws(game); + drawEnts(game); + drawGhosts(); + for (const pl of game.players) drawDog(game, pl, opts); + drawParticles(); + drawHud(game); + drawBanners(game); + ctx.restore(); +} + +function drawTiles(game, p) { + const tiles = game.map.tiles; + for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) { + const t = tiles[y * GW + x]; + if (t === T.EMPTY) continue; + const px = x * TILE, py = y * TILE; + if (t === T.SOLID) { + ctx.fillStyle = p.solid; + ctx.fillRect(px, py, TILE, TILE); + if (y === 0 || tiles[(y - 1) * GW + x] === T.EMPTY || tiles[(y - 1) * GW + x] === T.SPIKE) { + ctx.fillStyle = p.top; + ctx.fillRect(px, py, TILE, 3); + } + } else if (t === T.PLAT) { + ctx.fillStyle = p.plat; + ctx.fillRect(px, py, TILE, 4); + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.fillRect(px, py + 3, TILE, 1); + } else if (t === T.SPIKE) { + ctx.fillStyle = p.spike; + ctx.beginPath(); + ctx.moveTo(px, py + TILE); + ctx.lineTo(px + TILE / 4, py + 2); + ctx.lineTo(px + TILE / 2, py + TILE); + ctx.lineTo(px + TILE * 0.75, py + 2); + ctx.lineTo(px + TILE, py + TILE); + ctx.fill(); + } else if (t === T.PAD) { + ctx.fillStyle = p.pad; + const sq = Math.sin(frame * 0.15 + x) * 1.5; + ctx.fillRect(px, py + 4 + sq, TILE, TILE - 4 - sq); + ctx.fillStyle = '#ffffffaa'; + ctx.fillRect(px + 2, py + 4 + sq, TILE - 4, 2); + } else if (t === T.CRYSTAL) { + const hp = game.map.hp[y * GW + x] ?? 3; + ctx.fillStyle = p.crystal; + ctx.beginPath(); + ctx.moveTo(px + TILE / 2, py); + ctx.lineTo(px + TILE, py + TILE / 2); + ctx.lineTo(px + TILE / 2, py + TILE); + ctx.lineTo(px, py + TILE / 2); + ctx.fill(); + const tw = 0.5 + 0.5 * Math.sin(frame * 0.1 + x * 3 + y); + ctx.fillStyle = `rgba(255,255,255,${0.3 + tw * 0.4})`; + ctx.fillRect(px + 4, py + 3, 2, 2); + if (hp < 3) { // cracks + ctx.strokeStyle = 'rgba(0,0,0,0.6)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px + 3, py + 4); ctx.lineTo(px + 7, py + 8); + if (hp < 2) { ctx.moveTo(px + 9, py + 3); ctx.lineTo(px + 5, py + 9); } + ctx.stroke(); + } + } + } +} + +function drawSaws(game) { + for (const s of game.map.saws) { + if (s.x === undefined) continue; + ctx.save(); + ctx.translate(s.x, s.y); + ctx.rotate(frame * 0.3); + ctx.fillStyle = '#b0bec5'; + ctx.beginPath(); + for (let i = 0; i < 8; i++) { + const a = i / 8 * Math.PI * 2; + ctx.lineTo(Math.cos(a) * 13, Math.sin(a) * 13); + ctx.lineTo(Math.cos(a + 0.25) * 8, Math.sin(a + 0.25) * 8); + } + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#546e7a'; + ctx.beginPath(); ctx.arc(0, 0, 4, 0, 7); ctx.fill(); + ctx.restore(); + } +} + +const ITEM_COLORS = { ball: '#cddc39', bomb: '#37474f', shield: '#80d8ff', boost: '#ffd740', heal: '#69f0ae' }; + +function drawItemIcon(x, y, item, r = 5) { + ctx.fillStyle = ITEM_COLORS[item] || '#fff'; + ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.fill(); + ctx.fillStyle = '#000'; + ctx.font = `bold ${r + 3}px monospace`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + const ch = { ball: '●', bomb: '✶', shield: '◍', boost: '»', heal: '+' }[item] || '?'; + ctx.fillStyle = item === 'bomb' ? '#ff8a65' : '#00000088'; + ctx.fillText(ch, x, y + 0.5); +} + +function drawEnts(game) { + for (const e of game.ents) { + if (e.k === 'pickup') { + const bob = Math.sin(frame * 0.1 + e.id) * 2; + drawItemIcon(e.x, e.y + bob, e.item, 6); + ctx.strokeStyle = '#ffffff66'; + ctx.beginPath(); ctx.arc(e.x, e.y + bob, 8 + Math.sin(frame * 0.2) * 1.5, 0, 7); ctx.stroke(); + } else if (e.k === 'ball') { + ctx.fillStyle = '#cddc39'; + ctx.beginPath(); ctx.arc(e.x, e.y, 4, 0, 7); ctx.fill(); + ctx.strokeStyle = '#9e9d24'; + ctx.beginPath(); ctx.arc(e.x, e.y, 4, 0.5, 2.2); ctx.stroke(); + } else if (e.k === 'bomb') { + const flash = e.fuse < 30 && (frame >> 2) % 2 === 0; + ctx.fillStyle = flash ? '#ef5350' : '#37474f'; + ctx.beginPath(); ctx.arc(e.x, e.y, 5, 0, 7); ctx.fill(); + ctx.strokeStyle = '#ffab40'; + ctx.beginPath(); ctx.moveTo(e.x, e.y - 5); ctx.lineTo(e.x + 3, e.y - 9); ctx.stroke(); + } + } +} + +function drawGhosts() { + for (const gh of ghosts) { + gh.ttl--; + const a = gh.ttl / 12; + if (gh.kind === 'blinkline') { + ctx.strokeStyle = `rgba(206,147,216,${a * 0.7})`; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(gh.x, gh.y); ctx.lineTo(gh.x2, gh.y2); ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.globalAlpha = a * 0.35; + ctx.fillStyle = gh.c; + ctx.beginPath(); ctx.ellipse(gh.x, gh.y, 11, 7, 0, 0, 7); ctx.fill(); + ctx.globalAlpha = 1; + } + } + ghosts = ghosts.filter(g => g.ttl > 0); +} + +// --- the dogs ------------------------------------------------------------- +function drawDog(game, pl, opts) { + if (pl.stocks <= 0 || pl.respawn > 0) return; + const c = CHARS[pl.charId]; + + // motion smoothing for netplay snapshots + if (opts.smooth) { + if (pl._dx === undefined || Math.abs(pl._dx - pl.x) > 80 || Math.abs(pl._dy - pl.y) > 80) { pl._dx = pl.x; pl._dy = pl.y; } + pl._dx += (pl.x - pl._dx) * 0.5; + pl._dy += (pl.y - pl._dy) * 0.5; + } else { pl._dx = pl.x; pl._dy = pl.y; } + const x = pl._dx, y = pl._dy; + + // speed afterimages + if (Math.hypot(pl.vx, pl.vy) > 5.5 && frame % 2 === 0) { + ghosts.push({ x, y, ttl: 10, c: c.color, kind: 'blob' }); + } + + // grapple rope + const an = pl.st && pl.st.anchor; + if (an) { + ctx.strokeStyle = c.accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x + pl.face * 8, y - 2); + ctx.lineTo(an.x, an.y); + ctx.stroke(); + ctx.fillStyle = c.color; + ctx.beginPath(); ctx.arc(an.x, an.y, 3, 0, 7); ctx.fill(); + } + + ctx.save(); + ctx.translate(x, y); + if (pl.invuln > 0 && (frame >> 2) % 2 === 0) ctx.globalAlpha = 0.45; + if (pl.hitstun > 2) ctx.rotate(Math.sin(frame * 0.8) * 0.3); + if (pl.st && pl.st.pound) ctx.rotate(pl.face * 0.5); + ctx.scale(pl.face, 1); + + const squash = pl.landT > 0 ? 1 - pl.landT * 0.03 : 1; + const stretch = !pl.onGround && Math.abs(pl.vy) > 5 ? 1.12 : 1; + ctx.scale(1, squash * stretch); + + const running = pl.onGround && Math.abs(pl.vx) > 0.6; + const phase = (x * 0.45); + const bodyL = 11 * c.body, bodyH = 6.5 * c.body; + + // tail + const wag = Math.sin(frame * 0.3) * 3; + ctx.strokeStyle = c.color; + ctx.lineWidth = c.tail === 'fluff' || c.tail === 'plume' ? 4 : 2; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(-bodyL + 1, -2); + const tl = c.tail === 'stub' ? 3 : c.tail === 'whip' ? 10 : 7; + ctx.quadraticCurveTo(-bodyL - tl * 0.6, -6, -bodyL - tl, -4 + wag); + ctx.stroke(); + + // legs + ctx.strokeStyle = c.accent; + ctx.lineWidth = 2.5; + const legLen = c.key === 'rocket' ? 3.5 : 6; + for (let i = 0; i < 4; i++) { + const lx = -bodyL * 0.6 + i * (bodyL * 0.42); + const swing = running ? Math.sin(phase + i * 1.7) * 3 : 0; + const lift = !pl.onGround ? -2 : 0; + ctx.beginPath(); + ctx.moveTo(lx, bodyH - 2); + ctx.lineTo(lx + swing, bodyH + legLen + lift); + ctx.stroke(); + } + + // jetpack (corgi) + if (c.key === 'rocket') { + ctx.fillStyle = '#90a4ae'; + ctx.fillRect(-bodyL * 0.5 - 3, -bodyH - 4, 6, 8); + if (pl.st && pl.st.jet) { + ctx.fillStyle = '#ffab40'; + ctx.beginPath(); + ctx.moveTo(-bodyL * 0.5 - 2, -bodyH + 4); + ctx.lineTo(-bodyL * 0.5, -bodyH + 10 + Math.random() * 4); + ctx.lineTo(-bodyL * 0.5 + 2, -bodyH + 4); + ctx.fill(); + } + } + + // body + ctx.fillStyle = c.color; + ctx.beginPath(); + ctx.ellipse(0, 0, bodyL, bodyH, 0, 0, 7); + ctx.fill(); + // charge glow + if (pl.st && pl.st.charge) { + ctx.strokeStyle = '#ff7043'; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // head + const hx = bodyL * 0.75, hy = -bodyH * 0.8; + const hr = 5.5 * c.body; + ctx.beginPath(); ctx.arc(hx, hy, hr, 0, 7); ctx.fill(); + // snout + ctx.beginPath(); + ctx.ellipse(hx + hr * 0.8, hy + 1, hr * 0.7 * c.snout, hr * 0.45, 0, 0, 7); + ctx.fill(); + ctx.fillStyle = c.accent; + ctx.beginPath(); ctx.arc(hx + hr * 0.8 + hr * 0.7 * c.snout, hy + 0.5, 1.5, 0, 7); ctx.fill(); + + // ears + ctx.fillStyle = c.accent; + if (c.ears === 'point') { + ctx.beginPath(); ctx.moveTo(hx - 3, hy - hr + 1); ctx.lineTo(hx - 1, hy - hr - 6); ctx.lineTo(hx + 2, hy - hr + 2); ctx.fill(); + ctx.beginPath(); ctx.moveTo(hx + 2, hy - hr + 1); ctx.lineTo(hx + 4, hy - hr - 5); ctx.lineTo(hx + 6, hy - hr + 2); ctx.fill(); + } else if (c.ears === 'fold') { + ctx.beginPath(); ctx.ellipse(hx - 2, hy - hr + 1, 3, 1.8, -0.6, 0, 7); ctx.fill(); + } else if (c.ears === 'nub') { + ctx.beginPath(); ctx.arc(hx - 2, hy - hr + 1, 2, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(hx + 3, hy - hr + 1, 2, 0, 7); ctx.fill(); + } else if (c.ears === 'silk') { + ctx.beginPath(); ctx.ellipse(hx - 3, hy + 2, 2.2, 6, 0.3, 0, 7); ctx.fill(); + } + + // eye (X when stunned) + if (pl.hitstun > 2) { + ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(hx - 1, hy - 3); ctx.lineTo(hx + 3, hy + 1); + ctx.moveTo(hx + 3, hy - 3); ctx.lineTo(hx - 1, hy + 1); + ctx.stroke(); + } else { + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); ctx.arc(hx + 1, hy - 1.5, 1.6, 0, 7); ctx.fill(); + } + + // open mouth during attack + if (pl.atkT > 0) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(hx + hr * c.snout + 4, hy + 1, 5, -0.7, 0.7); + ctx.stroke(); + } + + ctx.restore(); + + // shield bubble + if (pl.shield > 0) { + ctx.strokeStyle = `rgba(128,216,255,${0.4 + 0.3 * Math.sin(frame * 0.2)})`; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x, y, 17, 0, 7); ctx.stroke(); + } + // boost sparkles + if (pl.boost > 0 && frame % 3 === 0) spawn(x - pl.face * 8, y, -pl.face, -0.5, 12, '#ffd740', 1.5, 0); + + // name + damage tag + ctx.font = 'bold 9px monospace'; + ctx.textAlign = 'center'; + const dmg = Math.round(pl.dmg); + const heat = Math.min(1, dmg / 120); + ctx.fillStyle = '#00000088'; + ctx.fillRect(x - 22, y - 27, 44, 11); + ctx.fillStyle = c.color; + ctx.fillText(pl.name, x - 6, y - 18.5); + ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`; + ctx.fillText(`${dmg}%`, x + 13, y - 18.5); +} + +function drawParticles() { + for (const pt of particles) { + pt.ttl--; + pt.x += pt.vx; pt.y += pt.vy; + pt.vy += pt.grav; + ctx.globalAlpha = Math.max(0, pt.ttl / pt.ttl0); + ctx.fillStyle = pt.c; + ctx.fillRect(pt.x - pt.r / 2, pt.y - pt.r / 2, pt.r, pt.r); + } + ctx.globalAlpha = 1; + particles = particles.filter(p => p.ttl > 0); +} + +function drawHud(game) { + game.players.forEach((pl, i) => { + const c = CHARS[pl.charId]; + const left = i === 0; + const x = left ? 12 : W - 152; + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + ctx.fillRect(x, 8, 140, 34); + ctx.fillStyle = c.color; + ctx.fillRect(x, 8, 4, 34); + ctx.font = 'bold 11px monospace'; + ctx.textAlign = 'left'; + ctx.fillText(`${pl.name}`, x + 10, 21); + // stocks as paws + for (let s = 0; s < pl.stocks; s++) { + ctx.beginPath(); ctx.arc(x + 14 + s * 12, 32, 3.5, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 10 + s * 12, 28, 1.6, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 14 + s * 12, 26.5, 1.6, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 18 + s * 12, 28, 1.6, 0, 7); ctx.fill(); + } + // damage + const dmg = Math.round(pl.dmg); + const heat = Math.min(1, dmg / 120); + ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`; + ctx.font = 'bold 14px monospace'; + ctx.fillText(`${dmg}%`, x + 72, 34); + // item slot + if (pl.item) drawItemIcon(x + 122, 28, pl.item, 7); + // special meter + const st = pl.st || {}; + let meter = null; + if (c.key === 'rocket') meter = (st.fuel ?? 70) / 70; + else if (c.key === 'wisp') meter = 1 - (st.blinkCd || 0) / 48; + else if (c.key === 'tank') meter = 1 - (st.chargeCd || 0) / 80; + else if (c.key === 'bolt') meter = st.dash || pl.onGround ? 1 : 0; + if (meter !== null) { + ctx.fillStyle = '#00000066'; + ctx.fillRect(x + 100, 12, 36, 5); + ctx.fillStyle = meter >= 1 ? '#69f0ae' : '#ffd740'; + ctx.fillRect(x + 100, 12, 36 * Math.max(0, Math.min(1, meter)), 5); + } + }); +} + +function drawBanners(game) { + // countdown + if (game.freeze > 0 && !game.over) { + const n = Math.ceil(game.freeze / 60); + ctx.font = 'bold 64px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000aa'; + ctx.lineWidth = 6; + const scale = 1 + (game.freeze % 60) / 120; + ctx.save(); + ctx.translate(W / 2, H * 0.4); + ctx.scale(scale, scale); + ctx.strokeText(String(n), 0, 0); + ctx.fillText(String(n), 0, 0); + ctx.restore(); + } + // victory + if (game.over) { + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(0, H * 0.3, W, 90); + ctx.font = 'bold 36px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#ffd740'; + const w = game.over.winner; + const txt = w >= 0 ? `${game.players[w].name} WINS!` : 'DOUBLE KO!'; + ctx.fillText(txt, W / 2, H * 0.3 + 55); + } + for (const b of banners) { + b.ttl--; + ctx.font = `bold ${b.big ? 48 : 20}px monospace`; + ctx.textAlign = 'center'; + ctx.globalAlpha = Math.min(1, b.ttl / 20); + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000aa'; + ctx.lineWidth = 4; + ctx.strokeText(b.text, W / 2, b.y); + ctx.fillText(b.text, W / 2, b.y); + ctx.globalAlpha = 1; + } + banners = banners.filter(b => b.ttl > 0); +} diff --git a/js/rng.js b/js/rng.js new file mode 100644 index 0000000..9f98812 --- /dev/null +++ b/js/rng.js @@ -0,0 +1,26 @@ +// Seeded RNG (mulberry32). All sim/mapgen randomness goes through this so a +// shared seed produces identical maps on both peers. +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function makeRng(seed) { + const f = mulberry32(seed); + return { + f, + int: (a, b) => a + Math.floor(f() * (b - a + 1)), + pick: (arr) => arr[Math.floor(f() * arr.length)], + chance: (p) => f() < p, + }; +} + +// UI-side only (room keys, match seeds) — never inside the sim. +export function randomSeed() { + return Math.floor(Math.random() * 0xffffffff) >>> 0; +} diff --git a/js/sim.js b/js/sim.js new file mode 100644 index 0000000..50f1b33 --- /dev/null +++ b/js/sim.js @@ -0,0 +1,478 @@ +// 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) 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--; + 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; + if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return; } + 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; + v.hitstun = Math.min(40, 9 + m * 1.6); + v.invuln = 14; + v.st.charge = 0; v.st.anchor = null; v.st.pound = false; + g.events.push({ t: 'hit', x: v.x, y: v.y, m }); +} + +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; + 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; + 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; + 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; + 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.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, + 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.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; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..80fb43b --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "barkour", + "version": "0.1.0", + "description": "Barkour — dogfights × parkour. Movement-PvP fighting game with procedural maps and P2P netplay.", + "type": "module", + "scripts": { + "dev": "python3 -m http.server 8137", + "test": "node test/smoke.mjs" + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..c8baa07 --- /dev/null +++ b/style.css @@ -0,0 +1,148 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: #07090f; + color: #e8eaf6; + font-family: 'Courier New', monospace; + overflow: hidden; +} + +#wrap { + position: relative; + width: 100vw; height: 100vh; + display: flex; align-items: center; justify-content: center; +} + +#cv { + width: min(100vw, calc(100vh * 16 / 9)); + height: min(100vh, calc(100vw * 9 / 16)); + image-rendering: pixelated; + background: #0b1026; + transition: filter 0.3s; +} +#cv.dim { filter: brightness(0.35) blur(2px); } + +#ui { position: absolute; inset: 0; pointer-events: none; } +#ui .screen { pointer-events: auto; } +#ui .screen.passthrough { pointer-events: none; } + +.screen { + position: absolute; inset: 0; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 14px; + text-align: center; +} +.hidden { display: none !important; } + +h1 { + font-size: clamp(40px, 9vw, 96px); + letter-spacing: 0.08em; + color: #ffd740; + text-shadow: 4px 4px 0 #7a3b10, 8px 8px 0 #00000088; +} +h1 span { color: #4fc3f7; text-shadow: 4px 4px 0 #01579b, 8px 8px 0 #00000088; } +h2 { font-size: 28px; color: #ffd740; } +.tagline { opacity: 0.7; margin-bottom: 16px; } + +button { + font: inherit; + font-size: 18px; + padding: 12px 28px; + min-width: 320px; + background: #1b2845; + color: #e8eaf6; + border: 2px solid #4fc3f7; + border-radius: 6px; + cursor: pointer; + transition: transform 0.06s, background 0.15s; +} +button:hover:not(:disabled) { background: #27375c; transform: translateY(-2px); } +button:disabled { opacity: 0.4; cursor: default; } +button.ghost { border-color: #555; min-width: 160px; font-size: 14px; } +.row { display: flex; gap: 12px; } +.row button { min-width: 180px; } + +input { + font: inherit; + font-size: 28px; + letter-spacing: 0.4em; + text-transform: uppercase; + text-align: center; + width: 280px; + padding: 10px; + background: #11182d; + color: #ffd740; + border: 2px solid #4fc3f7; + border-radius: 6px; +} + +.bigkey { + font-size: 56px; + letter-spacing: 0.3em; + color: #ffd740; + background: #11182d; + border: 2px dashed #ffd740; + border-radius: 8px; + padding: 12px 32px; + user-select: all; +} + +.status { min-height: 1.4em; color: #9be7ff; } +.status.err { color: #ff8a80; } +.hint { opacity: 0.6; font-size: 13px; max-width: 420px; } + +.help { + margin-top: 28px; + font-size: 13px; + opacity: 0.65; + line-height: 1.8; +} + +/* char select */ +.screen.wide { justify-content: center; } +#char-grid { + display: flex; gap: 10px; + flex-wrap: wrap; justify-content: center; + max-width: 1100px; +} +.card { + width: 195px; + background: #11182dee; + border: 2px solid; + border-radius: 8px; + padding: 10px; + cursor: pointer; + transition: transform 0.08s; + display: flex; flex-direction: column; gap: 5px; + align-items: center; +} +.card:hover { transform: translateY(-4px) scale(1.02); } +.card-head { font-size: 20px; font-weight: bold; } +.card-breed { font-size: 11px; opacity: 0.6; } +.card-desc { font-size: 11px; opacity: 0.85; min-height: 3em; } +.card-moves { font-size: 10px; text-align: left; opacity: 0.7; padding-left: 14px; } +.card-moves li { margin: 2px 0; } +.card-tags { min-height: 20px; display: flex; gap: 6px; } +.tag { + font-size: 11px; font-weight: bold; + padding: 2px 8px; border-radius: 10px; +} +.tag.p1 { background: #4fc3f7; color: #07090f; } +.tag.p2 { background: #ff8a65; color: #07090f; } + +#mute { + position: absolute; top: 10px; right: 14px; + font-size: 18px; opacity: 0.6; + pointer-events: none; +} +#pause-tag, #room-key-tag { + position: absolute; top: 10px; left: 50%; + transform: translateX(-50%); + background: #000000aa; + padding: 4px 16px; + border-radius: 12px; + font-size: 14px; +} +#room-key-tag b { color: #ffd740; letter-spacing: 0.2em; } diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..b44bb66 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,118 @@ +// Headless smoke test for the DOM-free sim core. +// Runs full matches with random inputs across many seeds and asserts sanity. +import { createGame, step, snapshot, applySnapshot } from '../js/sim.js'; +import { generateMap, sawPos } from '../js/mapgen.js'; +import { mulberry32 } from '../js/rng.js'; +import { GW, GH, T, B } from '../js/consts.js'; + +let failures = 0; +const fail = (msg) => { failures++; console.error('FAIL:', msg); }; +const ok = (msg) => console.log(' ok:', msg); + +// --- mapgen across many seeds --- +for (let s = 1; s <= 40; s++) { + const seed = s * 2654435761 >>> 0; + const map = generateMap(seed); + if (map.spawns.length < 2) fail(`seed ${seed}: only ${map.spawns.length} spawns`); + if (map.tiles.length !== GW * GH) fail(`seed ${seed}: bad tile count`); + let solids = 0, crystals = 0; + for (const t of map.tiles) { if (t === T.SOLID) solids++; if (t === T.CRYSTAL) crystals++; } + if (solids < 200) fail(`seed ${seed}: suspiciously empty map (${solids} solids)`); + if (crystals < 1) fail(`seed ${seed}: no crystals`); + for (const sp of map.spawns) { + if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) fail(`seed ${seed}: NaN spawn`); + if (sp.y < 0 || sp.y > GH * 12) fail(`seed ${seed}: spawn off map (${sp.x},${sp.y})`); + } + // determinism: same seed → identical map + const map2 = generateMap(seed); + for (let i = 0; i < map.tiles.length; i++) { + if (map.tiles[i] !== map2.tiles[i]) { fail(`seed ${seed}: non-deterministic tiles`); break; } + } + for (const saw of map.saws) { + const p = sawPos(saw, 0.37); + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) fail(`seed ${seed}: NaN saw pos`); + } +} +ok('mapgen: 40 seeds, deterministic, sane geometry'); + +// --- 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, + B.L | B.ATK, B.D, B.D | B.JUMP, B.ITM, B.R | B.JUMP | B.SPC | B.U, B.U | B.SPC, B.L | B.D | B.SPC]; + +for (let trial = 0; trial < 12; trial++) { + const seed = (trial * 48271 + 7) >>> 0; + const rng = mulberry32(seed ^ 0x5151); + const charA = trial % 5, charB = (trial + 3) % 5; + const g = createGame(seed, [{ charId: charA }, { charId: charB }]); + let kos = 0, ticks = 0; + const inputs = [0, 0]; + for (let t = 0; t < 12000 && !g.over; t++) { + ticks++; + if (t % 7 === 0) inputs[0] = ALL_BITS[Math.floor(rng() * ALL_BITS.length)]; + if (t % 5 === 0) inputs[1] = ALL_BITS[Math.floor(rng() * ALL_BITS.length)]; + const evs = step(g, inputs); + for (const ev of evs) if (ev.t === 'ko') kos++; + for (const p of g.players) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.vx) || !Number.isFinite(p.vy)) { + fail(`trial ${trial} (chars ${charA}v${charB}) tick ${t}: non-finite player state`); + t = 1e9; break; + } + } + for (const e of g.ents) { + if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) { fail(`trial ${trial} tick ${t}: non-finite entity`); t = 1e9; break; } + } + // snapshot round-trip every so often + if (t % 500 === 250) { + const snap = JSON.parse(JSON.stringify(snapshot(g, evs))); + const g2 = createGame(seed, [{ charId: charA }, { charId: charB }]); + applySnapshot(g2, snap); + if (Math.abs(g2.players[0].x - g.players[0].x) > 0.2) fail(`trial ${trial}: snapshot round-trip diverged`); + if (g2.tick !== g.tick) fail(`trial ${trial}: snapshot tick mismatch`); + } + } + if (!g.over && kos === 0) { + // chaotic play that never produced a single KO in 12k ticks would be odd but + // not impossible; only flag it, don't fail + console.log(` note: trial ${trial} (chars ${charA} vs ${charB}) — no KOs in ${ticks} ticks`); + } +} +ok('sim: 12 chaotic matches, all character matchups, finite state throughout'); + +// --- targeted: mining drops a pickup --- +{ + // build a controlled scenario: place a crystal next to player 0 and bite it + const g = createGame(12345, [{ charId: 1 }, { charId: 1 }]); + g.freeze = 0; + const p = g.players[0]; + 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]); + if (g.ents.some(e => e.k === 'pickup')) dropped = true; + } + if (!dropped) fail('mining a crystal never dropped a pickup'); + else ok('mining: crystal breaks into a powerup pickup'); +} + +// --- targeted: KO + stocks → match over --- +{ + const g = createGame(999, [{ charId: 0 }, { charId: 4 }]); + g.freeze = 0; + // hurl player 1 into the abyss repeatedly + for (let n = 0; n < 5 && !g.over; n++) { + g.players[1].x = -2000; // past blast zone + for (let i = 0; i < 200 && !g.over; i++) { + step(g, [0, 0]); + if (g.players[1].respawn > 0 && g.players[1].stocks > 0) g.players[1].respawn = 1; + } + } + if (!g.over) fail('match never ended after repeated KOs'); + else if (g.over.winner !== 0) fail(`wrong winner: ${g.over.winner}`); + else ok('stocks: 3 KOs end the match with the right winner'); +} + +console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.'); +process.exit(failures ? 1 : 0);