initial Game
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user