use core::time::Duration;
use std::cell::Cell;
use std::future::Future;
use std::io;
use std::rc::Rc;
use js_sys::{Array, Uint8Array};
use rayon::ThreadBuilder;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::{
DedicatedWorkerGlobalScope, Document, Element, HtmlCanvasElement, Response, Worker,
WorkerOptions, WorkerType,
};
use winit::application::ApplicationHandler;
use winit::platform::web::{EventLoopExtWebSys, WindowAttributesExtWebSys, WindowExtWebSys};
use winit::window::{CursorGrabMode, Window, WindowAttributes};
use crate::platform::Platform;
use crate::platform::threads::{DisplayEnd, GameEnd, Paced, Starting};
use crate::{Config, Error, Game};
pub use web_time::Instant;
pub(crate) const PLATFORM: Platform = Platform::Browser;
pub(crate) const AFTER_LOSS: &str = "Reload the page to try again. On a laptop with two GPUs, set the browser to use the high-performance one in the system's graphics settings (Windows: Settings, System, Display, Graphics).";
pub(crate) const AFTER_OUT_OF_MEMORY: &str =
"Close other tabs and programs that use the GPU, then reload the page to try again.";
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";
const WORKER_ATTRIBUTE: &str = "data-mirage-worker";
const SPAWN: &str = "spawn";
const GAME_THREAD: &str = "the game thread";
const NOT_ISOLATED: &str = "the page is not cross-origin isolated, so the game starts no worker. Serve it with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. On itch.io that is the SharedArrayBuffer support setting in the project's embed options.";
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);
}
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(app: impl ApplicationHandler + 'static) {
if web_sys::window().is_none() {
return;
}
let Some(event_loop) = crate::platform::event_loop() else {
return;
};
event_loop.spawn_app(app);
}
#[wasm_bindgen]
pub fn mirage_worker_entry(game: u32) {
let game = unsafe { Box::from_raw(game as usize as *mut Box<dyn FnOnce() + Send>) };
game();
}
#[wasm_bindgen]
pub fn mirage_rayon_worker_entry(worker: u32) {
let worker = unsafe { Box::from_raw(worker as usize as *mut ThreadBuilder) };
worker.run();
}
pub(crate) fn hardware_threads() -> usize {
web_sys::window().map_or(1, |window| {
window.navigator().hardware_concurrency() as usize
})
}
pub(crate) fn spawn_worker(worker: ThreadBuilder) -> io::Result<()> {
let scope = js_sys::global()
.dyn_into::<DedicatedWorkerGlobalScope>()
.map_err(|_| io::Error::other("a worker of the pool starts from the game thread alone"))?;
let boxed = Boxed::of(worker);
scope
.post_message(&Array::of2(
&JsValue::from_str(SPAWN),
&JsValue::from(boxed.address()),
))
.map_err(|error| {
io::Error::other(format!("the page was asked for no worker: {error:?}"))
})?;
boxed.posted();
Ok(())
}
pub(crate) fn close(window: &Window, config: &Config) {
if config.canvas_id().is_some() {
return;
}
if let Some(canvas) = window.canvas() {
canvas.remove();
}
}
pub(crate) struct PointerHold {
set: bool,
}
impl PointerHold {
pub(crate) fn released() -> Self {
Self { set: false }
}
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)
}
pub(crate) fn see_gesture(&mut self, window: &Window) {
if self.set && !holds(window) {
let _ = window.set_cursor_grab(CursorGrabMode::Locked);
}
}
pub(crate) fn see_focus_change(&mut self) {
self.set = false;
}
}
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>())
}
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 start_game<G: Game>(
end: GameEnd,
starting: Starting<G>,
) -> Result<GameThreadHandle, Error> {
require_isolation()?;
let script: Rc<str> = worker_script(&document()?)?.into();
let failure = WorkerFailure::default();
let pool = PoolWorkers::new(Rc::clone(&script), failure.clone());
let run: Box<dyn FnOnce() + Send> = Box::new(move || {
if let Some(mut paced) = Paced::started(&end, starting) {
paced.run(&end);
}
});
let game = Watched::started(&script, GAME_THREAD, Boxed::of(run), |worker| {
failure.watching(
worker,
format!("the worker {script} never ran"),
posting(pool.clone(), failure.clone()),
)
})?;
Ok(GameThreadHandle {
game,
pool,
failure,
})
}
fn posting(pool: PoolWorkers, failure: WorkerFailure) -> Closure<dyn FnMut(JsValue)> {
Closure::new(move |event: JsValue| match spawned(&event) {
Some(address) => pool.start(address),
None => failure.write(stated(&event, "data", "the game thread stopped")),
})
}
fn spawned(event: &JsValue) -> Option<u32> {
let posted: Array = js_sys::Reflect::get(event, &JsValue::from_str("data"))
.ok()?
.dyn_into()
.ok()?;
if posted.get(0).as_string()? != SPAWN {
return None;
}
Some(posted.get(1).as_f64()? as u32)
}
trait RunOnWorker {
const ENTRY: &'static str;
}
impl RunOnWorker for Box<dyn FnOnce() + Send> {
const ENTRY: &'static str = "mirage_worker_entry";
}
impl RunOnWorker for ThreadBuilder {
const ENTRY: &'static str = "mirage_rayon_worker_entry";
}
struct Boxed<T>(*mut T);
impl<T> Boxed<T> {
fn of(value: T) -> Self {
Self(Box::into_raw(Box::new(value)))
}
unsafe fn from_address(address: u32) -> Self {
Self(address as usize as *mut T)
}
fn address(&self) -> u32 {
self.0 as usize as u32
}
fn posted(self) {
core::mem::forget(self);
}
}
impl<T> Drop for Boxed<T> {
fn drop(&mut self) {
drop(unsafe { Box::from_raw(self.0) });
}
}
struct Watched {
worker: Worker,
_reporting: [Closure<dyn FnMut(JsValue)>; 2],
}
impl Watched {
fn started<T: RunOnWorker>(
script: &str,
named: &str,
boxed: Boxed<T>,
watch: impl FnOnce(&Worker) -> [Closure<dyn FnMut(JsValue)>; 2],
) -> Result<Self, Error> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options(script, &options).map_err(|error| {
Error::msg(format!("the page started no worker for {named}: {error:?}"))
})?;
worker
.post_message(&Array::of4(
&wasm_bindgen::module(),
&wasm_bindgen::memory(),
&JsValue::from_str(T::ENTRY),
&JsValue::from(boxed.address()),
))
.map_err(|error| Error::msg(format!("{named} was handed nothing: {error:?}")))?;
boxed.posted();
Ok(Self {
_reporting: watch(&worker),
worker,
})
}
}
#[derive(Clone)]
struct PoolWorkers {
script: Rc<str>,
failure: WorkerFailure,
started: Rc<Cell<Started>>,
}
#[derive(Default)]
struct Started {
spawns: usize,
watched: Vec<Watched>,
}
impl PoolWorkers {
fn new(script: Rc<str>, failure: WorkerFailure) -> Self {
Self {
script,
failure,
started: Rc::default(),
}
}
fn start(&self, address: u32) {
let mut started = self.started.take();
started.spawns += 1;
let named = format!("worker {} of the pool", started.spawns);
let boxed = unsafe { Boxed::<ThreadBuilder>::from_address(address) };
let watched = Watched::started(&self.script, &named, boxed, |worker| {
self.failure.watching(
worker,
format!("{named} never ran"),
self.failure.writing("data", format!("{named} stopped")),
)
});
match watched {
Ok(watched) => started.watched.push(watched),
Err(error) => self.failure.write(error.to_string()),
}
self.started.set(started);
}
fn ended(&self) {
for watched in self.started.take().watched {
watched.worker.terminate();
}
}
}
#[derive(Clone, Default)]
struct WorkerFailure(Rc<Cell<Option<Error>>>);
impl WorkerFailure {
fn watching(
&self,
worker: &Worker,
never_ran: String,
posted: Closure<dyn FnMut(JsValue)>,
) -> [Closure<dyn FnMut(JsValue)>; 2] {
let errored = self.writing("message", never_ran);
worker.set_onerror(Some(errored.as_ref().unchecked_ref()));
worker.set_onmessage(Some(posted.as_ref().unchecked_ref()));
[errored, posted]
}
fn writing(&self, key: &'static str, about: String) -> Closure<dyn FnMut(JsValue)> {
let failure = self.clone();
Closure::new(move |event: JsValue| failure.write(stated(&event, key, &about)))
}
fn write(&self, error: String) {
let first = self.0.take().unwrap_or_else(|| Error::msg(error));
self.0.set(Some(first));
}
fn taken(&self) -> Option<Error> {
self.0.take()
}
}
fn stated(event: &JsValue, key: &str, about: &str) -> String {
js_sys::Reflect::get(event, &JsValue::from_str(key))
.ok()
.and_then(|value| value.as_string())
.map_or_else(|| about.to_owned(), |text| format!("{about}: {text}"))
}
pub(crate) struct GameThreadHandle {
game: Watched,
pool: PoolWorkers,
failure: WorkerFailure,
}
impl GameThreadHandle {
pub(crate) fn failed(&self) -> Option<Error> {
self.failure.taken()
}
pub(crate) fn ended(self, end: DisplayEnd) {
drop(end);
self.game.worker.terminate();
self.pool.ended();
}
}
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);
}
pub(crate) fn double_click_interval() -> Option<Duration> {
None
}
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(())
}
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 require_isolation() -> Result<(), Error> {
web_sys::window()
.and_then(|window| {
js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("crossOriginIsolated")).ok()
})
.is_some_and(|isolated| isolated.is_truthy())
.then_some(())
.ok_or_else(|| Error::msg(NOT_ISOLATED))
}
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 worker_script(document: &Document) -> Result<String, Error> {
document
.body()
.and_then(|body| body.get_attribute(WORKER_ATTRIBUTE))
.filter(|script| !script.is_empty())
.ok_or_else(|| {
Error::msg(format!(
"the page names no worker script in `{WORKER_ATTRIBUTE}` on its body"
))
})
}
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")))
}