├── .github ├── renovate.json └── workflows │ ├── release.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── README.md ├── assets ├── Beachball.png ├── Beachball.toml ├── button-down.png ├── button-hover.png ├── button-normal.png ├── checkmark.png ├── settings.toml ├── slider-bar.png └── slider-handle.png ├── img └── sprite.png ├── lib ├── Cargo.toml ├── benches │ └── bench.rs └── src │ └── lib.rs ├── release-plz.toml ├── run-wasm ├── Cargo.toml └── src │ └── main.rs └── src ├── assets.rs ├── font.rs ├── input.rs ├── main.rs ├── sprite.rs ├── sprites.rs ├── widgets ├── button.rs ├── checkbox.rs ├── grid.rs ├── mod.rs ├── radio.rs └── slider.rs └── window.rs /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":automergeAll", 6 | ":automergeBranch" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | release-plz: 14 | name: Release PR 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Install Rust toolchain 23 | uses: dtolnay/rust-toolchain@stable 24 | 25 | - name: Run release-plz 26 | uses: MarcoIeni/release-plz-action@v0.5 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | paths-ignore: 5 | - "docs/**" 6 | - "**.md" 7 | 8 | jobs: 9 | # Check for formatting 10 | rustfmt: 11 | name: Formatter check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | profile: minimal 19 | toolchain: stable 20 | components: rustfmt 21 | override: true 22 | 23 | - run: rustup component add rustfmt 24 | - uses: actions-rs/cargo@v1 25 | with: 26 | command: fmt 27 | args: --all -- --check 28 | 29 | # Run test check on Linux, macOS, and Windows 30 | test: 31 | name: Test 32 | runs-on: ${{ matrix.os }} 33 | strategy: 34 | fail-fast: true 35 | matrix: 36 | os: [ubuntu-latest, macOS-latest, windows-latest] 37 | steps: 38 | # Checkout the branch being tested 39 | - uses: actions/checkout@v4 40 | 41 | # Install rust stable 42 | - uses: dtolnay/rust-toolchain@master 43 | with: 44 | toolchain: stable 45 | 46 | # Cache the built dependencies 47 | - uses: Swatinem/rust-cache@v2.7.3 48 | with: 49 | save-if: ${{ github.event_name == 'push' }} 50 | 51 | # Install cargo-hack 52 | - uses: taiki-e/install-action@cargo-hack 53 | 54 | # Test all feature combinations on the target platform 55 | - name: Test 56 | run: cargo hack --feature-powerset test 57 | 58 | # Build the WASM target & push it to GitHub pages 59 | wasm: 60 | name: WASM test & build 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | 65 | # Install rust stable 66 | - uses: dtolnay/rust-toolchain@master 67 | with: 68 | toolchain: stable 69 | targets: wasm32-unknown-unknown 70 | 71 | # Cache the built dependencies 72 | - uses: Swatinem/rust-cache@v2.7.3 73 | with: 74 | save-if: ${{ github.event_name == 'push' }} 75 | 76 | # Build the WASM 77 | - name: Build 78 | run: cargo run --package run-wasm -- --bin sprite --release --build-only 79 | 80 | # Deploy to GitHub pages 81 | - name: Deploy to GitHub Pages 82 | uses: s0/git-publish-subdir-action@master 83 | env: 84 | REPO: self 85 | BRANCH: gh-pages 86 | FOLDER: target/wasm-examples/sprite 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/target/ 2 | /target/ 3 | **/*.rs.bk 4 | Cargo.lock 5 | *.zip 6 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sprite" 3 | version = "0.3.0" 4 | edition = "2021" 5 | authors = ["Thomas Versteeg "] 6 | license = "GPL-3.0" 7 | homepage = "https://github.com/tversteeg/sprite-gen" 8 | 9 | readme = "README.md" 10 | description = "Procedurally generate pixel sprites and save them in different formats" 11 | 12 | repository = "https://github.com/tversteeg/sprite-gen.git" 13 | keywords = ["gamedev", "sprite", "procedural", "procgen"] 14 | categories = ["games", "rendering", "game-engines"] 15 | 16 | [workspace] 17 | members = ["run-wasm", "lib"] 18 | 19 | [features] 20 | default = ["embed-assets"] 21 | embed-assets = [] 22 | 23 | [dependencies] 24 | sprite-gen = { path = "lib", version = "0.2" } 25 | 26 | winit = "0.28" 27 | log = "0.4" 28 | pixels = "0.13" 29 | blit = "0.8" 30 | game-loop = { version = "1.0", features = ["winit"] } 31 | miette = { version = "5", features = ["fancy"] } 32 | image = { version = "0.24", default-features = false, features = ["png"] } 33 | rotsprite = "0.1" 34 | vek = "0.16" 35 | assets_manager = { version = "0.10", features = ["embedded", "hot-reloading", "toml", "png"], default-features = false } 36 | serde = "1" 37 | taffy = "0.3" 38 | rfd = { version = "0.12.1", default-features = false, features = ["xdg-portal"] } 39 | 40 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 41 | tokio = { version = "1", features = ["macros", "sync", "rt-multi-thread"] } 42 | fastrand = "2" 43 | 44 | [target.'cfg(target_arch = "wasm32")'.dependencies] 45 | web-sys = { version = "0.3", features = ["CanvasRenderingContext2d", "Document", "Element", "HtmlCanvasElement", "ImageData", "Window"] } 46 | wasm-bindgen = "0.2" 47 | wasm-bindgen-futures = "0.4" 48 | console_log = { version = "1", features = ["wasm-bindgen", "color"] } 49 | console_error_panic_hook = "0.1" 50 | fastrand = { version = "2", default-features = false, features = ["js"] } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/tversteeg/sprite-gen/workflows/CI/badge.svg) 2 | 3 | # [sprite](https://tversteeg.itch.io/sprite) (Executable) 4 | 5 | [![Cargo](https://img.shields.io/crates/v/sprite.svg)](https://crates.io/crates/sprite) [![License: GPL-3.0](https://img.shields.io/crates/l/sprite.svg)](#license) [![Downloads](https://img.shields.io/crates/d/sprite.svg)](#downloads) 6 | 7 | ## Run 8 | 9 | ```bash 10 | cargo install sprite 11 | sprite 12 | ``` 13 | 14 | This should produce the following window: 15 | 16 | ![Sprite](img/sprite.png?raw=true) 17 | 18 | # sprite-gen (Library) 19 | 20 | A Rust library for procedurally generating 2D sprites. Port of https://github.com/zfedoran/pixel-sprite-generator 21 | 22 | [![Cargo](https://img.shields.io/crates/v/sprite-gen.svg)](https://crates.io/crates/sprite-gen) [![License: GPL-3.0](https://img.shields.io/crates/l/sprite-gen.svg)](#license) [![Downloads](https://img.shields.io/crates/d/sprite-gen.svg)](#downloads) 23 | 24 | ### [Documentation](https://docs.rs/sprite-gen/) 25 | 26 | ## Usage 27 | 28 | Add this to your `Cargo.toml`: 29 | 30 | ```toml 31 | [dependencies] 32 | sprite-gen = "0.2" 33 | ``` 34 | -------------------------------------------------------------------------------- /assets/Beachball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/Beachball.png -------------------------------------------------------------------------------- /assets/Beachball.toml: -------------------------------------------------------------------------------- 1 | char_width = 10 2 | char_height = 10 3 | -------------------------------------------------------------------------------- /assets/button-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/button-down.png -------------------------------------------------------------------------------- /assets/button-hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/button-hover.png -------------------------------------------------------------------------------- /assets/button-normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/button-normal.png -------------------------------------------------------------------------------- /assets/checkmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/checkmark.png -------------------------------------------------------------------------------- /assets/settings.toml: -------------------------------------------------------------------------------- 1 | min_x_pixels = 4 2 | max_x_pixels = 40 3 | min_y_pixels = 4 4 | max_y_pixels = 32 5 | preview_requested = { w = 3, h = 3 } 6 | -------------------------------------------------------------------------------- /assets/slider-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/slider-bar.png -------------------------------------------------------------------------------- /assets/slider-handle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/assets/slider-handle.png -------------------------------------------------------------------------------- /img/sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tversteeg/sprite-gen/d38736ba3a212f0210d79f968cd0b1025dcf980f/img/sprite.png -------------------------------------------------------------------------------- /lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sprite-gen" 3 | version = "0.2.0" 4 | authors = ["Thomas Versteeg "] 5 | license = "GPL-3.0" 6 | homepage = "https://github.com/tversteeg/sprite-gen" 7 | edition = "2021" 8 | 9 | readme = "../README.md" 10 | description = "Procedurally generate pixel sprites library" 11 | documentation = "https://docs.rs/sprite-gen" 12 | 13 | repository = "https://github.com/tversteeg/sprite-gen.git" 14 | keywords = ["gamedev", "sprite", "procedural", "procgen"] 15 | categories = ["games", "rendering", "game-engines"] 16 | 17 | [dependencies] 18 | hsl = "0.1.1" 19 | randomize = "3.0.1" 20 | 21 | [dev-dependencies] 22 | criterion = "0.5.1" 23 | 24 | [[bench]] 25 | name = "bench" 26 | harness = false 27 | -------------------------------------------------------------------------------- /lib/benches/bench.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, Criterion}; 2 | use sprite_gen::*; 3 | 4 | fn criterion_benchmark(c: &mut Criterion) { 5 | let buffer_10x10: Vec = (0..10 * 10).map(|index| index % 3 - 1).collect(); 6 | let buffer_100x100: Vec = (0..100 * 100).map(|index| (index % 3 - 1) as i8).collect(); 7 | 8 | c.bench_function("gen color 10x10", |b| { 9 | let mut seed = 0; 10 | b.iter(|| { 11 | let result = gen_sprite( 12 | &buffer_10x10, 13 | 10, 14 | Options { 15 | colored: true, 16 | seed, 17 | ..Default::default() 18 | }, 19 | ); 20 | assert_eq!(result.len(), 100); 21 | seed += 1; 22 | }); 23 | }); 24 | c.bench_function("gen color 100x100", |b| { 25 | let mut seed = 0; 26 | b.iter(|| { 27 | let result = gen_sprite( 28 | &buffer_100x100, 29 | 100, 30 | Options { 31 | colored: true, 32 | seed, 33 | ..Default::default() 34 | }, 35 | ); 36 | assert_eq!(result.len(), 100 * 100); 37 | seed += 1; 38 | }); 39 | }); 40 | c.bench_function("gen bw 10x10", |b| { 41 | let mut seed = 0; 42 | b.iter(|| { 43 | let result = gen_sprite( 44 | &buffer_10x10, 45 | 10, 46 | Options { 47 | colored: false, 48 | seed, 49 | ..Default::default() 50 | }, 51 | ); 52 | assert_eq!(result.len(), 100); 53 | seed += 1; 54 | }); 55 | }); 56 | c.bench_function("gen bw 100x100", |b| { 57 | let mut seed = 0; 58 | b.iter(|| { 59 | let result = gen_sprite( 60 | &buffer_100x100, 61 | 100, 62 | Options { 63 | colored: false, 64 | seed, 65 | ..Default::default() 66 | }, 67 | ); 68 | assert_eq!(result.len(), 100 * 100); 69 | seed += 1; 70 | }); 71 | }); 72 | } 73 | 74 | criterion_group!(benches, criterion_benchmark); 75 | criterion_main!(benches); 76 | -------------------------------------------------------------------------------- /lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | use hsl::HSL; 2 | use randomize::{formulas, PCG32}; 3 | 4 | /// Replacement for the `i8` datatype that can be passed to `gen_sprite`. 5 | #[derive(Debug, Default, Clone, Eq, PartialEq)] 6 | pub enum MaskValue { 7 | /// - `-1`: This pixel will always be a border. 8 | Solid, 9 | /// - `0`: This pixel will always be empty. 10 | #[default] 11 | Empty, 12 | /// - `1`: This pixel will either be empty or filled (body). 13 | Body1, 14 | /// - `2`: This pixel will either be a border or filled (body). 15 | Body2, 16 | } 17 | 18 | impl MaskValue { 19 | pub fn i8(&self) -> i8 { 20 | match self { 21 | MaskValue::Solid => -1, 22 | MaskValue::Empty => 0, 23 | MaskValue::Body1 => 1, 24 | MaskValue::Body2 => 2, 25 | } 26 | } 27 | } 28 | 29 | impl From for i8 { 30 | fn from(from: MaskValue) -> Self { 31 | from.i8() 32 | } 33 | } 34 | 35 | impl From for MaskValue { 36 | fn from(from: i8) -> Self { 37 | match from { 38 | -1 => MaskValue::Solid, 39 | 1 => MaskValue::Body1, 40 | 2 => MaskValue::Body2, 41 | _ => MaskValue::Empty, 42 | } 43 | } 44 | } 45 | 46 | /// The options for the `gen_sprite` function. 47 | #[derive(Debug, Copy, Clone)] 48 | pub struct Options { 49 | /// `true` if the result buffer should be mirrored along the X axis. 50 | pub mirror_x: bool, 51 | /// `true` if the result buffer should be mirrored along the Y axis. 52 | pub mirror_y: bool, 53 | /// `true` if the output should be colored. `false` if the output should be 1-bit. The 54 | /// Fields after this field only apply if `colored` is `true`. 55 | pub colored: bool, 56 | /// A value from `0.0` - `1.0`. 57 | pub edge_brightness: f32, 58 | /// A value from `0.0` - `1.0`. 59 | pub color_variations: f32, 60 | /// A value from `0.0` - `1.0`. 61 | pub brightness_noise: f32, 62 | /// A value from `0.0` - `1.0`. 63 | pub saturation: f32, 64 | /// The seed for the random generator. 65 | pub seed: u64, 66 | } 67 | 68 | impl Default for Options { 69 | /// - `mirror_x`: `false` 70 | /// - `mirror_y`: `false` 71 | /// - `colored`: `true` 72 | /// - `edge_brightness`: `0.3` 73 | /// - `color_variations`: `0.2` 74 | /// - `brightness_noise`: `0.3` 75 | /// - `saturation`: `0.5` 76 | /// - `seed`: `0` 77 | fn default() -> Self { 78 | Options { 79 | mirror_x: false, 80 | mirror_y: false, 81 | colored: true, 82 | edge_brightness: 0.3, 83 | color_variations: 0.2, 84 | brightness_noise: 0.3, 85 | saturation: 0.5, 86 | seed: 0, 87 | } 88 | } 89 | } 90 | 91 | /// Randomly generate a new sprite. 92 | /// 93 | /// A mask buffer of `i8` values should be passed together with the width of that buffer. 94 | /// The height is automatically calculated by dividing the size of the buffer with the width. 95 | /// The `i8` values should be one of the following, and will generate a bitmap: 96 | /// - `-1`: This pixel will always be a border. 97 | /// - `0`: This pixel will always be empty. 98 | /// - `1`: This pixel will either be empty or filled (body). 99 | /// - `2`: This pixel will either be a border or filled (body). 100 | /// 101 | /// ``` 102 | /// use sprite_gen::{gen_sprite, Options, MaskValue}; 103 | /// 104 | /// let mask = vec![MaskValue::Empty; 12 * 12]; 105 | /// let buffer = gen_sprite(&mask, 12, Options::default()); 106 | /// ``` 107 | pub fn gen_sprite(mask_buffer: &[T], mask_width: usize, options: Options) -> Vec 108 | where 109 | T: Into + Clone, 110 | { 111 | let mask_height = mask_buffer.len() / mask_width; 112 | 113 | // Copy the array to this vector 114 | let mut mask: Vec = mask_buffer 115 | .iter() 116 | .map(|v| std::convert::Into::into(v.clone())) 117 | .collect::<_>(); 118 | 119 | let mut rng = PCG32::seed(options.seed, 5); 120 | 121 | // Generate a random sample, if it's a internal body there is a 50% chance it will be empty 122 | // If it's a regular body there is a 50% chance it will turn into a border 123 | for val in mask.iter_mut() { 124 | if *val == 1 { 125 | // Either 0 or 1 126 | *val = formulas::f32_closed(rng.next_u32()).round() as i8; 127 | } else if *val == 2 { 128 | // Either -1 or 1 129 | *val = formulas::f32_closed_neg_pos(rng.next_u32()).signum() as i8; 130 | } 131 | } 132 | 133 | // Generate edges 134 | for y in 0..mask_height { 135 | for x in 0..mask_width { 136 | let index = x + y * mask_width; 137 | if mask[index] <= 0 { 138 | continue; 139 | } 140 | 141 | if y > 0 && mask[index - mask_width] == 0 { 142 | mask[index - mask_width] = -1; 143 | } 144 | if y < mask_height - 1 && mask[index + mask_width] == 0 { 145 | mask[index + mask_width] = -1; 146 | } 147 | if x > 0 && mask[index - 1] == 0 { 148 | mask[index - 1] = -1; 149 | } 150 | if x < mask_width - 1 && mask[index + 1] == 0 { 151 | mask[index + 1] = -1; 152 | } 153 | } 154 | } 155 | 156 | // Color the mask image 157 | let colored: Vec = if options.colored { 158 | color_output(&mask, (mask_width, mask_height), &options, &mut rng) 159 | } else { 160 | onebit_output(&mask) 161 | }; 162 | 163 | // Check for mirroring 164 | if options.mirror_x && options.mirror_y { 165 | // Mirror both X & Y 166 | let width = mask_width * 2; 167 | let height = mask_height * 2; 168 | let mut result = vec![0; width * height]; 169 | 170 | for y in 0..mask_height { 171 | for x in 0..mask_width { 172 | let index = x + y * mask_width; 173 | let value = colored[index]; 174 | 175 | let index = x + y * width; 176 | result[index] = value; 177 | 178 | let index = (width - x - 1) + y * width; 179 | result[index] = value; 180 | 181 | let index = x + (height - y - 1) * width; 182 | result[index] = value; 183 | 184 | let index = (width - x - 1) + (height - y - 1) * width; 185 | result[index] = value; 186 | } 187 | } 188 | 189 | return result; 190 | } else if options.mirror_x { 191 | // Only mirror X 192 | let width = mask_width * 2; 193 | let mut result = vec![0; width * mask_height]; 194 | 195 | for y in 0..mask_height { 196 | for x in 0..mask_width { 197 | let index = x + y * mask_width; 198 | let value = colored[index]; 199 | 200 | let index = x + y * width; 201 | result[index] = value; 202 | 203 | let index = (width - x - 1) + y * width; 204 | result[index] = value; 205 | } 206 | } 207 | 208 | return result; 209 | } else if options.mirror_y { 210 | // Only mirror Y 211 | let height = mask_height * 2; 212 | let mut result = vec![0; mask_width * height]; 213 | 214 | for y in 0..mask_height { 215 | for x in 0..mask_width { 216 | let index = x + y * mask_width; 217 | let value = colored[index]; 218 | result[index] = value; 219 | 220 | let index = x + (height - y - 1) * mask_width; 221 | result[index] = value; 222 | } 223 | } 224 | 225 | return result; 226 | } 227 | 228 | colored 229 | } 230 | 231 | #[inline] 232 | fn onebit_output(mask: &[i8]) -> Vec { 233 | mask.iter() 234 | .map(|&v| match v { 235 | -1 => 0, 236 | _ => 0xFF_FF_FF_FF, 237 | }) 238 | .collect() 239 | } 240 | 241 | #[inline] 242 | fn color_output( 243 | mask: &[i8], 244 | mask_size: (usize, usize), 245 | options: &Options, 246 | rng: &mut PCG32, 247 | ) -> Vec { 248 | let mut result = vec![0xFF_FF_FF_FF; mask.len()]; 249 | 250 | let is_vertical_gradient = formulas::f32_closed_neg_pos(rng.next_u32()) > 0.0; 251 | let saturation = formulas::f32_closed(rng.next_u32()) * options.saturation; 252 | let mut hue = formulas::f32_closed(rng.next_u32()); 253 | 254 | let variation_check = 1.0 - options.color_variations; 255 | let brightness_inv = 1.0 - options.brightness_noise; 256 | 257 | let uv_size = if is_vertical_gradient { 258 | (mask_size.1, mask_size.0) 259 | } else { 260 | mask_size 261 | }; 262 | 263 | for u in 0..uv_size.0 { 264 | // Create a non-uniform random number being constrained more to the center (0) 265 | let is_new_color = (formulas::f32_closed(rng.next_u32()) 266 | + formulas::f32_closed(rng.next_u32()) 267 | + formulas::f32_closed(rng.next_u32())) 268 | / 3.0; 269 | 270 | if is_new_color > variation_check { 271 | hue = formulas::f32_closed(rng.next_u32()); 272 | } 273 | 274 | let u_sin = ((u as f32 / uv_size.0 as f32) * std::f32::consts::PI).sin(); 275 | 276 | for v in 0..uv_size.1 { 277 | let index = if is_vertical_gradient { 278 | v + u * mask_size.0 279 | } else { 280 | u + v * mask_size.0 281 | }; 282 | 283 | let val = mask[index]; 284 | if val == 0 { 285 | continue; 286 | } 287 | 288 | let brightness = u_sin * brightness_inv 289 | + formulas::f32_closed(rng.next_u32()) * options.brightness_noise; 290 | 291 | let mut rgb = HSL { 292 | h: hue as f64 * 360.0, 293 | s: saturation as f64, 294 | l: brightness as f64, 295 | } 296 | .to_rgb(); 297 | 298 | // Make the edges darker 299 | if val == -1 { 300 | rgb.0 = (rgb.0 as f32 * options.edge_brightness) as u8; 301 | rgb.1 = (rgb.1 as f32 * options.edge_brightness) as u8; 302 | rgb.2 = (rgb.2 as f32 * options.edge_brightness) as u8; 303 | } 304 | 305 | result[index] = ((rgb.0 as u32) << 16) | ((rgb.1 as u32) << 8) | (rgb.2 as u32); 306 | } 307 | } 308 | 309 | result 310 | } 311 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | # Disable the changelog for all packages 3 | changelog_update = false 4 | 5 | [[package]] 6 | name = "sprite" 7 | # Use the changelog for this package 8 | changelog_update = true 9 | changelog_path = "./CHANGELOG.md" 10 | 11 | [[package]] 12 | name = "sprite-gen" 13 | # Use the changelog for this package 14 | changelog_update = true 15 | changelog_path = "./CHANGELOG.md" 16 | 17 | # Ignore run-wasm 18 | [[package]] 19 | name = "run-wasm" 20 | semver_check = false 21 | changelog_update = false 22 | git_tag_enable = false 23 | git_release_enable = false 24 | publish = false 25 | -------------------------------------------------------------------------------- /run-wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "run-wasm" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | cargo-run-wasm = "0.3.2" 8 | -------------------------------------------------------------------------------- /run-wasm/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }"); 3 | } 4 | -------------------------------------------------------------------------------- /src/assets.rs: -------------------------------------------------------------------------------- 1 | use assets_manager::{AssetCache, AssetGuard, Compound}; 2 | 3 | use crate::Settings; 4 | 5 | /// All external data. 6 | #[cfg(not(feature = "embed-assets"))] 7 | pub struct Assets(AssetCache); 8 | #[cfg(feature = "embed-assets")] 9 | pub struct Assets(AssetCache>); 10 | 11 | impl Assets { 12 | /// Construct the asset loader. 13 | pub fn load() -> Self { 14 | // Load the assets from disk, allows hot-reloading 15 | #[cfg(not(feature = "embed-assets"))] 16 | let source = assets_manager::source::FileSystem::new("assets").unwrap(); 17 | 18 | // Embed all assets into the binary 19 | #[cfg(feature = "embed-assets")] 20 | let source = 21 | assets_manager::source::Embedded::from(assets_manager::source::embed!("assets")); 22 | 23 | let asset_cache = AssetCache::with_source(source); 24 | 25 | Self(asset_cache) 26 | } 27 | 28 | /// Load the settings. 29 | pub fn settings(&self) -> AssetGuard { 30 | self.0.load_expect("settings").read() 31 | } 32 | 33 | /// Load an generic asset. 34 | pub fn asset(&self, path: &str) -> AssetGuard 35 | where 36 | T: Compound, 37 | { 38 | self.0.load_expect(path).read() 39 | } 40 | 41 | /// Hot reload from disk if applicable. 42 | pub fn enable_hot_reloading(&'static self) { 43 | self.0.enhance_hot_reloading(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/font.rs: -------------------------------------------------------------------------------- 1 | use assets_manager::{loader::TomlLoader, AnyCache, Asset, BoxedError, Compound, SharedString}; 2 | use blit::{prelude::SubRect, Blit, BlitBuffer, BlitOptions}; 3 | use serde::Deserialize; 4 | use vek::{Extent2, Vec2}; 5 | 6 | use crate::{sprite::Sprite, SIZE}; 7 | 8 | /// Pixel font loaded from an image. 9 | pub struct Font { 10 | /// Image to render. 11 | sprite: BlitBuffer, 12 | /// Size of a single character. 13 | pub char_size: Extent2, 14 | } 15 | 16 | impl Font { 17 | /// Load a font from image bytes. 18 | /// Render text on a pixel buffer. 19 | pub fn render(&self, text: &str, pos: Vec2, canvas: &mut [u32]) { 20 | // First character in the image 21 | let char_start = '!'; 22 | let char_end = '~'; 23 | 24 | let pos: Vec2 = pos.as_() - (self.char_size.w as i32, 0); 25 | let mut x = pos.x; 26 | let mut y = pos.y; 27 | 28 | // Draw each character from the string 29 | text.chars().for_each(|ch| { 30 | // Move the cursor 31 | x += self.char_size.w as i32; 32 | 33 | // Don't draw characters that are not in the picture 34 | if ch < char_start || ch > char_end { 35 | if ch == '\n' { 36 | x = pos.x; 37 | y += self.char_size.h as i32; 38 | } else if ch == '\t' { 39 | x += self.char_size.w as i32 * 3; 40 | } 41 | return; 42 | } 43 | 44 | // The sub rectangle offset of the character is based on the starting character and counted using the ASCII index 45 | let char_offset = (ch as u8 - char_start as u8) as u32 * self.char_size.w as u32; 46 | 47 | // Draw the character 48 | self.sprite.blit( 49 | canvas, 50 | SIZE.into_tuple().into(), 51 | &BlitOptions::new_position(x, y).with_sub_rect(SubRect::new( 52 | char_offset, 53 | 0, 54 | self.char_size.into_tuple(), 55 | )), 56 | ); 57 | }); 58 | } 59 | 60 | pub fn render_centered(&self, text: &str, pos: Vec2, canvas: &mut [u32]) { 61 | self.render( 62 | text, 63 | pos - Vec2::new( 64 | (text.len() as f64 * self.char_size.w as f64) / 2.0, 65 | self.char_size.h as f64 / 2.0, 66 | ), 67 | canvas, 68 | ) 69 | } 70 | } 71 | 72 | impl Compound for Font { 73 | fn load(cache: AnyCache, id: &SharedString) -> Result { 74 | // Load the sprite 75 | let sprite = cache.load_owned::(id)?.into_blit_buffer(); 76 | 77 | // Load the metadata 78 | let metadata = cache.load::(id)?.read(); 79 | let char_size = Extent2::new(metadata.char_width, metadata.char_height); 80 | 81 | Ok(Self { sprite, char_size }) 82 | } 83 | } 84 | 85 | /// Font metadata to load. 86 | #[derive(Deserialize)] 87 | struct FontMetadata { 88 | /// Width of a single character. 89 | char_width: u8, 90 | /// Height of a single character. 91 | char_height: u8, 92 | } 93 | 94 | impl Asset for FontMetadata { 95 | const EXTENSION: &'static str = "toml"; 96 | 97 | type Loader = TomlLoader; 98 | } 99 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | use vek::Vec2; 2 | 3 | /// Current input. 4 | #[derive(Debug, Default)] 5 | pub struct Input { 6 | pub mouse_pos: Vec2, 7 | 8 | pub left_mouse: ButtonState, 9 | pub right_mouse: ButtonState, 10 | pub up: ButtonState, 11 | pub down: ButtonState, 12 | pub left: ButtonState, 13 | pub right: ButtonState, 14 | pub space: ButtonState, 15 | 16 | pub r: ButtonState, 17 | pub g: ButtonState, 18 | pub c: ButtonState, 19 | pub o: ButtonState, 20 | pub n: ButtonState, 21 | pub x: ButtonState, 22 | } 23 | 24 | impl Input { 25 | /// Unset the released state. 26 | pub fn update(&mut self) { 27 | self.left_mouse.update(); 28 | self.right_mouse.update(); 29 | self.up.update(); 30 | self.down.update(); 31 | self.left.update(); 32 | self.right.update(); 33 | self.space.update(); 34 | self.r.update(); 35 | self.g.update(); 36 | self.c.update(); 37 | self.o.update(); 38 | self.n.update(); 39 | self.x.update(); 40 | } 41 | } 42 | 43 | /// Input button state. 44 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 45 | pub enum ButtonState { 46 | /// Button is not being pressed. 47 | #[default] 48 | None, 49 | /// Button was released this update tick. 50 | Released, 51 | /// Button is being pressed down this update tick. 52 | Pressed, 53 | /// Button is being held down. 54 | Down, 55 | } 56 | 57 | impl ButtonState { 58 | /// Whether the button is pressed this tick. 59 | pub fn is_pressed(&self) -> bool { 60 | *self == Self::Pressed 61 | } 62 | 63 | /// Whether the button is being held down. 64 | pub fn is_down(&self) -> bool { 65 | *self == Self::Pressed || *self == Self::Down 66 | } 67 | 68 | /// Whether the button is released this tick. 69 | pub fn is_released(&self) -> bool { 70 | *self == Self::Released 71 | } 72 | 73 | /// Move state from released to none. 74 | pub fn update(&mut self) { 75 | if *self == Self::Released { 76 | *self = Self::None; 77 | } else if *self == Self::Pressed { 78 | *self = Self::Down; 79 | } 80 | } 81 | 82 | /// Handle the window state. 83 | pub fn handle_bool(&mut self, pressed: bool) { 84 | if (*self == Self::None || *self == Self::Released) && pressed { 85 | *self = Self::Pressed; 86 | } else if (*self == Self::Pressed || *self == Self::Down) && !pressed { 87 | *self = Self::Released; 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod assets; 2 | mod font; 3 | mod input; 4 | mod sprite; 5 | mod sprites; 6 | mod widgets; 7 | mod window; 8 | 9 | use std::{future::Future, sync::OnceLock}; 10 | 11 | use assets::Assets; 12 | use assets_manager::{loader::TomlLoader, Asset, AssetGuard}; 13 | use font::Font; 14 | use input::Input; 15 | use miette::Result; 16 | use rfd::AsyncFileDialog; 17 | use serde::Deserialize; 18 | use sprite::Sprite; 19 | use sprite_gen::{MaskValue, Options}; 20 | use sprites::Sprites; 21 | use taffy::{ 22 | prelude::{Node, Rect, Size}, 23 | style::{AlignContent, AlignItems, Display, FlexDirection, FlexWrap, Style}, 24 | style_helpers::TaffyMaxContent, 25 | tree::LayoutTree, 26 | Taffy, 27 | }; 28 | #[cfg(not(target_arch = "wasm32"))] 29 | use tokio::runtime::Builder; 30 | use vek::{Extent2, Vec2}; 31 | use widgets::{button::Button, checkbox::CheckboxGroup, grid::Grid, radio::Radio, slider::Slider}; 32 | 33 | /// Window size. 34 | pub const SIZE: Extent2 = Extent2::new(640, 600); 35 | 36 | /// The assets as a 'static reference. 37 | pub static ASSETS: OnceLock = OnceLock::new(); 38 | 39 | /// Application state. 40 | struct State { 41 | /// Rendered sprites. 42 | sprites: Sprites, 43 | /// Grid for drawing. 44 | drawing_area: Grid, 45 | /// Slider for X pixels value. 46 | x_pixels_slider: Slider, 47 | /// Slider for Y pixels value. 48 | y_pixels_slider: Slider, 49 | /// Button to clear the canvas. 50 | clear_canvas_button: Button, 51 | /// Button to save the sheet. 52 | save_sheet_button: Button, 53 | /// Radio button group for the brush. 54 | brush_radio: Radio<4>, 55 | /// Options checkbox group. 56 | options_group: CheckboxGroup<3>, 57 | /// Selected brush type. 58 | brush: MaskValue, 59 | /// Slider for edge brightness. 60 | edge_brightness_slider: Slider, 61 | /// Slider for color variations. 62 | color_variations_slider: Slider, 63 | /// Slider for brightness noise. 64 | brightness_noise_slider: Slider, 65 | /// Slider for saturation. 66 | saturation_slider: Slider, 67 | /// Flexbox grid to lay out the widgets. 68 | layout: Taffy, 69 | /// Root grid node. 70 | root: Node, 71 | } 72 | 73 | impl State { 74 | /// Construct the initial state. 75 | pub fn new() -> Self { 76 | let settings = crate::settings(); 77 | 78 | // Define the layout 79 | let mut layout = Taffy::new(); 80 | 81 | // Grid for editing the sprite shape 82 | let drawing_area = Grid::new( 83 | layout 84 | .new_leaf(Style { 85 | justify_content: Some(AlignContent::Center), 86 | min_size: Size::from_percent(0.6, 0.5), 87 | flex_grow: 1.0, 88 | ..Default::default() 89 | }) 90 | .unwrap(), 91 | Extent2::new(settings.min_x_pixels, settings.min_y_pixels).as_(), 92 | ); 93 | 94 | let slider_style = Style { 95 | size: Size::from_points(250.0, 20.0), 96 | margin: Rect { 97 | left: taffy::style_helpers::points(5.0), 98 | right: taffy::style_helpers::auto(), 99 | top: taffy::style_helpers::auto(), 100 | bottom: taffy::style_helpers::auto(), 101 | }, 102 | ..Default::default() 103 | }; 104 | let x_pixels_slider = Slider { 105 | node: layout.new_leaf(slider_style.clone()).unwrap(), 106 | length: 100.0, 107 | value_label: Some("X Pixels".to_string()), 108 | min: settings.min_x_pixels, 109 | max: settings.max_x_pixels, 110 | steps: Some((settings.max_x_pixels - settings.min_x_pixels) / 4.0), 111 | ..Default::default() 112 | }; 113 | 114 | let y_pixels_slider = Slider { 115 | node: layout.new_leaf(slider_style.clone()).unwrap(), 116 | length: 100.0, 117 | min: settings.min_y_pixels, 118 | max: settings.max_y_pixels, 119 | value_label: Some("Y Pixels".to_string()), 120 | steps: Some((settings.max_y_pixels - settings.min_y_pixels) / 4.0), 121 | ..Default::default() 122 | }; 123 | 124 | let button_style = Style { 125 | size: Size::from_points(120.0, 18.0), 126 | ..Default::default() 127 | }; 128 | let clear_canvas_button = Button { 129 | node: layout.new_leaf(button_style.clone()).unwrap(), 130 | label: Some("Clear".to_string()), 131 | ..Default::default() 132 | }; 133 | 134 | let brush_radio = Radio::new( 135 | ["Solid", "Empty", "Body1", "Body2"], 136 | Some("Brush".to_string()), 137 | 0, 138 | layout 139 | .new_leaf(Style { 140 | min_size: Size::from_points(80.0, 150.0), 141 | ..Default::default() 142 | }) 143 | .unwrap(), 144 | ); 145 | let brush = MaskValue::Solid; 146 | 147 | let options_group = CheckboxGroup::new( 148 | [("Colored", true), ("Mirror X", true), ("Mirror Y", false)], 149 | Some("Options".to_string()), 150 | layout 151 | .new_leaf(Style { 152 | min_size: Size::from_points(100.0, 120.0), 153 | ..Default::default() 154 | }) 155 | .unwrap(), 156 | ); 157 | 158 | let edge_brightness_slider = Slider { 159 | node: layout.new_leaf(slider_style.clone()).unwrap(), 160 | length: 80.0, 161 | value_label: Some("Edge Brightness".to_string()), 162 | min: 0.0, 163 | max: 100.0, 164 | pos: 0.17, 165 | ..Default::default() 166 | }; 167 | 168 | let color_variations_slider = Slider { 169 | node: layout.new_leaf(slider_style.clone()).unwrap(), 170 | length: 80.0, 171 | value_label: Some("Color Variations".to_string()), 172 | min: 0.0, 173 | max: 100.0, 174 | pos: 0.2, 175 | ..Default::default() 176 | }; 177 | 178 | let brightness_noise_slider = Slider { 179 | node: layout.new_leaf(slider_style.clone()).unwrap(), 180 | length: 80.0, 181 | value_label: Some("Brightness Noise".to_string()), 182 | min: 0.0, 183 | max: 100.0, 184 | pos: 0.81, 185 | ..Default::default() 186 | }; 187 | 188 | let saturation_slider = Slider { 189 | node: layout.new_leaf(slider_style.clone()).unwrap(), 190 | length: 80.0, 191 | value_label: Some("Saturation".to_string()), 192 | min: 0.0, 193 | max: 100.0, 194 | pos: 0.54, 195 | ..Default::default() 196 | }; 197 | 198 | let save_sheet_button = Button { 199 | node: layout.new_leaf(button_style.clone()).unwrap(), 200 | label: Some("Save Sheet".to_string()), 201 | ..Default::default() 202 | }; 203 | 204 | let sprites = Sprites { 205 | offset: Vec2::new(5.0, 470.0), 206 | size: Extent2::new( 207 | x_pixels_slider.value() as usize, 208 | y_pixels_slider.value() as usize, 209 | ), 210 | amount: settings.preview_requested, 211 | ..Default::default() 212 | }; 213 | 214 | let gap = Size { 215 | width: taffy::style_helpers::points(2.0), 216 | height: taffy::style_helpers::points(2.0), 217 | }; 218 | 219 | let groups = layout 220 | .new_with_children( 221 | Style { 222 | display: Display::Flex, 223 | flex_direction: FlexDirection::Row, 224 | justify_content: Some(AlignContent::SpaceAround), 225 | gap, 226 | ..Default::default() 227 | }, 228 | &[options_group.node, brush_radio.node], 229 | ) 230 | .unwrap(); 231 | let pixel_sliders = layout 232 | .new_with_children( 233 | Style { 234 | display: Display::Flex, 235 | flex_direction: FlexDirection::Column, 236 | justify_content: Some(AlignContent::Center), 237 | margin: Rect { 238 | left: taffy::style_helpers::auto(), 239 | right: taffy::style_helpers::auto(), 240 | top: taffy::style_helpers::points(5.0), 241 | bottom: taffy::style_helpers::points(5.0), 242 | }, 243 | gap, 244 | ..Default::default() 245 | }, 246 | &[x_pixels_slider.node, y_pixels_slider.node], 247 | ) 248 | .unwrap(); 249 | 250 | // Split the layout top vertical part into two horizontal parts 251 | let topleft = layout 252 | .new_with_children( 253 | Style { 254 | display: Display::Flex, 255 | flex_direction: FlexDirection::Column, 256 | gap, 257 | ..Default::default() 258 | }, 259 | &[clear_canvas_button.node, pixel_sliders, groups], 260 | ) 261 | .unwrap(); 262 | 263 | // Split the layout into two vertical parts 264 | let top = layout 265 | .new_with_children( 266 | Style { 267 | min_size: Size { 268 | width: taffy::style_helpers::percent(1.0), 269 | height: taffy::style_helpers::percent(0.9), 270 | }, 271 | display: Display::Flex, 272 | flex_direction: FlexDirection::Row, 273 | justify_content: Some(AlignContent::SpaceBetween), 274 | align_items: Some(AlignItems::Stretch), 275 | gap, 276 | ..Default::default() 277 | }, 278 | &[topleft, drawing_area.node], 279 | ) 280 | .unwrap(); 281 | let bottom = layout 282 | .new_with_children( 283 | Style { 284 | min_size: Size { 285 | width: taffy::style_helpers::percent(1.0), 286 | height: taffy::style_helpers::auto(), 287 | }, 288 | gap, 289 | flex_wrap: FlexWrap::Wrap, 290 | ..Default::default() 291 | }, 292 | &[ 293 | edge_brightness_slider.node, 294 | saturation_slider.node, 295 | color_variations_slider.node, 296 | brightness_noise_slider.node, 297 | save_sheet_button.node, 298 | ], 299 | ) 300 | .unwrap(); 301 | 302 | // Everything together 303 | let root = layout 304 | .new_with_children( 305 | Style { 306 | display: Display::Flex, 307 | flex_direction: FlexDirection::Column, 308 | justify_content: Some(AlignContent::SpaceBetween), 309 | size: Size::from_points(SIZE.w as f32, SIZE.h as f32 - 136.0), 310 | padding: Rect::points(5.0), 311 | ..Default::default() 312 | }, 313 | &[top, bottom], 314 | ) 315 | .unwrap(); 316 | 317 | let mut this = Self { 318 | sprites, 319 | drawing_area, 320 | x_pixels_slider, 321 | y_pixels_slider, 322 | clear_canvas_button, 323 | save_sheet_button, 324 | brush_radio, 325 | options_group, 326 | brush, 327 | edge_brightness_slider, 328 | color_variations_slider, 329 | brightness_noise_slider, 330 | saturation_slider, 331 | layout, 332 | root, 333 | }; 334 | 335 | this.update_layout(); 336 | this.generate(); 337 | 338 | this 339 | } 340 | 341 | /// Update application state and handle input. 342 | pub fn update(&mut self, input: &Input) { 343 | if self.x_pixels_slider.update(input) || self.y_pixels_slider.update(input) { 344 | let x_pixels = self.x_pixels_slider.value(); 345 | let y_pixels = self.y_pixels_slider.value(); 346 | // Resize the drawing area 347 | self.drawing_area.resize( 348 | Extent2::new(x_pixels, y_pixels).as_(), 349 | Extent2::new( 350 | if x_pixels == 4.0 { 351 | 64 352 | } else if x_pixels < 12.0 { 353 | 32 354 | } else if x_pixels < 24.0 { 355 | 16 356 | } else { 357 | 9 358 | }, 359 | if y_pixels == 4.0 { 360 | 64 361 | } else if y_pixels < 12.0 { 362 | 32 363 | } else if y_pixels < 24.0 { 364 | 16 365 | } else { 366 | 9 367 | }, 368 | ), 369 | ); 370 | 371 | // Resize the sprite results 372 | self.sprites.resize( 373 | Extent2::new(self.x_pixels_slider.value(), self.y_pixels_slider.value()).as_(), 374 | ); 375 | 376 | self.generate(); 377 | self.update_layout(); 378 | } 379 | 380 | // Allow user to draw 381 | if self.drawing_area.update(input, self.brush.clone()) { 382 | self.generate(); 383 | } 384 | 385 | if self.clear_canvas_button.update(input) { 386 | self.drawing_area.clear(); 387 | 388 | self.generate(); 389 | } 390 | 391 | // Open the dialog to save the file 392 | if self.save_sheet_button.update(input) { 393 | block_async(async move { 394 | if let Some(file_handle) = AsyncFileDialog::new() 395 | .set_title("Save Sprite Sheet") 396 | .add_filter("image", &["png"]) 397 | .set_file_name("sprites.png") 398 | .save_file() 399 | .await 400 | { 401 | dbg!(file_handle); 402 | } 403 | }); 404 | } 405 | 406 | // Update the brush according to the radio group 407 | if let Some(selected) = self.brush_radio.update(input) { 408 | self.brush = match selected { 409 | 0 => MaskValue::Solid, 410 | 1 => MaskValue::Empty, 411 | 2 => MaskValue::Body1, 412 | 3 => MaskValue::Body2, 413 | _ => panic!(), 414 | }; 415 | } 416 | 417 | if self.options_group.update(input).is_some() { 418 | self.generate(); 419 | } 420 | 421 | if self.edge_brightness_slider.update(input) 422 | || self.color_variations_slider.update(input) 423 | || self.brightness_noise_slider.update(input) 424 | || self.saturation_slider.update(input) 425 | { 426 | self.generate(); 427 | } 428 | } 429 | 430 | /// Render the window. 431 | pub fn render(&self, canvas: &mut [u32]) { 432 | self.drawing_area.render(canvas); 433 | self.x_pixels_slider.render(canvas); 434 | self.y_pixels_slider.render(canvas); 435 | self.clear_canvas_button.render(canvas); 436 | self.save_sheet_button.render(canvas); 437 | self.brush_radio.render(canvas); 438 | self.options_group.render(canvas); 439 | self.sprites.render(canvas); 440 | self.edge_brightness_slider.render(canvas); 441 | self.color_variations_slider.render(canvas); 442 | self.brightness_noise_slider.render(canvas); 443 | self.saturation_slider.render(canvas); 444 | } 445 | 446 | /// Update the layout. 447 | pub fn update_layout(&mut self) { 448 | // Compute the layout 449 | self.layout 450 | .compute_layout(self.root, Size::MAX_CONTENT) 451 | .unwrap(); 452 | 453 | self.drawing_area.update_layout( 454 | self.abs_location(self.drawing_area.node), 455 | self.layout.layout(self.drawing_area.node).unwrap(), 456 | ); 457 | self.x_pixels_slider 458 | .update_layout(self.abs_location(self.x_pixels_slider.node)); 459 | self.y_pixels_slider 460 | .update_layout(self.abs_location(self.y_pixels_slider.node)); 461 | self.clear_canvas_button.update_layout( 462 | self.abs_location(self.clear_canvas_button.node), 463 | self.layout.layout(self.clear_canvas_button.node).unwrap(), 464 | ); 465 | self.save_sheet_button.update_layout( 466 | self.abs_location(self.save_sheet_button.node), 467 | self.layout.layout(self.save_sheet_button.node).unwrap(), 468 | ); 469 | self.brush_radio 470 | .update_layout(self.abs_location(self.brush_radio.node)); 471 | self.options_group 472 | .update_layout(self.abs_location(self.options_group.node)); 473 | self.edge_brightness_slider 474 | .update_layout(self.abs_location(self.edge_brightness_slider.node)); 475 | self.saturation_slider 476 | .update_layout(self.abs_location(self.saturation_slider.node)); 477 | self.color_variations_slider 478 | .update_layout(self.abs_location(self.color_variations_slider.node)); 479 | self.brightness_noise_slider 480 | .update_layout(self.abs_location(self.brightness_noise_slider.node)); 481 | } 482 | 483 | /// Generate new sprites. 484 | pub fn generate(&mut self) { 485 | // Scale to fill the rectangle with the lowest factor 486 | let area = Extent2::new(SIZE.w - 10, SIZE.h - self.sprites.offset.y as usize - 10); 487 | let width = self.x_pixels_slider.value() as usize 488 | * if self.options_group.checked(1) { 2 } else { 1 } 489 | + 4; 490 | let x_factor = area.w / width / settings().preview_requested.w; 491 | let height = self.y_pixels_slider.value() as usize 492 | * if self.options_group.checked(2) { 2 } else { 1 } 493 | + 4; 494 | let y_factor = area.h / height / settings().preview_requested.h; 495 | let scale = x_factor.min(y_factor).max(2); 496 | 497 | // Amount that can actually fit with the current size 498 | let amount = Extent2::new(area.w / width / scale, area.h / height / scale); 499 | 500 | // Redraw all sprites 501 | self.sprites.generate( 502 | self.drawing_area.mask(), 503 | Options { 504 | colored: self.options_group.checked(0), 505 | mirror_x: self.options_group.checked(1), 506 | mirror_y: self.options_group.checked(2), 507 | edge_brightness: self.edge_brightness_slider.value() as f32 / 100.0, 508 | color_variations: self.color_variations_slider.value() as f32 / 100.0, 509 | brightness_noise: self.brightness_noise_slider.value() as f32 / 100.0, 510 | saturation: self.saturation_slider.value() as f32 / 100.0, 511 | ..Default::default() 512 | }, 513 | amount, 514 | scale, 515 | ); 516 | } 517 | 518 | /// Get absolute coordinates for a node. 519 | /// 520 | /// We have to do this recursively because taffy doesn't expose it directly. 521 | pub fn abs_location(&self, mut node: Node) -> Vec2 { 522 | let layout = self.layout.layout(node).unwrap().location; 523 | let mut coord = Vec2::new(layout.x as f64, layout.y as f64); 524 | 525 | while let Some(parent) = self.layout.parent(node) { 526 | let layout = self.layout.layout(parent).unwrap().location; 527 | coord.x += layout.x as f64; 528 | coord.y += layout.y as f64; 529 | 530 | node = parent; 531 | } 532 | 533 | coord 534 | } 535 | } 536 | 537 | /// Application settings loaded from a file so it's easier to change them with hot-reloading. 538 | #[derive(Deserialize)] 539 | pub struct Settings { 540 | /// Minimum amount of X pixels. 541 | min_x_pixels: f64, 542 | /// Maximum amount of X pixels. 543 | max_x_pixels: f64, 544 | /// Minimum amount of Y pixels. 545 | min_y_pixels: f64, 546 | /// Maximum amount of Y pixels. 547 | max_y_pixels: f64, 548 | /// Ideal amount of preview images. 549 | preview_requested: Extent2, 550 | } 551 | 552 | impl Asset for Settings { 553 | const EXTENSION: &'static str = "toml"; 554 | 555 | type Loader = TomlLoader; 556 | } 557 | 558 | async fn run() -> Result<()> { 559 | // Initialize the asset loader 560 | let assets = ASSETS.get_or_init(Assets::load); 561 | assets.enable_hot_reloading(); 562 | 563 | // Run the application window 564 | window::run( 565 | State::new(), 566 | SIZE, 567 | 60, 568 | |g, input| { 569 | // Update the application state 570 | g.update(input); 571 | }, 572 | |g, buffer| { 573 | // Clear with gray 574 | buffer.fill(0xFF999999); 575 | 576 | // Draw the application 577 | g.render(buffer); 578 | }, 579 | ) 580 | .await?; 581 | 582 | Ok(()) 583 | } 584 | 585 | /// Entry point starting either a WASM future or a Tokio runtime. 586 | fn main() { 587 | #[cfg(target_arch = "wasm32")] 588 | { 589 | std::panic::set_hook(Box::new(console_error_panic_hook::hook)); 590 | console_log::init_with_level(log::Level::Info).expect("error initializing logger"); 591 | 592 | wasm_bindgen_futures::spawn_local(async { run().await.unwrap() }); 593 | } 594 | 595 | #[cfg(not(target_arch = "wasm32"))] 596 | { 597 | let rt = Builder::new_multi_thread().enable_all().build().unwrap(); 598 | rt.block_on(async { run().await.unwrap() }); 599 | } 600 | } 601 | 602 | /// Spawn an async task. 603 | #[cfg(target_arch = "wasm32")] 604 | fn block_async(task: T) 605 | where 606 | T: Future + 'static, 607 | { 608 | wasm_bindgen_futures::spawn_local(task); 609 | } 610 | 611 | /// Spawn an async task. 612 | #[cfg(not(target_arch = "wasm32"))] 613 | fn block_async(task: T) 614 | where 615 | T: Future + Send + 'static, 616 | T::Output: Send + 'static, 617 | { 618 | tokio::spawn(task); 619 | } 620 | 621 | /// Load the global settings. 622 | pub fn settings() -> AssetGuard<'static, Settings> { 623 | ASSETS 624 | .get() 625 | .expect("Asset handling not initialized yet") 626 | .settings() 627 | } 628 | 629 | /// Load the font. 630 | pub fn font() -> AssetGuard<'static, Font> { 631 | ASSETS 632 | .get() 633 | .expect("Asset handling not initialized yet") 634 | .asset("Beachball") 635 | } 636 | 637 | /// Load the sprite. 638 | pub fn sprite(path: &str) -> AssetGuard<'static, Sprite> { 639 | ASSETS 640 | .get() 641 | .expect("Asset handling not initialized yet") 642 | .asset(path) 643 | } 644 | -------------------------------------------------------------------------------- /src/sprite.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use assets_manager::{loader::Loader, Asset}; 4 | use blit::{slice::Slice, Blit, BlitBuffer, BlitOptions, ToBlitBuffer}; 5 | use image::ImageFormat; 6 | use vek::{Extent2, Vec2}; 7 | 8 | use crate::SIZE; 9 | 10 | /// Sprite that can be drawn on the canvas. 11 | #[derive(Debug)] 12 | pub struct Sprite { 13 | /// Pixels to render. 14 | sprite: BlitBuffer, 15 | } 16 | 17 | impl Sprite { 18 | /// Create a sprite from a buffer of colors. 19 | pub fn from_buffer(buffer: &[u32], size: Extent2) -> Self { 20 | let sprite = BlitBuffer::from_buffer(buffer, size.w, 127); 21 | 22 | Self { sprite } 23 | } 24 | 25 | /// Draw the sprite. 26 | pub fn render(&self, canvas: &mut [u32], offset: Vec2) { 27 | self.sprite.blit( 28 | canvas, 29 | SIZE.into_tuple().into(), 30 | &BlitOptions::new_position(offset.x as i32, offset.y as i32), 31 | ); 32 | } 33 | 34 | /// Draw the sprite as a slice9 scaling. 35 | pub fn render_vertical_slice( 36 | &self, 37 | canvas: &mut [u32], 38 | offset: Vec2, 39 | width: f64, 40 | slice: Slice, 41 | ) { 42 | self.sprite.blit( 43 | canvas, 44 | SIZE.into_tuple().into(), 45 | &BlitOptions::new_position(offset.x as i32, offset.y as i32) 46 | .with_vertical_slice(slice) 47 | .with_area((width, self.height())), 48 | ); 49 | } 50 | 51 | /// Draw the sprite as a slice9 scaling. 52 | pub fn render_options(&self, canvas: &mut [u32], blit_options: &BlitOptions) { 53 | self.sprite 54 | .blit(canvas, SIZE.into_tuple().into(), blit_options); 55 | } 56 | 57 | /// Whether a pixel on the image is transparent. 58 | pub fn is_pixel_transparent(&self, pixel: Vec2) -> bool { 59 | let offset: Vec2 = pixel.as_(); 60 | 61 | let index: i32 = offset.x + offset.y * self.sprite.width() as i32; 62 | let pixel = self.sprite.pixels()[index as usize]; 63 | 64 | pixel == 0 65 | } 66 | 67 | /// Width of the image. 68 | pub fn width(&self) -> u32 { 69 | self.sprite.width() 70 | } 71 | 72 | /// Height of the image. 73 | pub fn height(&self) -> u32 { 74 | self.sprite.height() 75 | } 76 | 77 | /// Size of the image. 78 | pub fn size(&self) -> Extent2 { 79 | Extent2::new(self.width(), self.height()) 80 | } 81 | 82 | /// Raw buffer. 83 | pub fn into_blit_buffer(self) -> BlitBuffer { 84 | self.sprite 85 | } 86 | 87 | /// Get the raw pixels. 88 | pub fn pixels_mut(&mut self) -> &mut [u32] { 89 | self.sprite.pixels_mut() 90 | } 91 | } 92 | 93 | impl Asset for Sprite { 94 | // We only support PNG images currently 95 | const EXTENSION: &'static str = "png"; 96 | 97 | type Loader = SpriteLoader; 98 | } 99 | 100 | /// Sprite asset loader. 101 | pub struct SpriteLoader; 102 | 103 | impl Loader for SpriteLoader { 104 | fn load(content: Cow<[u8]>, _ext: &str) -> Result { 105 | let sprite = image::load_from_memory_with_format(&content, ImageFormat::Png)? 106 | .into_rgba8() 107 | .to_blit_buffer_with_alpha(127); 108 | 109 | Ok(Sprite { sprite }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/sprites.rs: -------------------------------------------------------------------------------- 1 | use blit::{prelude::Size, Blit, BlitBuffer, BlitOptions}; 2 | use sprite_gen::{MaskValue, Options}; 3 | use vek::{Extent2, Vec2}; 4 | 5 | use crate::SIZE; 6 | 7 | /// A grid for rendering result sprites. 8 | #[derive(Debug)] 9 | pub struct Sprites { 10 | /// Top-left position of the widget in pixels. 11 | pub offset: Vec2, 12 | /// Different generated sprites. 13 | pub sprites: Vec, 14 | /// Size of a single sprite. 15 | pub size: Extent2, 16 | /// Amount of sprites in each dimension. 17 | pub amount: Extent2, 18 | } 19 | 20 | impl Sprites { 21 | /// Render the sprites. 22 | pub fn render(&self, canvas: &mut [u32]) { 23 | // Draw each sprite 24 | for (index, sprite) in self.sprites.iter().enumerate() { 25 | let x = 26 | (index % self.amount.w) * (sprite.width() as usize + 4) + self.offset.x as usize; 27 | let y = 28 | (index / self.amount.w) * (sprite.height() as usize + 4) + self.offset.y as usize; 29 | 30 | sprite.blit( 31 | canvas, 32 | Size::from_tuple(SIZE.as_().into_tuple()), 33 | &BlitOptions::new_position(x, y), 34 | ); 35 | } 36 | } 37 | 38 | /// Generate each sprite. 39 | pub fn generate( 40 | &mut self, 41 | mask: &[MaskValue], 42 | mut options: Options, 43 | amount: Extent2, 44 | scale: usize, 45 | ) { 46 | self.amount = amount; 47 | self.sprites = (0..(self.amount.product())) 48 | .map(|_| { 49 | // Generate sprite 50 | options.seed = fastrand::u64(0..u64::MAX); 51 | let buf = sprite_gen::gen_sprite(mask, self.size.w, options); 52 | 53 | let width = if options.mirror_x { 54 | self.size.w * 2 55 | } else { 56 | self.size.w 57 | }; 58 | let height = if options.mirror_y { 59 | self.size.h * 2 60 | } else { 61 | self.size.h 62 | }; 63 | 64 | // Buffer for the scaled pixels 65 | let mut scaled_buf = vec![0; buf.len() * scale * scale]; 66 | for y in 0..height { 67 | let y_index = y * width; 68 | for x in 0..width { 69 | let pixel = buf[x + y_index]; 70 | 71 | for y2 in 0..scale { 72 | let y2_index = (y_index * scale + y2 * width) * scale; 73 | for x2 in 0..scale { 74 | scaled_buf[x * scale + x2 + y2_index] = pixel; 75 | } 76 | } 77 | } 78 | } 79 | 80 | // Convert to blit buffer so it's easier to draw 81 | BlitBuffer::from_buffer(&scaled_buf, width * scale, 0) 82 | }) 83 | .collect(); 84 | } 85 | 86 | /// Resize the size of the canvas. 87 | pub fn resize(&mut self, size: Extent2) { 88 | self.size = size; 89 | } 90 | } 91 | 92 | impl Default for Sprites { 93 | fn default() -> Self { 94 | Self { 95 | offset: Vec2::zero(), 96 | sprites: Vec::new(), 97 | size: Extent2::zero(), 98 | amount: Extent2::zero(), 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/widgets/button.rs: -------------------------------------------------------------------------------- 1 | use blit::BlitOptions; 2 | use taffy::prelude::{Layout, Node}; 3 | use vek::{Extent2, Rect, Vec2}; 4 | 5 | use crate::input::Input; 6 | 7 | /// A simple button widget. 8 | #[derive(Debug)] 9 | pub struct Button { 10 | /// Top-left position of the widget in pixels. 11 | pub offset: Vec2, 12 | /// Size of the button in pixels. 13 | pub size: Extent2, 14 | /// Extra size of the click region in pixels. 15 | /// 16 | /// Relative to the offset. 17 | pub click_region: Option>, 18 | /// A custom label with text centered at the button. 19 | pub label: Option, 20 | /// Current button state. 21 | pub state: State, 22 | /// Taffy layout node. 23 | pub node: Node, 24 | } 25 | 26 | impl Button { 27 | /// Handle the input. 28 | /// 29 | /// Return when the button is released. 30 | pub fn update(&mut self, input: &Input) -> bool { 31 | let mut rect = Rect::new(self.offset.x, self.offset.y, self.size.w, self.size.h); 32 | if let Some(mut click_region) = self.click_region { 33 | click_region.x += self.offset.x; 34 | click_region.y += self.offset.y; 35 | rect = rect.union(click_region); 36 | } 37 | 38 | match self.state { 39 | State::Normal => { 40 | if !input.left_mouse.is_down() && rect.contains_point(input.mouse_pos.as_()) { 41 | self.state = State::Hover; 42 | } 43 | 44 | false 45 | } 46 | State::Hover => { 47 | if !rect.contains_point(input.mouse_pos.as_()) { 48 | self.state = State::Normal; 49 | } else if input.left_mouse.is_down() { 50 | self.state = State::Down; 51 | } 52 | 53 | false 54 | } 55 | State::Down => { 56 | if input.left_mouse.is_released() { 57 | self.state = State::Normal; 58 | true 59 | } else { 60 | false 61 | } 62 | } 63 | } 64 | } 65 | 66 | /// Render the slider. 67 | pub fn render(&self, canvas: &mut [u32]) { 68 | let button = crate::sprite(match self.state { 69 | State::Normal => "button-normal", 70 | State::Hover => "button-hover", 71 | State::Down => "button-down", 72 | }); 73 | button.render_options( 74 | canvas, 75 | &BlitOptions::new_position(self.offset.x, self.offset.y) 76 | .with_slice9((2, 2, 1, 2)) 77 | .with_area((self.size.w, self.size.h)), 78 | ); 79 | 80 | if let Some(label) = &self.label { 81 | crate::font().render_centered( 82 | label, 83 | self.offset + (self.size.w / 2.0, self.size.h / 2.0), 84 | canvas, 85 | ); 86 | } 87 | } 88 | 89 | /// Update from layout changes. 90 | pub fn update_layout(&mut self, location: Vec2, layout: &Layout) { 91 | self.offset.x = location.x; 92 | self.offset.y = location.y; 93 | 94 | self.size.w = layout.size.width as f64; 95 | self.size.h = layout.size.height as f64; 96 | } 97 | } 98 | 99 | impl Default for Button { 100 | fn default() -> Self { 101 | Self { 102 | offset: Vec2::zero(), 103 | size: Extent2::zero(), 104 | label: None, 105 | state: State::default(), 106 | click_region: None, 107 | node: Node::default(), 108 | } 109 | } 110 | } 111 | 112 | /// In which state the button can be. 113 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] 114 | pub enum State { 115 | /// Button is doing nothing. 116 | #[default] 117 | Normal, 118 | /// Button is hovered over by the mouse. 119 | Hover, 120 | /// Button is hold down. 121 | Down, 122 | } 123 | -------------------------------------------------------------------------------- /src/widgets/checkbox.rs: -------------------------------------------------------------------------------- 1 | use taffy::prelude::Node; 2 | use vek::{Extent2, Rect, Vec2}; 3 | 4 | use crate::input::Input; 5 | 6 | use super::button::Button; 7 | 8 | /// A simple button widget. 9 | #[derive(Debug)] 10 | pub struct Checkbox { 11 | /// Top-left position of the widget in pixels. 12 | pub offset: Vec2, 13 | /// A custom label with text at the right of the checkbox. 14 | pub label: Option, 15 | /// Checked or not. 16 | pub checked: bool, 17 | /// Checkbox button frame. 18 | pub button: Button, 19 | } 20 | 21 | impl Checkbox { 22 | /// Construct a new checkbox button. 23 | pub fn new(offset: Vec2, label: Option, checked: bool) -> Self { 24 | let mut button = Button { 25 | offset, 26 | size: Extent2::new(20.0, 20.0), 27 | ..Default::default() 28 | }; 29 | 30 | // Allow the checkbox to be selected by clicking the label to 31 | if let Some(label) = &label { 32 | let char_size = crate::font().char_size; 33 | button.click_region = Some(Rect::new( 34 | 20.0, 35 | 0.0, 36 | char_size.w as f64 * label.len() as f64 + 5.0, 37 | 20.0, 38 | )); 39 | } 40 | 41 | Self { 42 | offset, 43 | checked, 44 | button, 45 | label, 46 | } 47 | } 48 | 49 | /// Handle the input. 50 | /// 51 | /// Return when the button is changed. 52 | pub fn update(&mut self, input: &Input) -> bool { 53 | if self.button.update(input) { 54 | self.checked = !self.checked; 55 | 56 | true 57 | } else { 58 | false 59 | } 60 | } 61 | 62 | /// Render the slider. 63 | pub fn render(&self, canvas: &mut [u32]) { 64 | self.button.render(canvas); 65 | 66 | if self.checked { 67 | crate::sprite("checkmark").render(canvas, self.offset + (2.0, 3.0)); 68 | } 69 | 70 | if let Some(label) = &self.label { 71 | crate::font().render(label, self.offset + (25.0, 5.0), canvas); 72 | } 73 | } 74 | 75 | /// Set whether the checkbox is checked or not. 76 | pub fn set(&mut self, state: bool) { 77 | self.checked = state; 78 | } 79 | 80 | /// Update the layout. 81 | pub fn update_layout(&mut self, location: Vec2) { 82 | self.offset = location; 83 | self.button.offset = location; 84 | } 85 | } 86 | 87 | /// A group of checkboxes. 88 | #[derive(Debug)] 89 | pub struct CheckboxGroup { 90 | /// Top-left position of the widget in pixels. 91 | pub offset: Vec2, 92 | /// A custom label with text. 93 | pub title: Option, 94 | /// All checkboxes. 95 | pub boxes: [Checkbox; N], 96 | /// Taffy layout node. 97 | pub node: Node, 98 | } 99 | 100 | impl CheckboxGroup { 101 | /// Construct a new checkbox button group. 102 | pub fn new(boxes: [(&str, bool); N], title: Option, node: Node) -> Self { 103 | let offset = Vec2::zero(); 104 | let boxes = boxes 105 | .map(|(label, checked)| Checkbox::new(Vec2::zero(), Some(label.to_string()), checked)); 106 | 107 | Self { 108 | offset, 109 | title, 110 | boxes, 111 | node, 112 | } 113 | } 114 | 115 | /// Handle the input. 116 | /// 117 | /// Return which checkbox changed. 118 | pub fn update(&mut self, input: &Input) -> Option { 119 | let mut changed = None; 120 | 121 | // Update the state of all checkboxes 122 | for index in 0..N { 123 | if self.boxes[index].update(input) { 124 | changed = Some(index); 125 | } 126 | } 127 | 128 | changed 129 | } 130 | 131 | /// Render the slider. 132 | pub fn render(&self, canvas: &mut [u32]) { 133 | for index in 0..N { 134 | self.boxes[index].render(canvas); 135 | } 136 | 137 | if let Some(label) = &self.title { 138 | crate::font().render(label, self.offset, canvas); 139 | } 140 | } 141 | 142 | /// Get the value of the checkbox at the index. 143 | pub fn checked(&self, index: usize) -> bool { 144 | assert!(index < N); 145 | 146 | self.boxes[index].checked 147 | } 148 | 149 | /// Update the layout. 150 | pub fn update_layout(&mut self, location: Vec2) { 151 | self.offset = location; 152 | for index in 0..self.boxes.len() { 153 | self.boxes.get_mut(index).unwrap().update_layout( 154 | location 155 | + ( 156 | 0.0, 157 | index as f64 * 30.0 + if self.title.is_some() { 20.0 } else { 0.0 }, 158 | ), 159 | ); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/widgets/grid.rs: -------------------------------------------------------------------------------- 1 | use sprite_gen::MaskValue; 2 | use taffy::prelude::{Layout, Node}; 3 | use vek::{Extent2, Vec2}; 4 | 5 | use crate::{input::Input, SIZE}; 6 | 7 | /// A simple slider widget. 8 | #[derive(Debug)] 9 | pub struct Grid { 10 | /// Top-left position of the widget in pixels. 11 | pub offset: Vec2, 12 | /// Size of the grid. 13 | pub size: Extent2, 14 | /// Size of each grid item in pixels. 15 | pub scaling: Extent2, 16 | /// Each value of the grid. 17 | pub values: Vec, 18 | /// Which item is hovered over by the mouse. 19 | pub hover_pos: Option>, 20 | /// Taffy layout node. 21 | pub node: Node, 22 | } 23 | 24 | impl Grid { 25 | /// Construct a new grid. 26 | pub fn new(node: Node, size: Extent2) -> Self { 27 | let values = vec![MaskValue::Empty; size.w * size.h]; 28 | let hover_pos = None; 29 | let scaling = Extent2::zero(); 30 | let offset = Vec2::zero(); 31 | 32 | Self { 33 | offset, 34 | size, 35 | scaling, 36 | values, 37 | hover_pos, 38 | node, 39 | } 40 | } 41 | 42 | /// Handle the input. 43 | /// 44 | /// Return when a value got updated. 45 | pub fn update(&mut self, input: &Input, value_on_click: MaskValue) -> bool { 46 | let mouse: Vec2 = input.mouse_pos.as_(); 47 | if mouse.x >= self.offset.x 48 | && mouse.y >= self.offset.y 49 | && mouse.x < self.offset.x + self.width() as f64 50 | && mouse.y < self.offset.y + self.height() as f64 51 | { 52 | let x = (input.mouse_pos.x as f64 - self.offset.x) / self.scaling.w as f64; 53 | let y = (input.mouse_pos.y as f64 - self.offset.y) / self.scaling.h as f64; 54 | let pos = Vec2::new(x, y).as_(); 55 | self.hover_pos = Some(pos); 56 | 57 | // Handle click, returning whether a tile got changed 58 | let index = pos.x + pos.y * self.size.w; 59 | if input.left_mouse.is_down() { 60 | let prev = self.values[index].clone(); 61 | self.values[index] = value_on_click.clone(); 62 | 63 | prev != value_on_click 64 | } else if input.right_mouse.is_down() { 65 | let prev = self.values[index].clone(); 66 | self.values[index] = MaskValue::Empty; 67 | 68 | prev != MaskValue::Empty 69 | } else { 70 | false 71 | } 72 | } else { 73 | self.hover_pos = None; 74 | 75 | false 76 | } 77 | } 78 | 79 | /// Render the slider. 80 | pub fn render(&self, canvas: &mut [u32]) { 81 | // Draw tiles 82 | for y in 0..self.height() { 83 | let start = self.offset.x as usize + (self.offset.y as usize + y) * SIZE.w; 84 | let y_descaled = y / self.scaling.h; 85 | 86 | for x in 0..self.size.w { 87 | let start = start + x * self.scaling.w; 88 | 89 | // Offset grid pattern 90 | let is_filled = 91 | (y_descaled % 2 == 0 && x % 2 == 0) || (y_descaled % 2 == 1 && x % 2 == 1); 92 | 93 | // The color 94 | let mut color = 95 | Self::mask_value_color(self.values[x + y_descaled * self.size.w].clone()); 96 | 97 | if is_filled { 98 | color ^= 0x000A0A0A; 99 | } 100 | 101 | canvas[start..(start + self.scaling.w)].fill(color); 102 | } 103 | } 104 | 105 | // Highlight the active tile 106 | if let Some(hover) = self.hover_pos { 107 | for y in 0..self.scaling.h { 108 | let start = self.offset.x as usize 109 | + (hover.x * self.scaling.w) 110 | + (self.offset.y as usize + y + (hover.y * self.scaling.h)) * SIZE.w; 111 | canvas 112 | .iter_mut() 113 | .skip(start) 114 | .take(self.scaling.w) 115 | .for_each(|pixel| *pixel ^= 0x00101010); 116 | } 117 | } 118 | } 119 | 120 | /// Resize the grid. 121 | pub fn resize(&mut self, size: Extent2, scaling: Extent2) { 122 | if self.size != size { 123 | self.size = size; 124 | 125 | self.values.resize(self.size.product(), MaskValue::Empty); 126 | } 127 | self.scaling = scaling; 128 | } 129 | 130 | /// Update from layout changes. 131 | pub fn update_layout(&mut self, location: Vec2, layout: &Layout) { 132 | self.scaling.w = layout.size.width as usize / self.size.w; 133 | self.scaling.h = layout.size.width as usize / self.size.h; 134 | 135 | self.offset = location; 136 | } 137 | 138 | /// Clear the grid. 139 | pub fn clear(&mut self) { 140 | self.values.fill(MaskValue::Empty); 141 | } 142 | 143 | /// Get the resulting mask. 144 | pub fn mask(&self) -> &[MaskValue] { 145 | &self.values 146 | } 147 | 148 | /// Total width. 149 | pub fn width(&self) -> usize { 150 | self.size.w * self.scaling.w 151 | } 152 | 153 | /// Total height. 154 | pub fn height(&self) -> usize { 155 | self.size.h * self.scaling.h 156 | } 157 | 158 | /// Color for a maskvalue. 159 | fn mask_value_color(mask_value: MaskValue) -> u32 { 160 | match mask_value { 161 | MaskValue::Solid => 0xFF444444, 162 | MaskValue::Empty => 0xFFFFFFFF, 163 | MaskValue::Body1 => 0xFFFF9999, 164 | MaskValue::Body2 => 0xFF9999FF, 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod button; 2 | pub mod checkbox; 3 | pub mod grid; 4 | pub mod radio; 5 | pub mod slider; 6 | -------------------------------------------------------------------------------- /src/widgets/radio.rs: -------------------------------------------------------------------------------- 1 | use taffy::prelude::Node; 2 | use vek::Vec2; 3 | 4 | use crate::input::Input; 5 | 6 | use super::checkbox::Checkbox; 7 | 8 | /// A simple button widget. 9 | #[derive(Debug)] 10 | pub struct Radio { 11 | /// Top-left position of the widget in pixels. 12 | pub offset: Vec2, 13 | /// A custom label with text. 14 | pub title: Option, 15 | /// Which box is selected. 16 | pub selected: usize, 17 | /// All checkboxes. 18 | pub boxes: [Checkbox; N], 19 | /// Taffy layout node. 20 | pub node: Node, 21 | } 22 | 23 | impl Radio { 24 | /// Construct a new checkbox button. 25 | pub fn new(boxes: [&str; N], title: Option, selected: usize, node: Node) -> Self { 26 | assert!(selected < N); 27 | 28 | let offset = Vec2::zero(); 29 | let mut index = 0; 30 | let boxes = boxes.map(|label| { 31 | index += 1; 32 | Checkbox::new(Vec2::zero(), Some(label.to_string()), index - 1 == selected) 33 | }); 34 | 35 | Self { 36 | offset, 37 | title, 38 | selected, 39 | boxes, 40 | node, 41 | } 42 | } 43 | 44 | /// Handle the input. 45 | /// 46 | /// Return when the a new selection is made. 47 | pub fn update(&mut self, input: &Input) -> Option { 48 | let mut changed = false; 49 | 50 | // Update the state of all checkboxes 51 | for index in 0..N { 52 | if self.boxes[index].update(input) { 53 | if self.selected != index { 54 | self.selected = index; 55 | changed = true; 56 | } 57 | 58 | // Unset all other checkboxes and set this one 59 | for index in 0..N { 60 | self.boxes[index].set(index == self.selected); 61 | } 62 | } 63 | } 64 | 65 | if changed { 66 | Some(self.selected) 67 | } else { 68 | None 69 | } 70 | } 71 | 72 | /// Render the slider. 73 | pub fn render(&self, canvas: &mut [u32]) { 74 | for index in 0..N { 75 | self.boxes[index].render(canvas); 76 | } 77 | 78 | if let Some(label) = &self.title { 79 | crate::font().render(label, self.offset, canvas); 80 | } 81 | } 82 | 83 | /// Update the layout. 84 | pub fn update_layout(&mut self, location: Vec2) { 85 | self.offset = location; 86 | for index in 0..self.boxes.len() { 87 | self.boxes.get_mut(index).unwrap().update_layout( 88 | location 89 | + ( 90 | 0.0, 91 | index as f64 * 30.0 + if self.title.is_some() { 20.0 } else { 0.0 }, 92 | ), 93 | ); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/widgets/slider.rs: -------------------------------------------------------------------------------- 1 | use blit::slice::Slice; 2 | use taffy::prelude::Node; 3 | use vek::{Rect, Vec2}; 4 | 5 | use crate::input::Input; 6 | 7 | /// A simple slider widget. 8 | #[derive(Debug)] 9 | pub struct Slider { 10 | /// Top-left position of the widget in pixels. 11 | pub offset: Vec2, 12 | /// Length of the slider in pixels. 13 | pub length: f64, 14 | /// Minimum value of the slider. 15 | pub min: f64, 16 | /// Maximum value of the slider. 17 | pub max: f64, 18 | /// How many steps the slider should snap to. 19 | pub steps: Option, 20 | /// Current positition of the slider as a fraction. 21 | pub pos: f64, 22 | /// Whether the slider state is being captured by the mouse. 23 | pub dragged: bool, 24 | /// A custom label with the value. 25 | pub value_label: Option, 26 | /// Taffy layout node. 27 | pub node: Node, 28 | } 29 | 30 | impl Slider { 31 | /// Handle the input. 32 | /// 33 | /// Returns whether the value changed. 34 | pub fn update(&mut self, input: &Input) -> bool { 35 | if !self.dragged { 36 | // Detect whether the mouse is being pressed on the handle 37 | let handle = crate::sprite("slider-handle"); 38 | let handle_rect = Rect::new( 39 | self.offset.x - handle.width() as f64 / 2.0, 40 | self.offset.y - handle.height() as f64 / 2.0, 41 | handle.width() as f64 * 2.0 + self.length, 42 | handle.height() as f64 * 2.0, 43 | ); 44 | 45 | if !self.dragged 46 | && input.left_mouse.is_pressed() 47 | && handle_rect.contains_point(input.mouse_pos.as_()) 48 | { 49 | self.dragged = true; 50 | } 51 | 52 | false 53 | } else if input.left_mouse.is_released() { 54 | // Always release the slider when the mouse is released 55 | self.dragged = false; 56 | 57 | false 58 | } else if input.left_mouse.is_down() { 59 | let prev = self.pos; 60 | // Drag the slider 61 | self.pos = ((input.mouse_pos.x as f64 - self.offset.x) / self.length).clamp(0.0, 1.0); 62 | 63 | // Clamp to steps 64 | let steps = self.steps.unwrap_or(self.length); 65 | self.pos = (self.pos * steps).round() / steps; 66 | 67 | self.pos != prev 68 | } else { 69 | false 70 | } 71 | } 72 | 73 | /// Render the slider. 74 | pub fn render(&self, canvas: &mut [u32]) { 75 | let handle = crate::sprite("slider-handle"); 76 | let bar = crate::sprite("slider-bar"); 77 | bar.render_vertical_slice( 78 | canvas, 79 | self.offset 80 | + ( 81 | 0.0, 82 | handle.height() as f64 / 2.0 - bar.height() as f64 / 2.0, 83 | ), 84 | self.length, 85 | Slice::Ternary { 86 | split_first: 2, 87 | split_last: 3, 88 | }, 89 | ); 90 | handle.render( 91 | canvas, 92 | self.offset 93 | + ( 94 | self.pos.clamp(0.0, 1.0) * self.length - handle.width() as f64 / 2.0, 95 | 0.0, 96 | ), 97 | ); 98 | 99 | // Draw the optional label 100 | if let Some(value_label) = &self.value_label { 101 | crate::font().render( 102 | &format!("{value_label}: {}", self.value().round()), 103 | self.offset + (self.length + 12.0, 2.0), 104 | canvas, 105 | ); 106 | } 107 | } 108 | 109 | /// Update from layout changes. 110 | pub fn update_layout(&mut self, location: Vec2) { 111 | self.offset = location; 112 | } 113 | 114 | /// Actual value of the slider. 115 | pub fn value(&self) -> f64 { 116 | (self.max - self.min) * self.pos + self.min 117 | } 118 | } 119 | 120 | impl Default for Slider { 121 | fn default() -> Self { 122 | Self { 123 | offset: Vec2::zero(), 124 | length: 100.0, 125 | min: 0.0, 126 | max: 1.0, 127 | steps: None, 128 | pos: 0.0, 129 | dragged: false, 130 | value_label: None, 131 | node: Node::default(), 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/window.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use game_loop::winit::{dpi::LogicalSize, window::WindowBuilder}; 4 | use miette::{IntoDiagnostic, Result}; 5 | use pixels::{wgpu::BlendState, PixelsBuilder, SurfaceTexture}; 6 | use vek::{Extent2, Vec2}; 7 | use winit::{ 8 | event::{ 9 | ElementState, Event, KeyboardInput, MouseButton, TouchPhase, VirtualKeyCode, WindowEvent, 10 | }, 11 | event_loop::EventLoop, 12 | }; 13 | 14 | use crate::input::Input; 15 | 16 | /// Create a new window with an event loop and run the application. 17 | pub async fn run( 18 | game_state: G, 19 | size: Extent2, 20 | updates_per_second: u32, 21 | mut update: U, 22 | mut render: R, 23 | ) -> Result<()> 24 | where 25 | G: 'static, 26 | U: FnMut(&mut G, &Input) + 'static, 27 | R: FnMut(&mut G, &mut [u32]) + 'static, 28 | { 29 | #[cfg(target_arch = "wasm32")] 30 | let canvas = wasm::setup_canvas(); 31 | 32 | // Build the window builder with the event loop the user supplied 33 | let event_loop = EventLoop::new(); 34 | let logical_size = LogicalSize::new(size.w as f64, size.h as f64); 35 | #[allow(unused_mut)] 36 | let mut window_builder = WindowBuilder::new() 37 | .with_title("Sprite") 38 | .with_inner_size(logical_size) 39 | .with_min_inner_size(logical_size); 40 | 41 | // Setup the WASM canvas if running on the browser 42 | #[cfg(target_arch = "wasm32")] 43 | { 44 | use winit::platform::web::WindowBuilderExtWebSys; 45 | 46 | window_builder = window_builder.with_canvas(Some(canvas)); 47 | } 48 | 49 | let window = window_builder.build(&event_loop).into_diagnostic()?; 50 | 51 | let pixels = { 52 | let surface_texture = SurfaceTexture::new(size.w as u32 * 2, size.h as u32 * 2, &window); 53 | PixelsBuilder::new(size.w as u32, size.h as u32, surface_texture) 54 | .clear_color(pixels::wgpu::Color::WHITE) 55 | .blend_state(BlendState::REPLACE) 56 | .build_async() 57 | .await 58 | } 59 | .into_diagnostic()?; 60 | 61 | #[cfg(target_arch = "wasm32")] 62 | wasm::update_canvas(size); 63 | 64 | // Open the window and run the event loop 65 | let mut buffer = vec![0u32; size.w * size.h]; 66 | 67 | game_loop::game_loop( 68 | event_loop, 69 | Arc::new(window), 70 | (game_state, pixels, Input::default()), 71 | updates_per_second, 72 | 0.1, 73 | move |g| { 74 | update(&mut g.game.0, &g.game.2); 75 | 76 | g.game.2.update(); 77 | }, 78 | move |g| { 79 | render(&mut g.game.0, &mut buffer); 80 | 81 | { 82 | // Blit draws the pixels in RGBA format, but the pixels crate expects BGRA, so convert it 83 | g.game 84 | .1 85 | .frame_mut() 86 | .chunks_exact_mut(4) 87 | .zip(buffer.iter()) 88 | .for_each(|(target, source)| { 89 | let source = source.to_ne_bytes(); 90 | target[0] = source[2]; 91 | target[1] = source[1]; 92 | target[2] = source[0]; 93 | target[3] = source[3]; 94 | }); 95 | } 96 | 97 | // Render the pixel buffer 98 | if let Err(err) = g.game.1.render() { 99 | dbg!(err); 100 | // TODO: properly handle error 101 | g.exit(); 102 | } 103 | }, 104 | move |g, ev| { 105 | match ev { 106 | // Handle close event 107 | Event::WindowEvent { 108 | event: WindowEvent::CloseRequested, 109 | .. 110 | } => g.exit(), 111 | 112 | // Resize the window 113 | Event::WindowEvent { 114 | event: WindowEvent::Resized(new_size), 115 | .. 116 | } => { 117 | g.game 118 | .1 119 | .resize_surface(new_size.width, new_size.height) 120 | .into_diagnostic() 121 | .unwrap(); 122 | } 123 | 124 | // Handle key presses 125 | Event::WindowEvent { 126 | event: 127 | WindowEvent::KeyboardInput { 128 | input: 129 | KeyboardInput { 130 | virtual_keycode, 131 | state, 132 | .. 133 | }, 134 | .. 135 | }, 136 | .. 137 | } => match virtual_keycode { 138 | Some(VirtualKeyCode::Up | VirtualKeyCode::W) => { 139 | g.game.2.up.handle_bool(state == &ElementState::Pressed) 140 | } 141 | Some(VirtualKeyCode::Down | VirtualKeyCode::S) => { 142 | g.game.2.down.handle_bool(state == &ElementState::Pressed) 143 | } 144 | Some(VirtualKeyCode::Left | VirtualKeyCode::A) => { 145 | g.game.2.left.handle_bool(state == &ElementState::Pressed) 146 | } 147 | Some(VirtualKeyCode::Right | VirtualKeyCode::D) => { 148 | g.game.2.right.handle_bool(state == &ElementState::Pressed) 149 | } 150 | Some(VirtualKeyCode::Space) => { 151 | g.game.2.space.handle_bool(state == &ElementState::Pressed) 152 | } 153 | Some(VirtualKeyCode::R) => { 154 | g.game.2.r.handle_bool(state == &ElementState::Pressed) 155 | } 156 | Some(VirtualKeyCode::G) => { 157 | g.game.2.g.handle_bool(state == &ElementState::Pressed) 158 | } 159 | Some(VirtualKeyCode::C) => { 160 | g.game.2.c.handle_bool(state == &ElementState::Pressed) 161 | } 162 | Some(VirtualKeyCode::O) => { 163 | g.game.2.o.handle_bool(state == &ElementState::Pressed) 164 | } 165 | Some(VirtualKeyCode::N) => { 166 | g.game.2.n.handle_bool(state == &ElementState::Pressed) 167 | } 168 | Some(VirtualKeyCode::X) => { 169 | g.game.2.x.handle_bool(state == &ElementState::Pressed) 170 | } 171 | // Close the window when the key is pressed 172 | Some(VirtualKeyCode::Escape) => g.exit(), 173 | _ => (), 174 | }, 175 | 176 | // Handle mouse pressed 177 | Event::WindowEvent { 178 | event: WindowEvent::MouseInput { button, state, .. }, 179 | .. 180 | } => { 181 | if *button == MouseButton::Left { 182 | g.game 183 | .2 184 | .left_mouse 185 | .handle_bool(*state == ElementState::Pressed); 186 | } else if *button == MouseButton::Right { 187 | g.game 188 | .2 189 | .right_mouse 190 | .handle_bool(*state == ElementState::Pressed); 191 | } 192 | } 193 | 194 | Event::WindowEvent { 195 | event: WindowEvent::Touch(touch), 196 | .. 197 | } => match touch.phase { 198 | TouchPhase::Started => g.game.2.left_mouse.handle_bool(true), 199 | TouchPhase::Moved => (), 200 | TouchPhase::Ended | TouchPhase::Cancelled => { 201 | g.game.2.left_mouse.handle_bool(false) 202 | } 203 | }, 204 | 205 | // Handle mouse move 206 | Event::WindowEvent { 207 | event: WindowEvent::CursorMoved { position, .. }, 208 | .. 209 | } => { 210 | // Map raw window pixel to actual pixel 211 | g.game.2.mouse_pos = g 212 | .game 213 | .1 214 | .window_pos_to_pixel((position.x as f32, position.y as f32)) 215 | .map(|(x, y)| Vec2::new(x as i32, y as i32)) 216 | // We also map the mouse when it's outside of the bounds 217 | .unwrap_or_else(|(x, y)| Vec2::new(x as i32, y as i32)) 218 | } 219 | _ => (), 220 | } 221 | }, 222 | ); 223 | } 224 | 225 | #[cfg(target_arch = "wasm32")] 226 | mod wasm { 227 | use vek::Extent2; 228 | use wasm_bindgen::JsCast; 229 | use web_sys::HtmlCanvasElement; 230 | 231 | /// Attach the winit window to a canvas. 232 | pub fn setup_canvas() -> HtmlCanvasElement { 233 | log::debug!("Binding window to HTML canvas"); 234 | 235 | let window = web_sys::window().unwrap(); 236 | 237 | let document = window.document().unwrap(); 238 | let body = document.body().unwrap(); 239 | body.style().set_css_text("text-align: center"); 240 | 241 | let canvas = document 242 | .create_element("canvas") 243 | .unwrap() 244 | .dyn_into::() 245 | .unwrap(); 246 | 247 | canvas.set_id("canvas"); 248 | body.append_child(&canvas).unwrap(); 249 | 250 | let header = document.create_element("h2").unwrap(); 251 | header.set_text_content(Some("Sprite")); 252 | body.append_child(&header).unwrap(); 253 | 254 | canvas 255 | } 256 | 257 | /// Update the size of the canvas. 258 | pub fn update_canvas(size: Extent2) { 259 | let window = web_sys::window().unwrap(); 260 | 261 | let document = window.document().unwrap(); 262 | 263 | let canvas = document 264 | .get_element_by_id("canvas") 265 | .unwrap() 266 | .dyn_into::() 267 | .unwrap(); 268 | 269 | canvas.style().set_css_text(&format!( 270 | "display:block; margin: auto; image-rendering: pixelated; width: {}px; height: {}px", 271 | size.w * 2, 272 | size.h * 2 273 | )); 274 | canvas.set_width(size.w as u32 * 2); 275 | canvas.set_height(size.h as u32 * 2); 276 | } 277 | } 278 | --------------------------------------------------------------------------------