diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ebe7fc9 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,16 @@ +# Which game gets baked into wasm builds (WEB_BUNDLE in mrpci/src/main.rs). +# It is a WorldBundle JSON, not a folder: the browser has no filesystem, and +# `mrpci-headless --export-bundle` already writes exactly this document. +# web/build.sh regenerates it and overrides the path per build. +[env] +MRPCI_WEB_BUNDLE = { value = "web/game.bundle.json", relative = true } + +# Web builds: miniquad's JS imports (console_log, init_webgl, ...) are +# resolved at runtime by web/mq_js_bundle.js — tell the linker to allow them. +# The mrpci_* exports are the page's game-loading bridge into the engine. +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "link-arg=--allow-undefined", + "-C", "link-arg=--export=mrpci_alloc", + "-C", "link-arg=--export=mrpci_world_in", +] diff --git a/.gitignore b/.gitignore index 1ebe09b..1d13293 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target games/*/saves/ .DS_Store +web/dist/ +web/game.bundle.json diff --git a/Cargo.lock b/Cargo.lock index 9001122..b3ba154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,6 +534,7 @@ version = "0.1.0" dependencies = [ "macroquad", "mrpci-core", + "serde_json", ] [[package]] diff --git a/mrpci/Cargo.toml b/mrpci/Cargo.toml index 80ce395..9a36761 100644 --- a/mrpci/Cargo.toml +++ b/mrpci/Cargo.toml @@ -7,3 +7,6 @@ description = "Monster Robot Party Creative Interpreter — the SCI-generation s [dependencies] mrpci-core = { path = "../mrpci-core" } macroquad = { version = "0.4", features = ["audio"] } +# The browser build has no filesystem: it boots from a WorldBundle JSON baked +# in at compile time, and the page can hand it another one at runtime. +serde_json = "1" diff --git a/mrpci/src/main.rs b/mrpci/src/main.rs index db05a8a..a3e8454 100644 --- a/mrpci/src/main.rs +++ b/mrpci/src/main.rs @@ -101,25 +101,82 @@ fn conf() -> Conf { } } +/// The browser build's default game, baked in at compile time. +/// +/// A folder of JSON is a filesystem idea and the browser hasn't got one, so +/// the web build carries a `WorldBundle` — the same single-document form +/// `mrpci-headless --export-bundle` writes and the MRP3GI bridge boots from. +/// Which bundle is `MRPCI_WEB_BUNDLE` at compile time (see .cargo/config.toml). +#[cfg(target_arch = "wasm32")] +const WEB_BUNDLE: &str = include_str!(env!("MRPCI_WEB_BUNDLE")); + +/// Rebuild a whole world from a bundle JSON. `None` if it doesn't parse — +/// a bad drag-and-drop should be a shrug, not a blank screen. +pub fn world_from_bundle(json: &str) -> Option { + let b: mrpci_core::WorldBundle = serde_json::from_str(json).ok()?; + // Sorted, like the headless `--bundle` path: both boots must bake + // identical cels or the same game would look different in two windows. + let mut sprites: Vec<_> = b.sprites.into_iter().collect(); + sprites.sort_by(|a, c| a.0.cmp(&c.0)); + Some(World::from_memory(b.manifest, b.rooms, b.scripts, sprites)) +} + +/// The web save/load bridge, mirroring MRPGI's. The page writes a bundle +/// into an `mrpci_alloc` buffer and hands it over; the main loop picks it up +/// on the next frame. No threads, no async. +#[cfg(target_arch = "wasm32")] +mod web_bridge { + use std::sync::Mutex; + + pub static PENDING_LOAD: Mutex> = Mutex::new(None); + + /// JS asks for a buffer to write into. + #[no_mangle] + pub extern "C" fn mrpci_alloc(len: usize) -> *mut u8 { + Box::into_raw(vec![0u8; len].into_boxed_slice()) as *mut u8 + } + + /// JS hands over a bundle JSON it wrote into that buffer. + #[no_mangle] + pub extern "C" fn mrpci_world_in(ptr: *mut u8, len: usize) { + let s = unsafe { + let boxed = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len) as *mut [u8]); + String::from_utf8_lossy(&boxed).into_owned() + }; + *PENDING_LOAD.lock().unwrap() = Some(s); + } +} + fn main() { - let args: Vec = std::env::args().collect(); - let game_dir = args - .iter() - .position(|a| a == "--game") - .and_then(|i| args.get(i + 1).cloned()) - .unwrap_or_else(|| { - // Default to the demo if it's been generated, else the cwd. - if std::path::Path::new("games/neon-precinct/game.json").exists() { - "games/neon-precinct".into() - } else { - ".".into() - } - }); + #[cfg(not(target_arch = "wasm32"))] + let game_dir = { + let args: Vec = std::env::args().collect(); + args.iter() + .position(|a| a == "--game") + .and_then(|i| args.get(i + 1).cloned()) + .unwrap_or_else(|| { + // Default to the demo if it's been generated, else the cwd. + if std::path::Path::new("games/neon-precinct/game.json").exists() { + "games/neon-precinct".into() + } else { + ".".into() + } + }) + }; + // On the web the "game dir" is only ever an audio-lookup prefix; the + // world itself is embedded. Relative, so it works under any sub-path. + #[cfg(target_arch = "wasm32")] + let game_dir = String::from("game"); macroquad::Window::from_config(conf(), amain(game_dir)); } async fn amain(game_dir: String) { + #[cfg(not(target_arch = "wasm32"))] let world = World::load_game(&game_dir); + // A malformed baked bundle would be a build error we'd rather see as an + // empty room than a white screen. + #[cfg(target_arch = "wasm32")] + let world = world_from_bundle(WEB_BUNDLE).unwrap_or_else(|| World::load_game(".")); let mut gs = GameState::new(world); let mut audio = sound::LazyAudio::start(&game_dir); let mut ui = Ui { @@ -167,7 +224,32 @@ async fn amain(game_dir: String) { show_mouse(false); loop { + // --- a game handed over by the page ---------------------------------- + // The browser build is a *player*: the picker (and drag-and-drop) + // swap the whole world underneath it, and the session restarts. + #[cfg(target_arch = "wasm32")] + { + let incoming = web_bridge::PENDING_LOAD.lock().unwrap().take(); + if let Some(json) = incoming { + if let Some(w) = world_from_bundle(&json) { + gs = GameState::new(w); + ui.log.clear(); + ui.selected_item = None; + ui.inventory_open = false; + ui.typing = false; + ui.fade = 0.0; + let boot = gs.apply(Command::NewGame { room: None }); + handle_events(&boot, &mut ui, &mut audio); + } else { + ui.log.push("(that file isn't an MRPCI game bundle.)".into()); + } + } + } + // --- edit-mode toggle ------------------------------------------------- + // Native only. Authoring is a desktop job; what ships to a player is + // the game, not the tool that made it. + #[cfg(not(target_arch = "wasm32"))] if is_key_pressed(KeyCode::F8) { ed.active = !ed.active; if ed.active { diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..b352208 --- /dev/null +++ b/web/build.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Build the browser version of MRPCI into web/dist/ (self-contained, static). +# Requires the wasm target: rustup target add wasm32-unknown-unknown +# +# ./web/build.sh # bakes games/neon-precinct +# ./web/build.sh /path/to/game # bake any game folder instead +# +# The browser build is a PLAYER: it boots straight into the game and the F8 +# editor toggle is compiled out. Authoring stays on the desktop. +# +# Unlike MRPGI's build, which embeds a game *folder* with include_dir!, this +# bakes a single WorldBundle JSON — the same document `--export-bundle` +# writes and the MRP3GI bridge boots from. One format, three engines, and the +# page can hand the engine another one at runtime. +set -e +cd "$(dirname "$0")/.." + +GAME="${1:-games/neon-precinct}" +BUNDLE="web/game.bundle.json" + +echo "baking game: $GAME" +cargo build --release -p mrpci-core --bin mrpci-headless +./target/release/mrpci-headless --game "$GAME" --export-bundle "$BUNDLE" + +# include_str! reads the path at compile time; make sure the crate recompiles +# when only the bundle changed. +touch mrpci/src/main.rs +cargo build -p mrpci --release --target wasm32-unknown-unknown + +rm -rf web/dist +mkdir -p web/dist +cp target/wasm32-unknown-unknown/release/mrpci.wasm web/dist/ +cp web/index.html web/mq_js_bundle.js web/dist/ + +# A game's own sfx/ and music/ ride along if it has them; the engine falls +# back to its built-in synth for anything missing, so this is optional. +for kind in sfx music; do + if [ -d "$GAME/$kind" ]; then + mkdir -p "web/dist/game/$kind" + cp "$GAME/$kind"/*.wav "$GAME/$kind"/*.ogg "$GAME/$kind"/*.mp3 \ + "web/dist/game/$kind/" 2>/dev/null || true + fi +done + +echo "web build ready: $(du -sh web/dist | cut -f1) in web/dist/" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e855c9d --- /dev/null +++ b/web/index.html @@ -0,0 +1,76 @@ + + + + + + MRPCI — Monster Robot Party Creative Interpreter + + + + + + +
starting the interpreter…
+
+ click verb at pixel · right-click cycle verb · F1–F4 walk/look/do/talk · + I inventory · Enter type a command · F5/F7 save/restore · M mute · N scanlines +
+ + + + + + + diff --git a/web/mq_js_bundle.js b/web/mq_js_bundle.js new file mode 100644 index 0000000..7271d05 --- /dev/null +++ b/web/mq_js_bundle.js @@ -0,0 +1,3 @@ +"use strict";const version=2,canvas=document.querySelector("#glcanvas");var gl,clipboard=null,wasm_memory,animation_frame_timeout,FS,GL,Module,wasm_exports,emscripten_shaders_hack,importObject,plugins=[],high_dpi=!1,blocking_event_loop=!1;function init_webgl(e){if(e==1){gl=canvas.getContext("webgl");function t(e){var t=e.getExtension("OES_vertex_array_object");t?(e.createVertexArray=function(){return t.createVertexArrayOES()},e.deleteVertexArray=function(e){t.deleteVertexArrayOES(e)},e.bindVertexArray=function(e){t.bindVertexArrayOES(e)},e.isVertexArray=function(e){return t.isVertexArrayOES(e)}):alert("Unable to get OES_vertex_array_object extension")}function n(e){var t=e.getExtension("ANGLE_instanced_arrays");t&&(e.vertexAttribDivisor=function(e,n){t.vertexAttribDivisorANGLE(e,n)},e.drawArraysInstanced=function(e,n,s,o){t.drawArraysInstancedANGLE(e,n,s,o)},e.drawElementsInstanced=function(e,n,s,o,i){t.drawElementsInstancedANGLE(e,n,s,o,i)})}function s(e){var t=e.getExtension("EXT_disjoint_timer_query");t&&(e.createQuery=function(){return t.createQueryEXT()},e.beginQuery=function(e,n){return t.beginQueryEXT(e,n)},e.endQuery=function(e){return t.endQueryEXT(e)},e.deleteQuery=function(e){t.deleteQueryEXT(e)},e.getQueryObject=function(e,n){return t.getQueryObjectEXT(e,n)})}function o(e){var t=e.getExtension("WEBGL_draw_buffers");t&&(e.drawBuffers=function(e){return t.drawBuffersWEBGL(e)})}try{gl.getExtension("EXT_shader_texture_lod"),gl.getExtension("OES_standard_derivatives")}catch(e){console.warn(e)}t(gl),n(gl),s(gl),o(gl),gl.getExtension("WEBGL_depth_texture")==null&&alert("Cant initialize WEBGL_depth_texture extension")}else gl=canvas.getContext("webgl2");gl===null&&alert("Unable to initialize WebGL. Your browser or machine may not support it.")}canvas.focus(),canvas.requestPointerLock=canvas.requestPointerLock||canvas.mozRequestPointerLock||function(){},document.exitPointerLock=document.exitPointerLock||document.mozExitPointerLock||function(){};function assert(e,t){e==!1&&alert(t)}function getArray(e,t,n){return new t(wasm_memory.buffer,e,n)}function UTF8ToString(e,t){let i=new Uint8Array(wasm_memory.buffer,e);for(var n,a,r,c,s=0,l=s+t,o="";!(s>=l);){if(n=i[s++],!n)return o;if(!(n&128)){o+=String.fromCharCode(n);continue}if(a=i[s++]&63,(n&224)==192){o+=String.fromCharCode((n&31)<<6|a);continue}r=i[s++]&63,(n&240)==224?n=(n&15)<<12|a<<6|r:((n&248)!=240&&console.warn("Invalid UTF-8 leading byte 0x"+n.toString(16)+" encountered when deserializing a UTF-8 string on the asm.js/wasm heap to a JS string!"),n=(n&7)<<18|a<<12|r<<6|i[s++]&63),n<65536?o+=String.fromCharCode(n):(c=n-65536,o+=String.fromCharCode(55296|c>>10,56320|c&1023))}return o}function stringToUTF8(e,t,n,s){for(var o,r,c=n,i=n+s,a=0;a=55296&&o<=57343&&(r=e.charCodeAt(++a),o=65536+((o&1023)<<10)|r&1023),o<=127){if(n>=i)break;t[n++]=o}else if(o<=2047){if(n+1>=i)break;t[n++]=192|o>>6,t[n++]=128|o&63}else if(o<=65535){if(n+2>=i)break;t[n++]=224|o>>12,t[n++]=128|o>>6&63,t[n++]=128|o&63}else{if(n+3>=i)break;o>=2097152&&console.warn("Invalid Unicode code point 0x"+o.toString(16)+" encountered when serializing a JS string to an UTF-8 string on the asm.js/wasm heap! (Valid unicode code points should be in range 0-0x1FFFFF)."),t[n++]=240|o>>18,t[n++]=128|o>>12&63,t[n++]=128|o>>6&63,t[n++]=128|o&63}return n-c}FS={loaded_files:[],unique_id:0},GL={counter:1,buffers:[],mappedBuffers:{},programs:[],framebuffers:[],renderbuffers:[],textures:[],uniforms:[],shaders:[],vaos:[],timerQueries:[],contexts:{},programInfos:{},getNewId:function(e){for(var n=GL.counter++,t=e.length;t=0&&n=GL.counter){console.error("GL_INVALID_VALUE in glGetProgramiv");return}var s,o=GL.programInfos[e];if(!o){console.error("GL_INVALID_OPERATION in glGetProgramiv(program="+e+", pname="+t+", p=0x"+n.toString(16)+"): The specified GL object name does not refer to a program object!");return}if(t==35716)s=gl.getProgramInfoLog(GL.programs[e]),assert(s!==null),getArray(n,Int32Array,1)[0]=s.length+1;else if(t==35719){console.error("unsupported operation");return}else if(t==35722){console.error("unsupported operation");return}else if(t==35381){console.error("unsupported operation");return}else getArray(n,Int32Array,1)[0]=gl.getProgramParameter(GL.programs[e],t)},glCreateShader:function(e){var t=GL.getNewId(GL.shaders);return GL.shaders[t]=gl.createShader(e),t},glStencilFuncSeparate:function(e,t,n,s){gl.stencilFuncSeparate(e,t,n,s)},glStencilMaskSeparate:function(e,t){gl.stencilMaskSeparate(e,t)},glStencilOpSeparate:function(e,t,n,s){gl.stencilOpSeparate(e,t,n,s)},glFrontFace:function(e){gl.frontFace(e)},glCullFace:function(e){gl.cullFace(e)},glCopyTexImage2D:function(e,t,n,s,o,i,a,r){gl.copyTexImage2D(e,t,n,s,o,i,a,r)},glShaderSource:function(e,t,n,s){GL.validateGLObjectID(GL.shaders,e,"glShaderSource","shader");var i,o=GL.getSource(e,t,n,s);emscripten_shaders_hack&&(o=o.replace(/#extension GL_OES_standard_derivatives : enable/g,""),o=o.replace(/#extension GL_EXT_shader_texture_lod : enable/g,""),i="",o.indexOf("gl_FragColor")!=-1&&(i+=`out mediump vec4 GL_FragColor; +`,o=o.replace(/gl_FragColor/g,"GL_FragColor")),o.indexOf("attribute")!=-1?(o=o.replace(/attribute/g,"in"),o=o.replace(/varying/g,"out")):o=o.replace(/varying/g,"in"),o=o.replace(/textureCubeLodEXT/g,"textureCubeLod"),o=o.replace(/texture2DLodEXT/g,"texture2DLod"),o=o.replace(/texture2DProjLodEXT/g,"texture2DProjLod"),o=o.replace(/texture2DGradEXT/g,"texture2DGrad"),o=o.replace(/texture2DProjGradEXT/g,"texture2DProjGrad"),o=o.replace(/textureCubeGradEXT/g,"textureCubeGrad"),o=o.replace(/textureCube/g,"texture"),o=o.replace(/texture1D/g,"texture"),o=o.replace(/texture2D/g,"texture"),o=o.replace(/texture3D/g,"texture"),o=o.replace(/#version 100/g,`#version 300 es +`+i)),gl.shaderSource(GL.shaders[e],o)},glGetProgramInfoLog:function(e,t,n,s){GL.validateGLObjectID(GL.programs,e,"glGetProgramInfoLog","program");var o,i=gl.getProgramInfoLog(GL.programs[e]);assert(i!==null);let a=getArray(s,Uint8Array,t);for(o=0;o(add_missing_functions_stabs(e),WebAssembly.instantiate(e,importObject))).then(e=>{wasm_memory=e.exports.memory,wasm_exports=e.exports;var t=wasm_exports.crate_version();version!=t&&console.error("Version mismatch: gl.js version is: "+version+", miniquad crate version is: "+t),init_plugins(plugins),e.exports.main()}).catch(e=>{console.error(e)}):t.then(function(e){return e.arrayBuffer()}).then(function(e){return WebAssembly.compile(e)}).then(function(e){return add_missing_functions_stabs(e),WebAssembly.instantiate(e,importObject)}).then(function(e){wasm_memory=e.exports.memory,wasm_exports=e.exports;var t=wasm_exports.crate_version();version!=t&&console.error("Version mismatch: gl.js version is: "+version+", rust sapp-wasm crate version is: "+t),init_plugins(plugins),e.exports.main()}).catch(e=>{console.error("WASM failed to load, probably incompatible gl.js version"),console.error(e)})}(function(){"use strict";const c=window.AudioContext||window.webkitAudioContext;let e,n=new Map,t=[],o=1,m=1;function d(){if(e==null){e=new c;let n=e.listener;{let s=window.AudioContext||window.webkitAudioContext,n=new s;var t=function(){console.log("fix"),e.resume();var i=n.createBuffer(1,1,22050),o=n.createBufferSource();o.buffer=i,o.connect(n.destination),o.start?o.start(0):o.play?o.play(0):o.noteOn&&o.noteOn(0),document.removeEventListener("touchstart",t),document.removeEventListener("touchend",t),document.removeEventListener("mousedown",t),document.removeEventListener("keydown",t)};document.addEventListener("touchstart",t),document.addEventListener("touchend",t),document.addEventListener("mousedown",t),document.addEventListener("keydown",t)}}}function r(t,s){let a=wasm_memory.buffer.slice(t,t+s),i=o;return o+=1,e.decodeAudioData(a,function(e){n.set(i,e)},function(e){console.error("Failed to decode audio buffer",e)}),i}function a(e){return n.has(e)&&n.get(e)!=void 0}function l(){let n=t.find(e=>e.sound_key===0);return n!=null?n.source=e.createBufferSource():(n={sound_key:0,playback_key:0,source:e.createBufferSource(),gain_node:e.createGain(),ended:null},t.push(n)),n}function s(e){try{e.source.removeEventListener("ended",e.ended),e.source.disconnect(),e.gain_node.disconnect(),e.sound_key=0,e.playback_key=0}catch(e){console.error("Error stopping sound",e)}}function u(t,o,i){let r=m++,a=l();a.sound_key=t,a.playback_key=r,a.source.connect(a.gain_node),a.gain_node.connect(e.destination),a.gain_node.gain.value=o,a.source.loop=i,a.ended=function(){s(a)},a.source.addEventListener("ended",a.ended);try{a.source.buffer=n.get(t),a.source.start(0)}catch(e){console.error("Error starting sound",e)}return r}function h(e,n){t.forEach(t=>{t.sound_key===e&&(t.gain_node.gain.value=n)})}function i(e){t.forEach(t=>{t.sound_key===e&&s(t)})}function f(e){i(e),n.delete(e)}function p(e){let n=t.find(t=>t.playback_key===e);n!=null&&s(n)}function g(e,n){let s=t.find(t=>t.playback_key===e);s!=null&&(s.gain_node.gain.value=n)}function v(e){e.env.audio_init=d,e.env.audio_add_buffer=r,e.env.audio_play_buffer=u,e.env.audio_source_is_loaded=a,e.env.audio_source_set_volume=h,e.env.audio_source_stop=i,e.env.audio_source_delete=f,e.env.audio_playback_stop=p,e.env.audio_playback_set_volume=g}miniquad_add_plugin({register_plugin:v,version:1,name:"macroquad_audio"})})(),function(){"use strict";var n,i=null,e={};e[-1]=null,e[-2]=void 0,n=0;function o(n){n.env.js_create_string=function(e,n){var s=UTF8ToString(e,n);return t(s)},n.env.js_create_buffer=function(e,n){var s=new Uint8Array(wasm_memory.buffer,e,n),o=new Uint8Array(new ArrayBuffer(s.byteLength));return o.set(new Uint8Array(s)),t(o)},n.env.js_create_object=function(){var e={};return t(e)},n.env.js_set_field_f32=function(t,n,s,o){var i=UTF8ToString(n,s);e[t][i]=o},n.env.js_set_field_u32=function(t,n,s,o){var i=UTF8ToString(n,s);e[t][i]=o},n.env.js_set_field_string=function(t,n,s,o,i){var a=UTF8ToString(n,s),r=UTF8ToString(o,i);e[t][a]=r},n.env.js_unwrap_to_str=function(t,n,o){for(var r=e[t],a=s(r),c=a.length,l=new Uint8Array(wasm_memory.buffer,n,o),i=0;i>6,128|t&63):t<55296||t>=57344?n.push(224|t>>12,128|t>>6&63,128|t&63):(s++,t=65536+((t&1023)<<10|e.charCodeAt(s)&1023),n.push(240|t>>18,128|t>>12&63,128|t>>6&63,128|t&63));return n}function t(t){if(t==null)return-2;if(t===null)return-1;var s=n;return e[s]=t,n+=1,s}function a(t){var n=e[t];return delete e[t],n}function r(t){return e[t]}}(),function(){function l(){}function d(e){e.env.ws_connect=a,e.env.ws_is_connected=i,e.env.ws_send=r,e.env.ws_try_recv=c,e.env.http_make_request=h,e.env.http_try_recv=u}miniquad_add_plugin({register_plugin:d,on_init:l,version:1,name:"quad_net"});var e,t,s,o=0,n=[];function i(){return o}function a(e){t=new WebSocket(consume_js_object(e)),t.binaryType="arraybuffer",t.onopen=function(){o=1},t.onmessage=function(e){if(typeof e.data=="string")n.push({text:1,data:e.data});else{var t=new Uint8Array(e.data);n.push({text:0,data:t})}}}function r(e){var n=consume_js_object(e);n.buffer!=void 0?t.send(n.buffer):t.send(n)}function c(){return n.length!=0?js_object(n.shift()):-1}s=0,e={};function u(t){if(e[t]!=void 0&&e[t]!=null){var n=e[t];return e[t]=null,js_object(n)}return-1}function h(t,n,o,i){var a,r,c,d,u,l=s;s+=1,t==0&&(r="POST"),t==1&&(r="PUT"),t==2&&(r="GET"),t==3&&(r="DELETE"),d=consume_js_object(n),u=consume_js_object(o),c=consume_js_object(i),a=new XMLHttpRequest,a.open(r,d,!0),a.responseType="arraybuffer";for(const e in c)a.setRequestHeader(e,c[e]);return a.onload=function(){if(this.status==200){var n=new Uint8Array(this.response);e[l]=n}},a.onerror=function(e){console.error("Failed to make a request"),console.error(e)},a.send(u),l}}() \ No newline at end of file