// 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 };