253 lines
8.6 KiB
JavaScript
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 };
|
|
})(); |