barkour/js/main.js

376 lines
13 KiB
JavaScript
Raw Normal View History

// 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('keydown', (e) => {
initAudio(); // browsers require a gesture before audio
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 (!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 (!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; 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');
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();
}
}