wip: 47 tiles

This commit is contained in:
liquidex 2024-02-16 22:01:19 +01:00
parent 1013c53975
commit ca94c06c5f
11 changed files with 1098 additions and 51 deletions

View file

@ -1,9 +1,12 @@
%% title = "tairu - an interactive exploration of 2D autotiling techniques" %% title = "tairu - an interactive exploration of 2D autotiling techniques"
scripts = [ scripts = [
"components/literate-programming.js",
"tairu/cardinal-directions.js", "tairu/cardinal-directions.js",
"tairu/framework.js", "tairu/framework.js",
"tairu/tairu.js", "tairu/tairu.js",
"tairu/tilemap-registry.js", "tairu/tilemap-registry.js",
"tairu/tilemap.js",
"vendor/codejar.js",
] ]
styles = ["tairu.css"] styles = ["tairu.css"]
@ -17,11 +20,11 @@ styles = ["tairu.css"]
- TODO: short videos demoing this here - TODO: short videos demoing this here
% id = "01HPD4XQPWJBTJ4DWAQE3J87C9" % id = "01HPD4XQPWJBTJ4DWAQE3J87C9"
- once upon a time I heard of a technique called...\ - once upon a time I stumbled upon a technique called...\
**bitwise autotiling** **bitwise autotiling**
% id = "01HPD4XQPW6VK3FDW5QRCE6HSS" % id = "01HPD4XQPW6VK3FDW5QRCE6HSS"
+ I learned about it back when I was 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 + 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
% id = "01HPD4XQPWJ1CE9ZVRW98X7HE6" % id = "01HPD4XQPWJ1CE9ZVRW98X7HE6"
- Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile: - Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile:
@ -112,6 +115,10 @@ styles = ["tairu.css"]
![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5] ![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.)
% id = "01HPMVT9BMMEM4HT4ANZ40992P" % id = "01HPMVT9BMMEM4HT4ANZ40992P"
- in JavaScript, drawing on a `<canvas>` using bitwise autotiling would look like this: - in JavaScript, drawing on a `<canvas>` using bitwise autotiling would look like this:
```javascript ```javascript
@ -193,21 +200,169 @@ styles = ["tairu.css"]
- (I'm totally not trying to say this implementation is an L so far) - (I'm totally not trying to say this implementation is an L so far)
% id = "01HPMVT9BMWG6QHQ125Z884W8Z" % id = "01HPMVT9BMWG6QHQ125Z884W8Z"
+ i'll cut right to the chase here and say it outright - the issue is that we simply don't have enough tiles to represent corner cases like this! + i'll cut right to the chase here and say it outright - the issue is that we simply don't have enough tiles to represent *corner* cases like this!
% id = "01HPMVT9BMQK8N1H68YV3J4CFQ" % id = "01HPMVT9BMQK8N1H68YV3J4CFQ"
- see what I did there? - see what I did there?
% id = "01HPMVT9BMJTG3KD3K5EJ3BC93" % id = "01HPMVT9BMJTG3KD3K5EJ3BC93"
- the solution here is to introduce more tiles to handle these edge cases. - the solution to that is to introduce more tiles to handle these edge cases.
TODO Explain % classes.branch = "tileset-four-to-eight-demo"
id = "01HPQCCV4R5N97FJ1GS36HZJZ7"
- to represent the corners, we'll turn our four cardinal directions...
<ul class="directions-square">
<li class="east">E</li>
<li class="south">S</li>
<li class="west">W</li>
<li class="north">N</li>
</ul>
into eight *ordinal* directions:
<ul class="directions-square">
<li class="east">E</li>
<li class="south-east">SE</li>
<li class="south">S</li>
<li class="south-west">SW</li>
<li class="west">W</li>
<li class="north-west">NW</li>
<li class="north">N</li>
<li class="north-east"><a href="https://github.com/NoiseStudio/NoiseEngine/" title="NoiseEngine????">NE</a></li>
</ul>
% id = "01HPQCCV4R3GNEWZQFWGWH4Z6R"
- you might think that at this point we'll need 8 bits to represent our tiles, and that would make...
***256 tiles!?***
nobody in their right mind would actually draw 256 separate tiles, right? ***RIGHT???***
% template = true
id = "01HPQCCV4RX13VR4DJAP2F9PFA"
- ...right! if you experiment with the bit combinations, you'll quickly find out that there is no difference if, relative to a single center tile, we have tiles on the corners:
<canvas
is="tairu-editor"
data-tilemap-id="bitwiseAutotilingCorners"
data-tile-size="40"
>
Your browser does not support &lt;canvas&gt;.
<img class="resource" src="{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}" data-tairu-tileset="1">
</canvas>
these should all render the same way, despite technically having some [new neighbors](https://en.wikipedia.org/wiki/Moore_neighborhood).
% classes.branch = "tileset-four-to-eight-demo"
id = "01HPQCCV4RHZ8A7VMT2KM7T27P"
- what we can do about this is to ignore corners whenever zero or one of the tiles at their cardinal directions is connected -
for example, in the case of `E | SE | S`:
<ul class="directions-square e-s">
<li class="east">E</li>
<li class="south-east">SE</li>
<li class="south">S</li>
</ul>
we can completely ignore what happens in the northeast, northwest, and southwest, because the tile's cardinal directions do not fully contain any of these direction pairs.
% 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'll start off by defining a bunch of variables to represent our ordinal directions:
```javascript ordinal-directions
const E = 0b00000001;
const SE = 0b00000010;
const S = 0b00000100;
const SW = 0b00001000;
const W = 0b00010000;
const NW = 0b00100000;
const N = 0b01000000;
const NE = 0b10000000;
const ALL = E | SE | S | SW | W | NW | N | NE;
```
as I've already said, we represent each direction using a single bit.
- 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.
- 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
function isSet(integer, bit) {
return (integer & bit) == bit;
}
```
- 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.
```javascript ordinal-directions
// t is a tile index; variable name is short for brevity
function removeRedundancies(t) {
if (isSet(t, SE) && (!isSet(t, S) || !isSet(t, E))) {
t &= ~SE;
}
if (isSet(t, SW) && (!isSet(t, S) || !isSet(t, W))) {
t &= ~SW;
}
if (isSet(t, NW) && (!isSet(t, N) || !isSet(t, W))) {
t &= ~NW;
}
if (isSet(t, NE) && (!isSet(t, N) || !isSet(t, E))) {
t &= ~NE;
}
return t;
}
```
- with that, we can find a set of all unique non-redundant combinations:
```javascript ordinal-directions
function ordinalDirections() {
let unique = new Set();
for (let i = 0; i <= ALL; ++i) {
unique.add(removeRedundancies(i));
}
return Array.from(unique).sort((a, b) => a - b);
}
```
- 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?
[`Array.prototype.sort`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
- and now it's time to _Let It Cook™_:
```javascript ordinal-directions
let dirs = ordinalDirections();
console.log(dirs.length);
```
```output ordinal-directions
47
```
- forty seven! that's how many unique tiles we actually need.
- 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.
- phew... the nesting's getting quite unwieldy, let's wrap up this tangent and return back to doing some bitwise autotiling!
- 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!
- 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.
% 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.
% id = "01HPD4XQPW5FQY8M04S6JEBDHQ" % id = "01HPD4XQPW5FQY8M04S6JEBDHQ"
- as I mentioned before, [I've known it since my Construct 2 days][branch:01HPD4XQPW6VK3FDW5QRCE6HSS], but when it comes to my released games [Planet Overgamma] would probably be the first to utilize it properly - as I mentioned before, [I've known it since my Construct 2 days][branch:01HPD4XQPW6VK3FDW5QRCE6HSS], but when it comes to any released games [Planet Overgamma] would probably be the first to utilize it properly.
TODO video of some Planet Overgamma gameplay showing the autotiling in action TODO video of some Planet Overgamma gameplay showing the autotiling in action
@ -215,7 +370,7 @@ styles = ["tairu.css"]
% id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ" % id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ"
- this accursed game has been haunting me for years since; there have been many iterations. - this accursed game has been haunting me for years since; there have been many iterations.
he autotiling source code of the one in the video can be found [here][autotiling source code]. the autotiling source code of the one in the video can be found [here][autotiling source code].
[autotiling source code]: https://github.com/liquidev/planet-overgamma/blob/classic/jam/map.lua#L209 [autotiling source code]: https://github.com/liquidev/planet-overgamma/blob/classic/jam/map.lua#L209
@ -223,19 +378,20 @@ styles = ["tairu.css"]
+ but one day I found a really cool project called [Tilekit](https://rxi.itch.io/tilekit) + but one day I found a really cool project called [Tilekit](https://rxi.itch.io/tilekit)
% id = "01HPD4XQPW11EQTBDQSGXW3S52" % id = "01HPD4XQPW11EQTBDQSGXW3S52"
+ (of course it's really cool, after all rxi made it) + (of course it's really cool, after all [rxi](https://github.com/rxi) made it)
% id = "01HPD4XQPWYHS327BV586SB085" % id = "01HPD4XQPWYHS327BV586SB085"
- for context rxi is the genius behind the Lua-powered, simple, and modular text editor `lite` that I was using for quite a while - for context rxi is the genius behind the Lua-powered, simple, and modular text editor [lite](https://github.com/rxi/lite) that I was using for quite a while
% id = "01HPD4XQPWJ9QAQ5MF2J5JBB8M" % id = "01HPD4XQPWJ9QAQ5MF2J5JBB8M"
- after a while I switched to a fork - Lite XL, which had better font rendering and more features - after a while I switched to a fork - [Lite XL](https://github.com/lite-xl/lite-xl), which had better font rendering and more features
% id = "01HPD4XQPWB11TZSX5VAAJ6TCD" % id = "01HPD4XQPWB11TZSX5VAAJ6TCD"
- I stopped using it because VS Code was just more feature packed and usable; no need to reinvent the wheel, rust-analyzer *just works.* - I stopped using it because VS Code was just more feature packed and usable; no need to reinvent the wheel, rust-analyzer *just works.*
% id = "01HPD4XQPW3G7BXTBBTD05MB8V" % id = "01HPD4XQPW3G7BXTBBTD05MB8V"
- the LSP plugin for Lite XL had some issues around autocompletions not filling in properly :pensive:\ - the LSP plugin for Lite XL had some issues around autocompletions not filling in properly :pensive:
it's likely a lot better now, but back then I decided this is too much for my nerves. it's likely a lot better now, but back then I decided this is too much for my nerves.
while tinkering with your editor is something really cool, in my experience it's only cool up to a point. while tinkering with your editor is something really cool, in my experience it's only cool up to a point.
@ -267,27 +423,10 @@ styles = ["tairu.css"]
% id = "01HPD4XQPWP847T0EAM0FJ88T4" % id = "01HPD4XQPWP847T0EAM0FJ88T4"
- then vines - then vines
- well... it's even simpler than that in terms of graphical presentation, but we'll get to that.
% id = "01HPD4XQPWK58Z63X6962STADR" % id = "01HPD4XQPWK58Z63X6962STADR"
- I mean, after all - bitwise autotiling is basically a clever solution to an `if` complexity problem, so why not extend that with more logic and rules and stuff to let you build more complex maps? - I mean, after all - bitwise autotiling is basically a clever solution to an `if` complexity problem, so why not extend that with more logic and rules and stuff to let you build more complex maps?
% id = "01HPJ8GHDFRA2SPNHKJYD0SYPP" % id = "01HPJ8GHDFRA2SPNHKJYD0SYPP"
- of course Tilekit's solution is a lot more simple, streamlined, and user-friendly, but you get the gist. - of course Tilekit's solution is a lot more simple, streamlined, and user-friendly, but you get the gist.
% id = "01HPD4XQPW4Y075XWJCT6AATB2"
- ever since then I've been wanting to build something just like Tilekit, but in the form of an educational, interactive blog post to demonstrate the ideas in a fun way
% id = "01HPD4XQPWR8J9WCNBNCTJERZS"
- and what you're reading is the result of that.
% id = "01HPD4XQPW1EP8YHACRJVMA0GM"
- so let's get going! first, we'll build a basic tile editor using JavaScript.
% id = "01HPD4XQPWPNRTVJFNFGNHJMG1"
+ not my favorite language, but we're on the Web so it's not like we have much more of a choice.
% id = "01HPD4XQPWGK7M4XJYC99XE4T6"
- I could use TypeScript, but this page follows a philosophy of not introducing complexity where I can deal without it.
TypeScript is totally cool, but not necessary.
% id = "01HPD4XQPWAE0ZH46WME6WJSVP"
- I'll be using Web Components (in particular, custom elements) combined with canvas to add stuff onto the page.

View file

@ -227,16 +227,29 @@ where
self.write_newline()?; self.write_newline()?;
} }
match info { match info {
CodeBlockKind::Fenced(info) => { CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
let lang = info.split(' ').next().unwrap(); CodeBlockMode::PlainText => self.write("<pre><code>"),
if lang.is_empty() { CodeBlockMode::SyntaxHighlightOnly { language } => {
self.write("<pre><code>")
} else {
self.write("<pre><code class=\"language-")?; self.write("<pre><code class=\"language-")?;
escape_html(&mut self.writer, lang)?; escape_html(&mut self.writer, language)?;
self.write("\">") self.write("\">")
} }
} CodeBlockMode::LiterateProgram {
language,
kind,
program_name,
} => {
self.write(match kind {
LiterateCodeKind::Input => "<th-literate-editor ",
LiterateCodeKind::Output => "<th-literate-output ",
})?;
self.write("data-program=\"")?;
escape_html(&mut self.writer, program_name)?;
self.write("\" data-language=\"")?;
escape_html(&mut self.writer, language)?;
self.write("\" role=\"code\">")
}
},
CodeBlockKind::Indented => self.write("<pre><code>"), CodeBlockKind::Indented => self.write("<pre><code>"),
} }
} }
@ -352,8 +365,21 @@ where
Tag::BlockQuote => { Tag::BlockQuote => {
self.write("</blockquote>\n")?; self.write("</blockquote>\n")?;
} }
Tag::CodeBlock(_) => { Tag::CodeBlock(kind) => {
self.write("</code></pre>\n")?; self.write(match kind {
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
CodeBlockMode::LiterateProgram {
kind: LiterateCodeKind::Input,
..
} => "</th-literate-editor>",
CodeBlockMode::LiterateProgram {
kind: LiterateCodeKind::Output,
..
} => "</th-literate-output>",
_ => "</code></pre>",
},
_ => "</code></pre>\n",
})?;
self.in_code_block = false; self.in_code_block = false;
} }
Tag::List(Some(_)) => { Tag::List(Some(_)) => {
@ -518,6 +544,44 @@ where
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LiterateCodeKind {
Input,
Output,
}
enum CodeBlockMode<'a> {
PlainText,
SyntaxHighlightOnly {
language: &'a str,
},
LiterateProgram {
language: &'a str,
kind: LiterateCodeKind,
program_name: &'a str,
},
}
impl<'a> CodeBlockMode<'a> {
fn parse(language: &'a str) -> CodeBlockMode<'a> {
if language.is_empty() {
CodeBlockMode::PlainText
} else if let Some((language, program_name)) = language.split_once(' ') {
CodeBlockMode::LiterateProgram {
language,
kind: if language == "output" {
LiterateCodeKind::Output
} else {
LiterateCodeKind::Input
},
program_name,
}
} else {
CodeBlockMode::SyntaxHighlightOnly { language }
}
}
}
/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and /// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
/// push it to a `String`. /// push it to a `String`.
/// ///

View file

@ -170,7 +170,9 @@ h4 {
pre, pre,
code, code,
kbd { kbd,
th-literate-editor,
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;
@ -210,19 +212,27 @@ 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 {
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-output {
display: block;
}
kbd { kbd {
padding: 3px 6px; padding: 3px 6px;
border: 1px solid var(--border-1); border: 1px solid var(--border-1);
border-radius: 6px; border-radius: 6px;
} }
pre { pre,
th-literate-editor,
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);
@ -231,11 +241,22 @@ pre {
transition: background-color var(--transition-duration); transition: background-color var(--transition-duration);
} }
.tree summary:hover pre { th-literate-editor,
background-color: var(--shaded-against-background-twice); th-literate-output {
white-space: pre;
} }
pre>code { .tree summary:hover {
& pre,
& th-literate-editor,
& th-literate-output {
background-color: var(--shaded-against-background-twice);
}
}
pre>code,
th-literate-output>code {
padding: 0; padding: 0;
background: none; background: none;
border-radius: 0px; border-radius: 0px;
@ -493,3 +514,40 @@ img[is="th-emoji"] {
display: inline; display: inline;
animation: 4s hello-there forwards; animation: 4s hello-there forwards;
} }
/* Literate programming support */
th-literate-editor {
/* Override the cursor with an I-beam, because the editor captures clicks and does not bubble
them back up to the caller */
cursor: text;
}
th-literate-output {
position: relative;
& code {
display: block;
}
& code.error {
color: #e39393;
}
& code .return-value {
content: 'Return value: ';
opacity: 50%;
}
&::after {
content: 'Output';
padding: 8px;
position: absolute;
right: 0;
top: 0;
opacity: 50%;
}
}

View file

@ -63,6 +63,11 @@
font-size: 14px; font-size: 14px;
position: absolute; position: absolute;
color: #d3dce9; color: #d3dce9;
text-shadow:
1px 0 0 #1a2039,
-1px 0 0 #1a2039,
0 1px 0 #1a2039,
0 -1px 0 #1a2039;
padding: 2px 4px; padding: 2px 4px;
} }
@ -99,11 +104,11 @@
} }
& .x-1 { & .x-1 {
background-position-x: 33.3333%; background-position-x: calc(100% / 3);
} }
& .x-2 { & .x-2 {
background-position-x: 66.6666%; background-position-x: calc(200% / 3);
} }
& .x-3 { & .x-3 {
@ -115,14 +120,102 @@
} }
& .y-1 { & .y-1 {
background-position-y: 33.3333%; background-position-y: calc(100% / 3);
} }
& .y-2 { & .y-2 {
background-position-y: 66.6666%; background-position-y: calc(200% / 3);
} }
& .y-3 { & .y-3 {
background-position-y: 100%; background-position-y: 100%;
} }
} }
.tileset-four-to-eight-demo th-bc {
& .directions-square {
--recursive-wght: 900;
--recursive-casl: 0.0;
--recursive-slnt: 0.0;
--recursive-mono: 1.0;
color: #d3dce9;
text-shadow:
1px 0 0 #1a2039,
-1px 0 0 #1a2039,
0 1px 0 #1a2039,
0 -1px 0 #1a2039;
margin-block: 8px;
margin-left: 16px;
padding-left: 0;
width: 72px;
height: 72px;
background-image: url('../pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png');
background-size: 400%;
background-position: 100% 100%;
image-rendering: crisp-edges;
position: relative;
li {
padding: 2px 4px;
position: absolute;
}
a {
text-decoration: none;
color: #d3dce9;
cursor: text;
}
& .east {
right: 0;
top: 50%;
transform: translateY(-50%);
}
& .south-east {
right: 0;
bottom: 0;
}
& .west {
left: 0;
top: 50%;
transform: translateY(-50%);
}
& .south-west {
left: 0;
bottom: 0;
}
& .north {
left: 50%;
top: 0;
transform: translateX(-50%);
}
& .north-west {
left: 0;
top: 0;
}
& .south {
left: 50%;
bottom: 0;
transform: translateX(-50%);
}
& .north-east {
right: 0;
top: 0;
}
&.e-s {
background-position: 0% 0%;
}
}
}

View file

@ -0,0 +1,135 @@
import { CodeJar } from "../vendor/codejar.js";
let literatePrograms = new Map();
function getLiterateProgram(name) {
if (literatePrograms.get(name) == null) {
literatePrograms.set(name, {
editors: [],
onChanged: [],
});
}
return literatePrograms.get(name);
}
function getLiterateProgramSourceCode(name) {
let sources = [];
let literateProgram = getLiterateProgram(name);
for (let editor of literateProgram.editors) {
sources.push(editor.textContent);
}
return sources.join("\n");
}
class LiterateEditor extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.literateProgramName = this.getAttribute("data-program");
getLiterateProgram(this.literateProgramName).editors.push(this);
this.codeJar = CodeJar(this, LiterateEditor.highlight);
this.codeJar.onUpdate(() => {
let literateProgram = getLiterateProgram(this.literateProgramName);
for (let handler of literateProgram.onChanged) {
handler(this.literateProgramName);
}
})
this.addEventListener("click", event => event.preventDefault());
}
static highlight(editor) {
// TODO: Syntax highlighting
}
}
customElements.define("th-literate-editor", LiterateEditor);
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;
}
connectedCallback() {
this.literateProgramName = this.getAttribute("data-program");
this.evaluate();
getLiterateProgram(this.literateProgramName).onChanged.push(_ => this.evaluate());
}
evaluate = () => {
// This is a small bit of debouncing. If we cleared the output right away, the page would
// jitter around irritatingly
this.clearResultsOnNextOutput = true;
if (this.worker != null) {
this.worker.terminate();
}
this.worker = new Worker(`${TREEHOUSE_SITE}/static/js/components/literate-programming/worker.js`, {
type: "module",
name: `evaluate LiterateOutput ${this.literateProgramName}`
});
this.worker.addEventListener("message", event => {
let message = event.data;
if (message.kind == "evalComplete") {
this.worker.terminate();
} else if (message.kind == "output") {
this.addOutput(message.output);
}
});
this.worker.postMessage({
action: "eval",
input: getLiterateProgramSourceCode(this.literateProgramName),
});
};
addOutput(output) {
if (this.clearResultsOnNextOutput) {
this.clearResultsOnNextOutput = false;
this.clearResults();
}
// Don't show anything if the function didn't return a value.
if (output.kind == "result" && output.message[0] === undefined) return;
let line = document.createElement("code");
line.classList.add("output");
line.classList.add(output.kind);
line.textContent = output.message.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
}).join(" ");
if (output.kind == "result") {
let returnValueText = document.createElement("span");
returnValueText.classList.add("return-value");
returnValueText.textContent = "Return value: ";
line.insertBefore(returnValueText, line.firstChild);
}
this.appendChild(line);
}
clearResults() {
this.replaceChildren();
}
}
customElements.define("th-literate-output", LiterateOutput);

View file

@ -0,0 +1,40 @@
console = {
log(...message) {
postMessage({
kind: "output",
output: {
kind: "log",
message: [...message],
}
});
}
};
addEventListener("message", event => {
let message = event.data;
if (message.action == "eval") {
try {
let func = new Function(message.input);
let result = func.apply({});
postMessage({
kind: "output",
output: {
kind: "result",
message: [result],
}
});
} catch (error) {
postMessage({
kind: "output",
output: {
kind: "error",
message: [error.toString()],
}
});
}
postMessage({
kind: "evalComplete",
});
}
});

View file

@ -29,5 +29,12 @@ export default {
" xxx ", " xxx ",
" ", " ",
]), ]),
bitwiseAutotilingCorners: parseTilemap([
" ",
" x x ",
" x ",
" x x ",
" ",
]),
}; };

511
static/js/vendor/codejar.js vendored Normal file
View file

@ -0,0 +1,511 @@
const globalWindow = window;
export function CodeJar(editor, highlight, opt = {}) {
const options = {
tab: '\t',
indentOn: /[({\[]$/,
moveToNewLine: /^[)}\]]/,
spellcheck: false,
catchTab: true,
preserveIdent: true,
addClosing: true,
history: true,
window: globalWindow,
...opt,
};
const window = options.window;
const document = window.document;
const listeners = [];
const history = [];
let at = -1;
let focus = false;
let onUpdate = () => void 0;
let prev; // code content prior keydown event
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';
const doHighlight = (editor, pos) => {
highlight(editor, pos);
};
let isLegacy = false; // true if plaintext-only is not supported
if (editor.contentEditable !== 'plaintext-only')
isLegacy = true;
if (isLegacy)
editor.setAttribute('contenteditable', 'true');
const debounceHighlight = debounce(() => {
const pos = save();
doHighlight(editor, pos);
restore(pos);
}, 30);
let recording = false;
const shouldRecord = (event) => {
return !isUndo(event) && !isRedo(event)
&& event.key !== 'Meta'
&& event.key !== 'Control'
&& event.key !== 'Alt'
&& !event.key.startsWith('Arrow');
};
const debounceRecordHistory = debounce((event) => {
if (shouldRecord(event)) {
recordHistory();
recording = false;
}
}, 300);
const on = (type, fn) => {
listeners.push([type, fn]);
editor.addEventListener(type, fn);
};
on('keydown', event => {
if (event.defaultPrevented)
return;
prev = toString();
if (options.preserveIdent)
handleNewLine(event);
else
legacyNewLineFix(event);
if (options.catchTab)
handleTabCharacters(event);
if (options.addClosing)
handleSelfClosingCharacters(event);
if (options.history) {
handleUndoRedo(event);
if (shouldRecord(event) && !recording) {
recordHistory();
recording = true;
}
}
if (isLegacy && !isCopy(event))
restore(save());
});
on('keyup', event => {
if (event.defaultPrevented)
return;
if (event.isComposing)
return;
if (prev !== toString())
debounceHighlight();
debounceRecordHistory(event);
onUpdate(toString());
});
on('focus', _event => {
focus = true;
});
on('blur', _event => {
focus = false;
});
on('paste', event => {
recordHistory();
handlePaste(event);
recordHistory();
onUpdate(toString());
});
on('cut', event => {
recordHistory();
handleCut(event);
recordHistory();
onUpdate(toString());
});
function save() {
const s = getSelection();
const pos = { start: 0, end: 0, dir: undefined };
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
if (!anchorNode || !focusNode)
throw 'error1';
// If the anchor and focus are the editor element, return either a full
// highlight or a start/end cursor position depending on the selection
if (anchorNode === editor && focusNode === editor) {
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
return pos;
}
// Selection anchor and focus are expected to be text nodes,
// so normalize them.
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
anchorNode = node;
anchorOffset = 0;
}
if (focusNode.nodeType === Node.ELEMENT_NODE) {
const node = document.createTextNode('');
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
focusNode = node;
focusOffset = 0;
}
visit(editor, el => {
if (el === anchorNode && el === focusNode) {
pos.start += anchorOffset;
pos.end += focusOffset;
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
return 'stop';
}
if (el === anchorNode) {
pos.start += anchorOffset;
if (!pos.dir) {
pos.dir = '->';
}
else {
return 'stop';
}
}
else if (el === focusNode) {
pos.end += focusOffset;
if (!pos.dir) {
pos.dir = '<-';
}
else {
return 'stop';
}
}
if (el.nodeType === Node.TEXT_NODE) {
if (pos.dir != '->')
pos.start += el.nodeValue.length;
if (pos.dir != '<-')
pos.end += el.nodeValue.length;
}
});
editor.normalize(); // collapse empty text nodes
return pos;
}
function restore(pos) {
const s = getSelection();
let startNode, startOffset = 0;
let endNode, endOffset = 0;
if (!pos.dir)
pos.dir = '->';
if (pos.start < 0)
pos.start = 0;
if (pos.end < 0)
pos.end = 0;
// Flip start and end if the direction reversed
if (pos.dir == '<-') {
const { start, end } = pos;
pos.start = end;
pos.end = start;
}
let current = 0;
visit(editor, el => {
if (el.nodeType !== Node.TEXT_NODE)
return;
const len = (el.nodeValue || '').length;
if (current + len > pos.start) {
if (!startNode) {
startNode = el;
startOffset = pos.start - current;
}
if (current + len > pos.end) {
endNode = el;
endOffset = pos.end - current;
return 'stop';
}
}
current += len;
});
if (!startNode)
startNode = editor, startOffset = editor.childNodes.length;
if (!endNode)
endNode = editor, endOffset = editor.childNodes.length;
// Flip back the selection
if (pos.dir == '<-') {
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
}
{
// If nodes not editable, create a text node.
const startEl = uneditable(startNode);
if (startEl) {
const node = document.createTextNode('');
startEl.parentNode?.insertBefore(node, startEl);
startNode = node;
startOffset = 0;
}
const endEl = uneditable(endNode);
if (endEl) {
const node = document.createTextNode('');
endEl.parentNode?.insertBefore(node, endEl);
endNode = node;
endOffset = 0;
}
}
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
editor.normalize(); // collapse empty text nodes
}
function uneditable(node) {
while (node && node !== editor) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node;
if (el.getAttribute('contenteditable') == 'false') {
return el;
}
}
node = node.parentNode;
}
}
function beforeCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setEnd(r0.startContainer, r0.startOffset);
return r.toString();
}
function afterCursor() {
const s = getSelection();
const r0 = s.getRangeAt(0);
const r = document.createRange();
r.selectNodeContents(editor);
r.setStart(r0.endContainer, r0.endOffset);
return r.toString();
}
function handleNewLine(event) {
if (event.key === 'Enter') {
const before = beforeCursor();
const after = afterCursor();
let [padding] = findPadding(before);
let newLinePadding = padding;
// If last symbol is "{" ident new line
if (options.indentOn.test(before)) {
newLinePadding += options.tab;
}
// Preserve padding
if (newLinePadding.length > 0) {
preventDefault(event);
event.stopPropagation();
insert('\n' + newLinePadding);
}
else {
legacyNewLineFix(event);
}
// Place adjacent "}" on next line
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
const pos = save();
insert('\n' + padding);
restore(pos);
}
}
}
function legacyNewLineFix(event) {
// Firefox does not support plaintext-only mode
// and puts <div><br></div> on Enter. Let's help.
if (isLegacy && event.key === 'Enter') {
preventDefault(event);
event.stopPropagation();
if (afterCursor() == '') {
insert('\n ');
const pos = save();
pos.start = --pos.end;
restore(pos);
}
else {
insert('\n');
}
}
}
function handleSelfClosingCharacters(event) {
const open = `([{'"`;
const close = `)]}'"`;
if (open.includes(event.key)) {
preventDefault(event);
const pos = save();
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
const text = event.key + wrapText + close[open.indexOf(event.key)];
insert(text);
pos.start++;
pos.end++;
restore(pos);
}
}
function handleTabCharacters(event) {
if (event.key === 'Tab') {
preventDefault(event);
if (event.shiftKey) {
const before = beforeCursor();
let [padding, start] = findPadding(before);
if (padding.length > 0) {
const pos = save();
// Remove full length tab or just remaining padding
const len = Math.min(options.tab.length, padding.length);
restore({ start, end: start + len });
document.execCommand('delete');
pos.start -= len;
pos.end -= len;
restore(pos);
}
}
else {
insert(options.tab);
}
}
}
function handleUndoRedo(event) {
if (isUndo(event)) {
preventDefault(event);
at--;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at < 0)
at = 0;
}
if (isRedo(event)) {
preventDefault(event);
at++;
const record = history[at];
if (record) {
editor.innerHTML = record.html;
restore(record.pos);
}
if (at >= history.length)
at--;
}
}
function recordHistory() {
if (!focus)
return;
const html = editor.innerHTML;
const pos = save();
const lastRecord = history[at];
if (lastRecord) {
if (lastRecord.html === html
&& lastRecord.pos.start === pos.start
&& lastRecord.pos.end === pos.end)
return;
}
at++;
history[at] = { html, pos };
history.splice(at + 1);
const maxHistory = 300;
if (at > maxHistory) {
at = maxHistory;
history.splice(0, 1);
}
}
function handlePaste(event) {
if (event.defaultPrevented)
return;
preventDefault(event);
const originalEvent = event.originalEvent ?? event;
const text = originalEvent.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n');
const pos = save();
insert(text);
doHighlight(editor);
restore({
start: Math.min(pos.start, pos.end) + text.length,
end: Math.min(pos.start, pos.end) + text.length,
dir: '<-',
});
}
function handleCut(event) {
const pos = save();
const selection = getSelection();
const originalEvent = event.originalEvent ?? event;
originalEvent.clipboardData.setData('text/plain', selection.toString());
document.execCommand('delete');
doHighlight(editor);
restore({
start: Math.min(pos.start, pos.end),
end: Math.min(pos.start, pos.end),
dir: '<-',
});
preventDefault(event);
}
function visit(editor, visitor) {
const queue = [];
if (editor.firstChild)
queue.push(editor.firstChild);
let el = queue.pop();
while (el) {
if (visitor(el) === 'stop')
break;
if (el.nextSibling)
queue.push(el.nextSibling);
if (el.firstChild)
queue.push(el.firstChild);
el = queue.pop();
}
}
function isCtrl(event) {
return event.metaKey || event.ctrlKey;
}
function isUndo(event) {
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
}
function isRedo(event) {
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
}
function isCopy(event) {
return isCtrl(event) && getKeyCode(event) === 'C';
}
function getKeyCode(event) {
let key = event.key || event.keyCode || event.which;
if (!key)
return undefined;
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
}
function insert(text) {
text = text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
document.execCommand('insertHTML', false, text);
}
function debounce(cb, wait) {
let timeout = 0;
return (...args) => {
clearTimeout(timeout);
timeout = window.setTimeout(() => cb(...args), wait);
};
}
function findPadding(text) {
// Find beginning of previous line.
let i = text.length - 1;
while (i >= 0 && text[i] !== '\n')
i--;
i++;
// Find padding of the line.
let j = i;
while (j < text.length && /[ \t]/.test(text[j]))
j++;
return [text.substring(i, j) || '', i, j];
}
function toString() {
return editor.textContent || '';
}
function preventDefault(event) {
event.preventDefault();
}
function getSelection() {
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
return editor.parentNode.getSelection();
}
return window.getSelection();
}
return {
updateOptions(newOptions) {
Object.assign(options, newOptions);
},
updateCode(code) {
editor.textContent = code;
doHighlight(editor);
onUpdate(code);
},
onUpdate(callback) {
onUpdate = callback;
},
toString,
save,
restore,
recordHistory,
destroy() {
for (let [type, fn] of listeners) {
editor.removeEventListener(type, fn);
}
},
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B