// 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 = `
${c.name}
${c.breed}
${c.desc}
`; 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('P1'); if (picks[1] === c.id) t.push('P2'); } else { if (myPick === c.id) t.push('YOU'); if (remotePick === c.id) t.push('THEM'); } 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(); } }