language: JavaScript
20.9 KB / 808 lines / 702 loc
// CURTAIN {{{
// CURTAIN }}}
// ACT1SCENE1 {{{
class ECS {
constructor() {
this.maxEntity = 0;
this.components = {};
}
addEntity(...components) {
const entity = ++this.maxEntity;
for (const c of components) {
this.attach(entity, c);
}
return entity;
}
attach(entity, component) {
(this.components[component.constructor.name] ||= {})[entity] = component;
}
}
class ComponentBounds {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
}
class ComponentWhiteBox {}
class SystemRender {
constructor(world, canvas) {
this.world = world;
this.ctx = canvas.getContext('2d');
}
render() {
const { width, height } = this.ctx.canvas;
this.ctx.clearRect(0, 0, width, height);
for (const entity in this.world.components.ComponentWhiteBox || []) {
const { x, y, width, height } = this.world.components.ComponentBounds[entity];
this.ctx.fillStyle = 'white';
this.ctx.fillRect(x, y, width, height);
}
}
}
let world;
window.onload = function() {
world = new ECS();
const canvas = document.getElementById('game');
const render = new SystemRender(world, canvas);
world.addEntity(
new ComponentBounds(100, 100, 80, 50),
new ComponentWhiteBox(),
);
render.render();
};
// ACT1SCENE1 }}}
// ACT1SCENE2 {{{
class ComponentDrag {}
class SystemInput {
constructor(world, canvas) {
this.world = world;
this.canvas = canvas;
this.transition('default');
canvas.addEventListener('mousedown', this.mousedown.bind(this));
canvas.addEventListener('mousemove', this.mousemove.bind(this));
canvas.addEventListener('mouseup', this.mouseup.bind(this));
}
transition(state, extra) {
this.state = state;
this.extra = extra;
}
localcoord(event) {
const rect = event.target.getBoundingClientRect();
// Does not account for padding;
const x = event.x - rect.left;
const y = event.y - rect.top;
const scale = this.canvas.getBoundingClientRect().height / this.canvas.height;
return { x: Math.floor(x / scale), y: Math.floor(y / scale) };
}
mousedown(event) {
const { x, y } = this.localcoord(event);
if (this.state == 'default') {
if (!event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey &&
event.button == 0) { // unmodified primary
const entity = this.hit(x, y);
this.transition('mousedown', { x, y, entity });
}
}
}
mousemove(event) {
const { x, y } = this.localcoord(event);
switch (this.state) {
case 'mousedown': { // drag
this.transition('drag', {
lastX: this.extra.x,
lastY: this.extra.y,
entity: this.extra.entity,
});
} // fallthrough
case 'drag': {
if (!event.buttons) { // stale mouse drag (outside of canvas)
this.mouseup(event);
break;
}
const dx = x - this.extra.lastX;
const dy = y - this.extra.lastY;
this.extra.lastX = x;
this.extra.lastY = y;
this.drag(dx, dy, this.extra.entity);
break;
}
}
}
mouseup(event) {
const { x, y } = this.localcoord(event);
switch (this.state) {
case 'mousedown': {
this.click(x, y, this.extra.entity);
break;
}
case 'drag': {
this.transition('default');
break;
}
}
}
click(x, y, entity) {
this.world.addEntity(
new ComponentBounds(x, y, 80, 50),
new ComponentWhiteBox(),
new ComponentDrag(),
);
}
drag(dx, dy, entity) {
if (entity in this.world.components.ComponentDrag) {
const bounds = this.world.components.ComponentBounds[entity];
bounds.x += dx;
bounds.y += dy;
}
}
hit(x, y) {
for (const entity in this.world.components.ComponentDrag || []) {
const bounds = this.world.components.ComponentBounds[entity];
if (bounds && bounds.x < x && x < bounds.x + bounds.width &&
bounds.y < y && y < bounds.y + bounds.height) {
return entity;
}
}
}
}
window.onload = function() {
world = new ECS();
const canvas = document.getElementById('game');
const render = new SystemRender(world, canvas);
new SystemInput(world, canvas);
function tick() {
render.render();
requestAnimationFrame(tick);
}
tick();
};
// ACT1SCENE2 }}}
// INTERMISSION {{{
class World extends ECS {
constructor() {
super();
this.listeners = {};
}
listen(type, listener) {
(this.listeners[type] ||= []).push(listener);
}
signal(type, data) {
for (const listener of this.listeners[type] || []) {
listener(data);
}
}
detach(entity, componentClass) {
delete this.components[componentClass.name]?.[entity];
}
destroy(entity) {
for (const c of Object.values(this.components)) {
delete c[entity];
}
}
queryComponent(componentClass) {
const components = this.components[componentClass.name] || {};
return Object.entries(components);
}
cast(entity, componentClass) {
return this.components[componentClass.name]?.[entity];
}
}
// INTERMISSION }}}
// ACT2SCENE1 {{{
class ComponentSprite {
constructor(img, scale = 1) {
this.canvas = document.createElement('canvas');
this.canvas.width = img.width * scale;
this.canvas.height = img.height * scale;
const ctx = this.canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
}
}
class SystemSpriteRender {
constructor(world, canvas) {
this.world = world;
this.ctx = canvas.getContext('2d');
this.dirty = false;
this.world.listen('recomposite', this.recomposite.bind(this));
this.recomposite();
}
recomposite() {
if (!this.dirty) {
this.dirty = true;
requestAnimationFrame(this.render.bind(this));
}
}
render() {
this.dirty = false;
const { width, height } = this.ctx.canvas;
this.ctx.clearRect(0, 0, width, height);
this.world.signal('vblank', { width, height });
for (const [entity, sprite] of this.world.queryComponent(ComponentSprite)) {
const { x, y } = this.world.cast(entity, ComponentBounds);
this.ctx.drawImage(sprite.canvas, x, y);
this.world.signal('blit', entity);
}
this.world.signal('scanout');
}
}
window.onload = function() {
world = new World();
const canvas = document.getElementById('game');
const render = new SystemSpriteRender(world, canvas);
let img = document.getElementById('flag');
world.addEntity(
new ComponentBounds(100, 100, 840, 800),
new ComponentSprite(img, 40),
new ComponentDrag(),
);
};
// ACT2SCENE1 }}}
// ACT2SCENE2 {{{
class SystemHitInput extends SystemInput {
constructor(world, canvas, hitcanvas) {
super(world, canvas);
this.hitcanvas = hitcanvas;
world.listen('vblank', this.vblank.bind(this));
world.listen('blit', this.blit.bind(this));
world.listen('scanout', this.scanout.bind(this));
}
vblank({ width, height }) {
this.hitcanvas.width = width;
this.hitcanvas.height = height;
this.hitctx = this.hitcanvas.getContext('2d', { alpha: false });
this.hitctx.clearRect(0, 0, width, height);
// Drawing must be pixel perfect (no antialiasing) for proper hit detection.
}
blit(entity) {
const { x, y, width, height } = this.world.cast(entity, ComponentBounds);
this.hitctx.fillStyle = this.swizzle(entity);
this.hitctx.fillRect(Math.floor(x), Math.floor(y), Math.ceil(width), Math.ceil(height));
}
scanout() {
this.hitmap = this.hitctx.getImageData(0, 0, this.hitcanvas.width, this.hitcanvas.height);
}
swizzle(entity) {
// Encode up to 24 bits in RGB color. Multiply for visually distinct colors.
const swizzled = (entity * 999331) % (1 << 24);
return '#' + (swizzled).toString(16).padStart(6, 0);
}
hit(x, y) {
const offset = (y * this.hitcanvas.width + x) * 4;
let swizzled = 0;
for (let i = 0; i < 3; i++ ) {
swizzled <<= 8;
swizzled += this.hitmap.data[offset + i];
}
if (!swizzled)
return undefined;
// Multiplicative inverse of swizzle() multiple.
return (swizzled * 8055819) % (1 << 24)
}
click(x, y, entity) {
if (entity) {
alert('Clicked ' + entity);
} else {
const img = document.getElementById('flag');
this.world.addEntity(
new ComponentBounds(x, y, 63, 60),
new ComponentSprite(img, 3),
new ComponentDrag(),
);
world.signal('recomposite');
}
}
}
window.onload = function() {
world = new World();
const canvas = document.getElementById('game');
const render = new SystemSpriteRender(world, canvas);
new SystemHitInput(world, canvas, document.getElementById('hit'));
};
// ACT2SCENE2 }}}
// ACT3SCENE1 {{{
class ComponentYou {}
class ComponentPush {}
class ComponentStop {}
class ComponentSink {}
class ComponentWin {}
ComponentBounds.prototype.overlaps = function(o) {
let dx;
let dy;
if (this.x <= o.x && o.x < this.x + this.width) {
dx = this.x + this.width - o.x;
}
if (this.x <= o.x + o.width && o.x + o.width < this.x + this.width) {
dx = dx ? 99 : this.x - o.x - o.width;
}
if (this.y <= o.y && o.y < this.y + this.height) {
dy = this.y + this.height - o.y;
}
if (this.y <= o.y + o.height && o.y + o.height < this.y + this.height) {
dy = dy ? 99 : this.y - o.y - o.height;
}
if (!dx || !dy) return null;
if (Math.abs(dx) < Math.abs(dy)) {
return [dx, 0];
} else {
return [0, dy];
}
}
class SystemGameInput extends SystemHitInput {
constructor(world, canvas, hitcanvas) {
super(world, canvas, hitcanvas);
this.won = false;
}
click(x, y, entity) { }
drag(dx, dy, entity) {
for (const [entity, ] of this.world.queryComponent(ComponentYou)) {
const bounds = this.world.cast(entity, ComponentBounds);
bounds.x += dx;
bounds.y += dy;
this.collide(entity);
for (const [collision, ] of this.world.queryComponent(ComponentWin)) {
if (this.world.cast(collision, ComponentBounds).overlaps(bounds)) {
this.mouseup(event);
this.world.signal('recomposite');
if (this.won) return; // hacky debounce
this.won = true;
this.world.signal('win');
return;
}
}
}
this.world.signal('recomposite');
}
collide(entity) {
const bounds = this.world.cast(entity, ComponentBounds);
let adjust = [0, 0];
for (const [collision, ] of this.world.queryComponent(ComponentSink)) {
if (this.world.cast(collision, ComponentBounds).overlaps(bounds)) {
this.world.destroy(entity);
this.world.destroy(collision);
return adjust;
}
}
for (const [collision, ] of this.world.queryComponent(ComponentStop)) {
const overlap = this.world.cast(collision, ComponentBounds).overlaps(bounds);
if (overlap) {
adjust[0] += overlap[0];
adjust[1] += overlap[1];
}
}
bounds.x += adjust[0];
bounds.y += adjust[1];
for (const [collision, ] of this.world.queryComponent(ComponentPush)) {
if (collision == entity) continue;
const pushed = this.world.cast(collision, ComponentBounds);
const overlap = pushed.overlaps(bounds);
if (overlap) {
pushed.x -= overlap[0];
pushed.y -= overlap[1];
const resist = this.collide(collision);
bounds.x += resist[0];
bounds.y += resist[1];
adjust[0] += resist[0];
adjust[1] += resist[1];
}
}
return adjust;
}
}
function addSprite(r, c, asset, ...components) {
const scale = 3;
const x = c * 24 * scale;
const y = r * 24 * scale;
const img = document.getElementById(asset);
const entity = world.addEntity(
new ComponentBounds(x, y, scale * img.width, scale * img.height),
new ComponentSprite(img, scale),
...components,
);
world.signal('recomposite');
return entity;
}
window.onload = function() {
const canvas = document.getElementById('game');
world = new World();
new SystemSpriteRender(world, canvas);
new SystemGameInput(world, canvas, document.getElementById('hit'));
addSprite(2, 5, 'rock', new ComponentPush());
addSprite(2, 9, 'wall', new ComponentStop());
addSprite(6, 5, 'water', new ComponentSink());
addSprite(6, 9, 'flag', new ComponentWin());
addSprite(4, 7, 'baba', new ComponentYou());
world.listen('win', () => alert('you win!'));
};
// ACT3SCENE1 }}}
// ACT3SCENE2 {{{
class ComponentNoun {
constructor(noun) {
this.noun = noun;
}
}
class ComponentIs {}
class ComponentVerb {
constructor(componentClass) {
this.componentClass = componentClass;
}
}
class ComponentSubject {
constructor(noun) {
this.noun = noun;
}
}
function addWord(r, c, asset, ...components) {
return addSprite(r, c, asset, new ComponentPush(), ...components);
}
function addNoun(r, c, noun) {
return addSprite(r, c, 'noun-' + noun,
new ComponentPush(),
new ComponentNoun(noun));
}
function addIs(r, c) {
return addSprite(r, c, 'is',
new ComponentPush(),
new ComponentIs());
}
function addVerb(r, c, verb) {
const capitalized = verb[0].toUpperCase() + verb.slice(1);
return addSprite(r, c, 'verb-' + verb,
new ComponentPush(),
new ComponentVerb(eval('Component' + capitalized)));
}
function addSubject(r, c, noun) {
return addSprite(r, c, noun, new ComponentSubject(noun));
}
class SystemBabaInput extends SystemGameInput {
constructor(world, canvas, hitcanvas) {
super(world, canvas, hitcanvas);
this.rules = [];
world.listen('scanout', this.attachDynamicComponents.bind(this));
}
attachDynamicComponents() {
// Clear all rules
for (const [entity, ] of this.world.queryComponent(ComponentSubject)) {
this.world.detach(entity, ComponentYou);
this.world.detach(entity, ComponentWin);
this.world.detach(entity, ComponentStop);
this.world.detach(entity, ComponentPush);
this.world.detach(entity, ComponentSink);
}
this.rules = [];
// Find active rules
for (const [entity, ] of this.world.queryComponent(ComponentIs)) {
const { x, y, width, height } = this.world.cast(entity, ComponentBounds);
// 10px of slop (pushing can jitter a mousemove delta).
for (const [[x1, y1], [x2, y2]] of [[[x-10, y + height/2], [x+width+9, y + height/2]],
[[x + width/2, y-10], [x + width/2, y+height+9]]]) {
const before = this.hit(x1, y1);
const after = this.hit(x2, y2);
const noun = this.world.cast(before, ComponentNoun);
const verb = this.world.cast(after, ComponentVerb);
if (noun && verb) {
this.rules.push([noun.noun, verb.componentClass]);
}
}
}
// Attach components (verbs) to entities (nouns)
let debugString = [];
for (const [wordNoun, componentClass] of this.rules) {
for (const [entity, { noun }] of this.world.queryComponent(ComponentSubject)) {
if (wordNoun == noun) {
this.world.attach(entity, new componentClass());
}
}
debugString.push(`${wordNoun} attached to ${componentClass.name}`);
}
document.getElementById('rules').innerHTML = debugString.join('<br>');
}
}
window.onload = function() {
const canvas = document.getElementById('game');
world = new World();
new SystemSpriteRender(world, canvas);
new SystemBabaInput(world, canvas, document.getElementById('hit'));
addSubject(2, 5, 'rock');
addSubject(2, 9, 'wall');
addSubject(6, 5, 'water');
addSubject(6, 9, 'flag');
addSubject(4, 7, 'baba');
addNoun(0, 0, 'baba');
addIs(0, 1);
addVerb(0, 2, 'you');
addNoun(3, 0, 'rock');
addIs(3, 1);
addVerb(3, 2, 'push');
addNoun(4, 0, 'wall');
addIs(4, 1);
addVerb(4, 2, 'stop');
addNoun(5, 0, 'water');
addIs(5, 1);
addVerb(5, 2, 'sink');
addNoun(6, 0, 'flag');
addIs(6, 1);
addVerb(6, 2, 'win');
world.listen('win', () => alert('dynamical!'));
};
// ACT3SCENE2 }}}
// FINALE {{{
// First letters map to objects, except wAter (A), stop (H for "halt"),
// wiN (N), and sink (K for "kill").
let levels = [
[
' ',
' wwwwwwwwwwww ',
' w w ',
' w I w ',
' w N w ',
' w F f w ',
' w w ',
' wwwwwwwwwwww ',
' w w ',
' B w W w ',
' I w I b w ',
' Y w H w ',
' w w ',
' wwwwwwwwww ',
' ',
],
[
'BWw ',
'IIw wwwwwwwww ',
'YHw w w ',
'www w b r w ',
' Aw w ',
' Iw r w ',
' Kw w ',
' wwwwaaawwwwwww ',
' w w w ',
' w w RIP w ',
' waaa w w ',
' waaa w FIN w ',
' wfaa w w ',
' wwwwwwwwwwwwww ',
' ',
],
[
'RIH r a ',
' r wwwawwwwww ',
'AIK r w a w w ',
' r w a f w ',
'FIN r w a w w ',
' r wb awwwwww ',
'rrrrr w aaaaaaaa',
' w w ',
' wwww w ',
' w w B ',
' w WIHw I ',
' w w Y ',
' wwwwwwwww r ',
' ',
' ',
],
[
' ',
' ',
' ',
' ',
' ww w w ',
' w w w w w w ',
' w w w w w w ',
' ww www www ',
' w w w w w w ',
' w w w w w w ',
' ww w w w w ',
' ',
' ',
' ',
' ',
],
];
let winText = [
'qngn cvcryvarf!',
'evyrl vf lbh!',
'onoxn vf lbh!',
];
function loadLevel(next) {
if (next) {
levels.shift();
alert(rot13(winText.shift()));
}
world = new World();
const canvas = document.getElementById('game');
new SystemSpriteRender(world, canvas);
new SystemBabaInput(world, canvas, document.getElementById('hit'));
world.listen('win', () => loadLevel(true));
let babaPos;
const level = levels[0];
for (let row = 0; row < level.length; row++) {
for (let col = 0; col < level[row].length; col++) {
switch (level[row][col]) {
case ' ': break;
case 'b': babaPos = [row, col]; break;
case 'r': addSubject(row, col, 'rock'); break;
case 'w': addSubject(row, col, 'wall'); break;
case 'a': addSubject(row, col, 'water'); break;
case 'f': addSubject(row, col, 'flag'); break;
case 'B': addNoun(row, col, 'baba'); break;
case 'R': addNoun(row, col, 'rock'); break;
case 'W': addNoun(row, col, 'wall'); break;
case 'A': addNoun(row, col, 'water'); break;
case 'F': addNoun(row, col, 'flag'); break;
case 'I': addIs(row, col); break;
case 'Y': addVerb(row, col, 'you'); break;
case 'P': addVerb(row, col, 'push'); break;
case 'H': addVerb(row, col, 'stop'); break;
case 'K': addVerb(row, col, 'sink'); break;
case 'N': addVerb(row, col, 'win'); break;
}
}
}
// Kludge to add baba last so he stacks on top.
if (babaPos)
addSubject(...babaPos, 'baba');
}
window.onload = function() {
loadLevel(false);
}
// FINALE }}}
// POSTMATTER {{{
function rot13(s) {
let r = [];
for (const c of s) {
r.push(c < 'a' ? c : String.fromCharCode((c.charCodeAt(0) - 97 + 13) % 26 + 97));
}
return r.join('');
}
function yourImage(src) {
const img = document.createElement('img');
img.src = src;
img.addEventListener('load', () => {
for (const entity in world.components.ComponentYou) {
world.attach(entity, new ComponentSprite(img));
}
world.signal('recomposite');
});
}
function babka() {
for (const [entity, subject] of world.queryComponent(ComponentSubject)) {
let standin;
switch (subject.noun) {
case 'baba': standin = 'babka'; break;
case 'rock': standin = 'scone'; break;
case 'wall': standin = 'tin'; break;
case 'water': standin = 'cup'; break;
case 'flag': standin = 'flour'; break;
}
const img = document.createElement('img');
img.src = standin + '.png';
img.addEventListener('load', () => {
world.attach(entity, new ComponentSprite(img));
world.signal('recomposite');
});
}
}
document.addEventListener('keydown', e => {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
switch (e.key) {
case 'r': { loadLevel(false); break; }
case 'w': { loadLevel(true); break; }
case 's': { const x = alert; alert = console.log; loadLevel(true); alert = x; break; }
case 'h': { document.body.classList.toggle('hit'); break; }
case 'l': { document.body.classList.toggle('rules'); break; }
case 'b': { yourImage('babka.png'); break; }
case 'n': { loadLevel(false); babka(); break; }
}
});
// set fdm=marker foldtext=v:folddashes.substitute(getline(v:foldstart),'//\ ','','')
// POSTMATTER }}}