feat: initial Barkour — movement-PvP dog fighting game

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.
This commit is contained in:
w1n5t0n 2026-06-10 14:26:58 +02:00
commit 2f60136b36
18 changed files with 2551 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
node_modules/
.DS_Store

26
MAP.md Normal file
View file

@ -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.

65
README.md Normal file
View file

@ -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.

82
index.html Normal file
View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BARKOUR — dogfights × parkour</title>
<link rel="stylesheet" href="style.css">
<!-- PeerJS: signaling broker only; game traffic is pure P2P WebRTC.
If offline, local 2-player still works. -->
<script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js"></script>
</head>
<body>
<div id="wrap">
<canvas id="cv" width="960" height="540"></canvas>
<div id="ui">
<div id="mute" title="M to mute">🔊</div>
<div id="pause-tag" class="hidden">⏸ PAUSED — Esc to resume</div>
<div id="room-key-tag" class="hidden">room <b id="room-key"></b></div>
<!-- main menu -->
<div id="s-menu" class="screen">
<h1>BARK<span>OUR</span></h1>
<p class="tagline">dogfights × parkour</p>
<button id="btn-local">🛋️ Local — 2 players, 1 keyboard</button>
<button id="btn-host">📡 Host online room</button>
<button id="btn-join">🔑 Join with room key</button>
<p id="menu-status" class="status"></p>
<div class="help">
<div><b>P1</b> WASD move · <b>F</b> bite · <b>G</b> special · <b>H</b> item</div>
<div><b>P2</b> arrows move · <b>K</b> bite · <b>L</b> special · <b>;</b> item</div>
<div>↓ drops through platforms · bite crystals to mine powerups · last dog standing wins</div>
</div>
</div>
<!-- hosting -->
<div id="s-host" class="screen hidden">
<h2>Hosting room</h2>
<div class="bigkey" id="host-key">·····</div>
<p class="status" id="host-status"></p>
<p class="hint">Send this key to your opponent. They pick “Join with room key”.</p>
<button id="btn-host-cancel" class="ghost">Cancel</button>
</div>
<!-- joining -->
<div id="s-join" class="screen hidden">
<h2>Join a room</h2>
<input id="join-key" maxlength="6" placeholder="ROOM KEY" autocomplete="off">
<button id="btn-join-go">Connect</button>
<p class="status" id="join-status"></p>
<button id="btn-join-cancel" class="ghost">Back</button>
</div>
<!-- character select -->
<div id="s-chars" class="screen wide hidden">
<h2>Choose your dog</h2>
<div id="char-grid"></div>
<p id="chars-status" class="status"></p>
<div class="row">
<button id="btn-start" disabled>Start match!</button>
<button id="btn-chars-back" class="ghost">Back</button>
</div>
</div>
<!-- in-game (canvas only; HUD is drawn) -->
<div id="s-game" class="screen passthrough hidden"></div>
<!-- results -->
<div id="s-result" class="screen hidden">
<h2 id="result-title">Someone wins!</h2>
<div class="row">
<button id="btn-rematch">Rematch (Enter)</button>
<button id="btn-newdogs">Change dogs</button>
<button id="btn-result-menu" class="ghost">Menu</button>
</div>
<p id="result-wait" class="status hidden">Waiting for the host…</p>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

105
js/audio.js Normal file
View file

@ -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++; }
}
}

180
js/characters.js Normal file
View file

@ -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;
}
}
}

15
js/consts.js Normal file
View file

@ -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)

30
js/input.js Normal file
View file

@ -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]; }

375
js/main.js Normal file
View file

@ -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 = `
<div class="card-head" style="color:${c.color}">${c.name}</div>
<div class="card-breed">${c.breed}</div>
<canvas class="card-cv" width="90" height="56"></canvas>
<div class="card-desc">${c.desc}</div>
<ul class="card-moves">${c.moves.map(m => `<li>${m}</li>`).join('')}</ul>
<div class="card-tags"></div>`;
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('<span class="tag p1">P1</span>');
if (picks[1] === c.id) t.push('<span class="tag p2">P2</span>');
} else {
if (myPick === c.id) t.push('<span class="tag p1">YOU</span>');
if (remotePick === c.id) t.push('<span class="tag p2">THEM</span>');
}
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();
}
}

178
js/mapgen.js Normal file
View file

@ -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 };
}

70
js/net.js Normal file
View file

@ -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;
}

94
js/physics.js Normal file
View file

@ -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;
}

548
js/render.js Normal file
View file

@ -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);
}

26
js/rng.js Normal file
View file

@ -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;
}

478
js/sim.js Normal file
View file

@ -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;
}

10
package.json Normal file
View file

@ -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"
}
}

148
style.css Normal file
View file

@ -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; }

118
test/smoke.mjs Normal file
View file

@ -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);