From ea60940b6ae71b5dfd1a581ed79a10917cc28dde Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 10:24:35 +0000 Subject: [PATCH] Add zero-dependency game engine scaffolding (loop, scenes, input) Groundwork for a self-contained games collection under public/games/: - engine/core.js: math/easing helpers, seeded RNG, localStorage-backed shared settings, DPR-aware letterboxed canvas, fixed-step (1/120s) simulation loop with spiral-of-death guard, and a scene stack that supports transparent overlay scenes for pause/dialogs. - engine/input.js: named-action bindings over keyboard, pointer, gamepad and on-screen touch controls (virtual stick + buttons), so gameplay code never touches raw key codes. No build step and no runtime dependencies; both files are plain scripts that work when served statically or opened directly. Parked here pending an architecture decision on whether the games are built on this hand-rolled base or on an external stack (Pixi/three + Rapier + Howler), which would replace most of the engine layer. Co-Authored-By: Claude --- public/games/engine/core.js | 211 +++++++++++++++++++++++++++++++++++ public/games/engine/input.js | 180 ++++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 public/games/engine/core.js create mode 100644 public/games/engine/input.js diff --git a/public/games/engine/core.js b/public/games/engine/core.js new file mode 100644 index 0000000..a393fed --- /dev/null +++ b/public/games/engine/core.js @@ -0,0 +1,211 @@ +/* Little Games Engine — core: math, RNG, storage, canvas, fixed-step loop, scenes. + * No dependencies, no build step, no assets. */ +(function (global) { + 'use strict'; + var E = global.E = global.E || {}; + + /* ---------------------------------------------------------------- math */ + var TAU = Math.PI * 2; + E.TAU = TAU; + E.clamp = function (v, a, b) { return v < a ? a : v > b ? b : v; }; + E.lerp = function (a, b, t) { return a + (b - a) * t; }; + /* frame-rate independent exponential smoothing */ + E.damp = function (a, b, lambda, dt) { return E.lerp(a, b, 1 - Math.exp(-lambda * dt)); }; + E.approach = function (a, b, d) { return a < b ? Math.min(a + d, b) : Math.max(a - d, b); }; + E.dist = function (a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }; + E.dist2 = function (ax, ay, bx, by) { var dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }; + E.wrapAngle = function (a) { return ((a + Math.PI) % TAU + TAU) % TAU - Math.PI; }; + E.angleTo = function (a, b, t) { return a + E.wrapAngle(b - a) * t; }; + E.smoothstep = function (t) { t = E.clamp(t, 0, 1); return t * t * (3 - 2 * t); }; + E.easeOut = function (t) { return 1 - Math.pow(1 - t, 3); }; + E.easeIn = function (t) { return t * t * t; }; + E.easeInOut = function (t) { return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }; + E.easeOutBack = function (t) { var c = 2.2; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); }; + E.easeOutElastic = function (t) { + if (t <= 0 || t >= 1) return E.clamp(t, 0, 1); + return Math.pow(2, -9 * t) * Math.sin((t * 10 - 0.75) * (TAU / 3)) + 1; + }; + + E.rnd = function (a, b) { return b === undefined ? Math.random() * a : a + Math.random() * (b - a); }; + E.rndi = function (a, b) { return Math.floor(E.rnd(a, b + 1)); }; + E.rndSign = function () { return Math.random() < .5 ? -1 : 1; }; + E.pick = function (arr) { return arr[(Math.random() * arr.length) | 0]; }; + E.chance = function (p) { return Math.random() < p; }; + /* deterministic stream — used for level/terrain generation */ + E.seeded = function (seed) { + var s = (seed >>> 0) || 1; + return function () { + s = s + 0x6D2B79F5 | 0; + var t = Math.imul(s ^ s >>> 15, 1 | s); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + }; + + /* ------------------------------------------------------------- storage */ + E.store = { + get: function (k, def) { + try { var v = localStorage.getItem('lg.' + k); return v == null ? def : JSON.parse(v); } + catch (e) { return def; } + }, + set: function (k, v) { try { localStorage.setItem('lg.' + k, JSON.stringify(v)); } catch (e) { } } + }; + + /* Settings are shared by every game in the collection. */ + var defaults = { master: .8, music: .6, sfx: .9, shake: 1, crt: 1, reduce: false }; + E.settings = {}; + for (var k in defaults) E.settings[k] = defaults[k]; + var saved = E.store.get('settings', {}); + for (var k2 in saved) if (k2 in defaults) E.settings[k2] = saved[k2]; + E.saveSettings = function () { + E.store.set('settings', E.settings); + if (E.audio) E.audio.applySettings(); + }; + + /* --------------------------------------------------------------- scene */ + function Scene() { } + Scene.prototype.enter = function () { }; + Scene.prototype.exit = function () { }; + Scene.prototype.update = function (dt) { }; + Scene.prototype.draw = function (g) { }; + /* when true the scene below still draws (used for pause / dialog overlays) */ + Scene.prototype.transparent = false; + E.Scene = Scene; + + /* ---------------------------------------------------------------- game */ + function Game(opts) { + opts = opts || {}; + this.W = opts.width || 960; + this.H = opts.height || 540; + this.canvas = opts.canvas || document.getElementById('c'); + this.ctx = this.canvas.getContext('2d', { alpha: false }); + this.bg = opts.background || '#05060c'; + this.scenes = []; + this.step = 1 / 120; /* fixed simulation step */ + this.acc = 0; + this.time = 0; + this.frame = 0; + this.dpr = 1; + this.scale = 1; this.ox = 0; this.oy = 0; + this.fps = 60; this._fpsAcc = 0; this._fpsN = 0; + this.blurred = false; + E.game = this; + + var self = this; + this._onResize = function () { self.resize(); }; + global.addEventListener('resize', this._onResize); + global.addEventListener('orientationchange', this._onResize); + global.addEventListener('blur', function () { self.blurred = true; }); + global.addEventListener('focus', function () { self.blurred = false; self.last = 0; }); + this.resize(); + } + + Game.prototype.resize = function () { + var cw = this.canvas.clientWidth || global.innerWidth; + var ch = this.canvas.clientHeight || global.innerHeight; + this.dpr = Math.min(global.devicePixelRatio || 1, 2); + var bw = Math.round(cw * this.dpr), bh = Math.round(ch * this.dpr); + if (this.canvas.width !== bw || this.canvas.height !== bh) { + this.canvas.width = bw; this.canvas.height = bh; + } + this.scale = Math.min(cw / this.W, ch / this.H); + this.ox = (cw - this.W * this.scale) / 2; + this.oy = (ch - this.H * this.scale) / 2; + if (E.fx) E.fx.resize(this); + }; + + /* screen (CSS px relative to canvas) -> design space */ + Game.prototype.toWorld = function (cx, cy, out) { + var r = this.canvas.getBoundingClientRect(); + out = out || {}; + out.x = (cx - r.left - this.ox) / this.scale; + out.y = (cy - r.top - this.oy) / this.scale; + return out; + }; + + Game.prototype.push = function (s) { + var top = this.scene(); + if (top && top.pause) top.pause(); + this.scenes.push(s); s.game = this; s.enter(this); + return s; + }; + Game.prototype.pop = function () { + var s = this.scenes.pop(); + if (s) s.exit(this); + var top = this.scene(); + if (top && top.resume) top.resume(); + return s; + }; + Game.prototype.replace = function (s) { + while (this.scenes.length) this.pop(); + return this.push(s); + }; + Game.prototype.scene = function () { return this.scenes[this.scenes.length - 1]; }; + + Game.prototype.start = function (scene) { + if (scene) this.replace(scene); + var self = this; + this.last = 0; + function frame(now) { + requestAnimationFrame(frame); + if (!self.last) { self.last = now; return; } + var dt = (now - self.last) / 1000; + self.last = now; + if (dt > .25) dt = .25; /* tab was hidden: don't fast-forward */ + self._fpsAcc += dt; self._fpsN++; + if (self._fpsAcc > .5) { self.fps = self._fpsN / self._fpsAcc; self._fpsAcc = 0; self._fpsN = 0; } + self.tick(dt); + self.render(); + } + requestAnimationFrame(frame); + return this; + }; + + Game.prototype.tick = function (dt) { + E.input.beginFrame(); + var top = this.scene(); + if (top) { + if (E.fx.hitstop > 0) { + E.fx.hitstop -= dt; /* freeze frames: everything holds still */ + } else { + this.acc += dt; + var steps = 0; + while (this.acc >= this.step && steps < 8) { + this.time += this.step; + top = this.scene(); + if (top) top.update(this.step); + this.acc -= this.step; steps++; + } + if (steps === 8) this.acc = 0; /* give up rather than spiral */ + } + E.fx.update(dt); + } + E.input.endFrame(); + this.frame++; + }; + + Game.prototype.render = function () { + var g = this.ctx, d = this.dpr; + g.setTransform(d, 0, 0, d, 0, 0); + g.fillStyle = '#000'; + g.fillRect(0, 0, this.canvas.width / d, this.canvas.height / d); + + /* everything below draws in design space, clipped to the 16:9 frame */ + var target = E.fx.begin(this); + var i, first = 0; + for (i = this.scenes.length - 1; i >= 0; i--) { first = i; if (!this.scenes[i].transparent) break; } + for (i = first; i < this.scenes.length; i++) this.scenes[i].draw(target, this); + E.fx.end(this); + }; + + E.Game = Game; + + /* Small helper so games can register a one-shot delayed callback. */ + E.after = function (list, t, fn) { list.push({ t: t, fn: fn }); }; + E.runTimers = function (list, dt) { + for (var i = list.length - 1; i >= 0; i--) { + list[i].t -= dt; + if (list[i].t <= 0) { var f = list[i].fn; list.splice(i, 1); f(); } + } + }; +})(window); diff --git a/public/games/engine/input.js b/public/games/engine/input.js new file mode 100644 index 0000000..4705b85 --- /dev/null +++ b/public/games/engine/input.js @@ -0,0 +1,180 @@ +/* Little Games Engine — input: keyboard, pointer, gamepad, on-screen touch pads. + * Games talk in terms of named actions, never raw key codes. */ +(function (global) { + 'use strict'; + var E = global.E; + + var I = { + keys: {}, keyDown: {}, keyUp: {}, + map: {}, /* action -> {keys:[], pad:[], axis:[i, sign]} */ + actions: {}, /* action -> {held, down, up} */ + pointers: {}, /* id -> {x,y,sx,sy,down,id} in design space */ + mouse: { x: 0, y: 0, down: false, pressed: false, released: false, wheel: 0 }, + touchMode: false, padMode: false, + stick: { x: 0, y: 0, active: false, id: null, ox: 0, oy: 0 }, + pads: [], /* on-screen buttons: {id,x,y,r,label,action} */ + anyDown: false, + _padHeld: {} + }; + E.input = I; + + function code(e) { return e.code || e.key; } + + global.addEventListener('keydown', function (e) { + if (e.repeat) return; + var c = code(e); + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab'].indexOf(c) >= 0) e.preventDefault(); + I.keys[c] = true; I.keyDown[c] = true; I.anyDown = true; + I.touchMode = false; + if (E.audio) E.audio.resume(); + }); + global.addEventListener('keyup', function (e) { var c = code(e); I.keys[c] = false; I.keyUp[c] = true; }); + + function updatePointer(e, down) { + var g = E.game; if (!g) return; + var p = I.pointers[e.pointerId] || (I.pointers[e.pointerId] = { sx: 0, sy: 0 }); + g.toWorld(e.clientX, e.clientY, p); + if (down) { p.sx = p.x; p.sy = p.y; } + p.id = e.pointerId; + p.touch = e.pointerType !== 'mouse'; + return p; + } + + var el = function () { return E.game ? E.game.canvas : document.body; }; + + global.addEventListener('pointerdown', function (e) { + var p = updatePointer(e, true); if (!p) return; + p.down = true; I.anyDown = true; + if (p.touch) I.touchMode = true; + if (!p.touch) { I.mouse.down = true; I.mouse.pressed = true; } + if (E.audio) E.audio.resume(); + /* on-screen button? */ + var hit = pickPad(p.x, p.y); + if (hit) { p.pad = hit.id; I._padHeld[hit.id] = true; } + else if (p.touch && p.x < E.game.W * .45 && !I.stick.active) { + I.stick.active = true; I.stick.id = e.pointerId; I.stick.ox = p.x; I.stick.oy = p.y; + I.stick.x = 0; I.stick.y = 0; + } + }, { passive: true }); + + global.addEventListener('pointermove', function (e) { + var p = I.pointers[e.pointerId]; + if (!p) { if (e.pointerType === 'mouse') { p = updatePointer(e, false); } else return; } + else updatePointer(e, false); + if (!p) return; + if (!p.touch) { I.mouse.x = p.x; I.mouse.y = p.y; } + if (I.stick.active && I.stick.id === e.pointerId) { + var dx = p.x - I.stick.ox, dy = p.y - I.stick.oy, len = Math.hypot(dx, dy), max = 56; + if (len > max) { dx *= max / len; dy *= max / len; len = max; } + I.stick.x = dx / max; I.stick.y = dy / max; + } + }, { passive: true }); + + function endPointer(e) { + var p = I.pointers[e.pointerId]; + if (p) { + if (p.pad) I._padHeld[p.pad] = false; + if (!p.touch) { I.mouse.down = false; I.mouse.released = true; } + delete I.pointers[e.pointerId]; + } + if (I.stick.id === e.pointerId) { I.stick.active = false; I.stick.id = null; I.stick.x = 0; I.stick.y = 0; } + } + global.addEventListener('pointerup', endPointer, { passive: true }); + global.addEventListener('pointercancel', endPointer, { passive: true }); + global.addEventListener('wheel', function (e) { I.mouse.wheel += Math.sign(e.deltaY); }, { passive: true }); + global.addEventListener('contextmenu', function (e) { if (e.target === el()) e.preventDefault(); }); + + function pickPad(x, y) { + for (var i = 0; i < I.pads.length; i++) { + var b = I.pads[i]; + if (E.dist2(x, y, b.x, b.y) < (b.r * 1.25) * (b.r * 1.25)) return b; + } + return null; + } + + /* bind({jump:{keys:['Space','KeyW'], pad:[0]}, left:{keys:['KeyA'], axis:[0,-1]}}) */ + I.bind = function (m) { + I.map = m; I.actions = {}; + for (var a in m) I.actions[a] = { held: false, down: false, up: false, value: 0 }; + return I; + }; + I.setPads = function (pads) { I.pads = pads || []; I._padHeld = {}; }; + + I.beginFrame = function () { + /* gamepad */ + var gp = null; + if (navigator.getGamepads) { + var list = navigator.getGamepads(); + for (var i = 0; i < list.length; i++) if (list[i] && list[i].connected) { gp = list[i]; break; } + } + I.gp = gp; + if (gp) { + for (var b = 0; b < gp.buttons.length; b++) if (gp.buttons[b].pressed) { I.padMode = true; I.touchMode = false; } + for (var ax = 0; ax < gp.axes.length; ax++) if (Math.abs(gp.axes[ax]) > .5) { I.padMode = true; I.touchMode = false; } + } + + for (var a in I.map) { + var def = I.map[a], st = I.actions[a], now = false, val = 0; + var keys = def.keys || []; + for (var k = 0; k < keys.length; k++) if (I.keys[keys[k]]) { now = true; val = 1; } + if (def.pad && gp) for (var p = 0; p < def.pad.length; p++) { + var btn = gp.buttons[def.pad[p]]; + if (btn && btn.pressed) { now = true; val = Math.max(val, btn.value || 1); } + } + if (def.axis && gp) { + var v = gp.axes[def.axis[0]] || 0; + if (v * def.axis[1] > .35) { now = true; val = Math.max(val, Math.abs(v)); } + } + if (def.stickX && I.stick.active) { + var sv = I.stick.x * def.stickX; + if (sv > .35) { now = true; val = Math.max(val, sv); } + } + if (def.stickY && I.stick.active) { + var sy = I.stick.y * def.stickY; + if (sy > .35) { now = true; val = Math.max(val, sy); } + } + if (def.button && I._padHeld[def.button]) { now = true; val = 1; } + st.down = now && !st.held; + st.up = !now && st.held; + st.held = now; st.value = val; + } + }; + + I.endFrame = function () { + I.keyDown = {}; I.keyUp = {}; + I.mouse.pressed = false; I.mouse.released = false; I.mouse.wheel = 0; + I.anyDown = false; + }; + + I.held = function (a) { var s = I.actions[a]; return !!(s && s.held); }; + I.down = function (a) { var s = I.actions[a]; return !!(s && s.down); }; + I.up = function (a) { var s = I.actions[a]; return !!(s && s.up); }; + I.value = function (a) { var s = I.actions[a]; return s ? s.value : 0; }; + I.axis = function (neg, pos) { return (I.held(pos) ? I.value(pos) : 0) - (I.held(neg) ? I.value(neg) : 0); }; + I.key = function (c) { return !!I.keys[c]; }; + I.keyPressed = function (c) { return !!I.keyDown[c]; }; + I.any = function () { return I.anyDown; }; + + /* Draws the virtual stick + buttons; only visible once a touch happens. */ + I.drawTouch = function (g) { + if (!I.touchMode) return; + g.save(); + if (I.stick.active) { + g.strokeStyle = 'rgba(255,255,255,.28)'; g.lineWidth = 2; + g.beginPath(); g.arc(I.stick.ox, I.stick.oy, 56, 0, E.TAU); g.stroke(); + g.fillStyle = 'rgba(255,255,255,.22)'; + g.beginPath(); g.arc(I.stick.ox + I.stick.x * 56, I.stick.oy + I.stick.y * 56, 24, 0, E.TAU); g.fill(); + } + for (var i = 0; i < I.pads.length; i++) { + var b = I.pads[i], on = I._padHeld[b.id]; + g.fillStyle = on ? 'rgba(255,255,255,.30)' : 'rgba(255,255,255,.12)'; + g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 2; + g.beginPath(); g.arc(b.x, b.y, b.r, 0, E.TAU); g.fill(); g.stroke(); + g.fillStyle = 'rgba(255,255,255,.8)'; + g.font = '600 15px ui-monospace, Menlo, Consolas, monospace'; + g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(b.label, b.x, b.y + 1); + } + g.restore(); + }; +})(window);