Files
space-arcade/public/js/core/engine.js
T

253 lines
8.6 KiB
JavaScript

/* ============================================================
DSP ARCADE — shared core: engine, input, particles, highscores
============================================================ */
(function () {
'use strict';
// ---------- Canvas helpers ----------
function fitCanvas(canvas, w, h) {
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
return ctx;
}
// ---------- Input ----------
class Input {
constructor() {
this.keys = {};
this.pressed = {};
this.mouse = { x: 0, y: 0, down: false, clicked: false };
window.addEventListener('keydown', (e) => {
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
if (!this.keys[e.code]) this.pressed[e.code] = true;
this.keys[e.code] = true;
});
window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
window.addEventListener('blur', () => { this.keys = {}; });
window.addEventListener('mousemove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
});
window.addEventListener('mousedown', (e) => {
if (!this.mouse.down) this.mouse.clicked = true;
this.mouse.down = true;
});
window.addEventListener('mouseup', () => { this.mouse.down = false; });
}
canvasPos(canvas) {
const r = canvas.getBoundingClientRect();
return {
x: (this.mouse.x - r.left) * (canvas.width / r.width),
y: (this.mouse.y - r.top) * (canvas.height / r.height),
};
}
down(...codes) { return codes.some(c => this.keys[c]); }
wasPressed(...codes) { return codes.some(c => this.pressed[c]); }
mouseClicked() { return this.mouse.clicked; }
endFrame() { this.pressed = {}; this.mouse.clicked = false; }
}
// ---------- Particles ----------
class Particles {
constructor() { this.list = []; }
burst(x, y, color, n = 12, speed = 2.5, life = 30) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const s = speed * (0.4 + Math.random() * 0.8);
this.list.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life, maxLife: life, color, size: 2 + Math.random() * 2 });
}
}
update() {
for (let i = this.list.length - 1; i >= 0; i--) {
const p = this.list[i];
p.x += p.vx; p.y += p.vy; p.vx *= 0.96; p.vy *= 0.96;
if (--p.life <= 0) this.list.splice(i, 1);
}
}
draw(ctx) {
for (const p of this.list) {
ctx.globalAlpha = p.life / p.maxLife;
ctx.fillStyle = p.color;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
}
ctx.globalAlpha = 1;
}
clear() { this.list = []; }
}
// ---------- Screen shake ----------
class Shake {
constructor() { this.t = 0; this.mag = 0; }
trigger(mag, t) { this.mag = Math.max(this.mag, mag); this.t = Math.max(this.t, t); }
offset() {
if (this.t <= 0) return [0, 0];
this.t--;
const m = this.mag * (this.t / 20);
return [(Math.random() - 0.5) * 2 * m, (Math.random() - 0.5) * 2 * m];
}
}
// ---------- High scores ----------
const HighScores = {
key(game) { return 'dsp_hs_' + game; },
load(game) {
try { return JSON.parse(localStorage.getItem(this.key(game))) || []; }
catch { return []; }
},
save(game, list) {
localStorage.setItem(this.key(game), JSON.stringify(list.slice(0, 10)));
},
submit(game, score, name) {
const list = this.load(game);
list.push({ score, name: (name || '???').toUpperCase().slice(0, 8), date: Date.now() });
list.sort((a, b) => b.score - a.score);
const trimmed = list.slice(0, 10);
this.save(game, trimmed);
const rank = trimmed.findIndex(e => e.date === list.find(e => e.score === score && e.name === (name || '???').toUpperCase().slice(0, 8)).date);
return rank >= 0 ? rank + 1 : null;
},
best(game) { const l = this.load(game); return l.length ? l[0].score : 0; }
};
// ---------- Game state machine ----------
const STATE = { LOADING: 0, TITLE: 1, PLAYING: 2, PAUSED: 3, GAMEOVER: 4, HIGHSCORE: 5 };
// ---------- Base game ----------
class Game {
constructor(opts) {
this.name = opts.name;
this.title = opts.title;
this.W = opts.width; this.H = opts.height;
this.canvas = opts.canvas;
this.ctx = fitCanvas(this.canvas, this.W, this.H);
this.input = new Input();
this.particles = new Particles();
this.shake = new Shake();
this.state = STATE.LOADING;
this.score = 0;
this.lives = 3;
this.level = 1;
this.best = HighScores.best(this.name);
this.loadProgress = 0;
this.loadStep = 0;
this.loadSteps = opts.loadSteps || ['LOADING SPRITES', 'COMPILING WAVEFORMS', 'CHARGING REACTOR', 'SYNCING GRID'];
this.time = 0;
this.gameOverRank = null;
this.onGameOver = opts.onGameOver || null;
this._loop = this._loop.bind(this);
this._last = 0;
this._acc = 0;
this.step = 1000 / 60;
requestAnimationFrame(this._loop);
}
start() {
this.score = 0; this.lives = 3; this.level = 1;
this.particles.clear();
this.state = STATE.PLAYING;
this.reset();
}
reset() {}
update() {}
render() {}
_loop(ts) {
requestAnimationFrame(this._loop);
if (!this._last) this._last = ts;
let dt = ts - this._last;
this._last = ts;
if (dt > 100) dt = 100;
this._acc += dt;
while (this._acc >= this.step) {
this.tick();
this._acc -= this.step;
}
this.draw();
this.input.endFrame();
}
tick() {
this.time++;
switch (this.state) {
case STATE.LOADING:
this.loadProgress += 0.012 + Math.random() * 0.01;
const stepIdx = Math.floor(this.loadProgress * this.loadSteps.length);
if (stepIdx !== this.loadStep) this.loadStep = stepIdx;
if (this.loadProgress >= 1) { this.loadProgress = 1; this.state = STATE.TITLE; }
break;
case STATE.TITLE:
if (this.input.wasPressed('Space', 'Enter')) {
if (window.DSP_AUDIO) window.DSP_AUDIO.sfx('start');
this.start();
}
break;
case STATE.PLAYING:
if (this.input.wasPressed('KeyP')) { this.state = STATE.PAUSED; }
else if (this.input.wasPressed('Escape')) { this.onMouseCaptureRelease(); }
this.update();
this.particles.update();
break;
case STATE.PAUSED:
if (this.input.wasPressed('KeyP', 'Escape', 'Space')) this.state = STATE.PLAYING;
break;
case STATE.GAMEOVER:
this.particles.update();
if (this.input.wasPressed('Space', 'Enter')) {
if (this.gameOverRank === null) {
this.state = STATE.HIGHSCORE;
} else {
this.state = STATE.TITLE;
}
}
break;
case STATE.HIGHSCORE:
if (this.input.wasPressed('Enter')) {
const name = (this._hsName || 'AAA');
this.gameOverRank = HighScores.submit(this.name, this.score, name);
this.best = HighScores.best(this.name);
this.state = STATE.GAMEOVER;
}
break;
}
}
draw() {
const ctx = this.ctx;
const [sx, sy] = this.shake.offset();
ctx.save();
ctx.translate(sx, sy);
this.render();
ctx.restore();
this.particles.draw(ctx);
this.drawCRT(ctx);
this.drawOverlay(ctx);
}
drawCRT(ctx) {
ctx.globalAlpha = 0.08;
ctx.fillStyle = '#000';
for (let y = 0; y < this.H; y += 3) ctx.fillRect(0, y, this.W, 1);
ctx.globalAlpha = 1;
const g = ctx.createRadialGradient(this.W/2, this.H/2, this.H*0.45, this.W/2, this.H/2, this.H*0.85);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, this.W, this.H);
}
drawOverlay(ctx) {
if (window.DSP_UI) window.DSP_UI.draw(this);
}
gameOver() {
this.state = STATE.GAMEOVER;
this.shake.trigger(8, 30);
if (window.DSP_AUDIO) window.DSP_AUDIO.sfx('gameover');
if (this.score > 0 && this.score >= this.best) {
this.gameOverRank = null;
} else {
this.gameOverRank = -1;
}
if (this.onGameOver) this.onGameOver(this.score);
}
addScore(n) { this.score += n; }
onMouseCaptureRelease() {}
}
window.DSP = { Game, STATE, Input, Particles, Shake, HighScores, fitCanvas };
})();