Sim: clamp knockback under tile size (wall tunneling), bomb/ball terminal velocity, grapple constraint no longer drags players into terrain, bite window ends on getting hit, shield/boost cleared on death, spawn invuln preserved through countdown, spike/saw events only on real damage. Mapgen: spawn footprint validation (was embedding dogs in 58% of seeds), saw paths rejected if they grind terrain or sweep spawns, towers no longer hang slabs over pits. Net: heartbeat+watchdog for dead peers, broker-error tolerance once P2P link is up, join/host reentrancy guards. UI: text-field keystrokes no longer hit game hotkeys, audio unlocks on mouse, textBaseline leak, snapshot carries onGround/landT so client dogs animate.
380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
// App shell: screens, character select, local/host/client match drivers.
|
|
import { SNAP_EVERY } from './consts.js';
|
|
import { randomSeed } from './rng.js';
|
|
import { CHARS } from './characters.js';
|
|
import { createGame, step, snapshot, applySnapshot } from './sim.js';
|
|
import { initInput, getBits, getMergedBits } from './input.js';
|
|
import { initRender, resetRender, render, fxEvents } from './render.js';
|
|
import { initAudio, playEvents, sfx, toggleMute } from './audio.js';
|
|
import { randomKey, hostRoom, joinRoom, peerAvailable } from './net.js';
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
// ---- state ----
|
|
let mode = null; // 'local' | 'host' | 'client'
|
|
let net = null;
|
|
let game = null;
|
|
let picks = [null, null]; // local char select
|
|
let myPick = null, remotePick = null, peerJoined = false;
|
|
let remoteBits = 0;
|
|
let pendingEvs = [];
|
|
let clientOverT = 0;
|
|
let resultShown = false;
|
|
let paused = false;
|
|
let rafId = null, acc = 0, last = 0;
|
|
const TICK_MS = 1000 / 60;
|
|
|
|
// ---- screens ----
|
|
const SCREENS = ['s-menu', 's-host', 's-join', 's-chars', 's-game', 's-result'];
|
|
function show(id) {
|
|
for (const s of SCREENS) $(s).classList.toggle('hidden', s !== id);
|
|
$('cv').classList.toggle('dim', id !== 's-game');
|
|
}
|
|
|
|
function setStatus(el, msg, isErr = false) {
|
|
el.textContent = msg;
|
|
el.classList.toggle('err', isErr);
|
|
}
|
|
|
|
// ---- boot ----
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
initInput();
|
|
initRender($('cv'));
|
|
buildCharCards();
|
|
wireMenus();
|
|
show('s-menu');
|
|
// background demo map behind menus
|
|
game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
|
|
resetRender(game);
|
|
startLoop();
|
|
});
|
|
|
|
window.addEventListener('pointerdown', () => initAudio()); // any gesture unlocks audio
|
|
window.addEventListener('keydown', (e) => {
|
|
initAudio(); // browsers require a gesture before audio
|
|
if (e.target && e.target.tagName === 'INPUT') return; // don't eat typing in the room-key field
|
|
if (e.code === 'KeyM') {
|
|
const m = toggleMute();
|
|
$('mute').textContent = m ? '🔇' : '🔊';
|
|
}
|
|
if (e.code === 'Escape') onEscape();
|
|
if (e.code === 'Enter' && !$('s-result').classList.contains('hidden')) doRematch();
|
|
});
|
|
|
|
function onEscape() {
|
|
if (!$('s-game').classList.contains('hidden')) {
|
|
if (mode === 'local') {
|
|
paused = !paused;
|
|
$('pause-tag').classList.toggle('hidden', !paused);
|
|
} else {
|
|
leaveToMenu('Left the match.');
|
|
}
|
|
} else if ($('s-menu').classList.contains('hidden')) {
|
|
leaveToMenu();
|
|
}
|
|
}
|
|
|
|
function leaveToMenu(msg) {
|
|
if (net) { try { net.send({ t: 'bye' }); net.destroy(); } catch (_) {} net = null; }
|
|
mode = null; paused = false; resultShown = false;
|
|
myPick = null; remotePick = null; peerJoined = false; picks = [null, null];
|
|
$('pause-tag').classList.add('hidden');
|
|
if (msg) setStatus($('menu-status'), msg);
|
|
game = createGame(randomSeed(), [{ charId: 0 }, { charId: 1 }]);
|
|
resetRender(game);
|
|
show('s-menu');
|
|
refreshCharUI();
|
|
}
|
|
|
|
// ---- menus ----
|
|
function wireMenus() {
|
|
$('btn-local').onclick = () => { sfx('ui'); mode = 'local'; picks = [null, null]; show('s-chars'); refreshCharUI(); };
|
|
$('btn-host').onclick = () => { sfx('ui'); startHost(); };
|
|
$('btn-join').onclick = () => { sfx('ui'); show('s-join'); setStatus($('join-status'), ''); $('join-key').focus(); };
|
|
$('btn-join-go').onclick = () => doJoin();
|
|
$('join-key').addEventListener('keydown', (e) => { if (e.key === 'Enter') doJoin(); });
|
|
$('btn-host-cancel').onclick = () => leaveToMenu();
|
|
$('btn-join-cancel').onclick = () => leaveToMenu();
|
|
$('btn-chars-back').onclick = () => leaveToMenu();
|
|
$('btn-start').onclick = () => tryStart();
|
|
$('btn-rematch').onclick = () => doRematch();
|
|
$('btn-newdogs').onclick = () => doChangeDogs();
|
|
$('btn-result-menu').onclick = () => leaveToMenu();
|
|
}
|
|
|
|
// ---- char select ----
|
|
function buildCharCards() {
|
|
const grid = $('char-grid');
|
|
grid.innerHTML = '';
|
|
for (const c of CHARS) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.id = `card-${c.id}`;
|
|
card.style.borderColor = c.color;
|
|
card.innerHTML = `
|
|
<div class="card-head" style="color:${c.color}">${c.name}</div>
|
|
<div class="card-breed">${c.breed}</div>
|
|
<canvas class="card-cv" width="90" height="56"></canvas>
|
|
<div class="card-desc">${c.desc}</div>
|
|
<ul class="card-moves">${c.moves.map(m => `<li>${m}</li>`).join('')}</ul>
|
|
<div class="card-tags"></div>`;
|
|
card.onclick = () => pickChar(c.id);
|
|
grid.appendChild(card);
|
|
drawCardDog(card.querySelector('.card-cv'), c);
|
|
}
|
|
}
|
|
|
|
function drawCardDog(cv2, c) {
|
|
const x = cv2.getContext('2d');
|
|
x.translate(45, 32);
|
|
x.fillStyle = c.color;
|
|
x.beginPath(); x.ellipse(0, 0, 22 * c.body, 13 * c.body, 0, 0, 7); x.fill();
|
|
x.beginPath(); x.arc(17, -12, 11 * c.body, 0, 7); x.fill();
|
|
x.beginPath(); x.ellipse(17 + 9, -10, 9 * c.snout, 5, 0, 0, 7); x.fill();
|
|
x.fillStyle = c.accent;
|
|
x.beginPath(); x.moveTo(13, -20); x.lineTo(16, -30); x.lineTo(20, -19); x.fill();
|
|
x.fillStyle = '#1a1a1a';
|
|
x.beginPath(); x.arc(19, -14, 2.5, 0, 7); x.fill();
|
|
x.strokeStyle = c.accent; x.lineWidth = 4; x.lineCap = 'round';
|
|
for (let i = 0; i < 4; i++) {
|
|
x.beginPath(); x.moveTo(-14 + i * 9, 10); x.lineTo(-14 + i * 9, 20); x.stroke();
|
|
}
|
|
x.lineWidth = 3;
|
|
x.beginPath(); x.moveTo(-21, -4); x.quadraticCurveTo(-32, -14, -34, -8); x.stroke();
|
|
}
|
|
|
|
function pickChar(id) {
|
|
sfx('ui');
|
|
if (mode === 'local') {
|
|
if (picks[0] === null) picks[0] = id;
|
|
else if (picks[1] === null) picks[1] = id;
|
|
else { picks = [id, null]; } // third click restarts picking
|
|
} else {
|
|
myPick = id;
|
|
if (net) net.send({ t: 'char', c: id });
|
|
}
|
|
refreshCharUI();
|
|
}
|
|
|
|
function refreshCharUI() {
|
|
for (const c of CHARS) {
|
|
const tags = $(`card-${c.id}`)?.querySelector('.card-tags');
|
|
if (!tags) continue;
|
|
const t = [];
|
|
if (mode === 'local') {
|
|
if (picks[0] === c.id) t.push('<span class="tag p1">P1</span>');
|
|
if (picks[1] === c.id) t.push('<span class="tag p2">P2</span>');
|
|
} else {
|
|
if (myPick === c.id) t.push('<span class="tag p1">YOU</span>');
|
|
if (remotePick === c.id) t.push('<span class="tag p2">THEM</span>');
|
|
}
|
|
tags.innerHTML = t.join('');
|
|
}
|
|
const status = $('chars-status');
|
|
const startBtn = $('btn-start');
|
|
if (mode === 'local') {
|
|
startBtn.classList.remove('hidden');
|
|
startBtn.disabled = picks[0] === null || picks[1] === null;
|
|
setStatus(status, picks[0] === null ? 'Player 1: pick your dog'
|
|
: picks[1] === null ? 'Player 2: pick your dog' : 'Ready! Hit Start.');
|
|
} else if (mode === 'host') {
|
|
startBtn.classList.remove('hidden');
|
|
startBtn.disabled = myPick === null || remotePick === null;
|
|
const key = $('room-key-tag');
|
|
key.classList.remove('hidden');
|
|
setStatus(status, !peerJoined ? 'Waiting for player 2 to join…'
|
|
: myPick === null ? 'Pick your dog'
|
|
: remotePick === null ? 'Waiting for them to pick…' : 'Both ready — Start!');
|
|
} else if (mode === 'client') {
|
|
startBtn.classList.add('hidden');
|
|
setStatus(status, myPick === null ? 'Pick your dog' : 'Waiting for host to start…');
|
|
}
|
|
$('room-key-tag').classList.toggle('hidden', mode !== 'host');
|
|
}
|
|
|
|
function tryStart() {
|
|
if (mode === 'local') {
|
|
if (picks[0] === null || picks[1] === null) return;
|
|
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]);
|
|
} else if (mode === 'host') {
|
|
if (myPick === null || remotePick === null) return;
|
|
const seed = randomSeed();
|
|
net.send({ t: 'start', seed, chars: [myPick, remotePick] });
|
|
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
|
|
}
|
|
}
|
|
|
|
// ---- networking ----
|
|
function startHost() {
|
|
if (net) return; // a connection attempt is already in flight
|
|
if (!peerAvailable()) { setStatus($('menu-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
|
mode = 'host';
|
|
myPick = null; remotePick = null; peerJoined = false;
|
|
const key = randomKey();
|
|
show('s-host');
|
|
setStatus($('host-status'), 'Opening room…');
|
|
$('host-key').textContent = key;
|
|
$('room-key').textContent = key;
|
|
net = hostRoom(key, {
|
|
open: () => setStatus($('host-status'), 'Room open! Share the key — waiting for player 2…'),
|
|
conn: () => {
|
|
peerJoined = true;
|
|
net.send({ t: 'welcome' });
|
|
show('s-chars');
|
|
refreshCharUI();
|
|
},
|
|
data: onNetData,
|
|
close: (msg) => netClosed(msg),
|
|
});
|
|
}
|
|
|
|
function doJoin() {
|
|
if (net) return; // a connection attempt is already in flight
|
|
if (!peerAvailable()) { setStatus($('join-status'), 'PeerJS not loaded — online play needs internet.', true); return; }
|
|
const key = $('join-key').value.toUpperCase().trim();
|
|
if (key.length < 4) { setStatus($('join-status'), 'Enter the 5-letter room key.', true); return; }
|
|
mode = 'client';
|
|
myPick = null; remotePick = null;
|
|
setStatus($('join-status'), 'Connecting…');
|
|
net = joinRoom(key, {
|
|
conn: () => { show('s-chars'); refreshCharUI(); },
|
|
data: onNetData,
|
|
close: (msg) => netClosed(msg),
|
|
});
|
|
}
|
|
|
|
function netClosed(msg) {
|
|
if (mode === 'host' || mode === 'client') leaveToMenu(msg || 'Connection closed.');
|
|
}
|
|
|
|
function onNetData(msg) {
|
|
if (!msg || typeof msg !== 'object') return;
|
|
switch (msg.t) {
|
|
case 'char':
|
|
remotePick = msg.c;
|
|
refreshCharUI();
|
|
break;
|
|
case 'start': // client: host started the match
|
|
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
|
|
break;
|
|
case 'i': // host: remote inputs
|
|
remoteBits = msg.b | 0;
|
|
break;
|
|
case 's': // client: snapshot
|
|
if (mode === 'client' && game) {
|
|
const evs = applySnapshot(game, msg.s);
|
|
fxEvents(evs, game);
|
|
playEvents(evs);
|
|
}
|
|
break;
|
|
case 'rematch':
|
|
if (mode === 'client') startMatch(msg.seed, [{ charId: msg.chars[0] }, { charId: msg.chars[1] }]);
|
|
break;
|
|
case 'tochars':
|
|
if (mode === 'client') { myPick = null; remotePick = null; resultShown = false; show('s-chars'); refreshCharUI(); }
|
|
break;
|
|
case 'bye':
|
|
netClosed('Other player left.');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- match lifecycle ----
|
|
function startMatch(seed, defs) {
|
|
game = createGame(seed, defs);
|
|
resetRender(game);
|
|
pendingEvs = [];
|
|
remoteBits = 0;
|
|
clientOverT = 0;
|
|
resultShown = false;
|
|
paused = false;
|
|
acc = 0;
|
|
show('s-game');
|
|
sfx('count');
|
|
}
|
|
|
|
function doRematch() {
|
|
sfx('ui');
|
|
if (mode === 'local') {
|
|
startMatch(randomSeed(), [{ charId: picks[0] }, { charId: picks[1] }]);
|
|
} else if (mode === 'host') {
|
|
const seed = randomSeed();
|
|
net.send({ t: 'rematch', seed, chars: [myPick, remotePick] });
|
|
startMatch(seed, [{ charId: myPick }, { charId: remotePick }]);
|
|
}
|
|
}
|
|
|
|
function doChangeDogs() {
|
|
sfx('ui');
|
|
resultShown = false; // lets the host tick() guard idle the finished match
|
|
if (mode === 'local') {
|
|
picks = [null, null];
|
|
show('s-chars'); refreshCharUI();
|
|
} else if (mode === 'host') {
|
|
net.send({ t: 'tochars' });
|
|
myPick = null; remotePick = null;
|
|
show('s-chars'); refreshCharUI();
|
|
}
|
|
}
|
|
|
|
function showResult() {
|
|
resultShown = true;
|
|
const w = game.over.winner;
|
|
$('result-title').textContent = w >= 0 ? `${game.players[w].name} wins!` : 'Double KO!';
|
|
$('result-title').style.color = w >= 0 ? CHARS[game.players[w].charId].color : '#fff';
|
|
const isClient = mode === 'client';
|
|
$('btn-rematch').classList.toggle('hidden', isClient);
|
|
$('btn-newdogs').classList.toggle('hidden', isClient);
|
|
$('result-wait').classList.toggle('hidden', !isClient);
|
|
show('s-result');
|
|
}
|
|
|
|
// ---- main loop ----
|
|
function startLoop() {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
last = performance.now();
|
|
const frame = (t) => {
|
|
rafId = requestAnimationFrame(frame);
|
|
acc += t - last;
|
|
last = t;
|
|
if (acc > 200) acc = 200; // tab was backgrounded; don't spiral
|
|
while (acc >= TICK_MS) {
|
|
acc -= TICK_MS;
|
|
tick();
|
|
}
|
|
if (game) render(game, { smooth: mode === 'client' });
|
|
};
|
|
rafId = requestAnimationFrame(frame);
|
|
}
|
|
|
|
function tick() {
|
|
if (!game || paused) return;
|
|
const inGame = !$('s-game').classList.contains('hidden');
|
|
|
|
if (mode === 'local' || mode === null) {
|
|
// local match, or attract-mode demo behind menus (no inputs)
|
|
const i0 = inGame ? getBits(0) : 0;
|
|
const i1 = inGame ? getBits(1) : 0;
|
|
const evs = step(game, [i0, i1]);
|
|
if (inGame) { fxEvents(evs, game); playEvents(evs); }
|
|
if (inGame && game.over && game.overT > 140 && !resultShown) showResult();
|
|
|
|
} else if (mode === 'host') {
|
|
if (!inGame && !resultShown) return; // still in lobby/charselect
|
|
const evs = step(game, [getMergedBits(), remoteBits]);
|
|
pendingEvs.push(...evs);
|
|
fxEvents(evs, game);
|
|
playEvents(evs);
|
|
if (game.tick % SNAP_EVERY === 0 && net) {
|
|
net.send({ t: 's', s: snapshot(game, pendingEvs) });
|
|
pendingEvs = [];
|
|
}
|
|
if (game.over && game.overT > 140 && !resultShown) showResult();
|
|
|
|
} else if (mode === 'client') {
|
|
if (!inGame && !resultShown) return;
|
|
if (net) net.send({ t: 'i', b: getMergedBits() });
|
|
// sim runs on host; we just count toward the result screen
|
|
if (game.over && ++clientOverT > 140 && !resultShown) showResult();
|
|
}
|
|
}
|