diff --git a/content/about-treehouse/emoji.tree b/content/about-treehouse/emoji.tree index ca4f067..48b0658 100644 --- a/content/about-treehouse/emoji.tree +++ b/content/about-treehouse/emoji.tree @@ -67,6 +67,13 @@ % id = "01H8W7VEVPSHDWDH58HFKBMGD6" - but I don't wanna replace it because it's just too good +- ### random places + + % id = "emoji/ahyes" + - :ahyes: - ah, yes + + - smuggest expression for the smuggest of moments, and some tea with it. + % id = "01HA4HJKQ7FKV8JJ70Q2CY9R86" - ### [Noto Color Emoji](https://github.com/googlefonts/noto-emoji/) diff --git a/content/programming/blog/tairu.tree b/content/programming/blog/tairu.tree index 731cc33..98a0eb6 100644 --- a/content/programming/blog/tairu.tree +++ b/content/programming/blog/tairu.tree @@ -1,11 +1,6 @@ %% title = "tairu - an interactive exploration of 2D autotiling techniques" scripts = [ "components/literate-programming.js", - "tairu/cardinal-directions.js", - "tairu/framework.js", - "tairu/tairu.js", - "tairu/tilemap-registry.js", - "tairu/tilemap.js", "vendor/codejar.js", ] styles = ["tairu.css"] @@ -20,8 +15,9 @@ styles = ["tairu.css"] - TODO: short videos demoing this here % id = "01HPD4XQPWJBTJ4DWAQE3J87C9" -- once upon a time I stumbled upon a technique called...\ -**bitwise autotiling** +- once upon a time I stumbled upon a technique called... + +- ### bitwise autotiling % id = "01HPD4XQPW6VK3FDW5QRCE6HSS" + I learned about it way back when I was just a kid building 2D Minecraft clones using [Construct 2](https://www.construct.net/en/construct-2/manuals/construct-2), and I wanted my terrain to look nice as it does in Terraria @@ -29,37 +25,194 @@ styles = ["tairu.css"] % id = "01HPD4XQPWJ1CE9ZVRW98X7HE6" - Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile: - % id = "01HPJ8GHDET8ZGNN0AH3FWA8HX" - - let's begin with a tilemap. say we have the following grid of tiles: (the examples are interactive, try editing it!) + - so to help us learn, I made a little tile editor so that we can experiment with rendering tiles! have a look: - - Your browser does not support <canvas>. - + ```javascript tairu + import { Tilemap } from "tairu/tilemap.js"; + import { TileEditor } from "tairu/editor.js"; - % id = "01HPJ8GHDEC0Z334M04MTNADV9" - - for each tile we can assign a bitset of cardinal directions, based on which tiles it should connect to - like so: + export const tilemapSquare = Tilemap.parse(" x", [ + " ", + " xxx ", + " xxx ", + " xxx ", + " ", + ]); - - Your browser does not support <canvas>. - + new TileEditor({ + tilemap: tilemapSquare, + tileSize: 40, + }); + ``` + + ```output tairu + ``` + + - `Tilemap` is a class wrapping a flat [`Uint8Array`] with a `width` and a `height`, so that we can index it using (x, y) coordinates. + + ```javascript tairu + console.log(tilemapSquare.at(0, 0)); + console.log(tilemapSquare.at(3, 1)); + ``` + ```output tairu + ``` + + [`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array + + - `at` has a `setAt` counterpart which sets tiles instead of getting them. + + - `TileEditor` provides a graphical editor for a `Tilemap` based on a ``. + + - this editor is _Certified Battery Efficient™_, so it won't redraw unless it needs to!\ + we'll need to keep this in mind for later when we try to draw images, which may not be loaded during the initial draw. + + - to kick this off, let's set off a goal. I would like the tiles in our little renderer to connect together, like this: + + ![red rectangle with a black outline, made out of 3x3 tiles][pic:01HPYW5SNTY0Z0ENDE5K3XWMTH] + + - let's break this down into smaller steps. drawing a border around the rectangle will involve: + + - determining *on which tiles* to draw it, + + - determining *where in these tiles* to draw it, + + - and actually drawing it! + + - so let's zoom in a bit and look at the tiles one by one. in particular, let's focus on *these* two tiles: + + ![the same red rectangle, now with a focus on the northern tile at its center][pic:01HPYWPJB1P0GK53BSJFJFRAGR] + + - notice how the two highlighted tiles are *different.* therefore, we can infer we should probably connect together any tiles that are *the same*. + + - knowing that, we can extract the logic to a function: + + ```javascript tairu + export function shouldConnect(a, b) { + return a == b; + } + ``` + + + now, also note that the border around this particular tile is only drawn on its *northern* edge - + therefore we can infer that borders should only be drawn on edges for whom `shouldConnect(thisTile, adjacentTile)` is **`false`** (not `true`!). + a tile generally has four edges - east, south, west, north - so we need to perform this check for all of them, and draw our border accordingly. + + - you might be wondering why I'm using this particular order for cardinal directions - why not [north, south, east, west]? or [north, east, south, west]? + + - the reason comes from math - `[cos(0) sin(0)]` is a vector pointing rightwards, not upwards! + and I chose clockwise order, because that's how the vector rotates as we increase the angle, in a coordinate space where +Y points downward - such as the `` coordinate space. + + - this choice yields some nice orderliness in the code that handles fetching tiles for connections - first you check `+X`, then `+Y`, then `-X`, and then `-Y` - + which my pedantic mind really appreciates :ahyes:\ + as `X` is first alphabetically, so checking `Y` first would feel wrong. + + - to do that, I'm gonna override the tile editor's `drawTilemap` function - as this is where the actual tilemap rendering happens! + + ```javascript tairu + import { TileEditor } from "tairu/editor.js"; + + export class TileEditorWithBorders extends TileEditor { + constructor({ borderWidth, ...options }) { + super(options); + + this.borderWidth = borderWidth; + this.colorScheme.borderColor = "#000000"; + } + + drawTilemap() { + // Let the base class render out the infill, we'll just handle the borders. + super.drawTilemap(); + + this.ctx.fillStyle = this.colorScheme.borderColor; + + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + // We only want to draw non-empty tiles, so skip tile 0. + if (tile == 0) { + continue; + } + + // Check which of this tile's neighbors should *not* connect to it. + let disjointWithEast = !shouldConnect(tile, this.tilemap.at(x + 1, y)); + let disjointWithSouth = !shouldConnect(tile, this.tilemap.at(x, y + 1)); + let disjointWithWest = !shouldConnect(tile, this.tilemap.at(x - 1, y)); + let disjointWithNorth = !shouldConnect(tile, this.tilemap.at(x, y - 1)); + + let { borderWidth, tileSize } = this; + let tx = x * tileSize; + let ty = y * tileSize; + + // For each disjoint neighbor, we want to draw a border between us and them. + if (disjointWithEast) { + this.ctx.fillRect(tx + tileSize - borderWidth, ty, borderWidth, tileSize); + } + if (disjointWithSouth) { + this.ctx.fillRect(tx, ty + tileSize - borderWidth, tileSize, borderWidth); + } + if (disjointWithWest) { + this.ctx.fillRect(tx, ty, borderWidth, tileSize); + } + if (disjointWithNorth) { + this.ctx.fillRect(tx, ty, tileSize, borderWidth); + } + } + } + } + } + ``` + + and here's the result: + + ```javascript tairu + new TileEditorWithBorders({ + tilemap: tilemapSquare, + tileSize: 40, + borderWidth: 4, + }); + ``` + ```output tairu + ``` + + - this looks pretty perfect - maybe sans corners, which I'll conveniently skip for now - because most games don't actually render graphics in a vectorial way like this! + instead, the more common way is to use a tileset - a big texture with a bunch of sprites to use for rendering each tile. + + - not only does this have the advantage of allowing for richer graphics, but it is also a lot easier to modify by artists, because you no longer need knowledge of graphics APIs to draw tiles. % template = true - id = "01HPJ8GHDE9QKQ4QFZK1Z1KQD4" classes.branch = "tileset-cardinal-directions-demo" - + now given a tileset, such as the one below that I drew a while ago, we can assign each tile to a set of cardinal directions. - I'll indicate where there's a connection between individual tiles with the letters **N**, **E**, **S**, **W**, standing for the cardinal directions **N**orth, **E**ast, **S**outh, and **W**est. + - for example, here's a tileset I drew for the 3rd iteration of my game [Planet Overgamma] - though tweaked a bit because I had never used it before writing this post :hueh: - - - - - + ![heavy metal sheet tileset from Planet Overgamma, made out of 16 tiles. it looks like heavy embossed sheets of metal, resembling steel in its heavyness][pic:01HPHVDRV0F0251MD0A2EG66C4] + + [Planet Overgamma]: https://github.com/liquidev/planet-overgamma + + % classes.branch = "tileset-cardinal-directions-demo" + - we can split this tileset up into 16 individual tiles, each one 8 × 8 pixels; people choose various resolutions, I chose a fairly low one to hide my lack of artistic skill. + + + + + + + + + + + + + + + + + + + + + % classes.branch = "tileset-cardinal-directions-demo" + - the keen eyed among you have probably noticed that this is very similar to the case we had before with drawing procedural borders - + except that instead of determining which borders to draw based on a tile's neighbors, this time we'll determine which *whole tile* to draw based on its neighbors! + + ES ESW SW @@ -76,21 +229,28 @@ styles = ["tairu.css"] EW W - - + - % id = "01HPMVT9BM65YD5AXWPT4Z67H5" - - (it's frustratingly hard to center individual letters like this in CSS. please forgive me for how crooked these are!) + - previously we represented which single border to draw with a single boolean. + now we will represent which single tile to draw with *four* booleans, because each tile can connect to four different directions. - % id = "01HPMVT9BM5V4BP8K80X0C1HJZ" - - note that the state of connection for a given cardinal direction can be represented using two values: **connected**, and **not connected**. - two values make one bit, so we can pack these four connection states into four bits, and use that as an array index! + - four booleans like this can easily be packed into a single integer using some bitwise operations, hence we get ***bitwise autotiling*** - autotiling using bitwise operations! + + - now the clever part of bitwise autotiling is that we can use this packed integer *as an array index* - therefore selecting which tile to draw can be determined using just a single lookup table! neat, huh? + + - but because I'm lazy, and CPU time is valuable, instead of using an array I'll just rearrange the tileset texture a bit to be able to slice it in place using this index. + + - say we arrange our bits like this: + + ```javascript tairu + export const E = 0b0001; + export const S = 0b0010; + export const W = 0b0100; + export const N = 0b1000; + ``` % classes.branch = "tileset-cardinal-directions-demo" - id = "01HPMVT9BM4AXG2Z1D2QBH828G" - + for that to work though, we need to rearrange our tilemap somewhat such that we can index into it easily using our integer. - assuming we pack our bits as `NWSE` (bit 0 is east, each next bit we go clockwise), - therefore the final arrangement is this: + - that means we'll need to arrange our tiles like so, where the leftmost tile is at index 0 (`0b0000`) and the rightmost tile is at index 15 (`0b1111`): @@ -111,57 +271,129 @@ styles = ["tairu.css"] ESWN - packing that into a single tilesheet, or rather tile *strip*, we get this image: + - packing that into a single tileset, or rather this time, a *tile strip*, we get this image: ![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5] - % id = "01HPQCCV4RB65D5Q4RANJKGC0D" - - **hint:** you can actually just use the original image, but use a lookup table from these indices to (x, y) coordinates. - this makes creating the assets a lot easier! (at the expense of some CPU time, though it is totally possible to offload tilemap rendering to the GPU - in that case it barely even matters.) + - now it's time to actually implement it as code! I'll start by defining a *tile index* function as a general way of looking up tiles in a tileset. - % id = "01HPMVT9BMMEM4HT4ANZ40992P" - - in JavaScript, drawing on a `` using bitwise autotiling would look like this: - ```javascript - for (let y = 0; y < tilemap.height; ++y) { - for (let x = 0; x < tilemap.width; ++x) { - // Assume `tilemap.at` is a function which returns the type of tile - // stored at coordinates (x, y). - let tile = tilemap.at(x, y); + - I want to make the tile renderer a bit more general, so being able to attach a different tile lookup function to each tileset sounds like a great feature. - // We need to treat *some* tile as an empty (fully transparent) tile. - // In our case that'll be 0. - if (tile != 0) { - let tileset = tilesets[tile]; + - just imagine some game where glass connects to metal, but metal doesn't connect to glass - I bet that would look pretty great! - // Now it's time to represent the tile connections as bits. - // For each cardinal direction we produce a different bit value, or 0 if there is - // no connection: - let connectedWithEast = shouldConnect(tile, tilemap.at(x + 1, y)) ? 0b0001 : 0; - let connectedWithSouth = shouldConnect(tile, tilemap.at(x, y + 1)) ? 0b0010 : 0; - let connectedWithWest = shouldConnect(tile, tilemap.at(x - 1, y)) ? 0b0100 : 0; - let connectedWithNorth = shouldConnect(tile, tilemap.at(x, y - 1)) ? 0b1000 : 0; - // Then we OR them together into one integer. - let tileIndex = connectedWithNorth - | connectedWithWest - | connectedWithSouth - | connectedWithEast; + - …but anyways, here's the basic bitwise magic function: - // With that, we can draw the correct tile. - // Our strip is a single horizontal line, so we can assume - let tilesetTileSize = tileset.height; - let tilesetX = tileIndex * tilesetTileSize; - let tilesetY = 0; - ctx.drawImage( - tilesets[tile], - tilesetX, tilesetY, tilesetTileSize, tilesetTileSize, - x * tileSize, y * tileSize, tileSize, tileSize, - ); + ```javascript tairu + export function tileIndexInBitwiseTileset(tilemap, x, y) { + let tile = tilemap.at(x, y); + + let tileIndex = 0; + tileIndex |= shouldConnect(tile, tilemap.at(x + 1, y)) ? E : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x, y + 1)) ? S : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x - 1, y)) ? W : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x, y - 1)) ? N : 0; + + return tileIndex; + } + ``` + + % template = true + - we'll define our tilesets by their texture, tile size, and a tile indexing function. so let's create an object that will hold our tileset data: + + ```javascript tairu + // You'll probably want to host the assets on your own website rather than + // hotlinking to others. It helps longevity! + const tilesetImage = new Image(); + tilesetImage.src = "{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}"; + + export const heavyMetalTileset = { + image: tilesetImage, + tileSize: 8, + tileIndex: tileIndexInBitwiseTileset, + }; + ``` + + - with all that, we should now be able to write a tile renderer which can handle textures! so let's try it: + + ```javascript tairu + import { TileEditor } from "tairu/editor.js"; + + export class TilesetTileEditor extends TileEditor { + constructor({ tilesets, ...options }) { + super(options); + this.tilesets = tilesets; + + // The image may not be loaded once the editor is first drawn, so we need to request a + // redraw for each image that gets loaded in. + for (let tileset of this.tilesets) { + tileset.image.addEventListener("load", () => this.draw()); + } + } + + drawTilemap() { + // We're dealing with pixel tiles so we want our images to be pixelated, + // not interpolated. + this.ctx.imageSmoothingEnabled = false; + + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + if (tile == 0) { + continue; + } + + // Subtract one from the tile because tile 0 is always empty. + // Having to specify a null entry at array index 0 would be pretty annoying. + let tileset = this.tilesets[tile - 1]; + if (tileset != null) { + let { tileSize } = this; + let tileIndex = tileset.tileIndex(this.tilemap, x, y); + this.ctx.drawImage( + tileset.image, + tileIndex * tileset.tileSize, 0, tileset.tileSize, tileset.tileSize, + x * tileSize, y * tileSize, tileSize, tileSize, + ); + } + } } } } ``` - TODO this should be literate code + - drum roll please... + + ```javascript tairu + new TilesetTileEditor({ + tilemap: tilemapSquare, + tileSize: 40, + tilesets: [heavyMetalTileset], + }); + ``` + ```output tairu + ``` + + - it works! buuuut if you play around with it you'll quickly start noticing some problems: + + ```javascript tairu + import { Tilemap } from "tairu/tilemap.js"; + + export const tilemapEdgeCase = Tilemap.parse(" x", [ + " ", + " xxx ", + " x x ", + " xxx ", + " ", + ]); + new TilesetTileEditor({ + tilemap: tilemapEdgeCase, + tileSize: 40, + tilesets: [heavyMetalTileset], + }); + ``` + ```output tairu + ``` + + - where did our nice seamless connections go!? % template = true id = "01HPMVT9BM9CS9375MX4H9WKW8" diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5eb03be --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": "./static/js", + "paths": { + "treehouse/*": [ + "./*" + ], + "tairu/*": [ + "./components/tairu/*" + ] + } + }, +} diff --git a/static/css/main.css b/static/css/main.css index bd190ba..909b986 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -255,14 +255,12 @@ th-literate-program { .tree summary:hover { & pre, - & th-literate-program { + & th-literate-program:not([data-mode="output"]) { background-color: var(--shaded-against-background-twice); } } } - - pre>code, th-literate-program>code { padding: 0; @@ -276,7 +274,8 @@ th-literate-program { /* And don't let code examples fly off and overflow the window */ -pre { +pre, +th-literate-program { min-width: 0; width: auto; overflow: auto; @@ -554,30 +553,6 @@ th-literate-program[data-mode="input"] { } th-literate-program[data-mode="output"] { - position: relative; - - & code { - display: block; - } - - & code.error { - color: var(--error-color); - } - - &::after { - content: 'Output'; - - padding: 8px; - - position: absolute; - right: 0; - top: 0; - - opacity: 50%; - } -} - -th-literate-program[data-mode="graphics"] { padding: 0; background: none; border: none; @@ -593,6 +568,10 @@ th-literate-program[data-mode="graphics"] { display: none; } + & pre>code { + display: block; + } + & pre.error { color: var(--error-color); position: relative; @@ -614,6 +593,27 @@ th-literate-program[data-mode="graphics"] { opacity: 50%; } } + + & pre.console { + position: relative; + + &:empty { + display: none; + } + + &::after { + content: 'Console'; + + padding: 8px; + + position: absolute; + right: 0; + top: 0; + + color: var(--text-color); + opacity: 50%; + } + } } /* Syntax highlighting */ diff --git a/static/css/tairu.css b/static/css/tairu.css index 18e562d..ebb826d 100644 --- a/static/css/tairu.css +++ b/static/css/tairu.css @@ -1,28 +1,4 @@ .tileset-cardinal-directions-demo th-bc { - & ul { - display: flex; - flex-direction: row; - } - - & ul.tileset-demo { - margin-top: 16px; - } - - & ul.tileset-demo::after { - display: none !important; - } - - & li.full-image { - flex-shrink: 0; - } - - & li.tileset-pieces { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - } - & .horizontal-tile-strip { display: flex; flex-direction: row; diff --git a/static/css/tree.css b/static/css/tree.css index 070f93f..4d3f3ef 100644 --- a/static/css/tree.css +++ b/static/css/tree.css @@ -218,8 +218,8 @@ th-bc { flex-grow: 1; /* Bit of a hack to make s in have scrollbars proper. */ - &:has(pre) { - overflow: auto; + &:has(pre, th-literate-program) { + overflow: hidden; } } diff --git a/static/emoji/ahyes.png b/static/emoji/ahyes.png new file mode 100644 index 0000000..8da75f8 Binary files /dev/null and b/static/emoji/ahyes.png differ diff --git a/static/js/components/literate-programming.js b/static/js/components/literate-programming.js index e59756a..3fcc1e9 100644 --- a/static/js/components/literate-programming.js +++ b/static/js/components/literate-programming.js @@ -12,10 +12,8 @@ function getLiterateProgram(name) { outputCount: 0, nextOutputIndex() { - let index = this.outputCount; - ++this.outputCount; - return index; - } + return this.outputCount++; + }, }); } return literatePrograms.get(name); @@ -28,7 +26,7 @@ function getLiterateProgramWorkerCommands(name) { if (frame.mode == "input") { commands.push({ kind: "module", source: frame.textContent }); } else if (frame.mode == "output") { - commands.push({ kind: "output", expected: frame.textContent }); + commands.push({ kind: "output" }); } } return commands; @@ -51,7 +49,7 @@ class InputMode { { regex: /"(\\"|[^"])*"/, as: "string" }, { regex: /`(\\`|[^"])*`/, as: "string" }, // TODO: RegExp literals? - { regex: /[+=/*^%<>!~|&\.-]+/, as: "operator" }, + { regex: /[+=/*^%<>!~|&\.?:-]+/, as: "operator" }, { regex: /[,;]/, as: "punct" }, ], keywords: new Map([ @@ -132,54 +130,84 @@ class InputMode { } } +function messageOutputArrayToString(output) { + return output + .map(x => { + if (typeof x === "object") return JSON.stringify(x); + else return x + ""; + }) + .join(" "); +} + class OutputMode { constructor(frame) { - this.clearResultsOnNextOutput = false; - this.frame = frame; - this.frame.program.onChanged.push(_ => this.evaluate()); this.outputIndex = this.frame.program.nextOutputIndex(); - this.evaluate(); - } + this.console = document.createElement("pre"); + this.console.classList.add("console"); + this.frame.appendChild(this.console); + this.clearConsoleOnNextOutput = false; - evaluate() { - // This is a small bit of debouncing. If we cleared the output right away, the page would - // jitter around irritatingly. - this.clearResultsOnNextOutput = true; + this.error = document.createElement("pre"); + this.error.classList.add("error"); + this.frame.appendChild(this.error); - if (this.worker != null) { - this.worker.terminate(); - } - this.worker = new Worker(import.meta.resolve("./literate-programming/worker.js"), { - type: "module", - name: `evaluate LiterateOutput ${this.frame.programName}` - }); + this.iframe = document.createElement("iframe"); + this.iframe.classList.add("hidden"); + this.iframe.src = `${TREEHOUSE_SITE}/sandbox`; + this.frame.appendChild(this.iframe); - this.worker.addEventListener("message", event => { + this.iframe.contentWindow.treehouseSandboxInternals = { outputIndex: this.outputIndex }; + + this.iframe.contentWindow.addEventListener("message", event => { let message = event.data; - if (message.kind == "evalComplete") { - this.worker.terminate(); + if (message.kind == "ready") { + this.evaluate(); + } else if (message.kind == "resize" && message.outputIndex == this.outputIndex) { + this.resize(); } else if (message.kind == "output" && message.outputIndex == this.outputIndex) { - this.addOutput(message.output); + if (message.output.kind == "error") { + this.error.textContent = messageOutputArrayToString(message.output.message); + this.iframe.classList.add("hidden"); + } else { + this.addOutput(message.output); + } + } else if (message.kind == "evalComplete") { + this.error.textContent = ""; + this.flushConsoleClear(); } }); - this.worker.postMessage({ + this.frame.program.onChanged.push(_ => this.evaluate()); + } + + evaluate() { + this.requestConsoleClear(); + this.iframe.contentWindow.postMessage({ action: "eval", input: getLiterateProgramWorkerCommands(this.frame.programName), }); } - addOutput(output) { - if (this.clearResultsOnNextOutput) { - this.clearResultsOnNextOutput = false; - this.clearResults(); - } + clearConsole() { + this.console.replaceChildren(); + } - // Don't show anything if the function didn't return a value. - if (output.kind == "result" && output.message[0] === undefined) return; + requestConsoleClear() { + this.clearConsoleOnNextOutput = true; + } + + flushConsoleClear() { + if (this.clearConsoleOnNextOutput) { + this.clearConsole(); + this.clearConsoleOnNextOutput = false; + } + } + + addOutput(output) { + this.flushConsoleClear(); let line = document.createElement("code"); @@ -194,65 +222,22 @@ class OutputMode { }) .join(" "); - this.frame.appendChild(line); + this.console.appendChild(line); } - clearResults() { - this.frame.replaceChildren(); - } - - static messageOutputArrayToString(output) { - return output - .map(x => { - if (typeof x === "object") return JSON.stringify(x); - else return x + ""; - }) - .join(" "); - } -} - -class GraphicsMode { - constructor(frame) { - this.frame = frame; - - this.error = document.createElement("pre"); - this.error.classList.add("error"); - this.frame.appendChild(this.error); - - this.iframe = document.createElement("iframe"); - this.iframe.classList.add("hidden"); - this.iframe.src = import.meta.resolve("../../html/sandbox.html"); - this.frame.appendChild(this.iframe); - - this.iframe.contentWindow.addEventListener("message", event => { - let message = event.data; - if (message.kind == "ready") { - this.evaluate(); - } - else if (message.kind == "resize") { - this.resize(message); - } else if (message.kind == "output" && message.output.kind == "error") { - this.error.textContent = OutputMode.messageOutputArrayToString(message.output.message); - this.iframe.classList.add("hidden"); - } else if (message.kind == "evalComplete") { - this.error.textContent = ""; - } - }); - - this.frame.program.onChanged.push(_ => this.evaluate()); - } - - evaluate() { - this.iframe.contentWindow.postMessage({ - action: "eval", - input: getLiterateProgramWorkerCommands(this.frame.programName), - }); - } - - resize(message) { - this.iframe.width = message.width; - this.iframe.height = message.height; + resize() { + // iframe cannot be `display: none` to get its scrollWidth/scrollHeight. this.iframe.classList.remove("hidden"); + + let width = this.iframe.contentDocument.body.scrollWidth; + let height = this.iframe.contentDocument.body.scrollHeight; + + if (width == 0 || height == 0) { + this.iframe.classList.add("hidden"); + } else { + this.iframe.width = width; + this.iframe.height = height; + } } } @@ -266,8 +251,6 @@ class LiterateProgram extends HTMLElement { this.modeImpl = new InputMode(this); } else if (this.mode == "output") { this.modeImpl = new OutputMode(this); - } else if (this.mode == "graphics") { - this.modeImpl = new GraphicsMode(this); } } diff --git a/static/js/components/literate-programming/eval.js b/static/js/components/literate-programming/eval.js index f7b86de..19db75c 100644 --- a/static/js/components/literate-programming/eval.js +++ b/static/js/components/literate-programming/eval.js @@ -1,8 +1,21 @@ let outputIndex = 0; -export function getOutputIndex() { - return outputIndex; -} +export const jsConsole = console; + +// Overwrite globalThis.console with domConsole to redirect output to the DOM console. +// To always output to the JavaScript console regardless, use jsConsole. +export const domConsole = { + log(...message) { + postMessage({ + kind: "output", + output: { + kind: "console.log", + message: [...message], + }, + outputIndex, + }); + } +}; async function withTemporaryGlobalScope(callback) { let state = { @@ -13,6 +26,7 @@ async function withTemporaryGlobalScope(callback) { } }; await callback(state); + jsConsole.trace(state.oldValues, "bringing back old state"); for (let key in state.oldValues) { globalThis[key] = state.oldValues[key]; } @@ -20,15 +34,11 @@ async function withTemporaryGlobalScope(callback) { let evaluationComplete = null; -export async function evaluate(commands, { start, success, error }) { +export async function evaluate(commands, { error, newOutput }) { if (evaluationComplete != null) { await evaluationComplete; } - if (start != null) { - start(); - } - let signalEvaluationComplete; evaluationComplete = new Promise((resolve, _reject) => { signalEvaluationComplete = resolve; @@ -36,21 +46,19 @@ export async function evaluate(commands, { start, success, error }) { outputIndex = 0; try { - await withTemporaryGlobalScope(async scope => { - for (let command of commands) { - if (command.kind == "module") { - let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" })); - let module = await import(blobUrl); - for (let exportedKey in module) { - scope.set(exportedKey, module[exportedKey]); - } - } else if (command.kind == "output") { - ++outputIndex; + for (let command of commands) { + if (command.kind == "module") { + let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" })); + let module = await import(blobUrl); + for (let exportedKey in module) { + globalThis[exportedKey] = module[exportedKey]; } + } else if (command.kind == "output") { + if (newOutput != null) { + newOutput(outputIndex); + } + ++outputIndex; } - }); - if (success != null) { - success(); } postMessage({ kind: "evalComplete", diff --git a/static/js/components/literate-programming/worker.js b/static/js/components/literate-programming/worker.js deleted file mode 100644 index 381126c..0000000 --- a/static/js/components/literate-programming/worker.js +++ /dev/null @@ -1,23 +0,0 @@ -import { evaluate, getOutputIndex } from "./eval.js"; - -let debugLog = console.log; - -globalThis.console = { - log(...message) { - postMessage({ - kind: "output", - output: { - kind: "log", - message: [...message], - }, - outputIndex: getOutputIndex(), - }); - } -}; - -addEventListener("message", async event => { - let message = event.data; - if (message.action == "eval") { - evaluate(message.input, {}); - } -}); diff --git a/static/js/components/tairu/cardinal-directions.js b/static/js/components/tairu/cardinal-directions.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/components/tairu/editor.js b/static/js/components/tairu/editor.js new file mode 100644 index 0000000..d357e57 --- /dev/null +++ b/static/js/components/tairu/editor.js @@ -0,0 +1,134 @@ +import { Sketch } from "treehouse/sandbox.js"; + +export class TileEditor extends Sketch { + constructor({ tilemap, tileSize }) { + super(tilemap.width * tileSize, tilemap.height * tileSize); + + this.colorScheme = { + background: "#F7F7F7", + grid: "#00000011", + tileCursor: "#222222", + tiles: [ + "transparent", // never actually drawn to the screen with the default renderer! + "#eb134a", + ], + }; + + this.tilemap = tilemap; + this.tileSize = tileSize; + + this.hasFocus = false; + this.paintingTile = null; + this.tileCursor = { x: 0, y: 0 }; + + this.canvas.addEventListener("mousemove", event => this.mouseMoved(event)); + this.canvas.addEventListener("mousedown", event => this.mousePressed(event)); + this.canvas.addEventListener("mouseup", event => this.mouseReleased(event)); + + this.canvas.addEventListener("mouseenter", _ => this.mouseEnter()); + this.canvas.addEventListener("mouseleave", _ => this.mouseLeave()); + + this.canvas.addEventListener("contextmenu", event => event.preventDefault()); + + // Only draw first frame after the constructor already runs. + // That way we can modify the color scheme however much we want without causing additional + // redraws. + requestAnimationFrame(() => this.draw()); + } + + draw() { + this.drawBackground(); + this.drawTilemap(); + this.drawGrid(); + if (this.hasFocus) { + this.drawTileCursor(); + } + } + + drawBackground() { + this.ctx.fillStyle = this.colorScheme.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + drawTilemap() { + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + if (tile != 0) { + this.ctx.fillStyle = this.colorScheme.tiles[tile]; + this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize); + } + } + } + } + + drawGrid() { + this.ctx.beginPath(); + for (let x = 0; x < this.tilemap.width; ++x) { + this.ctx.moveTo(x * this.tileSize, 0); + this.ctx.lineTo(x * this.tileSize, this.canvas.height); + } + for (let y = 0; y < this.tilemap.width; ++y) { + this.ctx.moveTo(0, y * this.tileSize); + this.ctx.lineTo(this.canvas.width, y * this.tileSize); + } + this.ctx.strokeStyle = this.colorScheme.grid; + this.ctx.lineWidth = 1; + this.ctx.stroke(); + } + + drawTileCursor() { + this.ctx.strokeStyle = this.colorScheme.tileCursor; + this.ctx.lineWidth = 5; + this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize); + } + + + mouseMoved(event) { + this.tileCursor.x = Math.floor(event.offsetX / this.tileSize); + this.tileCursor.y = Math.floor(event.offsetY / this.tileSize); + this.paintTileUnderCursor(); + + this.draw(); + } + + mousePressed(event) { + event.preventDefault(); + + if (event.button == 0) { + this.paintingTile = 1; + } else if (event.button == 2) { + this.paintingTile = 0; + } + + this.paintTileUnderCursor(); + + this.draw(); + } + + mouseReleased(_event) { + this.stopPainting(); + this.draw(); + } + + mouseEnter() { + this.hasFocus = true; + this.draw(); + } + + mouseLeave() { + this.hasFocus = false; + this.stopPainting(); + this.draw(); + } + + paintTileUnderCursor() { + if (this.paintingTile != null) { + this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile); + } + } + + stopPainting() { + this.paintingTile = null; + } +} diff --git a/static/js/tairu/framework.js b/static/js/components/tairu/framework.js similarity index 100% rename from static/js/tairu/framework.js rename to static/js/components/tairu/framework.js diff --git a/static/js/tairu/cardinal-directions.js b/static/js/components/tairu/proto.js similarity index 61% rename from static/js/tairu/cardinal-directions.js rename to static/js/components/tairu/proto.js index 7a72d3d..d9eda1a 100644 --- a/static/js/tairu/cardinal-directions.js +++ b/static/js/components/tairu/proto.js @@ -1,9 +1,45 @@ -import { defineFrame, Frame } from './framework.js'; -import { TileEditor, canConnect, shouldConnect } from './tairu.js'; +import { TileEditor } from 'tairu/editor.js'; -class CardinalDirectionsEditor extends TileEditor { - constructor() { - super(); +export function alignTextInRectangle(ctx, text, x, y, width, height, hAlign, vAlign) { + let measurements = ctx.measureText(text); + + let leftX; + switch (hAlign) { + case "left": + leftX = x; + break; + case "center": + leftX = x + width / 2 - measurements.width / 2; + break; + case "right": + leftX = x + width - measurements.width; + break; + } + + let textHeight = measurements.fontBoundingBoxAscent; + let baselineY; + switch (vAlign) { + case "top": + baselineY = y + textHeight; + break; + case "center": + baselineY = y + height / 2 + textHeight / 2; + break; + case "bottom": + baselineY = y + height; + break; + } + + return { leftX, baselineY }; +} + +export function shouldConnect(a, b) { + return a == b; +} + +export class TileEditorWithCardinalDirections extends TileEditor { + constructor(options) { + super(options); this.colorScheme.tiles[1] = "#f96565"; } @@ -38,4 +74,3 @@ class CardinalDirectionsEditor extends TileEditor { } } } -defineFrame("tairu-editor-cardinal-directions", CardinalDirectionsEditor); diff --git a/static/js/tairu/tilemap-registry.js b/static/js/components/tairu/tilemap-registry.js similarity index 100% rename from static/js/tairu/tilemap-registry.js rename to static/js/components/tairu/tilemap-registry.js diff --git a/static/js/tairu/tilemap.js b/static/js/components/tairu/tilemap.js similarity index 62% rename from static/js/tairu/tilemap.js rename to static/js/components/tairu/tilemap.js index a519ea5..2556496 100644 --- a/static/js/tairu/tilemap.js +++ b/static/js/components/tairu/tilemap.js @@ -27,4 +27,16 @@ export class Tilemap { this.tiles[this.tileIndex(x, y)] = tile; } } + + static parse(alphabet, lineArray) { + let tilemap = new Tilemap(lineArray[0].length, lineArray.length); + for (let y in lineArray) { + let line = lineArray[y]; + for (let x = 0; x < line.length; ++x) { + let char = line.charAt(x); + tilemap.setAt(x, y, alphabet.indexOf(char)); + } + } + return tilemap; + } } diff --git a/static/js/sandbox.js b/static/js/sandbox.js index e61520c..bf2520a 100644 --- a/static/js/sandbox.js +++ b/static/js/sandbox.js @@ -1,5 +1,9 @@ export const internals = { body: document.createElement("body"), + + resetBody() { + this.body.replaceChildren(); + } }; export function body() { @@ -19,4 +23,13 @@ export class Sketch { addElement(this.canvas); } + + animate(draw) { + let animationCallback; + animationCallback = () => { + draw(); + requestAnimationFrame(animationCallback); + }; + animationCallback(); + } } diff --git a/static/js/tairu/tairu.js b/static/js/tairu/tairu.js deleted file mode 100644 index 6cd35a2..0000000 --- a/static/js/tairu/tairu.js +++ /dev/null @@ -1,251 +0,0 @@ -import { Frame, defineFrame } from "./framework.js"; -import tilemapRegistry from "./tilemap-registry.js"; - -export function canConnect(tile) { - return tile == 1; -} - -export function shouldConnect(a, b) { - return a == b; -} - -const dirs47 = { - E: 0b0000_0001, - SE: 0b0000_0010, - S: 0b0000_0100, - SW: 0b0000_1000, - W: 0b0001_0000, - NW: 0b0010_0000, - N: 0b0100_0000, - NE: 0b1000_0000, -}; - -function isSet(integer, bit) { - return (integer & bit) == bit; -} - -function removeRedundancies(t) { - if (isSet(t, dirs47.SE) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.E))) { - t &= ~dirs47.SE; - } - if (isSet(t, dirs47.SW) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.W))) { - t &= ~dirs47.SW; - } - if (isSet(t, dirs47.NW) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.W))) { - t &= ~dirs47.NW; - } - if (isSet(t, dirs47.NE) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.E))) { - t &= ~dirs47.NE; - } - return t; -} - -function ordinalDirections() { - let unique = new Set(); - for (let i = 0; i <= 0b1111_1111; ++i) { - unique.add(removeRedundancies(i)); - } - return Array.from(unique).sort((a, b) => a - b); -} - -let xToConnectionBitSet = ordinalDirections(); -let connectionBitSetToX = new Uint8Array(256); -for (let i = 0; i < xToConnectionBitSet.length; ++i) { - connectionBitSetToX[xToConnectionBitSet[i]] = i; -} -console.log(connectionBitSetToX); - -export class TileEditor extends Frame { - constructor() { - super(); - this.tileCursor = { x: 0, y: 0 }; - - this.colorScheme = { - background: "#F7F7F7", - grid: "#00000011", - tileCursor: "#222222", - tiles: [ - "transparent", - "#eb134a", - ], - }; - - this.tileColorPalette = [ - "transparent", - "#eb134a", - ]; - } - - connectedCallback() { - super.connectedCallback(); - - this.tileSize = parseInt(this.getAttribute("data-tile-size")); - - let tilemapId = this.getAttribute("data-tilemap-id"); - if (tilemapId != null) { - this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")]; - } else { - throw new ReferenceError(`tilemap '${tilemapId}' does not exist`); - } - - // 0st element is explicitly null because it represents the empty tile. - this.tilesets = [null]; - this.tilesets47 = [null]; - - let attachedImages = this.getElementsByTagName("img"); - for (let image of attachedImages) { - if (image.hasAttribute("data-tairu-tileset")) { - let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset")); - this.tilesets[tilesetIndex] = image; - } else if (image.hasAttribute("data-tairu-tileset-47")) { - let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset-47")); - this.tilesets47[tilesetIndex] = image; - } - } - - this.width = this.tilemap.width * this.tileSize; - this.height = this.tilemap.height * this.tileSize; - - this.hasFocus = false; - this.paintingTile = null; - - this.addEventListener("mousemove", event => this.mouseMoved(event)); - this.addEventListener("mousedown", event => this.mousePressed(event)); - this.addEventListener("mouseup", event => this.mouseReleased(event)); - - this.addEventListener("mouseenter", _ => this.hasFocus = true); - this.addEventListener("mouseleave", _ => this.hasFocus = false); - - this.addEventListener("contextmenu", event => event.preventDefault()); - - // TODO: This should also work on mobile. - } - - draw() { - this.ctx.fillStyle = this.colorScheme.background; - this.ctx.fillRect(0, 0, this.width, this.height); - - this.drawTiles(); - this.drawGrid(); - if (this.hasFocus) { - this.drawTileCursor(); - } - } - - drawGrid() { - this.ctx.beginPath(); - for (let x = 0; x < this.tilemap.width; ++x) { - this.ctx.moveTo(x * this.tileSize, 0); - this.ctx.lineTo(x * this.tileSize, this.height); - } - for (let y = 0; y < this.tilemap.width; ++y) { - this.ctx.moveTo(0, y * this.tileSize); - this.ctx.lineTo(this.width, y * this.tileSize); - } - this.ctx.strokeStyle = this.colorScheme.grid; - this.ctx.lineWidth = 1; - this.ctx.stroke(); - } - - drawTileCursor() { - this.ctx.strokeStyle = this.colorScheme.tileCursor; - this.ctx.lineWidth = 5; - this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize); - } - - get hasTilesets() { - // Remember that tile 0 represents emptiness. - return this.tilesets.length > 1 || this.tilesets47.length > 1; - } - - drawTiles() { - if (this.hasTilesets) { - this.drawTexturedTiles(); - } else { - this.drawColoredTiles(); - } - } - - drawColoredTiles() { - for (let y = 0; y < this.tilemap.height; ++y) { - for (let x = 0; x < this.tilemap.width; ++x) { - let tile = this.tilemap.at(x, y); - if (tile != 0) { - this.ctx.fillStyle = this.colorScheme.tiles[tile]; - this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize); - } - } - } - } - - drawTexturedTiles() { - this.ctx.imageSmoothingEnabled = false; - - for (let y = 0; y < this.tilemap.height; ++y) { - for (let x = 0; x < this.tilemap.width; ++x) { - let tile = this.tilemap.at(x, y); - if (tile != 0) { - let tileset16 = this.tilesets[tile]; - let tileset47 = this.tilesets47[tile]; - let tileset = tileset47 != null ? tileset47 : tileset16; - - let tileIndex = 0; - if (tileset47 != null) { - let rawTileIndex = 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? dirs47.E : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y + 1)) ? dirs47.SE : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? dirs47.S : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y + 1)) ? dirs47.SW : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? dirs47.W : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y - 1)) ? dirs47.NW : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? dirs47.N : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y - 1)) ? dirs47.NE : 0; - tileIndex = connectionBitSetToX[removeRedundancies(rawTileIndex)]; - } else { - tileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? 0b0001 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? 0b0010 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? 0b0100 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? 0b1000 : 0; - } - - let tilesetTileSize = tileset.height; - let tilesetX = tileIndex * tilesetTileSize; - let tilesetY = 0; - this.ctx.drawImage( - tileset, - tilesetX, tilesetY, tilesetTileSize, tilesetTileSize, - x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize, - ); - } - } - } - } - - mouseMoved(event) { - let mouse = this.getMousePositionFromEvent(event); - this.tileCursor.x = Math.floor(mouse.x / this.tileSize); - this.tileCursor.y = Math.floor(mouse.y / this.tileSize); - this.paintTileUnderCursor(); - } - - mousePressed(event) { - event.preventDefault(); - if (event.button == 0) { - this.paintingTile = 1; - } else if (event.button == 2) { - this.paintingTile = 0; - } - this.paintTileUnderCursor(); - } - - mouseReleased() { - this.paintingTile = null; - } - - paintTileUnderCursor() { - if (this.paintingTile != null) { - this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile); - } - } -} -defineFrame("tairu-editor", TileEditor); diff --git a/static/js/vendor/codejar.js b/static/js/vendor/codejar.js index 95f0ad0..4041771 100644 --- a/static/js/vendor/codejar.js +++ b/static/js/vendor/codejar.js @@ -23,9 +23,10 @@ export function CodeJar(editor, highlight, opt = {}) { editor.setAttribute('contenteditable', 'plaintext-only'); editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false'); editor.style.outline = 'none'; - editor.style.overflowWrap = 'break-word'; - editor.style.overflowY = 'auto'; - editor.style.whiteSpace = 'pre-wrap'; + // PATCH(liquidex): I think I know better how I want to handle overflow. + // editor.style.overflowWrap = 'break-word'; + // editor.style.overflowY = 'auto'; + editor.style.whiteSpace = 'pre'; const doHighlight = (editor, pos) => { highlight(editor, pos); }; diff --git a/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png new file mode 100644 index 0000000..26ddac5 Binary files /dev/null and b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png differ diff --git a/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png new file mode 100644 index 0000000..88011c5 Binary files /dev/null and b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png differ diff --git a/template/sandbox.hbs b/template/sandbox.hbs index ca31a2c..64a8c49 100644 --- a/template/sandbox.hbs +++ b/template/sandbox.hbs @@ -9,6 +9,8 @@ body { margin: 0; overflow: hidden; + width: fit-content; + height: fit-content; } canvas { @@ -16,43 +18,37 @@ } - +
s in have scrollbars proper. */ - &:has(pre) { - overflow: auto; + &:has(pre, th-literate-program) { + overflow: hidden; } } diff --git a/static/emoji/ahyes.png b/static/emoji/ahyes.png new file mode 100644 index 0000000..8da75f8 Binary files /dev/null and b/static/emoji/ahyes.png differ diff --git a/static/js/components/literate-programming.js b/static/js/components/literate-programming.js index e59756a..3fcc1e9 100644 --- a/static/js/components/literate-programming.js +++ b/static/js/components/literate-programming.js @@ -12,10 +12,8 @@ function getLiterateProgram(name) { outputCount: 0, nextOutputIndex() { - let index = this.outputCount; - ++this.outputCount; - return index; - } + return this.outputCount++; + }, }); } return literatePrograms.get(name); @@ -28,7 +26,7 @@ function getLiterateProgramWorkerCommands(name) { if (frame.mode == "input") { commands.push({ kind: "module", source: frame.textContent }); } else if (frame.mode == "output") { - commands.push({ kind: "output", expected: frame.textContent }); + commands.push({ kind: "output" }); } } return commands; @@ -51,7 +49,7 @@ class InputMode { { regex: /"(\\"|[^"])*"/, as: "string" }, { regex: /`(\\`|[^"])*`/, as: "string" }, // TODO: RegExp literals? - { regex: /[+=/*^%<>!~|&\.-]+/, as: "operator" }, + { regex: /[+=/*^%<>!~|&\.?:-]+/, as: "operator" }, { regex: /[,;]/, as: "punct" }, ], keywords: new Map([ @@ -132,54 +130,84 @@ class InputMode { } } +function messageOutputArrayToString(output) { + return output + .map(x => { + if (typeof x === "object") return JSON.stringify(x); + else return x + ""; + }) + .join(" "); +} + class OutputMode { constructor(frame) { - this.clearResultsOnNextOutput = false; - this.frame = frame; - this.frame.program.onChanged.push(_ => this.evaluate()); this.outputIndex = this.frame.program.nextOutputIndex(); - this.evaluate(); - } + this.console = document.createElement("pre"); + this.console.classList.add("console"); + this.frame.appendChild(this.console); + this.clearConsoleOnNextOutput = false; - evaluate() { - // This is a small bit of debouncing. If we cleared the output right away, the page would - // jitter around irritatingly. - this.clearResultsOnNextOutput = true; + this.error = document.createElement("pre"); + this.error.classList.add("error"); + this.frame.appendChild(this.error); - if (this.worker != null) { - this.worker.terminate(); - } - this.worker = new Worker(import.meta.resolve("./literate-programming/worker.js"), { - type: "module", - name: `evaluate LiterateOutput ${this.frame.programName}` - }); + this.iframe = document.createElement("iframe"); + this.iframe.classList.add("hidden"); + this.iframe.src = `${TREEHOUSE_SITE}/sandbox`; + this.frame.appendChild(this.iframe); - this.worker.addEventListener("message", event => { + this.iframe.contentWindow.treehouseSandboxInternals = { outputIndex: this.outputIndex }; + + this.iframe.contentWindow.addEventListener("message", event => { let message = event.data; - if (message.kind == "evalComplete") { - this.worker.terminate(); + if (message.kind == "ready") { + this.evaluate(); + } else if (message.kind == "resize" && message.outputIndex == this.outputIndex) { + this.resize(); } else if (message.kind == "output" && message.outputIndex == this.outputIndex) { - this.addOutput(message.output); + if (message.output.kind == "error") { + this.error.textContent = messageOutputArrayToString(message.output.message); + this.iframe.classList.add("hidden"); + } else { + this.addOutput(message.output); + } + } else if (message.kind == "evalComplete") { + this.error.textContent = ""; + this.flushConsoleClear(); } }); - this.worker.postMessage({ + this.frame.program.onChanged.push(_ => this.evaluate()); + } + + evaluate() { + this.requestConsoleClear(); + this.iframe.contentWindow.postMessage({ action: "eval", input: getLiterateProgramWorkerCommands(this.frame.programName), }); } - addOutput(output) { - if (this.clearResultsOnNextOutput) { - this.clearResultsOnNextOutput = false; - this.clearResults(); - } + clearConsole() { + this.console.replaceChildren(); + } - // Don't show anything if the function didn't return a value. - if (output.kind == "result" && output.message[0] === undefined) return; + requestConsoleClear() { + this.clearConsoleOnNextOutput = true; + } + + flushConsoleClear() { + if (this.clearConsoleOnNextOutput) { + this.clearConsole(); + this.clearConsoleOnNextOutput = false; + } + } + + addOutput(output) { + this.flushConsoleClear(); let line = document.createElement("code"); @@ -194,65 +222,22 @@ class OutputMode { }) .join(" "); - this.frame.appendChild(line); + this.console.appendChild(line); } - clearResults() { - this.frame.replaceChildren(); - } - - static messageOutputArrayToString(output) { - return output - .map(x => { - if (typeof x === "object") return JSON.stringify(x); - else return x + ""; - }) - .join(" "); - } -} - -class GraphicsMode { - constructor(frame) { - this.frame = frame; - - this.error = document.createElement("pre"); - this.error.classList.add("error"); - this.frame.appendChild(this.error); - - this.iframe = document.createElement("iframe"); - this.iframe.classList.add("hidden"); - this.iframe.src = import.meta.resolve("../../html/sandbox.html"); - this.frame.appendChild(this.iframe); - - this.iframe.contentWindow.addEventListener("message", event => { - let message = event.data; - if (message.kind == "ready") { - this.evaluate(); - } - else if (message.kind == "resize") { - this.resize(message); - } else if (message.kind == "output" && message.output.kind == "error") { - this.error.textContent = OutputMode.messageOutputArrayToString(message.output.message); - this.iframe.classList.add("hidden"); - } else if (message.kind == "evalComplete") { - this.error.textContent = ""; - } - }); - - this.frame.program.onChanged.push(_ => this.evaluate()); - } - - evaluate() { - this.iframe.contentWindow.postMessage({ - action: "eval", - input: getLiterateProgramWorkerCommands(this.frame.programName), - }); - } - - resize(message) { - this.iframe.width = message.width; - this.iframe.height = message.height; + resize() { + // iframe cannot be `display: none` to get its scrollWidth/scrollHeight. this.iframe.classList.remove("hidden"); + + let width = this.iframe.contentDocument.body.scrollWidth; + let height = this.iframe.contentDocument.body.scrollHeight; + + if (width == 0 || height == 0) { + this.iframe.classList.add("hidden"); + } else { + this.iframe.width = width; + this.iframe.height = height; + } } } @@ -266,8 +251,6 @@ class LiterateProgram extends HTMLElement { this.modeImpl = new InputMode(this); } else if (this.mode == "output") { this.modeImpl = new OutputMode(this); - } else if (this.mode == "graphics") { - this.modeImpl = new GraphicsMode(this); } } diff --git a/static/js/components/literate-programming/eval.js b/static/js/components/literate-programming/eval.js index f7b86de..19db75c 100644 --- a/static/js/components/literate-programming/eval.js +++ b/static/js/components/literate-programming/eval.js @@ -1,8 +1,21 @@ let outputIndex = 0; -export function getOutputIndex() { - return outputIndex; -} +export const jsConsole = console; + +// Overwrite globalThis.console with domConsole to redirect output to the DOM console. +// To always output to the JavaScript console regardless, use jsConsole. +export const domConsole = { + log(...message) { + postMessage({ + kind: "output", + output: { + kind: "console.log", + message: [...message], + }, + outputIndex, + }); + } +}; async function withTemporaryGlobalScope(callback) { let state = { @@ -13,6 +26,7 @@ async function withTemporaryGlobalScope(callback) { } }; await callback(state); + jsConsole.trace(state.oldValues, "bringing back old state"); for (let key in state.oldValues) { globalThis[key] = state.oldValues[key]; } @@ -20,15 +34,11 @@ async function withTemporaryGlobalScope(callback) { let evaluationComplete = null; -export async function evaluate(commands, { start, success, error }) { +export async function evaluate(commands, { error, newOutput }) { if (evaluationComplete != null) { await evaluationComplete; } - if (start != null) { - start(); - } - let signalEvaluationComplete; evaluationComplete = new Promise((resolve, _reject) => { signalEvaluationComplete = resolve; @@ -36,21 +46,19 @@ export async function evaluate(commands, { start, success, error }) { outputIndex = 0; try { - await withTemporaryGlobalScope(async scope => { - for (let command of commands) { - if (command.kind == "module") { - let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" })); - let module = await import(blobUrl); - for (let exportedKey in module) { - scope.set(exportedKey, module[exportedKey]); - } - } else if (command.kind == "output") { - ++outputIndex; + for (let command of commands) { + if (command.kind == "module") { + let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" })); + let module = await import(blobUrl); + for (let exportedKey in module) { + globalThis[exportedKey] = module[exportedKey]; } + } else if (command.kind == "output") { + if (newOutput != null) { + newOutput(outputIndex); + } + ++outputIndex; } - }); - if (success != null) { - success(); } postMessage({ kind: "evalComplete", diff --git a/static/js/components/literate-programming/worker.js b/static/js/components/literate-programming/worker.js deleted file mode 100644 index 381126c..0000000 --- a/static/js/components/literate-programming/worker.js +++ /dev/null @@ -1,23 +0,0 @@ -import { evaluate, getOutputIndex } from "./eval.js"; - -let debugLog = console.log; - -globalThis.console = { - log(...message) { - postMessage({ - kind: "output", - output: { - kind: "log", - message: [...message], - }, - outputIndex: getOutputIndex(), - }); - } -}; - -addEventListener("message", async event => { - let message = event.data; - if (message.action == "eval") { - evaluate(message.input, {}); - } -}); diff --git a/static/js/components/tairu/cardinal-directions.js b/static/js/components/tairu/cardinal-directions.js new file mode 100644 index 0000000..e69de29 diff --git a/static/js/components/tairu/editor.js b/static/js/components/tairu/editor.js new file mode 100644 index 0000000..d357e57 --- /dev/null +++ b/static/js/components/tairu/editor.js @@ -0,0 +1,134 @@ +import { Sketch } from "treehouse/sandbox.js"; + +export class TileEditor extends Sketch { + constructor({ tilemap, tileSize }) { + super(tilemap.width * tileSize, tilemap.height * tileSize); + + this.colorScheme = { + background: "#F7F7F7", + grid: "#00000011", + tileCursor: "#222222", + tiles: [ + "transparent", // never actually drawn to the screen with the default renderer! + "#eb134a", + ], + }; + + this.tilemap = tilemap; + this.tileSize = tileSize; + + this.hasFocus = false; + this.paintingTile = null; + this.tileCursor = { x: 0, y: 0 }; + + this.canvas.addEventListener("mousemove", event => this.mouseMoved(event)); + this.canvas.addEventListener("mousedown", event => this.mousePressed(event)); + this.canvas.addEventListener("mouseup", event => this.mouseReleased(event)); + + this.canvas.addEventListener("mouseenter", _ => this.mouseEnter()); + this.canvas.addEventListener("mouseleave", _ => this.mouseLeave()); + + this.canvas.addEventListener("contextmenu", event => event.preventDefault()); + + // Only draw first frame after the constructor already runs. + // That way we can modify the color scheme however much we want without causing additional + // redraws. + requestAnimationFrame(() => this.draw()); + } + + draw() { + this.drawBackground(); + this.drawTilemap(); + this.drawGrid(); + if (this.hasFocus) { + this.drawTileCursor(); + } + } + + drawBackground() { + this.ctx.fillStyle = this.colorScheme.background; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + drawTilemap() { + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + if (tile != 0) { + this.ctx.fillStyle = this.colorScheme.tiles[tile]; + this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize); + } + } + } + } + + drawGrid() { + this.ctx.beginPath(); + for (let x = 0; x < this.tilemap.width; ++x) { + this.ctx.moveTo(x * this.tileSize, 0); + this.ctx.lineTo(x * this.tileSize, this.canvas.height); + } + for (let y = 0; y < this.tilemap.width; ++y) { + this.ctx.moveTo(0, y * this.tileSize); + this.ctx.lineTo(this.canvas.width, y * this.tileSize); + } + this.ctx.strokeStyle = this.colorScheme.grid; + this.ctx.lineWidth = 1; + this.ctx.stroke(); + } + + drawTileCursor() { + this.ctx.strokeStyle = this.colorScheme.tileCursor; + this.ctx.lineWidth = 5; + this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize); + } + + + mouseMoved(event) { + this.tileCursor.x = Math.floor(event.offsetX / this.tileSize); + this.tileCursor.y = Math.floor(event.offsetY / this.tileSize); + this.paintTileUnderCursor(); + + this.draw(); + } + + mousePressed(event) { + event.preventDefault(); + + if (event.button == 0) { + this.paintingTile = 1; + } else if (event.button == 2) { + this.paintingTile = 0; + } + + this.paintTileUnderCursor(); + + this.draw(); + } + + mouseReleased(_event) { + this.stopPainting(); + this.draw(); + } + + mouseEnter() { + this.hasFocus = true; + this.draw(); + } + + mouseLeave() { + this.hasFocus = false; + this.stopPainting(); + this.draw(); + } + + paintTileUnderCursor() { + if (this.paintingTile != null) { + this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile); + } + } + + stopPainting() { + this.paintingTile = null; + } +} diff --git a/static/js/tairu/framework.js b/static/js/components/tairu/framework.js similarity index 100% rename from static/js/tairu/framework.js rename to static/js/components/tairu/framework.js diff --git a/static/js/tairu/cardinal-directions.js b/static/js/components/tairu/proto.js similarity index 61% rename from static/js/tairu/cardinal-directions.js rename to static/js/components/tairu/proto.js index 7a72d3d..d9eda1a 100644 --- a/static/js/tairu/cardinal-directions.js +++ b/static/js/components/tairu/proto.js @@ -1,9 +1,45 @@ -import { defineFrame, Frame } from './framework.js'; -import { TileEditor, canConnect, shouldConnect } from './tairu.js'; +import { TileEditor } from 'tairu/editor.js'; -class CardinalDirectionsEditor extends TileEditor { - constructor() { - super(); +export function alignTextInRectangle(ctx, text, x, y, width, height, hAlign, vAlign) { + let measurements = ctx.measureText(text); + + let leftX; + switch (hAlign) { + case "left": + leftX = x; + break; + case "center": + leftX = x + width / 2 - measurements.width / 2; + break; + case "right": + leftX = x + width - measurements.width; + break; + } + + let textHeight = measurements.fontBoundingBoxAscent; + let baselineY; + switch (vAlign) { + case "top": + baselineY = y + textHeight; + break; + case "center": + baselineY = y + height / 2 + textHeight / 2; + break; + case "bottom": + baselineY = y + height; + break; + } + + return { leftX, baselineY }; +} + +export function shouldConnect(a, b) { + return a == b; +} + +export class TileEditorWithCardinalDirections extends TileEditor { + constructor(options) { + super(options); this.colorScheme.tiles[1] = "#f96565"; } @@ -38,4 +74,3 @@ class CardinalDirectionsEditor extends TileEditor { } } } -defineFrame("tairu-editor-cardinal-directions", CardinalDirectionsEditor); diff --git a/static/js/tairu/tilemap-registry.js b/static/js/components/tairu/tilemap-registry.js similarity index 100% rename from static/js/tairu/tilemap-registry.js rename to static/js/components/tairu/tilemap-registry.js diff --git a/static/js/tairu/tilemap.js b/static/js/components/tairu/tilemap.js similarity index 62% rename from static/js/tairu/tilemap.js rename to static/js/components/tairu/tilemap.js index a519ea5..2556496 100644 --- a/static/js/tairu/tilemap.js +++ b/static/js/components/tairu/tilemap.js @@ -27,4 +27,16 @@ export class Tilemap { this.tiles[this.tileIndex(x, y)] = tile; } } + + static parse(alphabet, lineArray) { + let tilemap = new Tilemap(lineArray[0].length, lineArray.length); + for (let y in lineArray) { + let line = lineArray[y]; + for (let x = 0; x < line.length; ++x) { + let char = line.charAt(x); + tilemap.setAt(x, y, alphabet.indexOf(char)); + } + } + return tilemap; + } } diff --git a/static/js/sandbox.js b/static/js/sandbox.js index e61520c..bf2520a 100644 --- a/static/js/sandbox.js +++ b/static/js/sandbox.js @@ -1,5 +1,9 @@ export const internals = { body: document.createElement("body"), + + resetBody() { + this.body.replaceChildren(); + } }; export function body() { @@ -19,4 +23,13 @@ export class Sketch { addElement(this.canvas); } + + animate(draw) { + let animationCallback; + animationCallback = () => { + draw(); + requestAnimationFrame(animationCallback); + }; + animationCallback(); + } } diff --git a/static/js/tairu/tairu.js b/static/js/tairu/tairu.js deleted file mode 100644 index 6cd35a2..0000000 --- a/static/js/tairu/tairu.js +++ /dev/null @@ -1,251 +0,0 @@ -import { Frame, defineFrame } from "./framework.js"; -import tilemapRegistry from "./tilemap-registry.js"; - -export function canConnect(tile) { - return tile == 1; -} - -export function shouldConnect(a, b) { - return a == b; -} - -const dirs47 = { - E: 0b0000_0001, - SE: 0b0000_0010, - S: 0b0000_0100, - SW: 0b0000_1000, - W: 0b0001_0000, - NW: 0b0010_0000, - N: 0b0100_0000, - NE: 0b1000_0000, -}; - -function isSet(integer, bit) { - return (integer & bit) == bit; -} - -function removeRedundancies(t) { - if (isSet(t, dirs47.SE) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.E))) { - t &= ~dirs47.SE; - } - if (isSet(t, dirs47.SW) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.W))) { - t &= ~dirs47.SW; - } - if (isSet(t, dirs47.NW) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.W))) { - t &= ~dirs47.NW; - } - if (isSet(t, dirs47.NE) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.E))) { - t &= ~dirs47.NE; - } - return t; -} - -function ordinalDirections() { - let unique = new Set(); - for (let i = 0; i <= 0b1111_1111; ++i) { - unique.add(removeRedundancies(i)); - } - return Array.from(unique).sort((a, b) => a - b); -} - -let xToConnectionBitSet = ordinalDirections(); -let connectionBitSetToX = new Uint8Array(256); -for (let i = 0; i < xToConnectionBitSet.length; ++i) { - connectionBitSetToX[xToConnectionBitSet[i]] = i; -} -console.log(connectionBitSetToX); - -export class TileEditor extends Frame { - constructor() { - super(); - this.tileCursor = { x: 0, y: 0 }; - - this.colorScheme = { - background: "#F7F7F7", - grid: "#00000011", - tileCursor: "#222222", - tiles: [ - "transparent", - "#eb134a", - ], - }; - - this.tileColorPalette = [ - "transparent", - "#eb134a", - ]; - } - - connectedCallback() { - super.connectedCallback(); - - this.tileSize = parseInt(this.getAttribute("data-tile-size")); - - let tilemapId = this.getAttribute("data-tilemap-id"); - if (tilemapId != null) { - this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")]; - } else { - throw new ReferenceError(`tilemap '${tilemapId}' does not exist`); - } - - // 0st element is explicitly null because it represents the empty tile. - this.tilesets = [null]; - this.tilesets47 = [null]; - - let attachedImages = this.getElementsByTagName("img"); - for (let image of attachedImages) { - if (image.hasAttribute("data-tairu-tileset")) { - let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset")); - this.tilesets[tilesetIndex] = image; - } else if (image.hasAttribute("data-tairu-tileset-47")) { - let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset-47")); - this.tilesets47[tilesetIndex] = image; - } - } - - this.width = this.tilemap.width * this.tileSize; - this.height = this.tilemap.height * this.tileSize; - - this.hasFocus = false; - this.paintingTile = null; - - this.addEventListener("mousemove", event => this.mouseMoved(event)); - this.addEventListener("mousedown", event => this.mousePressed(event)); - this.addEventListener("mouseup", event => this.mouseReleased(event)); - - this.addEventListener("mouseenter", _ => this.hasFocus = true); - this.addEventListener("mouseleave", _ => this.hasFocus = false); - - this.addEventListener("contextmenu", event => event.preventDefault()); - - // TODO: This should also work on mobile. - } - - draw() { - this.ctx.fillStyle = this.colorScheme.background; - this.ctx.fillRect(0, 0, this.width, this.height); - - this.drawTiles(); - this.drawGrid(); - if (this.hasFocus) { - this.drawTileCursor(); - } - } - - drawGrid() { - this.ctx.beginPath(); - for (let x = 0; x < this.tilemap.width; ++x) { - this.ctx.moveTo(x * this.tileSize, 0); - this.ctx.lineTo(x * this.tileSize, this.height); - } - for (let y = 0; y < this.tilemap.width; ++y) { - this.ctx.moveTo(0, y * this.tileSize); - this.ctx.lineTo(this.width, y * this.tileSize); - } - this.ctx.strokeStyle = this.colorScheme.grid; - this.ctx.lineWidth = 1; - this.ctx.stroke(); - } - - drawTileCursor() { - this.ctx.strokeStyle = this.colorScheme.tileCursor; - this.ctx.lineWidth = 5; - this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize); - } - - get hasTilesets() { - // Remember that tile 0 represents emptiness. - return this.tilesets.length > 1 || this.tilesets47.length > 1; - } - - drawTiles() { - if (this.hasTilesets) { - this.drawTexturedTiles(); - } else { - this.drawColoredTiles(); - } - } - - drawColoredTiles() { - for (let y = 0; y < this.tilemap.height; ++y) { - for (let x = 0; x < this.tilemap.width; ++x) { - let tile = this.tilemap.at(x, y); - if (tile != 0) { - this.ctx.fillStyle = this.colorScheme.tiles[tile]; - this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize); - } - } - } - } - - drawTexturedTiles() { - this.ctx.imageSmoothingEnabled = false; - - for (let y = 0; y < this.tilemap.height; ++y) { - for (let x = 0; x < this.tilemap.width; ++x) { - let tile = this.tilemap.at(x, y); - if (tile != 0) { - let tileset16 = this.tilesets[tile]; - let tileset47 = this.tilesets47[tile]; - let tileset = tileset47 != null ? tileset47 : tileset16; - - let tileIndex = 0; - if (tileset47 != null) { - let rawTileIndex = 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? dirs47.E : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y + 1)) ? dirs47.SE : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? dirs47.S : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y + 1)) ? dirs47.SW : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? dirs47.W : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y - 1)) ? dirs47.NW : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? dirs47.N : 0; - rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y - 1)) ? dirs47.NE : 0; - tileIndex = connectionBitSetToX[removeRedundancies(rawTileIndex)]; - } else { - tileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? 0b0001 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? 0b0010 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? 0b0100 : 0; - tileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? 0b1000 : 0; - } - - let tilesetTileSize = tileset.height; - let tilesetX = tileIndex * tilesetTileSize; - let tilesetY = 0; - this.ctx.drawImage( - tileset, - tilesetX, tilesetY, tilesetTileSize, tilesetTileSize, - x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize, - ); - } - } - } - } - - mouseMoved(event) { - let mouse = this.getMousePositionFromEvent(event); - this.tileCursor.x = Math.floor(mouse.x / this.tileSize); - this.tileCursor.y = Math.floor(mouse.y / this.tileSize); - this.paintTileUnderCursor(); - } - - mousePressed(event) { - event.preventDefault(); - if (event.button == 0) { - this.paintingTile = 1; - } else if (event.button == 2) { - this.paintingTile = 0; - } - this.paintTileUnderCursor(); - } - - mouseReleased() { - this.paintingTile = null; - } - - paintTileUnderCursor() { - if (this.paintingTile != null) { - this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile); - } - } -} -defineFrame("tairu-editor", TileEditor); diff --git a/static/js/vendor/codejar.js b/static/js/vendor/codejar.js index 95f0ad0..4041771 100644 --- a/static/js/vendor/codejar.js +++ b/static/js/vendor/codejar.js @@ -23,9 +23,10 @@ export function CodeJar(editor, highlight, opt = {}) { editor.setAttribute('contenteditable', 'plaintext-only'); editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false'); editor.style.outline = 'none'; - editor.style.overflowWrap = 'break-word'; - editor.style.overflowY = 'auto'; - editor.style.whiteSpace = 'pre-wrap'; + // PATCH(liquidex): I think I know better how I want to handle overflow. + // editor.style.overflowWrap = 'break-word'; + // editor.style.overflowY = 'auto'; + editor.style.whiteSpace = 'pre'; const doHighlight = (editor, pos) => { highlight(editor, pos); }; diff --git a/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png new file mode 100644 index 0000000..26ddac5 Binary files /dev/null and b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png differ diff --git a/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png new file mode 100644 index 0000000..88011c5 Binary files /dev/null and b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png differ diff --git a/template/sandbox.hbs b/template/sandbox.hbs index ca31a2c..64a8c49 100644 --- a/template/sandbox.hbs +++ b/template/sandbox.hbs @@ -9,6 +9,8 @@ body { margin: 0; overflow: hidden; + width: fit-content; + height: fit-content; } canvas { @@ -16,43 +18,37 @@ } - +