Files
Infernal-Pinball/public/js/main.js
T
2026-09-12 01:33:07 -04:00

210 lines
6.8 KiB
JavaScript

// Entry point: game loop, keyboard input and the backbox HUD.
import { Game, PHYSICS_DT, BALLS_PER_GAME, fmt } from './game.js';
import { WIDTH, HEIGHT } from './table.js';
import { Renderer } from './render.js';
import { Sound } from './audio.js';
const HIGH_SCORE_KEY = 'neon-pinball-high-score';
function loadHighScore() {
try {
return Number(localStorage.getItem(HIGH_SCORE_KEY)) || 0;
} catch {
return 0;
}
}
function saveHighScore(value) {
try {
localStorage.setItem(HIGH_SCORE_KEY, String(value));
} catch {
// Storage can be unavailable (private mode, blocked site data); the game still works.
}
}
const canvas = document.getElementById('table');
const game = new Game({ highScore: loadHighScore() });
const renderer = new Renderer(canvas, game);
const sound = new Sound();
let paused = false;
// ---- Layout --------------------------------------------------------------------------------
const backbox = document.querySelector('.backbox');
const playfield = document.querySelector('.playfield');
// The table is tilted back in 3D (see .playfield-tilt in style.css) for a sense of depth. That
// foreshortens its ON-SCREEN height (rotateX + perspective compress the far edge) without changing its
// width (the near, bottom edge stays anchored at full width) — so .playfield is sized to the box the
// tilted result should actually occupy, while the canvas inside it is rendered taller by this measured
// factor so the tilt's own compression brings it back down to fill that box.
const TILT_HEIGHT_COMPENSATION = 1.14;
function layout() {
const narrow = window.innerWidth <= 760;
const pad = 24;
const maxHeight = window.innerHeight - pad;
const maxWidth = narrow ? window.innerWidth - pad : window.innerWidth - pad * 2 - backbox.offsetWidth;
const visualHeight = Math.max(320, Math.min(maxHeight, (maxWidth * HEIGHT) / WIDTH));
const visualWidth = (visualHeight * WIDTH) / HEIGHT;
playfield.style.width = `${visualWidth}px`;
playfield.style.height = `${visualHeight}px`;
renderer.resize(Math.floor(visualHeight * TILT_HEIGHT_COMPENSATION), window.devicePixelRatio || 1);
}
window.addEventListener('resize', layout);
layout();
// ---- Input ---------------------------------------------------------------------------------
const KEY_ACTIONS = {
KeyA: 'left',
ArrowLeft: 'left',
KeyD: 'right',
ArrowRight: 'right',
Space: 'launch',
};
// Several keys drive the same action, so track which ones are down.
const held = { left: new Set(), right: new Set(), launch: new Set() };
function releaseAll() {
for (const [action, keys] of Object.entries(held)) {
if (keys.size) {
keys.clear();
game.setInput(action, false);
}
}
}
function setPaused(value) {
if (paused === value || (value && game.state !== 'play' && game.state !== 'multiball')) return;
paused = value;
releaseAll();
}
window.addEventListener('keydown', (e) => {
sound.unlock(); // browsers only allow audio to start after a user gesture
const action = KEY_ACTIONS[e.code];
if (action) {
e.preventDefault(); // stop Space/arrow keys from scrolling the page
if (e.repeat || paused) return;
const keys = held[action];
const wasHeld = keys.size > 0;
keys.add(e.code);
if (!wasHeld) game.setInput(action, true);
return;
}
if (e.code === 'KeyP' || e.code === 'Escape') {
setPaused(!paused);
} else if (e.code === 'KeyM') {
const muted = sound.toggleMute();
game.say(muted ? 'SOUND OFF' : 'SOUND ON', '', 1.5);
}
});
window.addEventListener('keyup', (e) => {
const action = KEY_ACTIONS[e.code];
if (!action) return;
e.preventDefault();
const keys = held[action];
if (!keys.delete(e.code)) return;
if (keys.size === 0) game.setInput(action, false);
});
// Don't leave flippers stuck up when the window loses focus.
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => {
if (document.hidden) setPaused(true);
});
// ---- HUD -----------------------------------------------------------------------------------
const hud = {
score: document.getElementById('score'),
message: document.getElementById('message'),
sub: document.getElementById('sub'),
ball: document.getElementById('ball'),
bonus: document.getElementById('bonus'),
multiplier: document.getElementById('multiplier'),
high: document.getElementById('high'),
};
const shown = {};
function setText(key, value) {
if (shown[key] !== value) {
shown[key] = value;
hud[key].textContent = value;
}
}
function idleMessage() {
if (paused) return ['PAUSED', 'PRESS P TO RESUME'];
switch (game.state) {
case 'attract':
return ['PRESS SPACE', 'TO START'];
case 'gameover':
return ['GAME OVER', 'PRESS SPACE TO PLAY AGAIN'];
case 'bonus':
return ['BALL LOST', ''];
case 'multiball': {
if (game.jackpot.super) return ['SUPER JACKPOT LIT', 'SHOOT THE LOCK'];
const lit = (game.jackpot.left ? 1 : 0) + (game.jackpot.right ? 1 : 0);
return ['MULTIBALL', lit > 0 ? 'JACKPOTS LIT ON THE RAMPS' : `${game.balls.length} BALLS IN PLAY`];
}
default:
if (!game.inPlay) return ['LAUNCH!', game.skillShotLive ? 'AIM FOR THE BLINKING LANE' : 'HOLD SPACE TO PLUNGE'];
if (game.superBumpers > 0) return ['SUPER BUMPERS', `${Math.ceil(game.superBumpers)} SECONDS`];
if (game.locked > 0) return [`BALL ${game.ballNumber}`, `${game.locked}/2 LOCKED`];
return [`BALL ${game.ballNumber}`, ''];
}
}
function updateHud() {
setText('score', fmt(game.score));
const m = game.message;
const [text, sub] = !paused && m.time > 0 ? [m.text, m.sub] : idleMessage();
setText('message', text);
setText('sub', sub);
setText('ball', game.state === 'attract' ? `- / ${BALLS_PER_GAME}` : `${game.ballNumber} / ${BALLS_PER_GAME}`);
setText('bonus', fmt(game.bonus));
setText('multiplier', `${game.multiplier}X`);
setText('high', fmt(game.highScore));
}
// ---- Main loop: fixed physics timestep, interpolated rendering ------------------------------
let lastTime = null;
let accumulator = 0;
function frame(now) {
if (lastTime === null) lastTime = now;
let frameTime = (now - lastTime) / 1000;
lastTime = now;
if (frameTime > 0.25) frameTime = 0.25; // avoid a "spiral of death" after a stall
if (!paused) {
accumulator += frameTime;
while (accumulator >= PHYSICS_DT) {
game.step(PHYSICS_DT);
accumulator -= PHYSICS_DT;
}
}
for (const event of game.takeEvents()) {
sound.play(event);
renderer.onEvent(event);
if (event.type === 'gameover' && event.newHighScore) saveHighScore(game.highScore);
}
renderer.paused = paused;
renderer.render(paused ? 1 : accumulator / PHYSICS_DT, paused ? 0 : frameTime);
updateHud();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Handy for debugging from the browser console.
window.pinball = { game, renderer, sound };