diff --git a/content/programming/blog/tairu.tree b/content/programming/blog/tairu.tree
index 54050c7..d26e484 100644
--- a/content/programming/blog/tairu.tree
+++ b/content/programming/blog/tairu.tree
@@ -1,5 +1,11 @@
%% title = "tairu - an interactive exploration of 2D autotiling techniques"
- scripts = ["tairu/tiling-demo.js", "tairu/tairu.js"]
+scripts = [
+ "tairu/cardinal-directions.js",
+ "tairu/framework.js",
+ "tairu/tairu.js",
+ "tairu/tilemap-registry.js",
+]
+styles = ["tairu.css"]
% id = "01HPD4XQPWM8ECT2QM6AT9YRWB"
- I remember since my early days doing programming, I've been interested in how games like Terraria handle automatically tiling their terrain.
@@ -11,7 +17,8 @@
- TODO: short videos demoing this here
% id = "01HPD4XQPWJBTJ4DWAQE3J87C9"
-- once upon a time I heard of a technique called *bitwise autotiling*
+- once upon a time I heard of a technique called...\
+**bitwise autotiling**
% 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
@@ -19,32 +26,183 @@
% id = "01HPD4XQPWJ1CE9ZVRW98X7HE6"
- Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile:
- % id = "01HPD4XQPWHNFQPRHX13MYW8GT"
- - this technique involves assigning the cardinal directions (north, south, east, west) to a bitset.
- then for each tile you look at which adjacent tiles should be connected to
+ % id = "01HPJ8GHDET8ZGNN0AH3FWA8HX"
+ - let's begin with a tilemap. say we have the following grid of tiles: (the examples are interactive, try editing it!)
- % id = "01HPD4XQPWS2JS8RJH2P5TKPAB"
- - this connection condition can be whatever you want - in most cases it's just "is the adjacent tile of the same type as the current tile?"
+
+ Your browser does not support <canvas>.
+
- % id = "01HPD4XQPWAANYFBYX681787D1"
- - for example, "is the tile to the left a dirt tile?"
+ % id = "01HPJ8GHDEC0Z334M04MTNADV9"
+ - for each tile, we can assign a bitset of cardinal directions like so:
- % id = "01HPD4XQPWES5K2V2AKB7H0EHK"
- - and then you use this bitset to index into a lookup table of tiles
+
+ Your browser does not support <canvas>.
+
- % id = "01HPD4XQPWD00GDZ0N5H1DRH2P"
- - for example, say we have the following grid of tiles:\
- TODO editable grid on javascript
+ % 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 each tile, we can assign a bitset of cardinal directions like so:\
- TODO grid linked with the other grid to show which adjacent tiles each tile connects to
+
+
+
+
+
+ E S
+ E S W
+ S W
+ S
+ E S N
+ E S W N
+ S W N
+ S N
+ E N
+ E W N
+ W N
+ N
+ E
+ E W
+ W
+
+
+
- % id = "01HPD4XQPWM0AAE6F162EZTFQY"
- - in JavaScript it would look something like this:
+ % id = "01HPMVT9BM65YD5AXWPT4Z67H5"
+ - (it's frustratingly hard to center individual letters like this in CSS. please forgive me for how crooked these are!)
+
+ % 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!
+
+ % 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:
+
+
+
+ E
+ S
+ E S
+ W
+ E W
+ S W
+ E S W
+ N
+ E N
+ S N
+ E S N
+ W N
+ E W N
+ S W N
+ E S W N
+
+
+ packing that into a single tilesheet, or rather tile *strip*, we get this image:
+
+ ![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5]
+
+ % id = "01HPMVT9BMMEM4HT4ANZ40992P"
+ - in JavaScript, drawing on a `` using bitwise autotiling would look like this:
```javascript
- // TODO code example
+ 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);
+
+ // 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];
+
+ // 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;
+
+ // 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,
+ );
+ }
+ }
+ }
```
+ % template = true
+ id = "01HPMVT9BM9CS9375MX4H9WKW8"
+ - and that gives us this result:
+
+
+ Your browser does not support <canvas>.
+
+
+
+ % id = "01HPMVT9BM3WR0BNZFHP2BPZ8A"
+ - but if you play around with it (or have *already* played around with it, and are therefore left with a non-default tilemap)
+
+ ...something seems awful about it doesn't it?
+
+ % template = true
+ id = "01HPMVT9BMPA89037VPWPPWX8V"
+ - something's off about the corners. let me give you a fresh example to illustrate what I mean:
+
+
+ Your browser does not support <canvas>.
+
+
+
+ % id = "01HPMVT9BM16EF3TV5J1K19JAM"
+ + see that tile in the bottom left corner of the `L` shape? it's missing a corner.
+ the top-right corner, to be exact, which makes it visually disjoint from the tiles to the north and the east.
+
+ % id = "01HPMVT9BM5VWJSMDNPK2SRNZV"
+ - (I'm totally not trying to say this implementation is an L so far)
+
+ % 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!
+
+ % id = "01HPMVT9BMQK8N1H68YV3J4CFQ"
+ - see what I did there?
+
+ % id = "01HPMVT9BMJTG3KD3K5EJ3BC93"
+ - the solution here is to introduce more tiles to handle these edge cases.
+
+ TODO Explain
+
% id = "01HPD4XQPWT9N8X9BD9GKWD78F"
- bitwise autotiling is a really cool technique that I've used in plenty of games in the past
@@ -55,6 +213,7 @@
[Planet Overgamma]: https://liquidev.itch.io/planet-overgamma-classic
+ % id = "01HPJ8GHDEN4XRPT1AJ1BTNTFJ"
- 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].
@@ -111,6 +270,9 @@
% 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?
+ % id = "01HPJ8GHDFRA2SPNHKJYD0SYPP"
+ - 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
diff --git a/crates/treehouse/src/cli/generate.rs b/crates/treehouse/src/cli/generate.rs
index 533580b..7af2fec 100644
--- a/crates/treehouse/src/cli/generate.rs
+++ b/crates/treehouse/src/cli/generate.rs
@@ -229,15 +229,7 @@ impl Generator {
.thumbnail
.as_ref()
.map(|thumbnail| Thumbnail {
- url: format!(
- "{}/static/pic/{}",
- config.site,
- config
- .pics
- .get(&thumbnail.id)
- .map(|x| &**x)
- .unwrap_or("404.png")
- ),
+ url: config.pic_url(&thumbnail.id),
alt: thumbnail.alt.clone(),
}),
scripts: roots.attributes.scripts.clone(),
diff --git a/crates/treehouse/src/config.rs b/crates/treehouse/src/config.rs
index f37a52c..ff06789 100644
--- a/crates/treehouse/src/config.rs
+++ b/crates/treehouse/src/config.rs
@@ -99,4 +99,12 @@ impl Config {
}
Ok(())
}
+
+ pub fn pic_url(&self, id: &str) -> String {
+ format!(
+ "{}/static/pic/{}",
+ self.site,
+ self.pics.get(id).map(|x| &**x).unwrap_or("404.png")
+ )
+ }
}
diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs
index d6e7c52..1bb2423 100644
--- a/crates/treehouse/src/html/tree.rs
+++ b/crates/treehouse/src/html/tree.rs
@@ -7,7 +7,7 @@ use crate::{
config::Config,
html::EscapeAttribute,
state::{FileId, Treehouse},
- tree::{attributes::Content, SemaBranchId},
+ tree::{attributes::Content, mini_template, SemaBranchId},
};
use super::{markdown, EscapeHtml};
@@ -26,6 +26,12 @@ pub fn branch_to_html(
!branch.children.is_empty() || matches!(branch.attributes.content, Content::Link(_));
let class = if has_children { "branch" } else { "leaf" };
+ let mut class = String::from(class);
+ if !branch.attributes.classes.branch.is_empty() {
+ class.push(' ');
+ class.push_str(&branch.attributes.classes.branch);
+ }
+
let component = if let Content::Link(_) = branch.attributes.content {
"th-b-linked"
} else {
@@ -64,7 +70,7 @@ pub fn branch_to_html(
s.push_str(" ");
let raw_block_content = &source.input()[branch.content.clone()];
- let mut unindented_block_content = String::with_capacity(raw_block_content.len());
+ let mut final_markdown = String::with_capacity(raw_block_content.len());
for line in raw_block_content.lines() {
// Bit of a jank way to remove at most branch.indent_level spaces from the front.
let mut space_count = 0;
@@ -76,8 +82,8 @@ pub fn branch_to_html(
}
}
- unindented_block_content.push_str(&line[space_count..]);
- unindented_block_content.push('\n');
+ final_markdown.push_str(&line[space_count..]);
+ final_markdown.push('\n');
}
let broken_link_callback = &mut |broken_link: BrokenLink<'_>| {
@@ -112,8 +118,11 @@ pub fn branch_to_html(
None
}
};
+ if branch.attributes.template {
+ final_markdown = mini_template::render(config, treehouse, &final_markdown);
+ }
let markdown_parser = pulldown_cmark::Parser::new_with_broken_link_callback(
- &unindented_block_content,
+ &final_markdown,
{
use pulldown_cmark::Options;
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES
diff --git a/crates/treehouse/src/tree/attributes.rs b/crates/treehouse/src/tree/attributes.rs
index 4c3b169..ea12138 100644
--- a/crates/treehouse/src/tree/attributes.rs
+++ b/crates/treehouse/src/tree/attributes.rs
@@ -60,6 +60,10 @@ pub struct Attributes {
/// Strings of extra CSS class names to include in the generated HTML.
#[serde(default)]
pub classes: Classes,
+
+ /// Enable `mini_template` templating in this branch.
+ #[serde(default)]
+ pub template: bool,
}
/// Controls for block content presentation.
@@ -88,6 +92,10 @@ pub enum Content {
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct Classes {
+ /// Classes to append to the branch itself ().
+ #[serde(default)]
+ pub branch: String,
+
/// Classes to append to the branch's element containing its children.
#[serde(default)]
pub branch_children: String,
diff --git a/crates/treehouse/src/tree/mini_template.rs b/crates/treehouse/src/tree/mini_template.rs
new file mode 100644
index 0000000..947613e
--- /dev/null
+++ b/crates/treehouse/src/tree/mini_template.rs
@@ -0,0 +1,213 @@
+//! Minimalistic templating engine that integrates with the .tree format and Markdown.
+//!
+//! Mostly to avoid pulling in Handlebars everywhere; mini_template, unlike Handlebars, also allows
+//! for injecting *custom, stateful* context into the renderer, which is important for things like
+//! the `pic` template to work.
+
+use std::ops::Range;
+
+use pulldown_cmark::escape::escape_html;
+
+use crate::{config::Config, state::Treehouse};
+
+struct Lexer<'a> {
+ input: &'a str,
+ position: usize,
+
+ // Despite this parser's intentional simplicity, a peekahead buffer needs to be used for
+ // performance because tokens are usually quite long and therefore reparsing them would be
+ // too expensive.
+ peek_buffer: Option<(Token, usize)>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum TokenKind {
+ /// Verbatim text, may be inside of a template.
+ Text,
+ Open(EscapingMode), // {%
+ Close, // %}
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum EscapingMode {
+ EscapeHtml,
+ NoEscaping,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct Token {
+ kind: TokenKind,
+ range: Range,
+}
+
+impl<'a> Lexer<'a> {
+ fn new(input: &'a str) -> Self {
+ Self {
+ input,
+ position: 0,
+ peek_buffer: None,
+ }
+ }
+
+ fn current(&self) -> Option {
+ self.input[self.position..].chars().next()
+ }
+
+ fn advance(&mut self) {
+ self.position += self.current().map(|c| c.len_utf8()).unwrap_or(0);
+ }
+
+ fn create_token(&self, start: usize, kind: TokenKind) -> Token {
+ Token {
+ kind,
+ range: start..self.position,
+ }
+ }
+
+ fn next_inner(&mut self) -> Option {
+ if let Some((token, after_token)) = self.peek_buffer.take() {
+ self.position = after_token;
+ return Some(token);
+ }
+
+ let start = self.position;
+ match self.current() {
+ Some('{') => {
+ self.advance();
+ if self.current() == Some('%') {
+ self.advance();
+ if self.current() == Some('!') {
+ Some(self.create_token(start, TokenKind::Open(EscapingMode::NoEscaping)))
+ } else {
+ Some(self.create_token(start, TokenKind::Open(EscapingMode::EscapeHtml)))
+ }
+ } else {
+ self.advance();
+ Some(self.create_token(start, TokenKind::Text))
+ }
+ }
+ Some('%') => {
+ self.advance();
+ if self.current() == Some('}') {
+ self.advance();
+ Some(self.create_token(start, TokenKind::Close))
+ } else {
+ self.advance();
+ Some(self.create_token(start, TokenKind::Text))
+ }
+ }
+ Some(_) => {
+ while !matches!(self.current(), Some('{' | '%') | None) {
+ self.advance();
+ }
+ Some(self.create_token(start, TokenKind::Text))
+ }
+ None => None,
+ }
+ }
+
+ fn peek_inner(&mut self) -> Option {
+ let position = self.position;
+ let token = self.next();
+ let after_token = self.position;
+ self.position = position;
+
+ if let Some(token) = token.clone() {
+ self.peek_buffer = Some((token, after_token));
+ }
+
+ token
+ }
+
+ fn next(&mut self) -> Option {
+ self.next_inner().map(|mut token| {
+ // Coalesce multiple Text tokens into one.
+ if token.kind == TokenKind::Text {
+ while let Some(Token {
+ kind: TokenKind::Text,
+ ..
+ }) = self.peek_inner()
+ {
+ let next_token = self.next_inner().unwrap();
+ token.range.end = next_token.range.end;
+ }
+ }
+ token
+ })
+ }
+}
+
+struct Renderer<'a> {
+ lexer: Lexer<'a>,
+ output: String,
+}
+
+struct InvalidTemplate;
+
+impl<'a> Renderer<'a> {
+ fn emit_token_verbatim(&mut self, token: &Token) {
+ self.output.push_str(&self.lexer.input[token.range.clone()]);
+ }
+
+ fn render(&mut self, config: &Config, treehouse: &Treehouse) {
+ let kind_of = |token: &Token| token.kind;
+
+ while let Some(token) = self.lexer.next() {
+ match token.kind {
+ TokenKind::Open(escaping) => {
+ let inside = self.lexer.next();
+ let close = self.lexer.next();
+
+ if let Some((TokenKind::Text, TokenKind::Close)) = inside
+ .as_ref()
+ .map(kind_of)
+ .zip(close.as_ref().map(kind_of))
+ {
+ match Self::render_template(
+ config,
+ treehouse,
+ self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
+ ) {
+ Ok(s) => match escaping {
+ EscapingMode::EscapeHtml => {
+ _ = escape_html(&mut self.output, &s);
+ }
+ EscapingMode::NoEscaping => self.output.push_str(&s),
+ },
+ Err(InvalidTemplate) => {
+ inside.inspect(|token| self.emit_token_verbatim(token));
+ close.inspect(|token| self.emit_token_verbatim(token));
+ }
+ }
+ } else {
+ inside.inspect(|token| self.emit_token_verbatim(token));
+ close.inspect(|token| self.emit_token_verbatim(token));
+ }
+ }
+ _ => self.emit_token_verbatim(&token),
+ }
+ }
+ }
+
+ fn render_template(
+ config: &Config,
+ _treehouse: &Treehouse,
+ template: &str,
+ ) -> Result {
+ let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
+ match function {
+ "pic" => Ok(config.pic_url(arguments)),
+ "c++" => Ok("".into()),
+ _ => Err(InvalidTemplate),
+ }
+ }
+}
+
+pub fn render(config: &Config, treehouse: &Treehouse, input: &str) -> String {
+ let mut renderer = Renderer {
+ lexer: Lexer::new(input),
+ output: String::new(),
+ };
+ renderer.render(config, treehouse);
+ renderer.output
+}
diff --git a/crates/treehouse/src/tree/mod.rs b/crates/treehouse/src/tree/mod.rs
index 01ecf88..fc6fac5 100644
--- a/crates/treehouse/src/tree/mod.rs
+++ b/crates/treehouse/src/tree/mod.rs
@@ -1,4 +1,5 @@
pub mod attributes;
+pub mod mini_template;
use std::ops::Range;
diff --git a/static/css/main.css b/static/css/main.css
index 96b4dcd..8b73c17 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -87,6 +87,12 @@ body::selection {
src: url('../font/Recursive_VF_1.085.woff2');
}
+@font-face {
+ font-family: 'RecVarMono';
+ src: url('../font/Recursive_VF_1.085.woff2');
+ font-variation-settings: "MONO" 1.0;
+}
+
body,
pre,
@@ -256,6 +262,29 @@ img.pic {
margin: 8px 0;
}
+/* Image hints for tweaking rendering */
+img {
+ &[src*='+pixel'] {
+ image-rendering: crisp-edges;
+ border-radius: 0;
+ }
+
+ &[src*='+width160'] {
+ width: 160px;
+ height: auto;
+ }
+
+ &[src*='+width640'] {
+ width: 640px;
+ height: auto;
+ }
+
+ /* Resources for use in JavaScript. */
+ &.resource {
+ display: none;
+ }
+}
+
/* Fix the default blue and ugly purple links normally have */
a {
diff --git a/static/css/tairu.css b/static/css/tairu.css
index 8b13789..9106172 100644
--- a/static/css/tairu.css
+++ b/static/css/tairu.css
@@ -1 +1,128 @@
+.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;
+ justify-content: center;
+ flex-wrap: wrap;
+
+ & .metal {
+ width: 48px;
+ height: 48px;
+
+ &>span {
+ font-size: 12px;
+ }
+ }
+ }
+
+ & .metal {
+ display: inline-block;
+ width: 56px;
+ height: 56px;
+ margin: 8px;
+ background-image: url('../pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png');
+ background-size: 400%;
+ image-rendering: crisp-edges;
+ position: relative;
+ }
+
+
+ & .east,
+ & .south,
+ & .west,
+ & .north {
+ --recursive-wght: 900;
+ --recursive-casl: 0.0;
+ --recursive-slnt: 0.0;
+ --recursive-mono: 1.0;
+
+ font-size: 14px;
+ position: absolute;
+ color: #d3dce9;
+ padding: 2px 4px;
+ }
+
+ & .east {
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ & .south {
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ & .west {
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ & .north {
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ & .off {
+ opacity: 0%;
+ }
+
+ & .x-0 {
+ background-position-x: 0%;
+ }
+
+ & .x-1 {
+ background-position-x: 33.3333%;
+ }
+
+ & .x-2 {
+ background-position-x: 66.6666%;
+ }
+
+ & .x-3 {
+ background-position-x: 100%;
+ }
+
+ & .y-0 {
+ background-position-y: 0%;
+ }
+
+ & .y-1 {
+ background-position-y: 33.3333%;
+ }
+
+ & .y-2 {
+ background-position-y: 66.6666%;
+ }
+
+ & .y-3 {
+ background-position-y: 100%;
+ }
+}
diff --git a/static/css/tree.css b/static/css/tree.css
index 426d1a1..070f93f 100644
--- a/static/css/tree.css
+++ b/static/css/tree.css
@@ -351,7 +351,35 @@ th-bb .branch-date {
/* branch-quote class for "air quote branches"; used to separate a subtree from a parent tree
stylistically such that it's interpretable as a form of block quote. */
ul.branch-quote {
- padding: 8px;
+ --vertical-margin: 8px;
+ --padding: 8px;
+
+ margin-top: var(--vertical-margin);
+ margin-bottom: var(--vertical-margin);
+ padding-top: var(--padding);
+ padding-bottom: var(--padding);
+ padding-right: var(--padding);
border: 1px solid var(--border-1);
border-radius: 8px;
+
+ position: relative;
+
+
+ &::before {
+ --recursive-wght: 900;
+ --recursive-casl: 0;
+
+ content: '“';
+ position: absolute;
+ right: 16px;
+ top: 1px;
+ font-size: 3rem;
+ opacity: 50%;
+
+ transition: opacity var(--transition-duration);
+ }
+
+ &:hover::before {
+ opacity: 0%;
+ }
}
diff --git a/static/js/tairu/cardinal-directions.js b/static/js/tairu/cardinal-directions.js
new file mode 100644
index 0000000..7a72d3d
--- /dev/null
+++ b/static/js/tairu/cardinal-directions.js
@@ -0,0 +1,41 @@
+import { defineFrame, Frame } from './framework.js';
+import { TileEditor, canConnect, shouldConnect } from './tairu.js';
+
+class CardinalDirectionsEditor extends TileEditor {
+ constructor() {
+ super();
+ this.colorScheme.tiles[1] = "#f96565";
+ }
+
+ drawConnectionText(text, enabled, tileX, tileY, hAlign, vAlign) {
+ this.ctx.beginPath();
+ this.ctx.fillStyle = enabled ? "#6c023e" : "#d84161";
+ this.ctx.font = `800 14px ${Frame.monoFontFace}`;
+ const padding = 2;
+ let topLeftX = tileX * this.tileSize + padding;
+ let topLeftY = tileY * this.tileSize + padding;
+ let rectSize = this.tileSize - padding * 2;
+ let { leftX, baselineY } = this.getTextPositionInBox(text, topLeftX, topLeftY, rectSize, rectSize, hAlign, vAlign);
+ this.ctx.fillText(text, leftX, baselineY);
+ }
+
+ drawTiles() {
+ super.drawTiles();
+ 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 (canConnect(tile)) {
+ let connectedWithEast = shouldConnect(tile, this.tilemap.at(x + 1, y));
+ let connectedWithSouth = shouldConnect(tile, this.tilemap.at(x, y + 1));
+ let connectedWithNorth = shouldConnect(tile, this.tilemap.at(x, y - 1));
+ let connectedWithWest = shouldConnect(tile, this.tilemap.at(x - 1, y));
+ this.drawConnectionText("E", connectedWithEast, x, y, "right", "center");
+ this.drawConnectionText("S", connectedWithSouth, x, y, "center", "bottom");
+ this.drawConnectionText("N", connectedWithNorth, x, y, "center", "top");
+ this.drawConnectionText("W", connectedWithWest, x, y, "left", "center");
+ }
+ }
+ }
+ }
+}
+defineFrame("tairu-editor-cardinal-directions", CardinalDirectionsEditor);
diff --git a/static/js/tairu/framework.js b/static/js/tairu/framework.js
index 759f0d0..00cc96f 100644
--- a/static/js/tairu/framework.js
+++ b/static/js/tairu/framework.js
@@ -1,13 +1,87 @@
// A frameworking class assigning some CSS classes to the canvas to make it integrate nicer with CSS.
-class Frame extends HTMLCanvasElement {
+export class Frame extends HTMLCanvasElement {
+ static fontFace = "RecVar";
+ static monoFontFace = "RecVarMono";
+
constructor() {
super();
+ }
+ async connectedCallback() {
this.style.cssText = `
-
+ margin-top: 8px;
+ margin-bottom: 4px;
+ border-radius: 4px;
+ max-width: 100%;
`;
+
+ this.ctx = this.getContext("2d");
+
+ requestAnimationFrame(this.#drawLoop.bind(this));
+ }
+
+ #drawLoop() {
+ this.ctx.font = "14px RecVar";
+ this.draw();
+ requestAnimationFrame(this.#drawLoop.bind(this));
}
// Override this!
- draw() { }
+ draw() {
+ throw new ReferenceError("draw() must be overridden");
+ }
+
+ getTextPositionInBox(text, x, y, width, height, hAlign, vAlign) {
+ let measurements = this.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 };
+ }
+
+ get scaleInViewportX() {
+ return this.clientWidth / this.width;
+ }
+
+ get scaleInViewportY() {
+ return this.clientHeight / this.height;
+ }
+
+ getMousePositionFromEvent(event) {
+ return {
+ x: event.offsetX / this.scaleInViewportX,
+ y: event.offsetY / this.scaleInViewportY,
+ };
+ }
}
+
+export function defineFrame(elementName, claß) { // because `class` is a keyword.
+ customElements.define(elementName, claß, { extends: "canvas" });
+}
+
+defineFrame("tairu--frame", Frame);
diff --git a/static/js/tairu/tairu.js b/static/js/tairu/tairu.js
index 32ebfbe..93cd094 100644
--- a/static/js/tairu/tairu.js
+++ b/static/js/tairu/tairu.js
@@ -1,8 +1,192 @@
-class TileEditor extends HTMLCanvasElement {
+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;
+}
+
+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");
+ console.log(tilemapRegistry);
+ 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];
+
+ 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;
+ }
+ }
+
+ 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;
+ }
+
+ 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 tileset = this.tilesets[tile];
+
+ let connectedWithEast = shouldConnect(tile, this.tilemap.at(x + 1, y)) ? 0b0001 : 0;
+ let connectedWithSouth = shouldConnect(tile, this.tilemap.at(x, y + 1)) ? 0b0010 : 0;
+ let connectedWithWest = shouldConnect(tile, this.tilemap.at(x - 1, y)) ? 0b0100 : 0;
+ let connectedWithNorth = shouldConnect(tile, this.tilemap.at(x, y - 1)) ? 0b1000 : 0;
+ let tileIndex = connectedWithNorth
+ | connectedWithWest
+ | connectedWithSouth
+ | connectedWithEast;
+
+ let tilesetTileSize = tileset.height;
+ let tilesetX = tileIndex * tilesetTileSize;
+ let tilesetY = 0;
+ this.ctx.drawImage(
+ this.tilesets[tile],
+ 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);
+ }
}
}
-customElements.define("tairu-tile-editor", TileEditor)
+defineFrame("tairu-editor", TileEditor);
+
+console.log("tairu editor loaded");
diff --git a/static/js/tairu/tilemap-registry.js b/static/js/tairu/tilemap-registry.js
new file mode 100644
index 0000000..1f6a09a
--- /dev/null
+++ b/static/js/tairu/tilemap-registry.js
@@ -0,0 +1,33 @@
+import { Tilemap } from './tilemap.js';
+
+const alphabet = " x";
+
+function parseTilemap(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;
+}
+
+export default {
+ bitwiseAutotiling: parseTilemap([
+ " ",
+ " xxx ",
+ " xxx ",
+ " xxx ",
+ " ",
+ ]),
+ bitwiseAutotilingChapter2: parseTilemap([
+ " ",
+ " x ",
+ " x ",
+ " xxx ",
+ " ",
+ ]),
+};
+
diff --git a/static/js/tairu/tilemap.js b/static/js/tairu/tilemap.js
new file mode 100644
index 0000000..a519ea5
--- /dev/null
+++ b/static/js/tairu/tilemap.js
@@ -0,0 +1,30 @@
+export class Tilemap {
+ constructor(width, height) {
+ this.width = width;
+ this.height = height;
+ this.tiles = new Uint8Array(width * height);
+ this.default = 0;
+ }
+
+ tileIndex(x, y) {
+ return x + y * this.width;
+ }
+
+ inBounds(x, y) {
+ return x >= 0 && y >= 0 && x < this.width && y < this.height;
+ }
+
+ at(x, y) {
+ if (this.inBounds(x, y)) {
+ return this.tiles[this.tileIndex(x, y)];
+ } else {
+ return this.default;
+ }
+ }
+
+ setAt(x, y, tile) {
+ if (this.inBounds(x, y)) {
+ this.tiles[this.tileIndex(x, y)] = tile;
+ }
+ }
+}
diff --git a/static/js/tairu/tiling-demo.js b/static/js/tairu/tiling-demo.js
deleted file mode 100644
index e69de29..0000000
diff --git a/static/js/tree.js b/static/js/tree.js
index b0e0199..b401397 100644
--- a/static/js/tree.js
+++ b/static/js/tree.js
@@ -112,11 +112,17 @@ class LinkedBranch extends Branch {
let styles = main.getElementsByTagName("link");
let scripts = main.getElementsByTagName("script");
- this.append(...styles);
- this.append(...scripts);
this.loadingText.remove();
this.innerUL.innerHTML = ul.innerHTML;
+
+ this.append(...styles);
+ for (let script of scripts) {
+ // No need to await for the import because we don't use the resulting module.
+ // Just fire and forger 💀
+ // and let them run in parallel.
+ import(script.src);
+ }
} catch (error) {
this.loadingText.innerText = error.toString();
}
diff --git a/static/pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png b/static/pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png
new file mode 100644
index 0000000..801531c
Binary files /dev/null and b/static/pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png differ
diff --git a/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+height40.png b/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+height40.png
new file mode 100644
index 0000000..69c12bb
Binary files /dev/null and b/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+height40.png differ
diff --git a/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+width640.png b/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+width640.png
new file mode 100644
index 0000000..3a12f65
Binary files /dev/null and b/static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+width640.png differ
diff --git a/template/tree.hbs b/template/tree.hbs
index a442b78..f4d10c1 100644
--- a/template/tree.hbs
+++ b/template/tree.hbs
@@ -94,11 +94,11 @@
extracting them way more painful than it needs to be. --}}
{{#each page.styles}}
-
+
{{/each}}
{{#each page.scripts}}
-
+
{{/each}}
{{{ page.tree }}}