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:
commit
2f60136b36
18 changed files with 2551 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
26
MAP.md
Normal file
26
MAP.md
Normal 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
65
README.md
Normal 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
82
index.html
Normal 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
105
js/audio.js
Normal 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
180
js/characters.js
Normal 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
15
js/consts.js
Normal 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
30
js/input.js
Normal 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
375
js/main.js
Normal 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
178
js/mapgen.js
Normal 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
70
js/net.js
Normal 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
94
js/physics.js
Normal 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
548
js/render.js
Normal 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
26
js/rng.js
Normal 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
478
js/sim.js
Normal 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
10
package.json
Normal 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
148
style.css
Normal 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
118
test/smoke.mjs
Normal 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);
|
||||
Loading…
Reference in a new issue