Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions public/games/engine/core.js
Original file line number Diff line number Diff line change
@@ -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);
180 changes: 180 additions & 0 deletions public/games/engine/input.js
Original file line number Diff line number Diff line change
@@ -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);