wip!
This commit is contained in:
		
							parent
							
								
									1305ffbb16
								
							
						
					
					
						commit
						1013c53975
					
				
					 21 changed files with 988 additions and 43 deletions
				
			
		| 
						 | 
				
			
			@ -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?"
 | 
			
		||||
    <canvas
 | 
			
		||||
        is="tairu-editor"
 | 
			
		||||
        data-tilemap-id="bitwiseAutotiling"
 | 
			
		||||
        data-tile-size="40">
 | 
			
		||||
        Your browser does not support <canvas>.
 | 
			
		||||
    </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
 | 
			
		||||
    <canvas
 | 
			
		||||
        is="tairu-editor-cardinal-directions"
 | 
			
		||||
        data-tilemap-id="bitwiseAutotiling"
 | 
			
		||||
        data-tile-size="40">
 | 
			
		||||
        Your browser does not support <canvas>.
 | 
			
		||||
    </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
 | 
			
		||||
    <ul class="tileset-demo">
 | 
			
		||||
    <li class="full-image">
 | 
			
		||||
    <img alt="a 16-tile tileset of 8x8 pixel metal" src="{% pic 01HPHVDRV0F0251MD0A2EG66C4 %}">
 | 
			
		||||
    </li>
 | 
			
		||||
    <li class="tileset-pieces">
 | 
			
		||||
    <span class="metal x-0 y-0"><span class="east">E</span><span class="south">S</span></span>
 | 
			
		||||
    <span class="metal x-1 y-0"><span class="east">E</span><span class="south">S</span><span class="west">W</span></span>
 | 
			
		||||
    <span class="metal x-2 y-0"><span class="south">S</span><span class="west">W</span></span>
 | 
			
		||||
    <span class="metal x-3 y-0"><span class="south">S</span></span>
 | 
			
		||||
    <span class="metal x-0 y-1"><span class="east">E</span><span class="south">S</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-1 y-1"><span class="east">E</span><span class="south">S</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-2 y-1"><span class="south">S</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-3 y-1"><span class="south">S</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-0 y-2"><span class="east">E</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-1 y-2"><span class="east">E</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-2 y-2"><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-3 y-2"><span class="north">N</span></span>
 | 
			
		||||
    <span class="metal x-0 y-3"><span class="east">E</span></span>
 | 
			
		||||
    <span class="metal x-1 y-3"><span class="east">E</span><span class="west">W</span></span>
 | 
			
		||||
    <span class="metal x-2 y-3"><span class="west">W</span></span>
 | 
			
		||||
    <span class="metal x-3 y-3"></span>
 | 
			
		||||
    </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
 | 
			
		||||
    % 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:
 | 
			
		||||
 | 
			
		||||
        <div class="horizontal-tile-strip">
 | 
			
		||||
        <span class="metal x-3 y-3"></span>
 | 
			
		||||
        <span class="metal x-0 y-3"><span class="east">E</span></span>
 | 
			
		||||
        <span class="metal x-3 y-0"><span class="south">S</span></span>
 | 
			
		||||
        <span class="metal x-0 y-0"><span class="east">E</span><span class="south">S</span></span>
 | 
			
		||||
        <span class="metal x-2 y-3"><span class="west">W</span></span>
 | 
			
		||||
        <span class="metal x-1 y-3"><span class="east">E</span><span class="west">W</span></span>
 | 
			
		||||
        <span class="metal x-2 y-0"><span class="south">S</span><span class="west">W</span></span>
 | 
			
		||||
        <span class="metal x-1 y-0"><span class="east">E</span><span class="south">S</span><span class="west">W</span></span>
 | 
			
		||||
        <span class="metal x-3 y-2"><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-0 y-2"><span class="east">E</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-3 y-1"><span class="south">S</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-0 y-1"><span class="east">E</span><span class="south">S</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-2 y-2"><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-1 y-2"><span class="east">E</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-2 y-1"><span class="south">S</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
        <span class="metal x-1 y-1"><span class="east">E</span><span class="south">S</span><span class="west">W</span><span class="north">N</span></span>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        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 `<canvas>` 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:
 | 
			
		||||
 | 
			
		||||
    <canvas
 | 
			
		||||
        is="tairu-editor"
 | 
			
		||||
        data-tilemap-id="bitwiseAutotiling"
 | 
			
		||||
        data-tile-size="40"
 | 
			
		||||
    >
 | 
			
		||||
        Your browser does not support <canvas>.
 | 
			
		||||
        <img class="resource" src="{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}" data-tairu-tileset="1">
 | 
			
		||||
    </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:
 | 
			
		||||
 | 
			
		||||
        <canvas
 | 
			
		||||
            is="tairu-editor"
 | 
			
		||||
            data-tilemap-id="bitwiseAutotilingChapter2"
 | 
			
		||||
            data-tile-size="40"
 | 
			
		||||
        >
 | 
			
		||||
            Your browser does not support <canvas>.
 | 
			
		||||
            <img class="resource" src="{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}" data-tairu-tileset="1">
 | 
			
		||||
        </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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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")
 | 
			
		||||
        )
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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("<th-bp></th-bp>");
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 (<li is="th-b">).
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub branch: String,
 | 
			
		||||
 | 
			
		||||
    /// Classes to append to the branch's <ul> element containing its children.
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub branch_children: String,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										213
									
								
								crates/treehouse/src/tree/mini_template.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								crates/treehouse/src/tree/mini_template.rs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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<usize>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl<'a> Lexer<'a> {
 | 
			
		||||
    fn new(input: &'a str) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            input,
 | 
			
		||||
            position: 0,
 | 
			
		||||
            peek_buffer: None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fn current(&self) -> Option<char> {
 | 
			
		||||
        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<Token> {
 | 
			
		||||
        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<Token> {
 | 
			
		||||
        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<Token> {
 | 
			
		||||
        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<String, InvalidTemplate> {
 | 
			
		||||
        let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
 | 
			
		||||
        match function {
 | 
			
		||||
            "pic" => Ok(config.pic_url(arguments)),
 | 
			
		||||
            "c++" => Ok("<script>alert(1)</script>".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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
pub mod attributes;
 | 
			
		||||
pub mod mini_template;
 | 
			
		||||
 | 
			
		||||
use std::ops::Range;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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%;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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%;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										41
									
								
								static/js/tairu/cardinal-directions.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								static/js/tairu/cardinal-directions.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										33
									
								
								static/js/tairu/tilemap-registry.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								static/js/tairu/tilemap-registry.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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   ",
 | 
			
		||||
        "         ",
 | 
			
		||||
    ]),
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										30
									
								
								static/js/tairu/tilemap.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								static/js/tairu/tilemap.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 284 B  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 313 B  | 
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 313 B  | 
| 
						 | 
				
			
			@ -94,11 +94,11 @@
 | 
			
		|||
        extracting them way more painful than it needs to be. --}}
 | 
			
		||||
 | 
			
		||||
        {{#each page.styles}}
 | 
			
		||||
        <link rel="stylesheet" src="{{ ../config.site }}/static/css/{{ this }}">
 | 
			
		||||
        <link rel="stylesheet" href="{{ ../config.site }}/static/css/{{ this }}">
 | 
			
		||||
        {{/each}}
 | 
			
		||||
 | 
			
		||||
        {{#each page.scripts}}
 | 
			
		||||
        <script type="module" src="{{ ../config.site }}/static/js/{{ this }}" defer></script>
 | 
			
		||||
        <script type="module" src="{{ ../config.site }}/static/js/{{ this }}"></script>
 | 
			
		||||
        {{/each}}
 | 
			
		||||
 | 
			
		||||
        {{{ page.tree }}}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue