feat: top-down 'Dogpit' arena mode (dota-like view)

Second game mode selectable at dog select (host-authoritative online):
- js/sim_arena.js: top-down sim, same export surface as sim.js — x/y plane
  plus a z hop axis (Space / comma); KOs by knocking dogs into abyss pits
- js/mapgen_arena.js: mirrored symmetric arenas — walls, pits, center
  clearing, guaranteed spawn-to-center corridors, crystals/spikes/launch
  pads, mirrored saw pairs; fallback pit pair since pits are the only KO
  mechanic
- specials re-imagined per dog: zoom dash, ram + hop-slam, zip-leash
  slingshot, blink, hover (floats over pits/saws/spikes)
- render: arena tiles with wall shadows + void pits, top-down dog drawing
  with z-lift over a ground shadow; new fall event/sfx
- B.HOP input bit so 'up' can mean north in top-down (Space also jumps in
  side view); mode toggle UI + mode carried in start/rematch net messages

Tested: arena mapgen determinism/symmetry/connectivity + chaotic matches +
pit/hop scenarios headless; local chaos + full P2P arena match (host mode
flip syncs to client) in two headless browsers with zero page errors.
This commit is contained in:
w1n5t0n 2026-06-10 15:23:22 +02:00
parent 99468cd379
commit f57c1f16f8
14 changed files with 1226 additions and 51 deletions

10
MAP.md
View file

@ -6,15 +6,17 @@ Vanilla-JS canvas game, no build step. ES modules under `js/`, served statically
- `style.css` — overlay/menu styling; canvas letterboxed to 16:9 via CSS. - `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/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/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/mapgen.js` — side-view `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/mapgen_arena.js` — top-down `generateArena(seed)`: mirrored (left↔right) walls/pits, corner spawns, guaranteed corridors spawn→center, fallback pit pair (pits are the only KO mechanic), mirrored saw pairs.
- `js/physics.js` — center-based AABB vs tile grid: `moveEntity` (per-axis, one-way platform aware), `raycast`, `boxFree`, `standingOnPlat`. - `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/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/sim.js`**authoritative, DOM-free side-view sim**: `createGame`, `step(game, inputs)` (60 Hz), combat (`hurt`: damage% → knockback), mining/items/hazards/KO/respawn/match-end, `snapshot`/`applySnapshot` for netplay. Sets `game.kind='side'`.
- `js/sim_arena.js` — top-down "Dogpit" sim, **same export surface as sim.js** (main.js swaps modules per match). x/y plane + z hop axis; KOs via pit falls; specials re-imagined per dog (dash cooldown, ram/slam, zip-leash, blink, hover). Sets `game.kind='arena'`.
- `js/input.js` — keyboard → bitmasks; slot 0 = WASD+FGH, slot 1 = arrows+KL;. - `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. Runs a 2s heartbeat + 8s watchdog (WebRTC close events are unreliable); broker `network` errors are ignored once the data channel is up. - `js/net.js` — PeerJS wrapper: `hostRoom`/`joinRoom`; room key = host peer id suffix; signaling via PeerJS cloud, game traffic pure P2P. Runs a 2s heartbeat + 8s watchdog (WebRTC close events are unreliable); broker `network` errors are ignored once the data channel is up.
- `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners. - `js/render.js` — canvas: cached seeded background, tiles, procedural dog drawing, particles/ghosts driven by sim events (`fxEvents`), HUD, banners. Branches on `game.kind`: side view (`drawTiles`/`drawDog`) vs top-down (`drawTilesArena`/`drawDogTop`, z-height lifts the dog off its shadow).
- `js/audio.js` — WebAudio-synthesized sfx; `playEvents` maps sim events → sounds. - `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. - `js/main.js` — app state machine: screens, char select (incl. mode toggle: `gameMode` 'side'|'arena', host-authoritative over the wire via `mode`/`start`/`rematch` messages), three match drivers (local / host / client) sharing one rAF fixed-timestep loop; `matchSim` points at the active sim module.
- `test/smoke.mjs``npm test`: headless mapgen determinism + chaotic full matches + targeted mining/stocks scenarios. - `test/smoke.mjs``npm test`: headless mapgen determinism + chaotic full matches + targeted mining/stocks scenarios.
## Conventions & gotchas ## Conventions & gotchas

View file

@ -4,6 +4,12 @@
wildly different movement physics brawl across procedurally generated arenas wildly different movement physics brawl across procedurally generated arenas
full of traps and mineable powerups. Last dog standing wins. full of traps and mineable powerups. Last dog standing wins.
Two arena styles, picked at dog select:
- **⛰ Parkour** (side view) — gravity, wall-jumps, one-way platforms, blast-zone KOs.
- **🐾 Dogpit** (top-down) — 8-way movement on a dota-style mirrored arena;
hop (third axis) over saws, spikes and abyss pits; KO by knocking dogs into the void.
No build step, no game server, no asset files — everything (art, sound, maps) No build step, no game server, no asset files — everything (art, sound, maps)
is generated procedurally in the browser. is generated procedurally in the browser.
@ -18,12 +24,13 @@ Any static file server works (ES modules just need http://, not file://).
## Play ## Play
| | Move | Bite | Special | Item | | | Move | Bite | Special | Item | Hop (top-down) |
|---|---|---|---|---| |---|---|---|---|---|---|
| **P1** | WASD | F | G | H | | **P1** | WASD | F | G | H | Space |
| **P2** | arrows | K | L | ; | | **P2** | arrows | K | L | ; | , |
- **↑/W** jumps, **↓** drops through one-way platforms - Side view: **↑/W** jumps (Space works too for P1), **↓** drops through one-way platforms
- Top-down: **Space/,** hops over pits, saw blades, spikes and bites
- **Bite** is also your mining pick — crack open crystals for powerups - **Bite** is also your mining pick — crack open crystals for powerups
- Damage % makes knockback grow (Smash-style); die by being launched past - Damage % makes knockback grow (Smash-style); die by being launched past
the arena edges or into the abyss. 3 stocks each. the arena edges or into the abyss. 3 stocks each.
@ -39,13 +46,15 @@ inputs and renders snapshots.)
## The dogs ## The dogs
| Dog | Breed | Movement identity | | Dog | Breed | Parkour (side) | Dogpit (top-down) |
|---|---|---| |---|---|---|---|
| **Bolt** | Greyhound | Fastest runner, wall-slide/wall-jump, 8-way air dash | | **Bolt** | Greyhound | Wall-slide/wall-jump, 8-way air dash | 8-way zoom dash on cooldown |
| **Brick** | Bulldog | Massive knockback resistance, charging ram, ground-pound shockwave | | **Brick** | Bulldog | Charging ram, ground-pound shockwave | Same ram; slam down from a hop |
| **Lasso** | Lurcher | Leash grapple — anchor, reel, pendulum-swing, jump-release launch | | **Lasso** | Lurcher | Pendulum leash-swing, jump-release launch | Zip-leash: latch a wall, reel in, slingshot out |
| **Wisp** | Saluki | Low gravity, double jump, blink-teleports through walls | | **Wisp** | Saluki | Low gravity, double jump, blink through walls | Blink through walls and over pits |
| **Stubbs** | Corgi | Jetpack (fuel refills on ground), butt-bounce off hard landings | | **Stubbs** | Corgi | Jetpack, butt-bounce off hard landings | Hover — floats over pits, saws and spikes |
All dogs keep their weight class: Brick barely launches, Wisp flies across the map.
## Arenas ## Arenas

View file

@ -28,9 +28,9 @@
<button id="btn-join">🔑 Join with room key</button> <button id="btn-join">🔑 Join with room key</button>
<p id="menu-status" class="status"></p> <p id="menu-status" class="status"></p>
<div class="help"> <div class="help">
<div><b>P1</b> WASD move · <b>F</b> bite · <b>G</b> special · <b>H</b> item</div> <div><b>P1</b> WASD move · <b>F</b> bite · <b>G</b> special · <b>H</b> item · <b>Space</b> hop</div>
<div><b>P2</b> arrows move · <b>K</b> bite · <b>L</b> special · <b>;</b> item</div> <div><b>P2</b> arrows move · <b>K</b> bite · <b>L</b> special · <b>;</b> item · <b>,</b> hop</div>
<div>↓ drops through platforms · bite crystals to mine powerups · last dog standing wins</div> <div>bite crystals to mine powerups · last dog standing wins · two arena styles at dog select</div>
</div> </div>
</div> </div>
@ -55,6 +55,11 @@
<!-- character select --> <!-- character select -->
<div id="s-chars" class="screen wide hidden"> <div id="s-chars" class="screen wide hidden">
<h2>Choose your dog</h2> <h2>Choose your dog</h2>
<div class="row" id="mode-row">
<button id="mode-side" class="modebtn sel">⛰ Parkour <small>side-view</small></button>
<button id="mode-arena" class="modebtn">🐾 Dogpit <small>top-down</small></button>
</div>
<p id="mode-hint" class="hint"></p>
<div id="char-grid"></div> <div id="char-grid"></div>
<p id="chars-status" class="status"></p> <p id="chars-status" class="status"></p>
<div class="row"> <div class="row">

View file

@ -79,6 +79,7 @@ export function sfx(name) {
case 'count': tone(440, 0.1, 'square', 0.12); 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 '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 'drop': tone(880, 0.1, 'sine', 0.08, -200); break;
case 'fall': tone(420, 0.45, 'sawtooth', 0.2, -380); break;
case 'ui': tone(600, 0.05, 'square', 0.08); break; case 'ui': tone(600, 0.05, 'square', 0.08); break;
} }
} }
@ -91,7 +92,7 @@ const EVENT_SFX = {
shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple', shield: 'shield', boost: 'boost', heal: 'heal', grapple: 'grapple',
whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing', whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing',
charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko', charge: 'charge', thud: 'thud', pound: 'pound', ko: 'ko',
respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', respawn: 'respawn', go: 'go', over: 'over', drop: 'drop', fall: 'fall',
}; };
export function playEvents(events) { export function playEvents(events) {

View file

@ -100,7 +100,7 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
case 'swing': { case 'swing': {
if (st.anchor) { if (st.anchor) {
if (pressed & B.SPC) { st.anchor = null; break; } if (pressed & B.SPC) { st.anchor = null; break; }
if (pressed & B.JUMP) { if (pressed & (B.JUMP | B.HOP)) {
st.anchor = null; st.anchor = null;
p.vy -= 2.4; p.vx *= 1.15; p.vy -= 2.4; p.vx *= 1.15;
H.event({ t: 'jump', x: p.x, y: p.y }); H.event({ t: 'jump', x: p.x, y: p.y });

View file

@ -2,11 +2,13 @@
export const TILE = 12, GW = 80, GH = 45; export const TILE = 12, GW = 80, GH = 45;
export const W = GW * TILE, H = GH * TILE; // 960 x 540 export const W = GW * TILE, H = GH * TILE; // 960 x 540
// Tile types // Tile types. PAD is the side-view bounce block (solid); PADTOP is the
export const T = { EMPTY: 0, SOLID: 1, PLAT: 2, SPIKE: 3, PAD: 4, CRYSTAL: 5 }; // top-down launch pad (walkable floor tile).
export const T = { EMPTY: 0, SOLID: 1, PLAT: 2, SPIKE: 3, PAD: 4, CRYSTAL: 5, PIT: 6, PADTOP: 7 };
// Input bitmask // Input bitmask. JUMP rides on the up keys (side view); HOP is the dedicated
export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128 }; // hop key (Space / comma) so the top-down mode can move north without hopping.
export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128, HOP: 256 };
export const ITEMS = ['ball', 'bomb', 'shield', 'boost', 'heal']; export const ITEMS = ['ball', 'bomb', 'shield', 'boost', 'heal'];

View file

@ -3,8 +3,8 @@
import { B } from './consts.js'; import { B } from './consts.js';
const MAPS = [ 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 }, { KeyA: B.L, KeyD: B.R, KeyW: B.U | B.JUMP, KeyS: B.D, KeyF: B.ATK, KeyG: B.SPC, KeyH: B.ITM, Space: B.HOP },
{ ArrowLeft: B.L, ArrowRight: B.R, ArrowUp: B.U | B.JUMP, ArrowDown: B.D, KeyK: B.ATK, KeyL: B.SPC, Semicolon: 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, Comma: B.HOP },
]; ];
const bits = [0, 0]; const bits = [0, 0];

View file

@ -2,7 +2,8 @@
import { SNAP_EVERY } from './consts.js'; import { SNAP_EVERY } from './consts.js';
import { randomSeed } from './rng.js'; import { randomSeed } from './rng.js';
import { CHARS } from './characters.js'; import { CHARS } from './characters.js';
import { createGame, step, snapshot, applySnapshot } from './sim.js'; import * as simSide from './sim.js';
import * as simArena from './sim_arena.js';
import { initInput, getBits, getMergedBits } from './input.js'; import { initInput, getBits, getMergedBits } from './input.js';
import { initRender, resetRender, render, fxEvents } from './render.js'; import { initRender, resetRender, render, fxEvents } from './render.js';
import { initAudio, playEvents, sfx, toggleMute } from './audio.js'; import { initAudio, playEvents, sfx, toggleMute } from './audio.js';
@ -11,6 +12,9 @@ import { randomKey, hostRoom, joinRoom, peerAvailable } from './net.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
// ---- state ---- // ---- state ----
const SIMS = { side: simSide, arena: simArena }; // the two game modes
let gameMode = 'side'; // 'side' (parkour) | 'arena' (top-down dogpit)
let matchSim = simSide; // sim module driving the current game object
let mode = null; // 'local' | 'host' | 'client' let mode = null; // 'local' | 'host' | 'client'
let net = null; let net = null;
let game = null; let game = null;
@ -44,7 +48,8 @@ window.addEventListener('DOMContentLoaded', () => {
wireMenus(); wireMenus();
show('s-menu'); show('s-menu');
// background demo map behind menus // background demo map behind menus
game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]); matchSim = simSide;
game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game); resetRender(game);
startLoop(); startLoop();
}); });
@ -80,7 +85,8 @@ function leaveToMenu(msg) {
myPick = null; remotePick = null; peerJoined = false; picks = [null, null]; myPick = null; remotePick = null; peerJoined = false; picks = [null, null];
$('pause-tag').classList.add('hidden'); $('pause-tag').classList.add('hidden');
if (msg) setStatus($('menu-status'), msg); if (msg) setStatus($('menu-status'), msg);
game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]); matchSim = simSide;
game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game); resetRender(game);
show('s-menu'); show('s-menu');
refreshCharUI(); refreshCharUI();
@ -100,6 +106,29 @@ function wireMenus() {
$('btn-rematch').onclick = () => doRematch(); $('btn-rematch').onclick = () => doRematch();
$('btn-newdogs').onclick = () => doChangeDogs(); $('btn-newdogs').onclick = () => doChangeDogs();
$('btn-result-menu').onclick = () => leaveToMenu(); $('btn-result-menu').onclick = () => leaveToMenu();
$('mode-side').onclick = () => setGameMode('side');
$('mode-arena').onclick = () => setGameMode('arena');
}
function setGameMode(m) {
if (mode === 'client') return; // host picks the arena style
if (gameMode === m) return;
sfx('ui');
gameMode = m;
refreshModeUI();
if (mode === 'host' && net) net.send({ t: 'mode', m });
}
function refreshModeUI() {
$('mode-side').classList.toggle('sel', gameMode === 'side');
$('mode-arena').classList.toggle('sel', gameMode === 'arena');
const locked = mode === 'client';
$('mode-side').disabled = locked;
$('mode-arena').disabled = locked;
$('mode-hint').textContent =
locked ? 'The host picks the arena style.'
: gameMode === 'side' ? 'Side-view platforming: wall-jumps, blast zones, gravity.'
: 'Top-down dogpit: 8-way movement, hop (Space / ,) over pits and saws — knock dogs into the void!';
} }
// ---- char select ---- // ---- char select ----
@ -157,6 +186,7 @@ function pickChar(id) {
} }
function refreshCharUI() { function refreshCharUI() {
refreshModeUI();
for (const c of CHARS) { for (const c of CHARS) {
const tags = $(`card-${c.id}`)?.querySelector('.card-tags'); const tags = $(`card-${c.id}`)?.querySelector('.card-tags');
if (!tags) continue; if (!tags) continue;
@ -195,12 +225,12 @@ function refreshCharUI() {
function tryStart() { function tryStart() {
if (mode === 'local') { if (mode === 'local') {
if (picks[0] === null || picks[1] === null) return; if (picks[0] === null || picks[1] === null) return;
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]); startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }], gameMode);
} else if (mode === 'host') { } else if (mode === 'host') {
if (myPick === null || remotePick === null) return; if (myPick === null || remotePick === null) return;
const seed = randomSeed(); const seed = randomSeed();
net.send({ t: 'start', seed, chars: [myPick, remotePick] }); net.send({ t: 'start', seed, chars: [myPick, remotePick], mode: gameMode });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]); startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
} }
} }
@ -254,21 +284,24 @@ function onNetData(msg) {
remotePick = msg.c; remotePick = msg.c;
refreshCharUI(); refreshCharUI();
break; break;
case 'mode': // client: host flipped the arena style
if (mode === 'client') { gameMode = msg.m === 'arena' ? 'arena' : 'side'; refreshModeUI(); }
break;
case 'start': // client: host started the match case 'start': // client: host started the match
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]); if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }], msg.mode || 'side');
break; break;
case 'i': // host: remote inputs case 'i': // host: remote inputs
remoteBits = msg.b | 0; remoteBits = msg.b | 0;
break; break;
case 's': // client: snapshot case 's': // client: snapshot
if (mode === 'client' && game) { if (mode === 'client' && game) {
const evs = applySnapshot(game, msg.s); const evs = matchSim.applySnapshot(game, msg.s);
fxEvents(evs, game); fxEvents(evs, game);
playEvents(evs); playEvents(evs);
} }
break; break;
case 'rematch': case 'rematch':
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]); if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }], msg.mode || 'side');
break; break;
case 'tochars': case 'tochars':
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); } if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
@ -280,8 +313,10 @@ function onNetData(msg) {
} }
// ---- match lifecycle ---- // ---- match lifecycle ----
function startMatch(seed, defs) { function startMatch(seed, defs, modeKey = 'side') {
game = createGame(seed, defs); gameMode = modeKey;
matchSim = SIMS[modeKey] || simSide;
game = matchSim.createGame(seed, defs);
resetRender(game); resetRender(game);
pendingEvs = []; pendingEvs = [];
remoteBits = 0; remoteBits = 0;
@ -296,11 +331,11 @@ function startMatch(seed, defs) {
function doRematch() { function doRematch() {
sfx('ui'); sfx('ui');
if (mode === 'local') { if (mode === 'local') {
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]); startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }], gameMode);
} else if (mode === 'host') { } else if (mode === 'host') {
const seed = randomSeed(); const seed = randomSeed();
net.send({ t: 'rematch', seed, chars: [myPick, remotePick] }); net.send({ t: 'rematch', seed, chars: [myPick, remotePick], mode: gameMode });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]); startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
} }
} }
@ -355,18 +390,18 @@ function tick() {
// local match, or attract-mode demo behind menus (no inputs) // local match, or attract-mode demo behind menus (no inputs)
const i0 = inGame ? getBits(0) : 0; const i0 = inGame ? getBits(0) : 0;
const i1 = inGame ? getBits(1) : 0; const i1 = inGame ? getBits(1) : 0;
const evs = step(game, [i0, i1]); const evs = matchSim.step(game, [i0, i1]);
if (inGame) { fxEvents(evs, game); playEvents(evs); } if (inGame) { fxEvents(evs, game); playEvents(evs); }
if (inGame && game.over && game.overT > 140 && !resultShown) showResult(); if (inGame && game.over && game.overT > 140 && !resultShown) showResult();
} else if (mode === 'host') { } else if (mode === 'host') {
if (!inGame && !resultShown) return; // still in lobby/charselect if (!inGame && !resultShown) return; // still in lobby/charselect
const evs = step(game, [getMergedBits(), remoteBits]); const evs = matchSim.step(game, [getMergedBits(), remoteBits]);
pendingEvs.push(...evs); pendingEvs.push(...evs);
fxEvents(evs, game); fxEvents(evs, game);
playEvents(evs); playEvents(evs);
if (game.tick % SNAP_EVERY === 0 && net) { if (game.tick % SNAP_EVERY === 0 && net) {
net.send({ t: 's', s: snapshot(game, pendingEvs) }); net.send({ t: 's', s: matchSim.snapshot(game, pendingEvs) });
pendingEvs = []; pendingEvs = [];
} }
if (game.over && game.overT > 140 && !resultShown) showResult(); if (game.over && game.overT > 140 && !resultShown) showResult();

156
js/mapgen_arena.js Normal file
View file

@ -0,0 +1,156 @@
// Top-down "Dogpit" arena generation. Mirrored left↔right (dota-style fairness):
// border walls, wall blobs, abyss pits, a cleared center circle, guaranteed
// corridors spawn→center, crystals/spikes/launch pads, patrolling saws.
import { TILE, GW, GH, W, T } from './consts.js';
import { makeRng } from './rng.js';
export function generateArena(seed) {
const r = makeRng(seed);
const tiles = new Uint8Array(GW * GH);
const hp = {};
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];
const HALF = GW >> 1;
// --- border walls (2 thick — nothing leaves the pit) ---
for (let x = 0; x < GW; x++) { set(x, 0, T.SOLID); set(x, 1, T.SOLID); set(x, GH - 1, T.SOLID); set(x, GH - 2, T.SOLID); }
for (let y = 0; y < GH; y++) { set(0, y, T.SOLID); set(1, y, T.SOLID); set(GW - 1, y, T.SOLID); set(GW - 2, y, T.SOLID); }
// --- wall blobs on the left half ---
const nWalls = r.int(8, 12);
for (let i = 0; i < nWalls; i++) {
const w = r.int(2, 6), h = r.int(2, 5);
const x = r.int(3, HALF - 2 - w), y = r.int(3, GH - 4 - h);
for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) set(xx, yy, T.SOLID);
}
// --- abyss pits (the kill mechanic: knock dogs in) ---
const wantPits = r.int(2, 4);
let pitBlobs = 0, pitTries = 0;
while (pitBlobs < wantPits && pitTries++ < 150) {
const w = r.int(2, 4), h = r.int(2, 4);
const x = r.int(4, HALF - 3 - w), y = r.int(4, GH - 6 - h);
let clear = true;
for (let yy = y - 1; yy < y + h + 1 && clear; yy++)
for (let xx = x - 1; xx < x + w + 1; xx++) if (get(xx, yy) === T.SOLID) { clear = false; break; }
if (!clear) continue;
for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) set(xx, yy, T.PIT);
pitBlobs++;
}
// --- mirror left → right ---
for (let y = 0; y < GH; y++) for (let x = 0; x < HALF; x++) tiles[y * GW + (GW - 1 - x)] = tiles[y * GW + x];
// --- cleared center circle (the contested middle) ---
const ccx = GW / 2 - 0.5, ccy = GH / 2 - 0.5;
for (let y = 2; y < GH - 2; y++) for (let x = 2; x < GW - 2; x++) {
if (Math.hypot(x - ccx, y - ccy) < 7) set(x, y, T.EMPTY);
}
// --- spawns in the four corners, 3x3 cleared ---
const spawnT = [[5, 5], [GW - 6, 5], [5, GH - 6], [GW - 6, GH - 6]];
for (const [sx, sy] of spawnT)
for (let y = sy - 1; y <= sy + 1; y++) for (let x = sx - 1; x <= sx + 1; x++) set(x, y, T.EMPTY);
const nearSpawn = (x, y) => spawnT.some(([sx, sy]) => Math.abs(sx - x) < 5 && Math.abs(sy - y) < 5);
// --- guaranteed L-corridors spawn→center (2 wide) for connectivity ---
const carve = (x, y) => { if (x > 1 && x < GW - 2 && y > 1 && y < GH - 2) set(x, y, T.EMPTY); };
const mx = GW >> 1, my = GH >> 1;
for (const [sx, sy] of spawnT) {
for (let x = Math.min(sx, mx); x <= Math.max(sx, mx); x++) { carve(x, sy); carve(x, sy + 1); }
for (let y = Math.min(sy, my); y <= Math.max(sy, my); y++) { carve(mx, y); carve(mx + 1, y); }
}
// pits are the only KO mechanic — if blob placement + corridor carving left
// too few, stamp a guaranteed mirrored pair clear of corridors and spawns
let pitTiles = 0;
for (const t of tiles) if (t === T.PIT) pitTiles++;
if (pitTiles < 6) {
for (let y = 12; y <= 13; y++) for (let x = HALF - 12; x <= HALF - 10; x++) {
set(x, y, T.PIT);
set(GW - 1 - x, y, T.PIT);
}
}
// --- decorations, placed on the left and mirrored for fairness ---
const mirrorSet = (x, y, v, crystal) => {
set(x, y, v);
set(GW - 1 - x, y, v);
if (crystal) { hp[y * GW + x] = 3; hp[y * GW + (GW - 1 - x)] = 3; }
};
const floorAt = (x, y) => get(x, y) === T.EMPTY;
const wallAdjacent = (x, y) =>
get(x + 1, y) === T.SOLID || get(x - 1, y) === T.SOLID || get(x, y + 1) === T.SOLID || get(x, y - 1) === T.SOLID;
// crystals hug wall faces
let placed = 0, tries = 0;
while (placed < 7 && tries++ < 300) {
const x = r.int(3, HALF - 2), y = r.int(3, GH - 4);
if (!floorAt(x, y) || !wallAdjacent(x, y) || nearSpawn(x, y)) continue;
if (!floorAt(GW - 1 - x, y)) continue; // keep the mirror twin valid too
mirrorSet(x, y, T.CRYSTAL, true);
placed++;
}
// spike patches on open floor
let spikes = 0; tries = 0;
while (spikes < r.int(2, 4) && tries++ < 200) {
const x = r.int(4, HALF - 3), y = r.int(4, GH - 5);
if (!floorAt(x, y) || !floorAt(x + 1, y) || nearSpawn(x, y)) continue;
mirrorSet(x, y, T.SPIKE); mirrorSet(x + 1, y, T.SPIKE);
spikes++;
}
// launch pads (walkable; fling you into a high hop over pits)
let pads = 0; tries = 0;
while (pads < r.int(2, 3) && tries++ < 200) {
const x = r.int(4, HALF - 3), y = r.int(4, GH - 5);
if (!floorAt(x, y) || nearSpawn(x, y)) continue;
mirrorSet(x, y, T.PADTOP);
pads++;
}
// --- spawn pixel coords ---
const spawns = spawnT.map(([x, y]) => ({ x: x * TILE + TILE / 2, y: y * TILE + TILE / 2 }));
// --- patrolling saws, mirrored pairs, paths clear of walls/pits/spawns ---
const blocked = (t) => t === T.SOLID || t === T.CRYSTAL || t === T.PIT;
const sawPathOk = (pts, loop) => {
const nSegs = loop ? pts.length : pts.length - 1;
for (let s = 0; s < nSegs; s++) {
const a = pts[s], b = pts[(s + 1) % pts.length];
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
for (let d = 0; d <= len; d += 6) {
const px = a.x + (b.x - a.x) * d / len, py = a.y + (b.y - a.y) * d / len;
for (const [ox, oy] of [[0, 0], [12, 0], [-12, 0], [0, 12], [0, -12]]) {
if (blocked(get(Math.floor((px + ox) / TILE), Math.floor((py + oy) / TILE)))) return false;
}
for (const sp of spawns) if (Math.hypot(sp.x - px, sp.y - py) < 30) return false;
}
}
return true;
};
const saws = [];
const nSaws = r.int(1, 2);
for (let i = 0; i < nSaws; i++) {
for (let t = 0; t < 30; t++) {
const y = r.int(5, GH - 8) * TILE;
const x0 = r.int(4, HALF - 14) * TILE, x1 = x0 + r.int(8, 12) * TILE;
let pts, loop = false;
if (r.chance(0.5)) {
pts = [{ x: x0, y }, { x: x1, y }];
} else {
const y2 = Math.min((GH - 4) * TILE, y + r.int(4, 8) * TILE);
pts = [{ x: x0, y }, { x: x1, y }, { x: x1, y: y2 }, { x: x0, y: y2 }];
loop = true;
}
if (!sawPathOk(pts, loop)) continue;
const speed = 1.1 + r.f() * 1.1, prog = r.f();
saws.push({ pts, speed, prog, x: pts[0].x, y: pts[0].y, loop });
saws.push({ pts: pts.map(pt => ({ x: W - pt.x, y: pt.y })), speed, prog, x: W - pts[0].x, y: pts[0].y, loop });
break;
}
}
return { seed, tiles, hp, spawns, saws, pal: r.int(0, 3) };
}

View file

@ -21,13 +21,13 @@ export function initRender(canvas) {
export function resetRender(game) { export function resetRender(game) {
particles = []; ghosts = []; banners = []; shake = 0; particles = []; ghosts = []; banners = []; shake = 0;
buildBackground(game.map); buildBackground(game.map, game.kind);
} }
function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; } function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; }
// --- static background, cached to an offscreen canvas --- // --- static background, cached to an offscreen canvas ---
function buildBackground(map) { function buildBackground(map, kind) {
bgSeed = map.seed; bgSeed = map.seed;
bgCanvas = document.createElement('canvas'); bgCanvas = document.createElement('canvas');
bgCanvas.width = W; bgCanvas.height = H; bgCanvas.width = W; bgCanvas.height = H;
@ -35,6 +35,22 @@ function buildBackground(map) {
const p = PALETTES[map.pal % PALETTES.length]; const p = PALETTES[map.pal % PALETTES.length];
const r = mulberry32((map.seed ^ 0xBA9D) >>> 0); const r = mulberry32((map.seed ^ 0xBA9D) >>> 0);
if (kind === 'arena') { // top-down: tinted floor with mottled patches + vignette
b.fillStyle = p.hill;
b.fillRect(0, 0, W, H);
for (let i = 0; i < 260; i++) {
const x = r() * W, y = r() * H, s = 6 + r() * 22;
b.fillStyle = r() < 0.5 ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.05)';
b.fillRect(x, y, s, s);
}
const vg = b.createRadialGradient(W / 2, H / 2, H * 0.45, W / 2, H / 2, H * 0.95);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.45)');
b.fillStyle = vg;
b.fillRect(0, 0, W, H);
return;
}
const grad = b.createLinearGradient(0, 0, 0, H); const grad = b.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]); grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]);
b.fillStyle = grad; b.fillStyle = grad;
@ -118,6 +134,7 @@ export function fxEvents(events, game) {
case 'shield': burst(ev.x, ev.y, 10, '#80d8ff', 2, 20, 0); 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 '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 'drop': banners.push({ text: 'Care package!', ttl: 70, y: 60 }); break;
case 'fall': burst(ev.x, ev.y, 14, '#263238', 2.2, 26, -0.04); shake = Math.max(shake, 3); break;
case 'go': banners.push({ text: 'GO!', ttl: 45, y: H * 0.4, big: true }); break; case 'go': banners.push({ text: 'GO!', ttl: 45, y: H * 0.4, big: true }); break;
} }
} }
@ -127,7 +144,8 @@ export function fxEvents(events, game) {
export function render(game, opts = {}) { export function render(game, opts = {}) {
frame++; frame++;
const p = pal(game); const p = pal(game);
if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map); const arena = game.kind === 'arena';
if (!bgCanvas || bgSeed !== game.map.seed) buildBackground(game.map, game.kind);
ctx.save(); ctx.save();
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
@ -137,11 +155,11 @@ export function render(game, opts = {}) {
} else shake = 0; } else shake = 0;
ctx.drawImage(bgCanvas, 0, 0); ctx.drawImage(bgCanvas, 0, 0);
drawTiles(game, p); if (arena) drawTilesArena(game, p); else drawTiles(game, p);
drawSaws(game); drawSaws(game);
drawEnts(game); drawEnts(game);
drawGhosts(); drawGhosts();
for (const pl of game.players) drawDog(game, pl, opts); for (const pl of game.players) (arena ? drawDogTop : drawDog)(game, pl, opts);
drawParticles(); drawParticles();
drawHud(game); drawHud(game);
drawBanners(game); drawBanners(game);
@ -205,6 +223,75 @@ function drawTiles(game, p) {
} }
} }
// top-down arena tiles: walls with drop shadows, void pits, caltrops, pads
function drawTilesArena(game, p) {
const tiles = game.map.tiles;
// shadow pass so walls read as raised
ctx.fillStyle = 'rgba(0,0,0,0.3)';
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
const t = tiles[y * GW + x];
if (t === T.SOLID || t === T.CRYSTAL) ctx.fillRect(x * TILE + 3, y * TILE + 4, TILE, TILE);
}
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.SOLID) {
ctx.fillStyle = p.top;
ctx.fillRect(px, py, TILE, 3);
}
} else if (t === T.PIT) {
ctx.fillStyle = '#04060a';
ctx.fillRect(px, py, TILE, TILE);
if (y > 0 && tiles[(y - 1) * GW + x] !== T.PIT) { // rim on the near edge
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(px, py, TILE, 4);
}
} else if (t === T.SPIKE) {
ctx.fillStyle = p.spike;
for (const [ox, oy] of [[3, 3], [9, 5], [5, 9]]) {
ctx.beginPath();
ctx.moveTo(px + ox - 2.5, py + oy + 2);
ctx.lineTo(px + ox, py + oy - 3);
ctx.lineTo(px + ox + 2.5, py + oy + 2);
ctx.fill();
}
} else if (t === T.PADTOP) {
const pulse = 0.5 + 0.5 * Math.sin(frame * 0.12 + x + y);
ctx.strokeStyle = p.pad;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px + 6, py + 6, 4 + pulse * 1.5, 0, 7); ctx.stroke();
ctx.fillStyle = p.pad;
ctx.beginPath();
ctx.moveTo(px + 3.5, py + 7.5); ctx.lineTo(px + 6, py + 3.5); ctx.lineTo(px + 8.5, py + 7.5);
ctx.fill();
} 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) {
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) { function drawSaws(game) {
for (const s of game.map.saws) { for (const s of game.map.saws) {
if (s.x === undefined) continue; if (s.x === undefined) continue;
@ -449,6 +536,150 @@ function drawDog(game, pl, opts) {
ctx.fillText(`${dmg}%`, x + 13, y - 18.5); ctx.fillText(`${dmg}%`, x + 13, y - 18.5);
} }
// top-down dog: shadow at ground level, body lifted by hop height, rotated to
// the facing angle. Same per-character visual params as the side view.
function drawDogTop(game, pl, opts) {
if (pl.stocks <= 0 || pl.respawn > 0) return;
const c = CHARS[pl.charId];
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;
const z = pl.z || 0;
const falling = pl.falling || 0;
if (Math.hypot(pl.vx, pl.vy) > 5.5 && frame % 2 === 0) {
ghosts.push({ x, y, ttl: 10, c: c.color, kind: 'blob' });
}
// leash
const an = pl.st && pl.st.anchor;
if (an && !falling) {
ctx.strokeStyle = c.accent;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(x, y - z * 1.8); 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();
}
// shadow stays on the floor
if (!falling) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(x, y + 3, Math.max(3, 10 - z * 0.8), Math.max(2, 5 - z * 0.4), 0, 0, 7);
ctx.fill();
}
// hover flame between shadow and dog
if (pl.st && (pl.st.hover || pl.st.jet) && !falling) {
ctx.fillStyle = '#ffab40';
ctx.beginPath();
ctx.ellipse(x, y + 1 - z * 0.9, 3, 5 + Math.random() * 2, 0, 0, 7);
ctx.fill();
}
ctx.save();
ctx.translate(x, y - z * 1.8);
if (pl.invuln > 0 && (frame >> 2) % 2 === 0) ctx.globalAlpha = 0.45;
if (falling) { // spiral down the pit
const f = falling / 36;
ctx.globalAlpha = f;
ctx.scale(f, f);
ctx.rotate((36 - falling) * 0.35);
}
ctx.rotate(pl.ang !== undefined ? pl.ang : (pl.face < 0 ? Math.PI : 0));
if (pl.hitstun > 2) ctx.rotate(Math.sin(frame * 0.8) * 0.3);
const bodyL = 10 * c.body, bodyW = 6.5 * c.body;
const moving = Math.hypot(pl.vx, pl.vy) > 0.6 && z <= 0;
const phase = (x + y) * 0.45;
// tail (behind, wags sideways)
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';
const tl = c.tail === 'stub' ? 3 : c.tail === 'whip' ? 10 : 7;
ctx.beginPath();
ctx.moveTo(-bodyL + 1, 0);
ctx.quadraticCurveTo(-bodyL - tl * 0.6, wag * 0.5, -bodyL - tl, wag);
ctx.stroke();
// paws poking out the sides
ctx.fillStyle = c.accent;
for (let i = 0; i < 4; i++) {
const lx = -bodyL * 0.55 + (i >> 1) * bodyL * 0.9;
const ly = (i % 2 ? 1 : -1) * (bodyW + 1);
const swing = moving ? Math.sin(phase + i * 1.7) * 2.5 : 0;
ctx.beginPath(); ctx.arc(lx + swing, ly, 1.8, 0, 7); ctx.fill();
}
// jetpack on the back (corgi)
if (c.key === 'rocket') {
ctx.fillStyle = '#90a4ae';
ctx.fillRect(-bodyL * 0.5 - 2, -3, 5, 6);
}
// body + head + snout
ctx.fillStyle = c.color;
ctx.beginPath(); ctx.ellipse(0, 0, bodyL, bodyW, 0, 0, 7); ctx.fill();
if (pl.st && pl.st.charge) { ctx.strokeStyle = '#ff7043'; ctx.lineWidth = 2; ctx.stroke(); }
const hx = bodyL * 0.85, hr = 5 * c.body;
ctx.beginPath(); ctx.arc(hx, 0, hr, 0, 7); ctx.fill();
ctx.beginPath(); ctx.ellipse(hx + hr * 0.8, 0, hr * 0.65 * c.snout, hr * 0.45, 0, 0, 7); ctx.fill();
ctx.fillStyle = c.accent;
ctx.beginPath(); ctx.arc(hx + hr * 0.8 + hr * 0.65 * c.snout, 0, 1.5, 0, 7); ctx.fill();
// ears either side of the head
ctx.fillStyle = c.accent;
for (const side of [-1, 1]) {
if (c.ears === 'silk') {
ctx.beginPath(); ctx.ellipse(hx - 2, side * (hr + 1), 4, 2, side * 0.4, 0, 7); ctx.fill();
} else if (c.ears === 'nub') {
ctx.beginPath(); ctx.arc(hx - 1, side * hr, 1.8, 0, 7); ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(hx - 3, side * (hr - 1));
ctx.lineTo(hx - 1, side * (hr + 4));
ctx.lineTo(hx + 2, side * (hr - 1));
ctx.fill();
}
}
// bite arc
if (pl.atkT > 0) {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(hx + hr * c.snout + 4, 0, 5, -0.7, 0.7);
ctx.stroke();
}
ctx.restore();
if (falling) return; // no bubble/tag while disappearing into the void
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 - z * 1.8, 17, 0, 7); ctx.stroke();
}
if (pl.boost > 0 && frame % 3 === 0) spawn(x, y + 2, (Math.random() - 0.5) * 2, 0.5, 12, '#ffd740', 1.5, 0);
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 - z * 1.8 - 27, 44, 11);
ctx.fillStyle = c.color;
ctx.fillText(pl.name, x - 6, y - z * 1.8 - 18.5);
ctx.fillStyle = `rgb(255,${Math.round(255 - heat * 200)},${Math.round(255 - heat * 255)})`;
ctx.fillText(`${dmg}%`, x + 13, y - z * 1.8 - 18.5);
}
function drawParticles() { function drawParticles() {
for (const pt of particles) { for (const pt of particles) {
pt.ttl--; pt.ttl--;

View file

@ -11,7 +11,7 @@ const PW = 18, PH = 14;
export function createGame(seed, defs) { export function createGame(seed, defs) {
const map = generateMap(seed); const map = generateMap(seed);
const g = { const g = {
seed, tick: 0, map, players: [], ents: [], nextEnt: 1, kind: 'side', seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
rng: makeRng((seed ^ 0xC0FFEE) >>> 0), rng: makeRng((seed ^ 0xC0FFEE) >>> 0),
events: [], over: null, overT: 0, freeze: 170, dropTimer: 550, events: [], over: null, overT: 0, freeze: 170, dropTimer: 550,
}; };
@ -121,7 +121,7 @@ function stepPlayer(g, p, inp, pressed) {
// --- jumping --- // --- jumping ---
if (!stunned) { if (!stunned) {
if ((pressed & B.D) && p.onGround && standingOnPlat(g.map, p)) p.drop = 10; if ((pressed & B.D) && p.onGround && standingOnPlat(g.map, p)) p.drop = 10;
if (pressed & B.JUMP) { if (pressed & (B.JUMP | B.HOP)) {
if (p.onGround || p.coyote > 0) { if (p.onGround || p.coyote > 0) {
p.vy = -s.jump * Math.sqrt(bm); p.vy = -s.jump * Math.sqrt(bm);
p.coyote = 0; p.coyote = 0;

601
js/sim_arena.js Normal file
View file

@ -0,0 +1,601 @@
// Top-down "Dogpit" arena simulation. Same authoritative/DOM-free contract and
// export surface as sim.js — main.js picks the module per match.
//
// The plane is x/y; z is hop height (a third axis for dodging ground hazards
// and crossing pits). KOs come from being knocked into pits, not blast zones.
import { TILE, GW, GH, W, H, T, B, STOCKS, ITEMS } from './consts.js';
import { makeRng } from './rng.js';
import { generateArena } from './mapgen_arena.js';
import { sawPos } from './mapgen.js';
import { moveEntity, tileAt, raycast, boxFree } from './physics.js';
import { CHARS } from './characters.js';
const PW = 18, PH = 14;
const ZGRAV = 0.34;
const VCAP = TILE - 2.5; // collision is single-pass AABB; stay under tile size
export function createGame(seed, defs) {
const map = generateArena(seed);
const g = {
kind: 'arena', seed, tick: 0, map, players: [], ents: [], nextEnt: 1,
rng: makeRng((seed ^ 0xD06600D) >>> 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, z: 0, vz: 0, ang: i % 2 ? Math.PI : 0,
w: PW, h: PH, face: i % 2 ? -1 : 1, falling: 0,
onGround: true, landT: 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,
};
}
const alive = (p) => p.stocks > 0 && p.respawn <= 0 && p.falling <= 0;
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);
if (!g.over && --g.dropTimer <= 0) {
g.dropTimer = 520 + g.rng.int(0, 300);
// care package lands on a random clear floor tile
for (let t = 0; t < 40; t++) {
const tx = g.rng.int(3, GW - 4), ty = g.rng.int(3, GH - 4);
if (tileAt(g.map, tx, ty) === T.EMPTY) {
spawnPickup(g, tx * TILE + 6, ty * TILE + 6, g.rng.pick(ITEMS));
g.events.push({ t: 'drop' });
break;
}
}
}
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;
}
// mid-fall into a pit: no control, shrink, then KO
if (p.falling > 0) {
p.falling--;
p.vx *= 0.88; p.vy *= 0.88;
p.x += p.vx; p.y += p.vy;
if (p.falling === 0) kill(g, p);
return;
}
const c = CHARS[p.charId], s = c.stats, st = p.st;
if (p.invuln > 0 && g.freeze <= 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--;
const bm = p.boost > 0 ? 1.45 : 1;
const stunned = p.hitstun > 0;
if (stunned) p.hitstun--;
// --- 8-way movement ---
const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
if (!stunned && !(st.charge > 0) && !st.anchor) {
if (dx || dy) {
const n = Math.hypot(dx, dy), ux = dx / n, uy = dy / n;
const acc = s.accel * (p.z > 0 ? 0.55 : 1) * bm;
p.vx += ux * acc;
p.vy += uy * acc;
const cap = s.run * bm, spd = Math.hypot(p.vx, p.vy);
if (spd > cap) { // let dash overspeed decay instead of hard-clamping
const k = Math.max(cap, spd - s.accel * 2) / spd;
p.vx *= k; p.vy *= k;
}
p.ang = Math.atan2(uy, ux);
if (dx) p.face = dx > 0 ? 1 : -1;
} else if (p.z <= 0) {
p.vx *= s.fric; p.vy *= s.fric;
if (Math.hypot(p.vx, p.vy) < 0.1) { p.vx = 0; p.vy = 0; }
} else {
p.vx *= 0.99; p.vy *= 0.99;
}
} else if (stunned && p.z <= 0) {
p.vx *= 0.9; p.vy *= 0.9;
}
// --- hop ---
if (!stunned && (pressed & B.HOP) && p.z <= 0) {
p.vz = 3.0;
g.events.push({ t: 'jump', x: p.x, y: p.y });
}
// --- character specials ---
updateCharArena(g, p, c, inp, pressed);
// --- z physics ---
const wasAir = p.z > 0;
p.z += p.vz;
if (!st.hover) p.vz -= ZGRAV;
let justLanded = false;
if (p.z <= 0) {
if (wasAir) {
justLanded = true;
p.landT = 6;
if (-p.vz > 2) g.events.push({ t: 'land', x: p.x, y: p.y + 5, v: 4 });
}
p.z = 0;
if (p.vz < 0) p.vz = 0;
}
p.onGround = p.z <= 0;
if (justLanded && st.pound) {
st.pound = false;
g.events.push({ t: 'pound', x: p.x, y: p.y });
for (const o of g.players) {
if (o === p || !alive(o) || o.z > 6) continue;
const ox = o.x - p.x, oy = o.y - p.y, d = Math.hypot(ox, oy);
if (d < 55) { const n = d || 1; hurt(g, o, 9, ox / n, oy / n, 6); }
}
}
// --- integrate & wall-collide (y-axis hits count as walls here too) ---
const res = moveEntity(p, g.map, {});
p.wallHit = res.wall || ((res.ground || res.ceil) ? 2 : 0);
// --- ground tile sensors ---
if (p.z <= 0) {
const t = tileAt(g.map, Math.floor(p.x / TILE), Math.floor(p.y / TILE));
if (t === T.PIT) {
p.falling = 36;
p.atkT = 0; p.st.charge = 0; p.st.anchor = null;
g.events.push({ t: 'fall', x: p.x, y: p.y });
return;
}
if (t === T.PADTOP && p.vz <= 0) {
p.vz = 4.6;
g.events.push({ t: 'pad', x: p.x, y: p.y });
}
// spikes under any part of the footprint
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 sx = tx * TILE + 6, sy = ty * TILE + 6;
const n = Math.hypot(p.x - sx, p.y - sy) || 1;
if (hurt(g, p, 9, (p.x - sx) / n, (p.y - sy) / n, 5.5)) {
g.events.push({ t: 'spike', x: p.x, y: p.y });
}
}
}
}
// --- attack ---
if (!stunned && (pressed & B.ATK) && p.atkCd <= 0) {
p.atkCd = 24; p.atkT = 7;
p.hitMask = 0; p.minedSet = {};
p.vx += Math.cos(p.ang) * 1.4;
p.vy += Math.sin(p.ang) * 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); }
if (!stunned && (pressed & B.ITM) && p.item) useItem(g, p);
}
function updateCharArena(g, p, c, inp, pressed) {
const st = p.st;
const inputDir = () => {
const dx = (inp & B.L ? -1 : 0) + (inp & B.R ? 1 : 0);
const dy = (inp & B.U ? -1 : 0) + (inp & B.D ? 1 : 0);
if (dx || dy) { const n = Math.hypot(dx, dy); return [dx / n, dy / n]; }
return [Math.cos(p.ang), Math.sin(p.ang)];
};
switch (c.key) {
case 'bolt': { // zoom dash, on a cooldown instead of landing-reset
if (st.dashCd > 0) st.dashCd--;
if ((pressed & B.SPC) && st.dashCd <= 0 && p.hitstun <= 0) {
const [ux, uy] = inputDir();
p.vx = ux * 8.6; p.vy = uy * 8.6;
p.ang = Math.atan2(uy, ux);
st.dashCd = 50;
g.events.push({ 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 = Math.cos(st.chargeAng) * 6.4;
p.vy = Math.sin(st.chargeAng) * 6.4;
for (const o of g.players) {
if (o !== p && alive(o) && o.z < 6 &&
Math.abs(o.x - p.x) < 18 && Math.abs(o.y - p.y) < 16) {
hurt(g, o, 10, Math.cos(st.chargeAng), Math.sin(st.chargeAng), 6.5);
st.charge = 0;
}
}
if (p.wallHit) { // x or y wall — stepPlayer encodes both
st.charge = 0;
g.events.push({ t: 'thud', x: p.x, y: p.y });
}
} else if ((pressed & B.SPC) && p.hitstun <= 0) {
if (p.z <= 0 && st.chargeCd <= 0) {
st.charge = 34; st.chargeCd = 80; st.chargeAng = p.ang;
g.events.push({ t: 'charge', x: p.x, y: p.y });
} else if (p.z > 0 && !st.pound) {
st.pound = true;
p.vz = -4.5; // slam down
}
}
break;
}
case 'swing': { // zip-leash: latch a wall, get reeled in, slingshot out
if (st.anchor) {
if (pressed & B.SPC) { st.anchor = null; break; }
const dx = st.anchor.x - p.x, dy = st.anchor.y - p.y;
const d = Math.hypot(dx, dy) || 1;
if (d < 16 || (pressed & B.HOP)) { // arrived, or slingshot release
st.anchor = null;
p.vx *= 1.18; p.vy *= 1.18;
break;
}
const ux = dx / d, uy = dy / d;
p.vx += (ux * 7 - p.vx) * 0.25;
p.vy += (uy * 7 - p.vy) * 0.25;
p.ang = Math.atan2(uy, ux);
} else if ((pressed & B.SPC) && p.hitstun <= 0) {
const hit = raycast(g.map, p.x, p.y, Math.cos(p.ang), Math.sin(p.ang), 175);
if (hit) {
st.anchor = { x: hit.x, y: hit.y };
g.events.push({ t: 'grapple', x: hit.x, y: hit.y });
} else {
g.events.push({ t: 'whiff', x: p.x, y: p.y });
}
}
break;
}
case 'wisp': { // blink works exactly as in side view — through walls, over pits
if (st.blinkCd > 0) st.blinkCd--;
if ((pressed & B.SPC) && st.blinkCd <= 0 && p.hitstun <= 0) {
const [ux, uy] = inputDir();
for (let dist = 58; dist >= 12; dist -= 6) {
const tx = p.x + ux * dist, ty = p.y + uy * dist;
if (boxFree(g.map, tx, ty, p.w, p.h)) {
g.events.push({ 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 * 3.5;
p.ang = Math.atan2(uy, ux);
st.blinkCd = 48;
break;
}
}
}
break;
}
case 'rocket': { // hover: floats over pits/spikes/saws while fuel lasts
st.hover = false;
if (p.z <= 0) 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;
st.hover = true;
if (p.z < 5) p.vz = Math.max(p.vz, 0.9);
else { p.z = 5; p.vz = 0; }
if (g.tick % 5 === 0) g.events.push({ t: 'jet', x: p.x, y: p.y + 4 });
}
break;
}
}
}
function doAttack(g, p) {
const ax = p.x + Math.cos(p.ang) * 17, ay = p.y + Math.sin(p.ang) * 17;
const aw = 22, ah = 20;
for (const o of g.players) {
if (o === p || !alive(o)) continue;
if (p.hitMask & (1 << o.idx)) continue;
if (Math.abs(o.z - p.z) > 6) continue; // can't bite over/under a hopping dog
if (Math.abs(o.x - ax) < (aw + o.w) / 2 && Math.abs(o.y - ay) < (ah + o.h) / 2) {
p.hitMask |= 1 << o.idx;
const n = Math.hypot(o.x - p.x, o.y - p.y) || 1;
hurt(g, o, 8, (o.x - p.x) / n, (o.y - p.y) / n, 3.2);
p.vx *= 0.6; p.vy *= 0.6;
}
}
// crystals are full-height — mineable regardless of z
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 + 6, g.rng.pick(ITEMS));
}
export function hurt(g, v, dmg, nx, ny, base) {
if (v.invuln > 0 || v.respawn > 0 || v.stocks <= 0 || v.falling > 0) return false;
if (v.shield > 0) { g.events.push({ t: 'block', x: v.x, y: v.y }); return false; }
v.dmg += dmg;
const m = (base + v.dmg * 0.062) / CHARS[v.charId].stats.weight;
v.vx = Math.max(-VCAP, Math.min(VCAP, nx * m));
v.vy = Math.max(-VCAP, Math.min(VCAP, ny * m));
v.vz = Math.max(v.vz, 1.4); // little pop for juice
if (v.z <= 0) v.z = 0.01;
v.hitstun = Math.min(40, 9 + m * 1.6);
v.invuln = 14;
v.st.charge = 0; v.st.anchor = null; v.st.pound = false;
v.atkT = 0;
g.events.push({ t: 'hit', x: v.x, y: v.y, m });
return true;
}
function useItem(g, p) {
const it = p.item;
const cx = Math.cos(p.ang), cy = Math.sin(p.ang);
if (it === 'ball') {
spawnEnt(g, { k: 'ball', x: p.x + cx * 12, y: p.y + cy * 12, vx: cx * 7 + p.vx * 0.3, vy: cy * 7 + p.vy * 0.3, 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 + cx * 10, y: p.y + cy * 10, vx: cx * 4.5 + p.vx * 0.4, vy: cy * 4.5 + p.vy * 0.4, 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.ttl--;
for (const p of g.players) {
if (alive(p) && !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.ttl <= 0) e.dead = true;
} else if (e.k === 'ball') {
e.age++; e.ttl--;
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.wall) e.vx = -pvx * 0.85;
if (r.ground || r.ceil) e.vy = -pvy * 0.85; // top-down: ±y walls bounce too
for (const p of g.players) {
if (!alive(p) || p.z > 6) 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) {
const n = Math.hypot(pvx, pvy) || 1;
hurt(g, p, 6, pvx / n, pvy / n, 3.5);
e.dead = true;
break;
}
}
// balls sail over pits (they're light); they just expire
if (e.ttl <= 0) e.dead = true;
} else if (e.k === 'bomb') {
e.age++; e.fuse--;
e.vx *= 0.96; e.vy *= 0.96; // slides to a stop
const pvx = e.vx, pvy = e.vy;
const r = moveEntity(e, g.map, {});
if (r.wall) e.vx = -pvx * 0.6;
if (r.ground || r.ceil) e.vy = -pvy * 0.6;
// a bomb sliding onto a pit drops in
if (tileAt(g.map, Math.floor(e.x / TILE), Math.floor(e.y / TILE)) === T.PIT) { e.dead = true; continue; }
let contact = false;
for (const p of g.players) {
if (!alive(p) || p.z > 8) 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; }
}
}
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 (!alive(p) || p.z > 8) 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, 7);
}
}
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 = sawPosOf(s);
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 (!alive(p) || p.z > 5) continue; // hop over the blade
const dx = p.x - s.x, dy = p.y - s.y;
const d = Math.hypot(dx, dy);
if (d < 16) {
const n = d || 1;
if (hurt(g, p, 12, dx / n, dy / n, 6.5)) {
g.events.push({ t: 'saw', x: p.x, y: p.y });
}
}
}
}
}
function sawPosOf(s) { return sawPos(s, s.prog); }
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.shield = 0; p.boost = 0;
p.vx = 0; p.vy = 0; p.vz = 0; p.z = 0; p.falling = 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 && alive(o)) 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.z = 0; p.vz = 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 standing = g.players.filter(p => p.stocks > 0);
if (standing.length <= 1 && g.players.length > 1) {
g.over = { winner: standing[0] ? standing[0].idx : -1 };
g.overT = 0;
g.events.push({ t: 'over', winner: g.over.winner });
}
}
// --- snapshots: same contract as sim.js, plus z / facing-angle / falling ---
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),
z: +p.z.toFixed(1), fa: +p.ang.toFixed(2), fl: p.falling,
f: p.face, d: Math.round(p.dmg), s: p.stocks,
og: p.z <= 0 ? 1 : 0, lt: p.landT | 0,
r: p.respawn, iv: p.invuln, hs: p.hitstun, a: p.atkT,
it: p.item, am: p.ammo, sh: p.shield, bo: p.boost,
st: {
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.hover ? 1 : 0, fu: p.st.fuel | 0,
po: p.st.pound ? 1 : 0, da: (p.st.dashCd | 0) <= 0 ? 1 : 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.z = sp.z; p.ang = sp.fa; p.falling = sp.fl;
p.face = sp.f; p.dmg = sp.d; p.stocks = sp.s;
p.onGround = !!sp.og; p.landT = sp.lt;
p.respawn = sp.r; p.invuln = sp.iv; p.hitstun = sp.hs; p.atkT = sp.a;
p.item = sp.it; p.ammo = sp.am; p.shield = sp.sh; p.boost = sp.bo;
p.st = {
anchor: sp.st.an ? { x: sp.st.an[0], y: sp.st.an[1] } : null,
charge: sp.st.ch, chargeCd: sp.st.cc, hover: sp.st.jet, 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]; }
});
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;
}

View file

@ -102,6 +102,15 @@ input {
/* char select */ /* char select */
.screen.wide { justify-content: center; } .screen.wide { justify-content: center; }
.modebtn {
min-width: 220px;
font-size: 15px;
border-color: #555;
opacity: 0.6;
}
.modebtn small { display: block; font-size: 10px; opacity: 0.7; }
.modebtn.sel { border-color: #ffd740; color: #ffd740; opacity: 1; }
#mode-hint { margin-top: -6px; }
#char-grid { #char-grid {
display: flex; gap: 10px; display: flex; gap: 10px;
flex-wrap: wrap; justify-content: center; flex-wrap: wrap; justify-content: center;

View file

@ -1,7 +1,9 @@
// Headless smoke test for the DOM-free sim core. // Headless smoke test for the DOM-free sim core.
// Runs full matches with random inputs across many seeds and asserts sanity. // Runs full matches with random inputs across many seeds and asserts sanity.
import { createGame, step, snapshot, applySnapshot } from '../js/sim.js'; import { createGame, step, snapshot, applySnapshot } from '../js/sim.js';
import * as arena from '../js/sim_arena.js';
import { generateMap, sawPos } from '../js/mapgen.js'; import { generateMap, sawPos } from '../js/mapgen.js';
import { generateArena } from '../js/mapgen_arena.js';
import { mulberry32 } from '../js/rng.js'; import { mulberry32 } from '../js/rng.js';
import { boxFree, solidAtPx } from '../js/physics.js'; import { boxFree, solidAtPx } from '../js/physics.js';
import { GW, GH, T, B } from '../js/consts.js'; import { GW, GH, T, B } from '../js/consts.js';
@ -132,5 +134,127 @@ ok('sim: 12 chaotic matches, all character matchups, finite state throughout');
else ok('stocks: 3 KOs end the match with the right winner'); else ok('stocks: 3 KOs end the match with the right winner');
} }
// ===========================================================================
// Top-down "Dogpit" arena mode
// --- arena mapgen: determinism, symmetry, connectivity ---
for (let s = 1; s <= 30; s++) {
const seed = (s * 1103515245 + 12345) >>> 0;
const map = generateArena(seed);
if (map.spawns.length !== 4) fail(`arena seed ${seed}: expected 4 spawns`);
const map2 = generateArena(seed);
for (let i = 0; i < map.tiles.length; i++) {
if (map.tiles[i] !== map2.tiles[i]) { fail(`arena seed ${seed}: non-deterministic`); break; }
}
let pits = 0, walls = 0, crystals = 0;
for (const t of map.tiles) { if (t === T.PIT) pits++; if (t === T.SOLID) walls++; if (t === T.CRYSTAL) crystals++; }
if (pits < 4) fail(`arena seed ${seed}: too few pit tiles (${pits})`);
if (crystals < 2) fail(`arena seed ${seed}: too few crystals (${crystals})`);
// mirror symmetry of walls and pits
for (let y = 0; y < GH; y++) for (let x = 0; x < GW; x++) {
const a = map.tiles[y * GW + x], b = map.tiles[y * GW + (GW - 1 - x)];
const cls = (t) => (t === T.SOLID ? 1 : t === T.PIT ? 2 : 0);
if (cls(a) !== cls(b)) { fail(`arena seed ${seed}: asymmetric at ${x},${y}`); y = GH; break; }
}
// spawns clear + all mutually reachable over walkable tiles (corridors guarantee it)
for (const sp of map.spawns) {
if (!boxFree(map, sp.x, sp.y, 18, 14)) fail(`arena seed ${seed}: spawn blocked`);
}
{
const walkable = (t) => t === T.EMPTY || t === T.SPIKE || t === T.PADTOP;
const seen = new Uint8Array(GW * GH);
const q = [[Math.floor(map.spawns[0].x / 12), Math.floor(map.spawns[0].y / 12)]];
seen[q[0][1] * GW + q[0][0]] = 1;
while (q.length) {
const [x, y] = q.pop();
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx >= GW || ny < 0 || ny >= GH) continue;
const i = ny * GW + nx;
if (!seen[i] && walkable(map.tiles[i])) { seen[i] = 1; q.push([nx, ny]); }
}
}
for (const sp of map.spawns) {
if (!seen[Math.floor(sp.y / 12) * GW + Math.floor(sp.x / 12)]) fail(`arena seed ${seed}: spawn unreachable`);
}
}
}
ok('arena mapgen: 30 seeds, deterministic, mirrored, connected, clear spawns');
// --- arena chaotic matches (hop + specials in the input mix) ---
const ARENA_BITS = [0, B.L, B.R, B.U, B.D, B.L | B.U, B.R | B.D, B.HOP, B.R | B.HOP,
B.ATK, B.SPC, B.U | B.SPC, B.L | B.D | B.SPC, B.ITM, B.HOP | B.ATK, B.R | B.U | B.SPC | B.HOP];
for (let trial = 0; trial < 10; trial++) {
const seed = (trial * 69069 + 1) >>> 0;
const rng = mulberry32(seed ^ 0xA1A1);
const g = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
const inputs = [0, 0];
for (let t = 0; t < 12000 && !g.over; t++) {
if (t % 7 === 0) inputs[0] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
if (t % 5 === 0) inputs[1] = ARENA_BITS[Math.floor(rng() * ARENA_BITS.length)];
const evs = arena.step(g, inputs);
for (const p of g.players) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.z)) {
fail(`arena trial ${trial} tick ${t}: non-finite player state`);
t = 1e9; break;
}
}
if (t % 500 === 250) {
const snap = JSON.parse(JSON.stringify(arena.snapshot(g, evs)));
const g2 = arena.createGame(seed, [{ charId: trial % 5 }, { charId: (trial + 2) % 5 }]);
arena.applySnapshot(g2, snap);
if (Math.abs(g2.players[0].x - g.players[0].x) > 0.2 || Math.abs((g2.players[0].z || 0) - g.players[0].z) > 0.2) {
fail(`arena trial ${trial}: snapshot round-trip diverged`);
}
}
}
}
ok('arena sim: 10 chaotic matches, finite state, snapshot round-trips');
// --- targeted: walking onto a pit KOs after the fall animation ---
{
const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
g.freeze = 0;
const p = g.players[0];
// find a pit tile and park the dog on it
let pit = -1;
for (let i = 0; i < g.map.tiles.length; i++) if (g.map.tiles[i] === T.PIT) { pit = i; break; }
if (pit < 0) fail('arena seed 777 has no pit');
else {
p.x = (pit % GW) * 12 + 6; p.y = Math.floor(pit / GW) * 12 + 6; p.z = 0;
const before = p.stocks;
let fell = false;
for (let i = 0; i < 120; i++) {
const evs = arena.step(g, [0, 0]);
if (evs.some(e => e.t === 'fall')) fell = true;
if (p.stocks < before) break;
}
if (!fell) fail('pit never triggered a fall');
if (p.stocks !== before - 1) fail('pit fall did not cost a stock');
else ok('arena pits: falling in costs a stock');
}
}
// --- targeted: hop crosses a pit ---
{
const g = arena.createGame(777, [{ charId: 0 }, { charId: 1 }]);
g.freeze = 0;
const p = g.players[0];
// 3-wide pit straight ahead; hop from the edge while running right
const ty = Math.floor(p.y / 12);
const tx = Math.floor(p.x / 12);
for (let i = 1; i <= 3; i++) g.map.tiles[ty * GW + tx + 1 + i] = T.PIT;
// run right to build speed, hop at the lip
let lost = false;
for (let i = 0; i < 120; i++) {
const overLip = Math.floor(p.x / 12) >= tx + 1;
arena.step(g, [B.R | (overLip && p.z <= 0 && p.falling <= 0 ? B.HOP : 0), 0]);
if (p.falling > 0 || p.stocks < 3) { lost = true; break; }
if (Math.floor(p.x / 12) > tx + 4) break; // made it across
}
if (lost) fail('hop could not cross a 3-tile pit');
else ok('arena hop: clears a 3-tile pit at speed');
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.'); console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll smoke tests passed.');
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);