nightshade-api 0.53.0

Procedural high level API for the nightshade game engine
Documentation
//! Browser file plumbing shared by engine pages: saving bytes as a
//! download, opening a file picker, reading drag-and-drop payloads (with
//! `.zip` expansion and multi-file glTF grouping), and pointer lock. All of
//! it is protocol-free; the page decides which worker messages the bytes
//! become.

use wasm_bindgen::JsCast;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{Blob, DataTransfer};

/// Saves `bytes` as a browser download named `name`.
pub fn download_file(name: &str, bytes: &[u8]) {
    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
        return;
    };
    let array = js_sys::Uint8Array::from(bytes);
    let parts = js_sys::Array::new();
    parts.push(&array);
    let options = web_sys::BlobPropertyBag::new();
    options.set_type("application/octet-stream");
    let Ok(blob) = Blob::new_with_u8_array_sequence_and_options(&parts, &options) else {
        return;
    };
    let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
        return;
    };
    if let Ok(element) = document.create_element("a")
        && let Ok(anchor) = element.dyn_into::<web_sys::HtmlAnchorElement>()
    {
        anchor.set_href(&url);
        anchor.set_download(name);
        anchor.click();
    }
    let _ = web_sys::Url::revoke_object_url(&url);
}

/// Requests or exits pointer lock on the element with id `canvas`, the
/// canvas `Viewport` renders.
pub fn apply_pointer_lock(locked: bool) {
    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
        return;
    };
    if locked {
        if let Some(canvas) = document.get_element_by_id("canvas") {
            canvas.request_pointer_lock();
        }
    } else {
        document.exit_pointer_lock();
    }
}

/// Opens a browser file picker filtered by `accept` and reads the chosen
/// file's bytes into `on_file`.
pub fn pick_file(accept: &str, on_file: impl FnOnce(String, Vec<u8>) + 'static) {
    let Some(document) = web_sys::window().and_then(|window| window.document()) else {
        return;
    };
    let Ok(element) = document.create_element("input") else {
        return;
    };
    let Ok(input) = element.dyn_into::<web_sys::HtmlInputElement>() else {
        return;
    };
    input.set_type("file");
    input.set_accept(accept);
    let input_for_closure = input.clone();
    let closure = wasm_bindgen::closure::Closure::once(Box::new(move |_event: web_sys::Event| {
        let Some(files) = input_for_closure.files() else {
            return;
        };
        let Some(file) = files.get(0) else {
            return;
        };
        read_file(file, on_file);
    }) as Box<dyn FnOnce(_)>);
    input.set_onchange(Some(closure.as_ref().unchecked_ref()));
    closure.forget();
    input.click();
}

/// Reads one browser `File` into `on_file` as its name and bytes.
pub fn read_file(file: web_sys::File, on_file: impl FnOnce(String, Vec<u8>) + 'static) {
    spawn_local(async move {
        let name = file.name();
        let blob: &Blob = file.as_ref();
        if let Ok(buffer) = JsFuture::from(blob.array_buffer()).await {
            on_file(name, js_sys::Uint8Array::new(&buffer).to_vec());
        }
    });
}

/// Reads everything in a drop into `on_files` as named byte payloads: every
/// file the transfer carried, with a single dropped `.zip` expanded to its
/// entries. Route the result with [`route_dropped`].
pub fn read_dropped_files(
    transfer: DataTransfer,
    on_files: impl FnOnce(Vec<(String, Vec<u8>)>) + 'static,
) {
    let Some(files) = transfer.files() else {
        return;
    };
    if files.length() == 0 {
        return;
    }
    spawn_local(async move {
        let mut collected: Vec<(String, Vec<u8>)> = Vec::new();
        for index in 0..files.length() {
            if let Some(file) = files.item(index) {
                let name = file.name();
                let blob: &Blob = file.as_ref();
                if let Ok(buffer) = JsFuture::from(blob.array_buffer()).await {
                    collected.push((name, js_sys::Uint8Array::new(&buffer).to_vec()));
                }
            }
        }
        if collected.len() == 1
            && collected[0].0.to_lowercase().ends_with(".zip")
            && let Some(unzipped) = unzip(&collected[0].1)
        {
            collected = unzipped;
        }
        on_files(collected);
    });
}

/// A routed drop: either a multi-file glTF with its sibling resources keyed
/// relative to the `.gltf`, or plain files to load one by one.
pub enum DroppedFiles {
    GltfBundle {
        name: String,
        gltf: Vec<u8>,
        resources: Vec<(String, Vec<u8>)>,
    },
    Files(Vec<(String, Vec<u8>)>),
}

/// Groups dropped files: when the set contains a `.gltf` alongside other
/// files, the rest become its resources with the glTF's directory prefix
/// stripped; otherwise the files pass through unchanged.
pub fn route_dropped(mut files: Vec<(String, Vec<u8>)>) -> DroppedFiles {
    let gltf_index = files
        .iter()
        .position(|(name, _)| name.to_lowercase().ends_with(".gltf"));
    if let Some(index) = gltf_index
        && files.len() > 1
    {
        let (name, gltf) = files.remove(index);
        let directory = name
            .rsplit_once('/')
            .map(|(directory, _)| format!("{directory}/"))
            .unwrap_or_default();
        let resources: Vec<(String, Vec<u8>)> = files
            .into_iter()
            .map(|(path, bytes)| {
                (
                    path.strip_prefix(&directory).unwrap_or(&path).to_string(),
                    bytes,
                )
            })
            .collect();
        return DroppedFiles::GltfBundle {
            name,
            gltf,
            resources,
        };
    }
    DroppedFiles::Files(files)
}

fn unzip(bytes: &[u8]) -> Option<Vec<(String, Vec<u8>)>> {
    use std::io::Read;
    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
    let mut out = Vec::new();
    for index in 0..archive.len() {
        let Ok(mut entry) = archive.by_index(index) else {
            continue;
        };
        if entry.is_dir() {
            continue;
        }
        let name = entry.name().to_string();
        let mut data = Vec::new();
        if entry.read_to_end(&mut data).is_ok() {
            out.push((name, data));
        }
    }
    Some(out)
}