whackrow
Version:
JS dev kit for game+controls over WebSocket with Vite dev server.
134 lines (115 loc) • 4.24 kB
JavaScript
// Minimal game engine API compatible with Whackrow docs
// Exports startGame({ canvas, onTimerUpdate, onPerformanceUpdate, onWinner, onPlayerEliminated })
export async function startGame({ canvas, onTimerUpdate, onPerformanceUpdate, onWinner, onPlayerEliminated } = {}) {
const gameCanvas = canvas || document.getElementById('gameCanvas');
if (!gameCanvas) throw new Error('startGame: canvas not provided and #gameCanvas not found');
const ctx = gameCanvas.getContext('2d');
const playersById = new Map();
const eliminatedIds = new Set();
let isRunning = true;
let frameId = 0;
let last = performance.now();
let fps = 0;
let framesCount = 0;
let bucketMs = 0;
const targetFps = 60;
const targetFrameMs = 1000 / targetFps;
// Load Whackrow logo PNG from images folder
const logoImg = new Image();
logoImg.src = new URL('../images/whackrow_logo.PNG', import.meta.url).href;
function ensurePlayer(id) {
if (!id) id = 'P1';
if (eliminatedIds.has(id)) return null;
if (!playersById.has(id)) {
playersById.set(id, {
id,
pos: { x: gameCanvas.width / 2, y: gameCanvas.height / 2 },
joystick: { vector: { x: 0, y: 0 }, distance: 0 },
radius: 16,
size: 140
});
}
return playersById.get(id);
}
function setJoystickFor(id, payload) {
const rec = ensurePlayer(id);
if (!rec) return;
const v = payload && payload.vector ? payload.vector : { x: 0, y: 0 };
const d = typeof payload?.distance === 'number' ? payload.distance : Math.max(0, Math.min(1, Math.hypot(v.x || 0, v.y || 0)));
rec.joystick = { vector: { x: Number(v.x) || 0, y: Number(v.y) || 0 }, distance: d };
}
function reset() {
playersById.clear();
eliminatedIds.clear();
}
function pause() {
isRunning = false;
if (frameId) { try { cancelAnimationFrame(frameId); } catch {} frameId = 0; }
}
function resume() {
if (isRunning) return;
isRunning = true;
if (!frameId) frameId = requestAnimationFrame(loop);
}
function resize() {
const dpr = window.devicePixelRatio || 1;
gameCanvas.width = Math.floor(innerWidth * dpr);
gameCanvas.height = Math.floor(innerHeight * dpr);
gameCanvas.style.width = innerWidth + 'px';
gameCanvas.style.height = innerHeight + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// Ensure a default player exists so the logo shows immediately
ensurePlayer('P1');
function update(dt) {
const speed = 220; // px/sec at full stick
for (const rec of playersById.values()) {
const vx = (rec.joystick?.vector?.x || 0) * speed;
const vy = (rec.joystick?.vector?.y || 0) * speed;
rec.pos.x += vx * dt;
rec.pos.y += vy * dt;
rec.pos.x = Math.max(rec.radius, Math.min(innerWidth - rec.radius, rec.pos.x));
rec.pos.y = Math.max(rec.radius, Math.min(innerHeight - rec.radius, rec.pos.y));
}
}
function render() {
ctx.clearRect(0, 0, innerWidth, innerHeight);
for (const rec of playersById.values()) {
const size = Math.max(48, Math.min(300, rec.size || 140));
const half = size / 2;
if (logoImg && logoImg.complete) {
try { ctx.drawImage(logoImg, Math.floor(rec.pos.x - half), Math.floor(rec.pos.y - half), size, size); }
catch { drawFallback(rec, size); }
} else {
drawFallback(rec, size);
}
}
}
function drawFallback(rec, size) {
ctx.fillStyle = '#38bdf8';
ctx.beginPath();
ctx.arc(rec.pos.x, rec.pos.y, Math.max(16, size * 0.35), 0, Math.PI * 2);
ctx.fill();
}
function loop(now) {
const deltaMs = now - last;
const dt = Math.min(0.05, deltaMs / 1000);
last = now;
bucketMs += deltaMs;
framesCount++;
if (bucketMs >= 1000) {
fps = framesCount;
framesCount = 0;
bucketMs = 0;
try { typeof onPerformanceUpdate === 'function' && onPerformanceUpdate({ fps, targetFps }); } catch {}
}
update(dt);
render();
if (isRunning) frameId = requestAnimationFrame(loop);
}
frameId = requestAnimationFrame(loop);
return { ensurePlayer, setJoystickFor, reset, pause, resume };
}
export default startGame;