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