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:
parent
99468cd379
commit
f57c1f16f8
14 changed files with 1226 additions and 51 deletions
10
MAP.md
10
MAP.md
|
|
@ -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
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
11
index.html
11
index.html
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
10
js/consts.js
10
js/consts.js
|
|
@ -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'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
69
js/main.js
69
js/main.js
|
|
@ -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
156
js/mapgen_arena.js
Normal 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) };
|
||||||
|
}
|
||||||
241
js/render.js
241
js/render.js
|
|
@ -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--;
|
||||||
|
|
|
||||||
|
|
@ -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
601
js/sim_arena.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
124
test/smoke.mjs
124
test/smoke.mjs
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue