pixsy/src/lib.rs

396 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![feature(clamp)]
use bitsy_parser::game::Game;
use bitsy_parser::image::Image;
use bitsy_parser::tile::Tile;
use image::{GenericImageView, Pixel, DynamicImage};
use image::imageops::ColorMap;
use image::imageops::FilterType::CatmullRom;
use lazy_static::lazy_static;
use std::sync::Mutex;
use wasm_bindgen::prelude::*;
mod colour_map;
use colour_map::ColourMap;
const SD: u32 = 8;
enum SelectedPalette {
None,
Existing {
id: String,
},
New {
background: bitsy_parser::Colour,
foreground: bitsy_parser::Colour,
}
}
struct State {
game: Option<Game>,
image: Option<DynamicImage>,
room_name: Option<String>,
palette: SelectedPalette,
dither: bool,
brightness: i32,
}
lazy_static! {
static ref STATE: Mutex<State> = Mutex::new(
State {
game: None,
image: None,
room_name: None,
palette: SelectedPalette::None,
dither: true,
brightness: 0,
}
);
}
fn tile_name(prefix: &Option<String>, x: &u32, y: &u32) -> Option<String> {
if let Some(prefix) = prefix {
Some(format!("{} ({},{})", prefix, x, y))
} else {
None
}
}
#[wasm_bindgen]
pub fn load_default_game() {
let mut state = STATE.lock().unwrap();
state.game = Some(bitsy_parser::mock::game_default());
// yes, this will probably always just be "0", but to be safe…
state.palette = SelectedPalette::Existing {
id: bitsy_parser::mock::game_default().palette_ids()[0].clone()
}
}
#[wasm_bindgen]
pub fn load_game(game_data: String) -> String {
let mut state = STATE.lock().unwrap();
let result = Game::from(game_data);
match result {
Ok((game, _errs)) => {
let palette_id = game.palette_ids()[0].clone();
state.game = Some(game);
state.palette = SelectedPalette::Existing { id: palette_id };
format!("Loaded game")
},
_ => {
state.game = None;
state.palette = SelectedPalette::None;
format!("{}", result.err().unwrap())
}
}
}
#[wasm_bindgen]
pub fn load_image(image_base64: String) -> String {
let mut state = STATE.lock().expect("Couldn't lock application state");
let image_base64: Vec<&str> = image_base64.split("base64,").collect();
if image_base64.len() < 2 {
return format!("Error: Badly-formatted base64: {}", image_base64.join(""));
}
let image_base64 = image_base64[1];
match base64::decode(image_base64) {
Ok(image) => {
match image::load_from_memory(image.as_ref()) {
Ok(image) => {
let size = format!("{}×{}", image.width(), image.height());
// todo get rid of magic numbers! what about Bitsy HD?
let image = image.resize(128, 128, CatmullRom);
state.image = Some(image);
size
},
_ => {
state.image = None;
"Error: Couldn't load image".to_string()
}
}
},
_ => {
state.image = None;
"Error: Couldn't decode image".to_string()
}
}
}
#[wasm_bindgen]
pub fn set_dither(dither: bool) {
let mut state = STATE.lock().unwrap();
state.dither = dither;
}
#[wasm_bindgen]
pub fn set_palette(palette_id: &str, background: String, foreground: String) {
let mut state = STATE.lock().unwrap();
state.palette = match palette_id {
"NEW_PALETTE" => SelectedPalette::New {
background: bitsy_parser::Colour::from_hex(&background).unwrap(),
foreground: bitsy_parser::Colour::from_hex(&foreground).unwrap(),
},
"" => SelectedPalette::None,
_ => SelectedPalette::Existing { id: palette_id.to_string() },
}
}
#[wasm_bindgen]
pub fn set_room_name(room_name: String) {
let mut state = STATE.lock().unwrap();
match room_name.is_empty() {
true => { state.room_name = None },
false => { state.room_name = Some(room_name) },
}
}
#[wasm_bindgen]
pub fn set_brightness(brightness: i32) {
let mut state = STATE.lock().unwrap();
state.brightness = brightness;
}
#[wasm_bindgen]
pub fn get_palettes() -> String {
let state = STATE.lock().unwrap();
let mut palette_objects = json::JsonValue::new_array();
for palette in &state.game.as_ref().unwrap().palettes {
let mut object = json::JsonValue::new_object();
object.insert("id", palette.id.clone()).unwrap();
object.insert(
"name",
palette.name.clone().unwrap_or(
format!("Palette {}", palette.id))
).unwrap();
palette_objects.push(object).unwrap();
}
json::stringify(palette_objects)
}
fn image_to_base64(image: &DynamicImage) -> String {
let mut bytes: Vec<u8> = Vec::new();
image.write_to(&mut bytes, image::ImageOutputFormat::Png).unwrap();
format!("data:image/png;base64,{}", base64::encode(&bytes))
}
fn palette_from(bg: &bitsy_parser::Colour, fg: &bitsy_parser::Colour) -> bitsy_parser::Palette {
bitsy_parser::Palette {
id: "0".to_string(),
name: None,
colours: vec![
bg.clone(), fg.clone(), bitsy_parser::Colour { red: 0, green: 0, blue: 0 }
],
}
}
fn render_preview(state: &State) -> DynamicImage {
let mut buffer = state.image.as_ref().unwrap().clone().into_rgba();
let palette = match &state.palette {
SelectedPalette::None => bitsy_parser::mock::game_default().palettes[0].clone(),
SelectedPalette::Existing { id } => state.game.as_ref().unwrap().get_palette(id).unwrap().clone(),
SelectedPalette::New { background, foreground } => palette_from(background, foreground),
};
let colour_map = crate::ColourMap::from(&palette);
// adjust brightness
let mut buffer = image::imageops::brighten(&mut buffer, state.brightness);
if state.dither {
image::imageops::dither(&mut buffer, &colour_map);
} else {
// just do colour indexing
let indices = image::imageops::colorops::index_colors(&mut buffer, &colour_map);
// todo get rid of magic numbers! what about Bitsy HD?
buffer = image::ImageBuffer::from_fn(128, 128, |x, y| -> image::Rgba<u8> {
let p = indices.get_pixel(x, y);
colour_map
.lookup(p.0[0] as usize)
.expect("indexed colour out-of-range")
.into()
});
}
image::DynamicImage::ImageRgba8(buffer)
}
#[wasm_bindgen]
pub fn get_preview() -> String {
let state = STATE.lock().unwrap();
match &state.image.is_some() {
true => image_to_base64(&render_preview(&state)),
false => "".to_string(),
}
}
#[wasm_bindgen]
pub fn add_room() -> String {
let mut state = STATE.lock().expect("Couldn't lock application state");
if state.game.is_none() {
return "No game data loaded".to_string();
}
match &state.palette {
SelectedPalette::None => { return "No palette selected".to_string(); },
_ => {}
};
let mut game = state.game.clone().unwrap();
if state.image.is_none() {
return "No image loaded".to_string();
}
let palette_id = Some(match &state.palette {
SelectedPalette::None => bitsy_parser::mock::game_default().palettes[0].id.clone(),
SelectedPalette::Existing { id } => id.clone(),
SelectedPalette::New { background, foreground } => {
game.add_palette(palette_from(background, foreground))
},
});
let foreground = &game.palettes
.iter()
.find(|&palette| &palette.id == palette_id.as_ref().unwrap())
.unwrap().colours[1].clone();
let preview = render_preview(&state);
let width = 128;
let height = 128;
let columns = (width as f64 / SD as f64).floor() as u32;
let rows = (height as f64 / SD as f64).floor() as u32;
let mut tile_ids = Vec::new();
let initial_tile_count = game.tiles.len();
for row in 0..rows {
for column in 0..columns {
let mut pixels = Vec::with_capacity(64);
fn colour_match(a: &image::Rgb<u8>, b: &bitsy_parser::Colour) -> u8 {
if a[0] == b.red && a[1] == b.green && a[2] == b.blue { 1 } else { 0 }
};
for y in (row * SD)..((row + 1) * SD) {
for x in (column * SD)..((column + 1) * SD) {
let pixel = preview.get_pixel(x, y).to_rgb();
pixels.push(colour_match(&pixel, foreground));
}
}
let tile = Tile {
// "0" will get overwritten to a new, safe tile ID
id: "0".to_string(),
name: tile_name(&state.room_name, &column, &row),
wall: None,
animation_frames: vec![Image { pixels }],
colour_id: None
};
let tile_id = if game.tiles.contains(&tile) {
game.tiles.iter().find(|&t| t == &tile).unwrap().id.clone()
} else {
game.add_tile(tile)
};
tile_ids.push(tile_id);
}
}
game.add_room(bitsy_parser::Room {
id: "0".to_string(),
palette_id,
name: state.room_name.clone(),
tiles: tile_ids,
items: vec![],
exits: vec![],
endings: vec![],
walls: None
});
game.dedupe_tiles();
let new_tile_count = game.tiles.len();
state.game = Some(game.to_owned());
format!(
"Added room \"{}\" with {} new tiles",
&state.room_name.as_ref().unwrap_or(&"untitled".to_string()),
new_tile_count - initial_tile_count
)
}
#[wasm_bindgen]
pub fn output() -> String {
let state = STATE.lock().unwrap();
match &state.game {
Some(game) => game.to_string(),
None => "No game loaded".to_string(),
}
}
#[cfg(test)]
mod test {
use crate::{add_room, load_image, load_default_game, output, get_preview, set_room_name};
#[test]
fn image_to_base64() {
let image = image::load_from_memory(include_bytes!("test-resources/test.png")).unwrap();
let output = crate::image_to_base64(&image);
let expected = include_str!("test-resources/test.png.base64").trim();
assert_eq!(output, expected);
}
#[test]
fn get_palettes() {
load_default_game();
let output = crate::get_palettes();
let expected = "[{\"id\":\"0\",\"name\":\"blueprint\"}]";
assert_eq!(output, expected);
}
#[test]
fn render_preview() {
load_default_game();
load_image(include_str!("test-resources/colour_input.png.base64").trim().to_string());
let output = get_preview();
let expected = include_str!("test-resources/colour_input.png.base64.greyscale").trim();
assert_eq!(output, expected);
}
#[test]
fn example() {
load_default_game();
load_image(include_str!("test-resources/test.png.base64").trim().to_string());
set_room_name("test".to_string());
println!("add_room(): {}", add_room());
// todo what? why are extraneous pixels appearing in the output tiles?
assert_eq!(output(), include_str!("test-resources/expected.bitsy"));
}
}