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.
- `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/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/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/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/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.
## Conventions & gotchas

View file

@ -4,6 +4,12 @@
wildly different movement physics brawl across procedurally generated arenas
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)
is generated procedurally in the browser.
@ -18,12 +24,13 @@ 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 | ; |
| | Move | Bite | Special | Item | Hop (top-down) |
|---|---|---|---|---|---|
| **P1** | WASD | F | G | H | Space |
| **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
- Damage % makes knockback grow (Smash-style); die by being launched past
the arena edges or into the abyss. 3 stocks each.
@ -39,13 +46,15 @@ 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 |
| Dog | Breed | Parkour (side) | Dogpit (top-down) |
|---|---|---|---|
| **Bolt** | Greyhound | Wall-slide/wall-jump, 8-way air dash | 8-way zoom dash on cooldown |
| **Brick** | Bulldog | Charging ram, ground-pound shockwave | Same ram; slam down from a hop |
| **Lasso** | Lurcher | Pendulum leash-swing, jump-release launch | Zip-leash: latch a wall, reel in, slingshot out |
| **Wisp** | Saluki | Low gravity, double jump, blink through walls | Blink through walls and over pits |
| **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

View file

@ -28,9 +28,9 @@
<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><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 · <b>,</b> hop</div>
<div>bite crystals to mine powerups · last dog standing wins · two arena styles at dog select</div>
</div>
</div>
@ -55,6 +55,11 @@
<!-- character select -->
<div id="s-chars" class="screen wide hidden">
<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>
<p id="chars-status" class="status"></p>
<div class="row">

View file

@ -79,6 +79,7 @@ export function sfx(name) {
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 'fall': tone(420, 0.45, 'sawtooth', 0.2, -380); 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',
whiff: 'whiff', blink: 'blink', jet: 'jet', boing: 'boing',
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) {

View file

@ -100,7 +100,7 @@ export function updateCharacter(p, c, inp, pressed, game, H) {
case 'swing': {
if (st.anchor) {
if (pressed & B.SPC) { st.anchor = null; break; }
if (pressed & B.JUMP) {
if (pressed & (B.JUMP | B.HOP)) {
st.anchor = null;
p.vy -= 2.4; p.vx *= 1.15;
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 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 };
// Tile types. PAD is the side-view bounce block (solid); PADTOP is the
// 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
export const B = { L: 1, R: 2, U: 4, D: 8, JUMP: 16, ATK: 32, SPC: 64, ITM: 128 };
// Input bitmask. JUMP rides on the up keys (side view); HOP is the dedicated
// 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'];

View file

@ -3,8 +3,8 @@
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 },
{ 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, Comma: B.HOP },
];
const bits = [0, 0];

View file

@ -2,7 +2,8 @@
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 * as simSide from './sim.js';
import * as simArena from './sim_arena.js';
import { initInput, getBits, getMergedBits } from './input.js';
import { initRender, resetRender, render, fxEvents } from './render.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);
// ---- 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 net = null;
let game = null;
@ -44,7 +48,8 @@ window.addEventListener('DOMContentLoaded', () => {
wireMenus();
show('s-menu');
// background demo map behind menus
game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
matchSim = simSide;
game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game);
startLoop();
});
@ -80,7 +85,8 @@ function leaveToMenu(msg) {
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 }]);
matchSim = simSide;
game = simSide.createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
resetRender(game);
show('s-menu');
refreshCharUI();
@ -100,6 +106,29 @@ function wireMenus() {
$('btn-rematch').onclick = () => doRematch();
$('btn-newdogs').onclick = () => doChangeDogs();
$('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 ----
@ -157,6 +186,7 @@ function pickChar(id) {
}
function refreshCharUI() {
refreshModeUI();
for (const c of CHARS) {
const tags = $(`card-${c.id}`)?.querySelector('.card-tags');
if (!tags) continue;
@ -195,12 +225,12 @@ function refreshCharUI() {
function tryStart() {
if (mode === 'local') {
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') {
if (myPick === null || remotePick === null) return;
const seed = randomSeed();
net.send({ t: 'start', seed, chars: [myPick, remotePick] });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
net.send({ t: 'start', seed, chars: [myPick, remotePick], mode: gameMode });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
}
}
@ -254,21 +284,24 @@ function onNetData(msg) {
remotePick = msg.c;
refreshCharUI();
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
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;
case 'i': // host: remote inputs
remoteBits = msg.b | 0;
break;
case 's': // client: snapshot
if (mode === 'client' && game) {
const evs = applySnapshot(game, msg.s);
const evs = matchSim.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] }]);
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }], msg.mode || 'side');
break;
case 'tochars':
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
@ -280,8 +313,10 @@ function onNetData(msg) {
}
// ---- match lifecycle ----
function startMatch(seed, defs) {
game = createGame(seed, defs);
function startMatch(seed, defs, modeKey = 'side') {
gameMode = modeKey;
matchSim = SIMS[modeKey] || simSide;
game = matchSim.createGame(seed, defs);
resetRender(game);
pendingEvs = [];
remoteBits = 0;
@ -296,11 +331,11 @@ function startMatch(seed, defs) {
function doRematch() {
sfx('ui');
if (mode === 'local') {
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]);
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }], gameMode);
} else if (mode === 'host') {
const seed = randomSeed();
net.send({ t: 'rematch', seed, chars: [myPick, remotePick] });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
net.send({ t: 'rematch', seed, chars: [myPick, remotePick], mode: gameMode });
startMatch(seed, [{ charId: myPick }, { charId: remotePick }], gameMode);
}
}
@ -355,18 +390,18 @@ function tick() {
// 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]);
const evs = matchSim.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]);
const evs = matchSim.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) });
net.send({ t: 's', s: matchSim.snapshot(game, pendingEvs) });
pendingEvs = [];
}
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) {
particles = []; ghosts = []; banners = []; shake = 0;
buildBackground(game.map);
buildBackground(game.map, game.kind);
}
function pal(game) { return PALETTES[game.map.pal % PALETTES.length]; }
// --- static background, cached to an offscreen canvas ---
function buildBackground(map) {
function buildBackground(map, kind) {
bgSeed = map.seed;
bgCanvas = document.createElement('canvas');
bgCanvas.width = W; bgCanvas.height = H;
@ -35,6 +35,22 @@ function buildBackground(map) {
const p = PALETTES[map.pal % PALETTES.length];
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);
grad.addColorStop(0, p.sky[0]); grad.addColorStop(1, p.sky[1]);
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 '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 '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;
}
}
@ -127,7 +144,8 @@ export function fxEvents(events, game) {
export function render(game, opts = {}) {
frame++;
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.clearRect(0, 0, W, H);
@ -137,11 +155,11 @@ export function render(game, opts = {}) {
} else shake = 0;
ctx.drawImage(bgCanvas, 0, 0);
drawTiles(game, p);
if (arena) drawTilesArena(game, p); else drawTiles(game, p);
drawSaws(game);
drawEnts(game);
drawGhosts();
for (const pl of game.players) drawDog(game, pl, opts);
for (const pl of game.players) (arena ? drawDogTop : drawDog)(game, pl, opts);
drawParticles();
drawHud(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) {
for (const s of game.map.saws) {
if (s.x === undefined) continue;
@ -449,6 +536,150 @@ function drawDog(game, pl, opts) {
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() {
for (const pt of particles) {
pt.ttl--;

View file

@ -11,7 +11,7 @@ const PW = 18, PH = 14;
export function createGame(seed, defs) {
const map = generateMap(seed);
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),
events: [], over: null, overT: 0, freeze: 170, dropTimer: 550,
};
@ -121,7 +121,7 @@ function stepPlayer(g, p, inp, pressed) {
// --- jumping ---
if (!stunned) {
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) {
p.vy = -s.jump * Math.sqrt(bm);
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 */
.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 {
display: flex; gap: 10px;
flex-wrap: wrap; justify-content: center;

View file

@ -1,7 +1,9 @@
// 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 * as arena from '../js/sim_arena.js';
import { generateMap, sawPos } from '../js/mapgen.js';
import { generateArena } from '../js/mapgen_arena.js';
import { mulberry32 } from '../js/rng.js';
import { boxFree, solidAtPx } from '../js/physics.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');
}
// ===========================================================================
// 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.');
process.exit(failures ? 1 : 0);