mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use core::time::Duration;
use std::future::Future;

use js_sys::Uint8Array;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{Document, Element, HtmlCanvasElement, Response};
use winit::application::ApplicationHandler;
use winit::event_loop::EventLoop;
use winit::platform::web::{EventLoopExtWebSys, WindowAttributesExtWebSys, WindowExtWebSys};
use winit::window::{CursorGrabMode, Window, WindowAttributes};

use crate::platform::Platform;
use crate::{Config, Error};

/// The browser has no usable [`std::time::Instant`]; this one reads
/// `performance.now()` behind the same API.
pub use web_time::Instant;

/// What this build runs behind: a canvas in a browser page.
pub(crate) const PLATFORM: Platform = Platform::Browser;

/// What a player does after the device was lost, on this platform.
pub(crate) const AFTER_LOSS: &str = "Reload the page to try again. On a laptop with two graphics chips, set the browser to use the high-performance one in the system's graphics settings (Windows: Settings, System, Display, Graphics).";

/// What a player does after the device ran out of memory, on this platform.
pub(crate) const AFTER_OUT_OF_MEMORY: &str =
    "Reload the page to try again, with other tabs and programs that use the graphics chip closed.";

const CANVAS_STYLE: &str =
    "position:fixed;top:0;left:0;width:100%;height:100%;display:block;touch-action:none";

const OVERLAY_STYLE: &str = "position:fixed;top:0;left:0;right:0;bottom:0;margin:0;padding:1rem;\
     background:#101014;color:#ff9090;font:14px/1.5 monospace;white-space:pre-wrap;z-index:2147483647";

pub(crate) fn install_diagnostics() {
    console_error_panic_hook::set_once();
    if let Err(error) = console_log::init_with_level(log::Level::Info) {
        web_sys::console::warn_1(&JsValue::from_str(&format!("mirage-engine: {error}")));
    }
}

pub(crate) fn spawn(future: impl Future<Output = ()> + 'static) {
    wasm_bindgen_futures::spawn_local(future);
}

/// Loads one asset source, resolved against the game's page; a relative
/// source is a file next to `index.html`.
pub(crate) async fn read(source: &str) -> Result<Vec<u8>, Error> {
    let failed = |what: &str, error: JsValue| {
        Error::msg(format!("the asset source `{source}` {what}: {error:?}"))
    };

    let window =
        web_sys::window().ok_or_else(|| Error::msg("mirage-engine needs a browser window"))?;
    let response: Response = JsFuture::from(window.fetch_with_str(source))
        .await
        .map_err(|error| failed("could not be fetched", error))?
        .dyn_into()
        .map_err(|_| Error::msg(format!("the fetch of `{source}` answered with no response")))?;
    if !response.ok() {
        return Err(Error::msg(format!(
            "the asset source `{source}` answered with status {}",
            response.status()
        )));
    }

    let buffer = JsFuture::from(
        response
            .array_buffer()
            .map_err(|error| failed("could not be read", error))?,
    )
    .await
    .map_err(|error| failed("could not be read", error))?;

    Ok(Uint8Array::new(&buffer).to_vec())
}

pub(crate) fn window_attributes(config: &Config) -> Result<WindowAttributes, Error> {
    require_webgpu()?;
    let document = document()?;
    let canvas = match config.canvas_id() {
        Some(id) => page_canvas(&document, id)?,
        None => viewport_canvas(&document)?,
    };
    Ok(Window::default_attributes()
        .with_title(config.title())
        .with_canvas(Some(canvas)))
}

pub(crate) fn run_app(event_loop: EventLoop<()>, app: impl ApplicationHandler + 'static) {
    event_loop.spawn_app(app);
}

/// Drops the canvas the engine created from the page; one the game named
/// is the page's own and stays.
pub(crate) fn close(window: &Window, config: &Config) {
    if config.canvas_id().is_some() {
        return;
    }
    if let Some(canvas) = window.canvas() {
        canvas.remove();
    }
}

/// The hold a page has on the pointer: whether the last frame set one,
/// which is what the next gesture of the player's requests the pointer
/// lock for.
pub(crate) struct PointerHold {
    set: bool,
}

impl PointerHold {
    /// The hold a run starts with, which holds nothing.
    pub(crate) fn released() -> Self {
        Self { set: false }
    }

    /// Takes what a frame set, ends the pointer lock on `window` where a
    /// frame holds it no more, and reports whether the pointer is locked
    /// now.
    ///
    /// A browser takes the lock only from inside a gesture of the
    /// player's, so a frame that sets the hold is locked on their next
    /// press or touch, and reads false until then.
    pub(crate) fn set(&mut self, window: &Window, held: bool) -> bool {
        if core::mem::replace(&mut self.set, held) != held && !held {
            let _ = window.set_cursor_grab(CursorGrabMode::None);
        }
        held && holds(window)
    }

    /// Requests the pointer lock from inside the player's own gesture, the
    /// one place a browser takes the request from.
    ///
    /// A lock the browser does not take, and one the player ends with
    /// escape, is requested again on the gesture after it.
    pub(crate) fn see_gesture(&mut self, window: &Window) {
        if self.set && !holds(window) {
            let _ = window.set_cursor_grab(CursorGrabMode::Locked);
        }
    }

    /// Drops the hold, which no page keeps past its focus: a browser ends
    /// the lock as the canvas loses focus. The first frame to set a hold
    /// after either edge of the focus requests the lock again.
    pub(crate) fn see_focus_change(&mut self) {
        self.set = false;
    }
}

/// Whether the pointer is locked to `window`'s own canvas now.
fn holds(window: &Window) -> bool {
    let Some(canvas) = window.canvas() else {
        return false;
    };
    document()
        .ok()
        .and_then(|document| document.pointer_lock_element())
        .is_some_and(|held| held == *canvas.unchecked_ref::<Element>())
}

/// What the browser states of the adapter it hands a page beyond what wgpu
/// reads of it, requested as wgpu requests it; `None` where it states nothing.
pub(crate) async fn adapter_facts() -> Option<String> {
    let navigator = web_sys::window()?.navigator();
    let gpu = js_sys::Reflect::get(navigator.as_ref(), &JsValue::from_str("gpu")).ok()?;
    let request = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter"))
        .ok()?
        .dyn_into::<js_sys::Function>()
        .ok()?;
    let options = js_sys::Object::new();
    js_sys::Reflect::set(
        &options,
        &JsValue::from_str("powerPreference"),
        &JsValue::from_str("high-performance"),
    )
    .ok()?;
    let requested = request
        .call1(&gpu, &options)
        .ok()?
        .dyn_into::<js_sys::Promise>()
        .ok()?;
    let adapter = JsFuture::from(requested).await.ok()?;
    let info = js_sys::Reflect::get(&adapter, &JsValue::from_str("info")).ok()?;
    let stated = |key: &str| {
        js_sys::Reflect::get(&info, &JsValue::from_str(key))
            .ok()
            .and_then(|value| value.as_string())
            .filter(|text| !text.is_empty())
    };
    let facts: Vec<String> = ["vendor", "architecture"]
        .into_iter()
        .filter_map(stated)
        .collect();

    (!facts.is_empty()).then(|| facts.join(" "))
}

pub(crate) fn report_fatal(error: &Error) {
    let message = format!("mirage-engine: {error}");
    web_sys::console::error_1(&JsValue::from_str(&message));
    let _ = overlay_message(&message);
}

/// `None`: the browser states no double click interval of the machine's,
/// so a run in it counts by the engine's own.
pub(crate) fn double_click_interval() -> Option<Duration> {
    None
}

/// Text the store called `name` kept, out of the page's own store.
pub(crate) fn store_read(name: &str) -> Option<String> {
    match storage()?.get_item(&key(name)) {
        Ok(kept) => kept,
        Err(error) => {
            log::debug!("mirage-engine read nothing from the page: {error:?}");
            None
        }
    }
}

pub(crate) fn store_write(name: &str, text: &str) {
    let Some(storage) = storage() else {
        return;
    };
    if let Err(error) = storage.set_item(&key(name), text) {
        log::debug!("mirage-engine kept nothing in the page: {error:?}");
    }
}

fn storage() -> Option<web_sys::Storage> {
    web_sys::window()?.local_storage().ok()?
}

fn key(name: &str) -> String {
    format!("mirage-engine.{name}")
}

fn overlay_message(message: &str) -> Option<()> {
    let document = document().ok()?;
    let overlay = document.create_element("pre").ok()?;
    overlay.set_text_content(Some(message));
    overlay.set_attribute("style", OVERLAY_STYLE).ok()?;
    document.body()?.append_child(&overlay).ok()?;
    Some(())
}

// wgpu fails with a JS exception when `navigator.gpu` is absent, and no Rust
// error path catches it, so startup stops here and `report_fatal` states why.
// `Reflect`, not `web-sys`'s typed getter: that is still
// `web_sys_unstable_apis`-gated.
fn require_webgpu() -> Result<(), Error> {
    web_sys::window()
        .is_some_and(|window| {
            js_sys::Reflect::get(window.navigator().as_ref(), &JsValue::from_str("gpu"))
                .is_ok_and(|gpu| !gpu.is_undefined())
        })
        .then_some(())
        .ok_or_else(|| Error::msg("the browser has no WebGPU (navigator.gpu is missing)"))
}

fn document() -> Result<Document, Error> {
    web_sys::window()
        .and_then(|window| window.document())
        .ok_or_else(|| Error::msg("mirage-engine needs a browser window with a document"))
}

fn viewport_canvas(document: &Document) -> Result<HtmlCanvasElement, Error> {
    let canvas: HtmlCanvasElement = document
        .create_element("canvas")
        .map_err(|error| Error::msg(format!("the document refused a canvas: {error:?}")))?
        .dyn_into()
        .map_err(|_| Error::msg("the document made something other than a canvas"))?;
    canvas
        .set_attribute("style", CANVAS_STYLE)
        .map_err(|error| Error::msg(format!("the canvas refused its style: {error:?}")))?;

    let body = document
        .body()
        .ok_or_else(|| Error::msg("the page has no body to attach the canvas to"))?;
    body.append_child(&canvas)
        .map_err(|error| Error::msg(format!("the page refused the canvas: {error:?}")))?;

    Ok(canvas)
}

fn page_canvas(document: &Document, id: &str) -> Result<HtmlCanvasElement, Error> {
    document
        .get_element_by_id(id)
        .ok_or_else(|| Error::msg(format!("the page has no element with id `{id}`")))?
        .dyn_into()
        .map_err(|_| Error::msg(format!("the element with id `{id}` is not a canvas")))
}