module-based literate programming

This commit is contained in:
liquidex 2024-02-17 14:56:17 +01:00
parent 9ef9d57f13
commit b9218c8ace
7 changed files with 183 additions and 106 deletions

View file

@ -270,38 +270,42 @@ styles = ["tairu.css"]
% id = "01HPQCCV4R557T2SN7ES7Z4EJ7" % id = "01HPQCCV4R557T2SN7ES7Z4EJ7"
- we can verify this logic with a bit of code; with a bit of luck, we should be able to narrow down our tileset into something a lot more manageable. - we can verify this logic with a bit of code; with a bit of luck, we should be able to narrow down our tileset into something a lot more manageable.
% id = "01HPSY4Y19NQ6DZN10BP1KQEZN"
+ we'll start off by defining a bunch of variables to represent our ordinal directions: + we'll start off by defining a bunch of variables to represent our ordinal directions:
```javascript ordinal-directions ```javascript ordinal-directions
const E = 0b00000001; export const E = 0b0000_0001;
const SE = 0b00000010; export const SE = 0b0000_0010;
const S = 0b00000100; export const S = 0b0000_0100;
const SW = 0b00001000; export const SW = 0b0000_1000;
const W = 0b00010000; export const W = 0b0001_0000;
const NW = 0b00100000; export const NW = 0b0010_0000;
const N = 0b01000000; export const N = 0b0100_0000;
const NE = 0b10000000; export const NE = 0b1000_0000;
const ALL = E | SE | S | SW | W | NW | N | NE; export const ALL = E | SE | S | SW | W | NW | N | NE;
``` ```
as I've already said, we represent each direction using a single bit. as I've already said, we represent each direction using a single bit.
% id = "01HPSY4Y19AW70YX8PPA7AS4DH"
- I'm using JavaScript by the way, because it's the native programming language of your web browser. read on to the end of this tangent to see why. - I'm using JavaScript by the way, because it's the native programming language of your web browser. read on to the end of this tangent to see why.
% id = "01HPSY4Y19HPNXC54VP6TFFHXN"
- now I don't know about you, but I find the usual C-style way of checking whether a bit is set extremely hard to read, so let's take care of that: - now I don't know about you, but I find the usual C-style way of checking whether a bit is set extremely hard to read, so let's take care of that:
```javascript ordinal-directions ```javascript ordinal-directions
function isSet(integer, bit) { export function isSet(integer, bit) {
return (integer & bit) == bit; return (integer & bit) == bit;
} }
``` ```
% id = "01HPSY4Y1984H2FX6QY6K2KHKF"
- now we can write a function that will remove the aforementioned redundancies. - now we can write a function that will remove the aforementioned redundancies.
the logic is quite simple - for southeast, we only allow it to be set if both south and east are also set, and so on and so forth. the logic is quite simple - for southeast, we only allow it to be set if both south and east are also set, and so on and so forth.
```javascript ordinal-directions ```javascript ordinal-directions
// t is a tile index; variable name is short for brevity // t is a tile index; variable name is short for brevity
function removeRedundancies(t) { export function removeRedundancies(t) {
if (isSet(t, SE) && (!isSet(t, S) || !isSet(t, E))) { if (isSet(t, SE) && (!isSet(t, S) || !isSet(t, E))) {
t &= ~SE; t &= ~SE;
} }
@ -318,10 +322,11 @@ styles = ["tairu.css"]
} }
``` ```
% id = "01HPSY4Y19HWQQ9XBW1DDGW68T"
- with that, we can find a set of all unique non-redundant combinations: - with that, we can find a set of all unique non-redundant combinations:
```javascript ordinal-directions ```javascript ordinal-directions
function ordinalDirections() { export function ordinalDirections() {
let unique = new Set(); let unique = new Set();
for (let i = 0; i <= ALL; ++i) { for (let i = 0; i <= ALL; ++i) {
unique.add(removeRedundancies(i)); unique.add(removeRedundancies(i));
@ -330,11 +335,13 @@ styles = ["tairu.css"]
} }
``` ```
% id = "01HPSY4Y19KG8DC4SYXR1DJJ5F"
- by the way, I find it quite funny how JavaScript's [`Array.prototype.sort`] defaults to ASCII ordering *for all types.* - by the way, I find it quite funny how JavaScript's [`Array.prototype.sort`] defaults to ASCII ordering *for all types.*
even numbers! ain't that silly? even numbers! ain't that silly?
[`Array.prototype.sort`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort [`Array.prototype.sort`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
% id = "01HPSY4Y19V62YKTGK3TTKEB38"
- and now it's time to _Let It Cook™_: - and now it's time to _Let It Cook™_:
```javascript ordinal-directions ```javascript ordinal-directions
@ -346,18 +353,62 @@ styles = ["tairu.css"]
47 47
``` ```
% id = "01HPSY4Y194DYYDGSAT83MPQFR"
- forty seven! that's how many unique tiles we actually need. - forty seven! that's how many unique tiles we actually need.
% id = "01HPSY4Y19C303Z595KNVXYYVS"
- you may find pixel art tutorials saying you need forty *eight* and not forty *seven*, but that is not quite correct - - you may find pixel art tutorials saying you need forty *eight* and not forty *seven*, but that is not quite correct -
the forty eighth tile is actually just the empty tile! saying it's part of the tileset is quite misleading IMO. the forty eighth tile is actually just the empty tile! saying it's part of the tileset is quite misleading IMO.
% id = "01HPSY4Y19TM2K2WN06HHEM3D0"
- phew... the nesting's getting quite unwieldy, let's wrap up this tangent and return back to doing some bitwise autotiling! - phew... the nesting's getting quite unwieldy, let's wrap up this tangent and return back to doing some bitwise autotiling!
% id = "01HPSY4Y192FZ37K3KXZM90K9J"
- so in reality we actually only need 47 tiles and not 256 - that's a whole lot less, that's 81.640625% less tiles we have to draw! - so in reality we actually only need 47 tiles and not 256 - that's a whole lot less, that's 81.640625% less tiles we have to draw!
% id = "01HPSY4Y19HEBWBTNMDMM0AZSC"
- and it's even possible to autogenerate most of them given just a few smaller 4x4 pieces - but for now, let's not go down that path.\ - and it's even possible to autogenerate most of them given just a few smaller 4x4 pieces - but for now, let's not go down that path.\
maybe another time. maybe another time.
- so we only need to draw 47 tiles, but to actually display them in a game we still need to pack them into an image.
- we *could* use a similar approach to the 16 tile version, but that would leave us with lots of wasted space!
- think that with this redundancy elimination approach most of the tiles will never even be looked up by the renderer, because the bit combinations will be collapsed into a more canonical form before the lookup.
- we could also use the approach I mentioned briefly [here][branch:01HPQCCV4RB65D5Q4RANJKGC0D], which involves introducing a lookup table - which sounds reasonable, so let's do it!
- I don't want to write the lookup table by hand, so let's generate it! I'll reuse the redundancy elimination code from before to make this easier.
- we'll start by obtaining our ordinal directions array again:
```javascript ordinal-directions
export let xToConnectionBitSet = ordinalDirections();
```
- then we'll turn that array upside down... in other words, invert the index-value relationship, so that we can look up which X position in the tile strip to use for a specific connection combination.
remember that our array has only 256 values, so it should be pretty cheap to represent using a `Uint8Array`:
```javascript ordinal-directions
export let connectionBitSetToX = new Uint8Array(256);
for (let i = 0; i < xToConnectionBitSet.length; ++i) {
connectionBitSetToX[xToConnectionBitSet[i]] = i;
}
```
- and there we go! we now have a mapping from our bitset to positions within the tile strip. try to play around with the code example to see which bitsets correspond to which position!
```javascript ordinal-directions
console.log(connectionBitSetToX[E | SE | S]);
```
```output ordinal-directions
4
```
TODO: The value from the previous output should not leak into this one. how do we do this? do we emit extra `pushMessage` calls inbetween the editors so that they know when to end?
maybe use a `classic` context instead of a module? or maybe have a way of sharing data between outputs? (return value?)
% id = "01HPD4XQPWT9N8X9BD9GKWD78F" % id = "01HPD4XQPWT9N8X9BD9GKWD78F"
- bitwise autotiling is a really cool technique that I've used in plenty of games in the past. - bitwise autotiling is a really cool technique that I've used in plenty of games in the past.
@ -423,6 +474,7 @@ styles = ["tairu.css"]
% id = "01HPD4XQPWP847T0EAM0FJ88T4" % id = "01HPD4XQPWP847T0EAM0FJ88T4"
- then vines - then vines
% id = "01HPSY4Y19FA2HGYE4F3Y9NJ57"
- well... it's even simpler than that in terms of graphical presentation, but we'll get to that. - well... it's even simpler than that in terms of graphical presentation, but we'll get to that.
% id = "01HPD4XQPWK58Z63X6962STADR" % id = "01HPD4XQPWK58Z63X6962STADR"

View file

@ -240,8 +240,12 @@ where
program_name, program_name,
} => { } => {
self.write(match kind { self.write(match kind {
LiterateCodeKind::Input => "<th-literate-editor ", LiterateCodeKind::Input => {
LiterateCodeKind::Output => "<th-literate-output ", "<th-literate-program data-mode=\"input\" "
}
LiterateCodeKind::Output => {
"<th-literate-program data-mode=\"output\" "
}
})?; })?;
self.write("data-program=\"")?; self.write("data-program=\"")?;
escape_html(&mut self.writer, program_name)?; escape_html(&mut self.writer, program_name)?;
@ -368,14 +372,7 @@ where
Tag::CodeBlock(kind) => { Tag::CodeBlock(kind) => {
self.write(match kind { self.write(match kind {
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) { CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
CodeBlockMode::LiterateProgram { CodeBlockMode::LiterateProgram { .. } => "</th-literate-program>",
kind: LiterateCodeKind::Input,
..
} => "</th-literate-editor>",
CodeBlockMode::LiterateProgram {
kind: LiterateCodeKind::Output,
..
} => "</th-literate-output>",
_ => "</code></pre>", _ => "</code></pre>",
}, },
_ => "</code></pre>\n", _ => "</code></pre>\n",

View file

@ -171,8 +171,7 @@ h4 {
pre, pre,
code, code,
kbd, kbd,
th-literate-editor, th-literate-program {
th-literate-output {
--recursive-mono: 1.0; --recursive-mono: 1.0;
--recursive-casl: 0.0; --recursive-casl: 0.0;
--recursive-slnt: 0.0; --recursive-slnt: 0.0;
@ -213,13 +212,13 @@ body {
/* Make code examples a little prettier by giving them visual separation from the rest of the page */ /* Make code examples a little prettier by giving them visual separation from the rest of the page */
code, code,
th-literate-editor { th-literate-program {
padding: 3px 4px; padding: 3px 4px;
background-color: var(--shaded-background); background-color: var(--shaded-background);
border-radius: 4px; border-radius: 4px;
} }
th-literate-editor, th-literate-program,
th-literate-output { th-literate-output {
display: block; display: block;
} }
@ -231,8 +230,7 @@ kbd {
} }
pre, pre,
th-literate-editor, th-literate-program {
th-literate-output {
padding: 8px 12px; padding: 8px 12px;
margin: 12px 0; margin: 12px 0;
background-color: var(--shaded-against-background); background-color: var(--shaded-against-background);
@ -241,22 +239,20 @@ th-literate-output {
transition: background-color var(--transition-duration); transition: background-color var(--transition-duration);
} }
th-literate-editor, th-literate-program {
th-literate-output {
white-space: pre; white-space: pre;
} }
.tree summary:hover { .tree summary:hover {
& pre, & pre,
& th-literate-editor, & th-literate-program {
& th-literate-output {
background-color: var(--shaded-against-background-twice); background-color: var(--shaded-against-background-twice);
} }
} }
pre>code, pre>code,
th-literate-output>code { th-literate-program>code {
padding: 0; padding: 0;
background: none; background: none;
border-radius: 0px; border-radius: 0px;
@ -518,13 +514,13 @@ img[is="th-emoji"] {
/* Literate programming support */ /* Literate programming support */
th-literate-editor { th-literate-program[data-mode="input"] {
/* Override the cursor with an I-beam, because the editor captures clicks and does not bubble /* Override the cursor with an I-beam, because the editor captures clicks and does not bubble
them back up to the caller */ them back up to the caller */
cursor: text; cursor: text;
} }
th-literate-output { th-literate-program[data-mode="output"] {
position: relative; position: relative;
& code { & code {

View file

@ -5,74 +5,68 @@ let literatePrograms = new Map();
function getLiterateProgram(name) { function getLiterateProgram(name) {
if (literatePrograms.get(name) == null) { if (literatePrograms.get(name) == null) {
literatePrograms.set(name, { literatePrograms.set(name, {
editors: [], frames: [],
onChanged: [], onChanged: [],
outputCount: 0,
nextOutputIndex() {
let index = this.outputCount;
++this.outputCount;
return index;
}
}); });
} }
return literatePrograms.get(name); return literatePrograms.get(name);
} }
function getLiterateProgramSourceCode(name) { function getLiterateProgramWorkerCommands(name) {
let sources = []; let commands = [];
let literateProgram = getLiterateProgram(name); let literateProgram = getLiterateProgram(name);
for (let editor of literateProgram.editors) { for (let frame of literateProgram.frames) {
sources.push(editor.textContent); if (frame.mode == "input") {
commands.push({ kind: "module", source: frame.textContent });
} else if (frame.mode == "output") {
commands.push({ kind: "output", expected: frame.textContent });
}
} }
return sources.join("\n"); return commands;
} }
class LiterateEditor extends HTMLElement { class InputMode {
constructor() { constructor(frame) {
super(); this.frame = frame;
}
connectedCallback() { this.codeJar = CodeJar(frame, InputMode.highlight);
this.literateProgramName = this.getAttribute("data-program");
getLiterateProgram(this.literateProgramName).editors.push(this);
this.codeJar = CodeJar(this, LiterateEditor.highlight);
this.codeJar.onUpdate(() => { this.codeJar.onUpdate(() => {
let literateProgram = getLiterateProgram(this.literateProgramName); for (let handler of frame.program.onChanged) {
for (let handler of literateProgram.onChanged) { handler(frame.programName);
handler(this.literateProgramName);
} }
}) })
this.addEventListener("click", event => event.preventDefault()); frame.addEventListener("click", event => event.preventDefault());
} }
static highlight(editor) { static highlight(frame) {
// TODO: Syntax highlighting // TODO: Syntax highlighting
} }
} }
customElements.define("th-literate-editor", LiterateEditor); class OutputMode {
constructor(frame) {
function debounce(callback, timeout) {
let timeoutId = 0;
return (...args) => {
clearTimeout(timeout);
timeoutId = window.setTimeout(() => callback(...args), timeout);
};
}
class LiterateOutput extends HTMLElement {
constructor() {
super();
this.clearResultsOnNextOutput = false; this.clearResultsOnNextOutput = false;
}
connectedCallback() { this.frame = frame;
this.literateProgramName = this.getAttribute("data-program");
this.frame.program.onChanged.push(_ => this.evaluate());
this.outputIndex = this.frame.program.nextOutputIndex();
this.evaluate(); this.evaluate();
getLiterateProgram(this.literateProgramName).onChanged.push(_ => this.evaluate());
} }
evaluate = () => { evaluate() {
// This is a small bit of debouncing. If we cleared the output right away, the page would // This is a small bit of debouncing. If we cleared the output right away, the page would
// jitter around irritatingly // jitter around irritatingly.
this.clearResultsOnNextOutput = true; this.clearResultsOnNextOutput = true;
if (this.worker != null) { if (this.worker != null) {
@ -80,23 +74,23 @@ class LiterateOutput extends HTMLElement {
} }
this.worker = new Worker(`${TREEHOUSE_SITE}/static/js/components/literate-programming/worker.js`, { this.worker = new Worker(`${TREEHOUSE_SITE}/static/js/components/literate-programming/worker.js`, {
type: "module", type: "module",
name: `evaluate LiterateOutput ${this.literateProgramName}` name: `evaluate LiterateOutput ${this.frame.programName}`
}); });
this.worker.addEventListener("message", event => { this.worker.addEventListener("message", event => {
let message = event.data; let message = event.data;
if (message.kind == "evalComplete") { if (message.kind == "evalComplete") {
this.worker.terminate(); this.worker.terminate();
} else if (message.kind == "output") { } else if (message.kind == "output" && message.outputIndex == this.outputIndex) {
this.addOutput(message.output); this.addOutput(message.output);
} }
}); });
this.worker.postMessage({ this.worker.postMessage({
action: "eval", action: "eval",
input: getLiterateProgramSourceCode(this.literateProgramName), input: getLiterateProgramWorkerCommands(this.frame.programName),
}); });
}; }
addOutput(output) { addOutput(output) {
if (this.clearResultsOnNextOutput) { if (this.clearResultsOnNextOutput) {
@ -112,10 +106,13 @@ class LiterateOutput extends HTMLElement {
line.classList.add("output"); line.classList.add("output");
line.classList.add(output.kind); line.classList.add(output.kind);
line.textContent = output.message.map(x => { // One day this will be more fancy. Today is not that day.
if (typeof x === "object") return JSON.stringify(x); line.textContent = output.message
else return x + ""; .map(x => {
}).join(" "); if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
if (output.kind == "result") { if (output.kind == "result") {
let returnValueText = document.createElement("span"); let returnValueText = document.createElement("span");
@ -124,12 +121,30 @@ class LiterateOutput extends HTMLElement {
line.insertBefore(returnValueText, line.firstChild); line.insertBefore(returnValueText, line.firstChild);
} }
this.appendChild(line); this.frame.appendChild(line);
} }
clearResults() { clearResults() {
this.replaceChildren(); this.frame.replaceChildren();
} }
} }
customElements.define("th-literate-output", LiterateOutput); class LiterateProgram extends HTMLElement {
connectedCallback() {
this.programName = this.getAttribute("data-program");
this.program.frames.push(this);
this.mode = this.getAttribute("data-mode");
if (this.mode == "input") {
this.modeImpl = new InputMode(this);
} else if (this.mode == "output") {
this.modeImpl = new OutputMode(this);
}
}
get program() {
return getLiterateProgram(this.programName);
}
}
customElements.define("th-literate-program", LiterateProgram);

View file

@ -1,26 +1,50 @@
console = { let outputIndex = 0;
let debugLog = console.log;
globalThis.console = {
log(...message) { log(...message) {
postMessage({ postMessage({
kind: "output", kind: "output",
output: { output: {
kind: "log", kind: "log",
message: [...message], message: [...message],
} },
outputIndex,
}); });
} }
}; };
addEventListener("message", event => { async function withTemporaryGlobalScope(callback) {
let state = {
oldValues: {},
set(key, value) {
this.oldValues[key] = globalThis[key];
globalThis[key] = value;
}
};
await callback(state);
for (let key in state.oldValues) {
globalThis[key] = state.oldValues[key];
}
}
addEventListener("message", async event => {
let message = event.data; let message = event.data;
if (message.action == "eval") { if (message.action == "eval") {
outputIndex = 0;
try { try {
let func = new Function(message.input); await withTemporaryGlobalScope(async scope => {
let result = func.apply({}); for (let command of message.input) {
postMessage({ if (command.kind == "module") {
kind: "output", let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" }));
output: { let module = await import(blobUrl);
kind: "result", for (let exportedKey in module) {
message: [result], scope.set(exportedKey, module[exportedKey]);
}
} else if (command.kind == "output") {
++outputIndex;
}
} }
}); });
} catch (error) { } catch (error) {
@ -29,7 +53,8 @@ addEventListener("message", event => {
output: { output: {
kind: "error", kind: "error",
message: [error.toString()], message: [error.toString()],
} },
outputIndex,
}); });
} }

View file

@ -36,7 +36,6 @@ export class TileEditor extends Frame {
this.tileSize = parseInt(this.getAttribute("data-tile-size")); this.tileSize = parseInt(this.getAttribute("data-tile-size"));
let tilemapId = this.getAttribute("data-tilemap-id"); let tilemapId = this.getAttribute("data-tilemap-id");
console.log(tilemapRegistry);
if (tilemapId != null) { if (tilemapId != null) {
this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")]; this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")];
} else { } else {
@ -188,5 +187,3 @@ export class TileEditor extends Frame {
} }
} }
defineFrame("tairu-editor", TileEditor); defineFrame("tairu-editor", TileEditor);
console.log("tairu editor loaded");

View file

@ -163,15 +163,12 @@ function expandDetailsRecursively(element) {
} }
function navigateToPage(page) { function navigateToPage(page) {
console.log(page);
window.location.pathname = `${page}.html` window.location.pathname = `${page}.html`
} }
async function navigateToBranch(fragment) { async function navigateToBranch(fragment) {
if (fragment.length == 0) return; if (fragment.length == 0) return;
console.log(`nagivating to branch: ${fragment}`);
let element = document.getElementById(fragment); let element = document.getElementById(fragment);
if (element !== null) { if (element !== null) {
// If the element is already loaded on the page, we're good. // If the element is already loaded on the page, we're good.
@ -179,7 +176,6 @@ async function navigateToBranch(fragment) {
rehash(); rehash();
} else { } else {
// The element is not loaded, we need to load the tree that has it. // The element is not loaded, we need to load the tree that has it.
console.log("element is not loaded");
let parts = fragment.split(':'); let parts = fragment.split(':');
if (parts.length >= 2) { if (parts.length >= 2) {
let [page, _id] = parts; let [page, _id] = parts;
@ -189,7 +185,6 @@ async function navigateToBranch(fragment) {
// navigation maps with roots other than index. Currently though only index is // navigation maps with roots other than index. Currently though only index is
// generated so that doesn't matter. // generated so that doesn't matter.
let [_root, ...path] = fullPath; let [_root, ...path] = fullPath;
console.log(`_root: ${_root}, path: ${path}`)
if (path !== undefined) { if (path !== undefined) {
let isNotAtIndexHtml = let isNotAtIndexHtml =
window.location.pathname != "" && window.location.pathname != "" &&