210 lines
6.8 KiB
JavaScript
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 };
|