Add game engine core (state machine, input, particles, shake, highscores)
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/* ============================================================
|
||||
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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user