/* ============================================================ 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 }; })();