diff --git a/public/js/core/engine.js b/public/js/core/engine.js new file mode 100644 index 0000000..ebb9916 --- /dev/null +++ b/public/js/core/engine.js @@ -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 }; +})(); \ No newline at end of file