Skip to main content

shuyi_cli/
glyph_tiles.rs

1//! `glyph-tiles`: a tileset made from the font's glyphs, for a game that
2//! wants a sprite skin before it has art (as the game's `test-tileset`
3//! does): each tile is a square of a palette color with up to a few glyphs
4//! stamped on it, centred, in another; a tile with no background is clear
5//! but for its glyphs (a piece drawn over the ground). Tiles are laid out
6//! in the order `shuyi.ron` lists them, `columns` to a row, so tile `i` is
7//! at column `i % columns`, row `i / columns`.
8//!
9//! The same font, palette and list always give the same pixels: generated
10//! from the game's own data, not art.
11
12use std::fs;
13use std::path::Path;
14
15use shuyi_assets::font::FontAtlasDef;
16use shuyi_assets::image::Image;
17use shuyi_assets::palette::PaletteDef;
18
19use crate::codec::{decode_png, encode_png};
20use crate::project::GlyphTiles;
21
22/// The most glyphs one tile may have.
23pub const MAX_GLYPHS: usize = 3;
24
25/// The pixel of the top-left corner of tile `index` of `rules`.
26pub fn tile_origin(rules: &GlyphTiles, index: usize) -> (u32, u32) {
27    let i = u32::try_from(index).unwrap_or(u32::MAX);
28    let columns = rules.columns.max(1);
29    (i % columns * rules.tile, i / columns * rules.tile)
30}
31
32/// Draws the tileset `rules` describes with the font atlas `atlas` (its
33/// picture `atlas_image`) and the colors of
34/// `palette`. Fails on a color the palette lacks, a glyph the atlas lacks,
35/// or a tile of no glyphs, too many, or too many to fit.
36pub fn render(
37    rules: &GlyphTiles,
38    atlas: &FontAtlasDef,
39    atlas_image: &Image,
40    palette: &PaletteDef,
41) -> Result<Image, String> {
42    if rules.tile == 0 || rules.columns == 0 {
43        return Err("tile and columns must be above 0".to_owned());
44    }
45    let count = u32::try_from(rules.tiles.len()).unwrap_or(u32::MAX);
46    let width = rules.columns * rules.tile;
47    let height = count.div_ceil(rules.columns).max(1) * rules.tile;
48    let mut out = Image::filled(width, height, [0; 4]);
49    let color = |name: &str| {
50        palette
51            .get(name)
52            .ok_or_else(|| format!("the palette has no color \"{name}\""))
53    };
54    for (index, tile) in rules.tiles.iter().enumerate() {
55        let glyphs: Vec<char> = tile.glyphs.chars().collect();
56        let wide = u32::try_from(glyphs.len()).unwrap_or(u32::MAX) * atlas.cell_w;
57        if glyphs.is_empty() || glyphs.len() > MAX_GLYPHS {
58            return Err(format!(
59                "tile {index}: {} glyphs, not 1 to {MAX_GLYPHS}",
60                glyphs.len()
61            ));
62        }
63        if wide > rules.tile || atlas.cell_h > rules.tile {
64            return Err(format!(
65                "tile {index}: \"{}\" doesn't fit a {} px tile",
66                tile.glyphs, rules.tile
67            ));
68        }
69        let (x, y) = tile_origin(rules, index);
70        if let Some(bg) = &tile.bg {
71            let [red, green, blue] = color(bg)?;
72            for py in y..y + rules.tile {
73                for px in x..x + rules.tile {
74                    set(&mut out, px, py, [red, green, blue, 255]);
75                }
76            }
77        }
78        let fg = color(&tile.fg)?;
79        let left = x + (rules.tile - wide) / 2;
80        let top = y + (rules.tile - atlas.cell_h) / 2;
81        for (i, glyph) in (0..).zip(glyphs) {
82            let cell = atlas
83                .glyph_rect(glyph)
84                .ok_or_else(|| format!("tile {index}: the font has no '{glyph}'"))?;
85            for gy in 0..cell.h {
86                for gx in 0..cell.w {
87                    let cover = atlas_image
88                        .pixel(cell.x + gx, cell.y + gy)
89                        .map_or(0, |[.., alpha]| alpha);
90                    if cover > 0 {
91                        stamp(&mut out, left + i * atlas.cell_w + gx, top + gy, fg, cover);
92                    }
93                }
94            }
95        }
96    }
97    Ok(out)
98}
99
100fn index(image: &Image, x: u32, y: u32) -> Option<usize> {
101    (x < image.width && y < image.height)
102        .then(|| to_usize((u64::from(y) * u64::from(image.width) + u64::from(x)) * 4))
103}
104
105fn set(image: &mut Image, x: u32, y: u32, rgba: [u8; 4]) {
106    if let Some(at) = index(image, x, y) {
107        image.rgba[at..at + 4].copy_from_slice(&rgba);
108    }
109}
110
111/// Blends `(x, y)` of `image` towards `color` by `cover` (0 none, 255
112/// all). A clear pixel takes the color at that cover as its opacity.
113fn stamp(image: &mut Image, x: u32, y: u32, color: [u8; 3], cover: u8) {
114    let under = image.pixel(x, y).unwrap_or([0; 4]);
115    let out = if under[3] == 0 {
116        [color[0], color[1], color[2], cover]
117    } else {
118        let mix = |c: usize| blend(under[c], color[c], cover);
119        [mix(0), mix(1), mix(2), under[3]]
120    };
121    set(image, x, y, out);
122}
123
124/// Channel `under` blended towards `over` by `cover`, rounded.
125fn blend(under: u8, over: u8, cover: u8) -> u8 {
126    let cover = u32::from(cover);
127    let v = (u32::from(under) * (255 - cover) + u32::from(over) * cover + 127) / 255;
128    u8::try_from(v).unwrap_or(u8::MAX)
129}
130
131fn to_usize(v: u64) -> usize {
132    usize::try_from(v).unwrap_or(usize::MAX)
133}
134
135/// The tileset of `rules`, read from the files they name under `root`.
136pub fn render_files(rules: &GlyphTiles, root: &Path) -> Result<Image, String> {
137    let read = |rel: &str| {
138        let path = root.join(rel);
139        fs::read(&path).map_err(|e| format!("reading {}: {e}", path.display()))
140    };
141    let text = |rel: &str| {
142        let bytes = read(rel)?;
143        String::from_utf8(bytes).map_err(|e| format!("{rel}: {e}"))
144    };
145    let atlas_ron = format!("{}/atlas.ron", rules.font);
146    let atlas_png = format!("{}/atlas.png", rules.font);
147    let png = read(&atlas_png)?;
148    let joined = |errors: Vec<shuyi_assets::ContentError>| {
149        let errors: Vec<String> = errors.iter().map(ToString::to_string).collect();
150        errors.join("\n")
151    };
152    let atlas =
153        FontAtlasDef::from_source(&atlas_ron, &text(&atlas_ron)?, &atlas_png, Some(&png), "")
154            .map_err(joined)?;
155    let (width, height, rgba) = decode_png(&png)?;
156    let atlas_image = Image {
157        width,
158        height,
159        rgba,
160    };
161    let palette =
162        PaletteDef::from_source(&rules.palette, &text(&rules.palette)?, &[]).map_err(joined)?;
163    render(rules, &atlas, &atlas_image, &palette)
164}
165
166/// Runs the command: draws the tileset and writes it to `rules.out` under
167/// `root`; returns what to print.
168pub fn run(rules: &GlyphTiles, root: &Path) -> Result<String, String> {
169    let tiles = render_files(rules, root)?;
170    let png = encode_png(tiles.width, tiles.height, &tiles.rgba)?;
171    let path = root.join(&rules.out);
172    if let Some(dir) = path.parent() {
173        fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
174    }
175    fs::write(&path, png).map_err(|e| format!("writing {}: {e}", path.display()))?;
176    Ok(format!(
177        "glyph-tiles: {} tiles, {}×{} px → {}",
178        rules.tiles.len(),
179        tiles.width,
180        tiles.height,
181        rules.out
182    ))
183}
184
185#[cfg(test)]
186mod tests;