From 1013c53975cb843f14f9aa0fbc7c1095d91c8d40 Mon Sep 17 00:00:00 2001 From: lqdev Date: Wed, 14 Feb 2024 23:31:39 +0100 Subject: [PATCH] wip! --- content/programming/blog/tairu.tree | 200 ++++++++++++++-- crates/treehouse/src/cli/generate.rs | 10 +- crates/treehouse/src/config.rs | 8 + crates/treehouse/src/html/tree.rs | 19 +- crates/treehouse/src/tree/attributes.rs | 8 + crates/treehouse/src/tree/mini_template.rs | 213 ++++++++++++++++++ crates/treehouse/src/tree/mod.rs | 1 + static/css/main.css | 29 +++ static/css/tairu.css | 127 +++++++++++ static/css/tree.css | 30 ++- static/js/tairu/cardinal-directions.js | 41 ++++ static/js/tairu/framework.js | 80 ++++++- static/js/tairu/tairu.js | 188 +++++++++++++++- static/js/tairu/tilemap-registry.js | 33 +++ static/js/tairu/tilemap.js | 30 +++ static/js/tairu/tiling-demo.js | 0 static/js/tree.js | 10 +- ...-tilemap-heavy-metal-16+pixel+width160.png | Bin 0 -> 284 bytes ...-heavy-metal-bitwise-16+pixel+height40.png | Bin 0 -> 313 bytes ...-heavy-metal-bitwise-16+pixel+width640.png | Bin 0 -> 313 bytes template/tree.hbs | 4 +- 21 files changed, 988 insertions(+), 43 deletions(-) create mode 100644 crates/treehouse/src/tree/mini_template.rs create mode 100644 static/js/tairu/cardinal-directions.js create mode 100644 static/js/tairu/tilemap-registry.js create mode 100644 static/js/tairu/tilemap.js delete mode 100644 static/js/tairu/tiling-demo.js create mode 100644 static/pic/01HPHVDRV0F0251MD0A2EG66C4-tilemap-heavy-metal-16+pixel+width160.png create mode 100644 static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+height40.png create mode 100644 static/pic/01HPMMR6DGKYTPZ9CK0WQWKNX5-tilemap-heavy-metal-bitwise-16+pixel+width640.png 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 + - % 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 + ES + W + EW + SW + ESW + N + EN + SN + ESN + WN + EWN + SWN + ESWN +
+ + 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 0000000000000000000000000000000000000000..801531cde011d7b54f5ff1e8fff64a819737fbba GIT binary patch literal 284 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyjKx9jP7LeL$-D$|q5^zET!Hle z|NptzRTxxc8EiBeLd_Vu9obHK)xQZk^t+GY)qmE$4-6IyGZ=DLG334$_WJGTciX^V z4g*7hfWT=VhW`y*{}0Rm|EwaVV5y|(Vq_ER5mGvB+5XFSUY>cS904?4t|Z7W7-R=- z@Wn#P9H`RG)5S5wqBnVhB7?+|4c*?3N0$QWRTCyPb$B<5cz7l=Xc{nFUd-htI$@Oq z6F0+2=M1(rpA;lD&Njr#F-o*~>|0Q3!NAaRC3E7v)aQSJCNg-s`njxgN@xNAYS3~+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..69c12bbdaf98ae2b6dd763811a39eb76a47641e8 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^4M5Dn!3-qXnAv@R6k~CayA#8@b22Z1oTvbw5LY1m z|Nnn3b`=H{Sq2+ThEOwxZb!CLUiEK+4*l+9c=eyP?*oIy!VHGoRSdbWg}r|J`Q0`! zn8UzOARutshv9z%*Z;%v|39lpDOf6Lx)|BSdW4itTeko5otI}`DMtWJmn#YK3kKPN z8+@^lG6$+m_H=O!vFN?qcaZmh0SB|>VWYeC=gdSknp*r{tgbsMwl8AcGLKcCrt+oQ zJKwEZSS&N6rYHQqyu;y^`OEdc)mR>w~BOF<{jF?_u^~o%OrjW+XXkp gRYVy=8uI?L1u0}sy_fv-1<*PMPgg&ebxsLQ0G_LaJpcdz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3a12f659541d1e73c7d7b4887cc1efc7442edbc5 GIT binary patch literal 313 zcmeAS@N?(olHy`uVBq!ia0vp^4M5Dn!3-qXnAv@R6k~CayA#8@b22Z1oTvbw5LY1m z|Nnn3b`=H{Sq2+ThEOwxZb!CLUiEK+4*l+9c=eyP?*oIy!VHGoRSdbWg}r|J`Q0`! zn8UzOARutshv9z%*Z;%v|39lpDOf6Lx)|BSdW4itTeko5otI}`DMtWJmn#YK3kKPN z8+@^lG6$+m_H=O!vFN?qcaZmh0SB|>;i6st=lHq=cnih7TV0pP&39Y1D<@Va%P!Ua z@br`SB<3*a*&W|~o%@2J&wTm#FF!Xmu>EjmEa|O1u$-quB)FRK?Y$j2a~M}}cYJSi gWn~a`*t?%`HbdslTT + {{/each}} {{#each page.scripts}} - + {{/each}} {{{ page.tree }}}