├── .gitignore ├── web ├── favicon.ico ├── assets │ ├── default.rfn │ ├── default.frag.glsl │ └── default.vert.glsl ├── oozaru.json ├── logo-dark.svg ├── logo-light.svg ├── index.html ├── scripts │ ├── utilities.js │ ├── version.js │ ├── package.js │ ├── fido.js │ ├── main.js │ ├── deque.js │ ├── job-queue.js │ ├── game.js │ ├── data-stream.js │ ├── audialis.js │ ├── pegasus.js │ ├── fontso.js │ └── input-engine.js ├── runtime │ ├── sphere-runtime.js │ ├── focus-target.js │ ├── pact.js │ ├── logger.js │ ├── random.js │ ├── music.js │ ├── tween.js │ ├── task.js │ ├── prim.js │ ├── scene.js │ ├── data-stream.js │ ├── console.js │ └── from.js └── styles.css ├── .vscode ├── settings.json └── launch.json ├── jsconfig.json ├── LICENSE.txt ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_STORE 3 | 4 | web/dist/ 5 | web/games/ 6 | -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spheredev/oozaru/main/web/favicon.ico -------------------------------------------------------------------------------- /web/assets/default.rfn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spheredev/oozaru/main/web/assets/default.rfn -------------------------------------------------------------------------------- /web/oozaru.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Oozaru", 3 | "publisher": "Where'd She Go? LLC", 4 | "version": "0.7.1w", 5 | "copyright": "© 2025 Where'd She Go? LLC" 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 79, 119 ], 3 | "liveServer.settings.root": "/web", 4 | "typescript.tsserver.experimental.enableProjectDiagnostics": true 5 | } 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ "esnext", "dom" ], 5 | "module": "esnext", 6 | }, 7 | "include": [ 8 | "web/scripts/*.js", 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Chrome", 9 | "type": "chrome", 10 | "request": "launch", 11 | "url": "http://localhost:5500", 12 | "webRoot": "${workspaceFolder}/web" 13 | }, 14 | { 15 | "name": "Firefox", 16 | "type": "firefox", 17 | "request": "launch", 18 | "url": "http://localhost:5500", 19 | "webRoot": "${workspaceFolder}/web" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /web/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Oozaru: Sphere for the Web 5 | 6 | 11 | 12 | 13 | 14 | 21 |
22 |
23 | 24 |
25 |

26 |

27 | Oozaru JS Game Engine
28 | Click to Start 29 |

30 |
31 |
32 |
33 |

34 | 			
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Oozaru JavaScript game engine 2 | Copyright (c) 2016-2025, Where'd She Go? LLC 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of Spherical nor the names of its contributors may be used 16 | to endorse or promote products derived from this software without specific 17 | prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /web/assets/default.frag.glsl: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru JavaScript game engine 3 | * Copyright (c) 2015-2018, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of miniSphere nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | #ifdef GL_ES 34 | precision mediump float; 35 | #endif 36 | 37 | uniform sampler2D al_tex; 38 | uniform bool al_use_tex; 39 | 40 | varying vec4 auto_color; 41 | varying vec2 auto_texcoord; 42 | 43 | void main() 44 | { 45 | gl_FragColor = al_use_tex 46 | ? auto_color * texture2D(al_tex, auto_texcoord) 47 | : auto_color; 48 | } 49 | -------------------------------------------------------------------------------- /web/assets/default.vert.glsl: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru JavaScript game engine 3 | * Copyright (c) 2015-2018, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of miniSphere nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | attribute vec4 al_color; 34 | attribute vec4 al_pos; 35 | attribute vec2 al_texcoord; 36 | 37 | uniform mat4 al_projview_matrix; 38 | 39 | varying vec4 auto_color; 40 | varying vec2 auto_texcoord; 41 | 42 | void main() 43 | { 44 | gl_Position = al_projview_matrix * al_pos; 45 | auto_color = al_color; 46 | auto_texcoord = al_texcoord; 47 | } 48 | -------------------------------------------------------------------------------- /web/scripts/utilities.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export 34 | function fullURL(url) 35 | { 36 | const anchor = document.createElement('a'); 37 | anchor.setAttribute("href", url); 38 | return anchor.cloneNode(false).href; 39 | } 40 | 41 | export 42 | function isConstructible(object) 43 | { 44 | const ctorProxy = new Proxy(object, { 45 | construct() { return {}; } 46 | }); 47 | try { 48 | Reflect.construct(ctorProxy, []); 49 | return true; 50 | } 51 | catch { 52 | return false; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Oozaru: Sphere for the Web 2 | 3 | [![Release](https://img.shields.io/github/release/spheredev/oozaru.svg)](https://github.com/spheredev/oozaru/releases/latest) 4 | 5 | **Oozaru** is a lightweight implementation of the 6 | [Sphere](https://github.com/spheredev/neosphere) game engine API for the Web, 7 | written purely in JavaScript and based on standard HTML and JavaScript 8 | technologies; the engine is designed to run out-of-the-box in most modern 9 | browsers with no required build step. 10 | 11 | Oozaru is currently in beta, but will eventually be fully integrated 12 | into the official Sphere development toolchain. 13 | 14 | 15 | ## Download 16 | 17 | Oozaru is available for download from the GitHub repository's Releases page, 18 | which can be found here: 19 | 20 | * https://github.com/spheredev/oozaru/releases 21 | 22 | Oozaru is still in beta and doesn't yet support the full Sphere v2 API; as a 23 | result, it won't run all Sphere v2 games yet and glitches may occur. Overall 24 | support should be pretty high, though; if you find any bugs or missing 25 | features, please report them! 26 | 27 | 28 | ## About the Project 29 | 30 | ### The Name 🐒 31 | 32 | "Oozaru" is the Japanese name for the Saiyans' Great Ape transformation from 33 | Dragon Ball Z, and follows in a long tradition of Sphere v2-related projects 34 | being named for Dragon Ball concepts, joining the likes of **Cell**, the Sphere 35 | compiler; and **SSj**, the Sphere debugger. 36 | 37 | ### Goal ✔ 38 | 39 | The goal of the Oozaru project is to develop a lightweight browser-based game 40 | engine which is built on established Web and JavaScript technologies. It is 41 | intended that the engine be fully functional in a modern browser without the 42 | user having to install external plugins or extensions, and it should also be 43 | possible to write JavaScript code which will execute in both Oozaru and 44 | [neoSphere](https://github.com/fatcerberus/sphere) without modification. 45 | 46 | ### License 📜 47 | 48 | Like the rest of the Sphere platform, Oozaru is licensed under the terms of a 49 | BSD-style license. The engine can be used for any purpose, even commercially, 50 | with no other requirements except to maintain the accompanying copyright notice 51 | and license text. 52 | -------------------------------------------------------------------------------- /web/runtime/sphere-runtime.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export { default as Console } from './console.js'; 34 | export { default as FocusTarget } from './focus-target.js'; 35 | export { default as Music } from './music.js'; 36 | export { default as Pact } from './pact.js'; 37 | export { default as Prim } from './prim.js'; 38 | export { default as Scene } from './scene.js'; 39 | export { default as Task, default as Thread } from './task.js'; 40 | export { default as Tween, Easing } from './tween.js'; 41 | 42 | // Sphere Runtime shared modules 43 | export { default as DataStream } from './data-stream.js'; 44 | export { default as from, Query } from './from.js'; 45 | export { default as Logger } from './logger.js'; 46 | export { default as Random } from './random.js'; 47 | -------------------------------------------------------------------------------- /web/runtime/focus-target.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import from from './from.js'; 34 | 35 | let focusQueue = []; 36 | 37 | export default 38 | class FocusTarget 39 | { 40 | get [Symbol.toStringTag]() { return 'FocusTarget'; } 41 | 42 | constructor(options) 43 | { 44 | options = Object.assign({}, { 45 | priority: 0.0, 46 | }, options); 47 | 48 | this._priority = options.priority; 49 | } 50 | 51 | get hasFocus() 52 | { 53 | return focusQueue[focusQueue.length - 1] === this; 54 | } 55 | 56 | dispose() 57 | { 58 | this.yield(); 59 | } 60 | 61 | takeFocus() 62 | { 63 | focusQueue = from(focusQueue) 64 | .without(this) 65 | .plus(this) 66 | .ascending(it => it._priority) 67 | .toArray(); 68 | } 69 | 70 | yield() 71 | { 72 | focusQueue = from(focusQueue) 73 | .without(this) 74 | .toArray(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /web/scripts/version.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Fido from './fido.js'; 34 | 35 | var releaseData; 36 | 37 | export default 38 | class Version 39 | { 40 | static async initialize() 41 | { 42 | releaseData = await Fido.fetchJSON('oozaru.json'); 43 | } 44 | 45 | static get apiLevel() 46 | { 47 | return 4; 48 | } 49 | 50 | static get apiVersion() 51 | { 52 | return 2; 53 | } 54 | 55 | static get copyright() 56 | { 57 | return releaseData.copyright; 58 | } 59 | 60 | static get engine() 61 | { 62 | return typeof releaseData.name === 'string' 63 | ? releaseData.name : "Oozaru"; 64 | } 65 | 66 | static get version() 67 | { 68 | return typeof releaseData.version === 'string' 69 | ? releaseData.version : "WiP"; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/runtime/pact.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | const kResolve = Symbol('promise resolve'); 34 | const kReject = Symbol('promise reject'); 35 | 36 | export default 37 | class Pact extends Promise 38 | { 39 | get [Symbol.toStringTag]() { return 'Pact'; } 40 | get [Symbol.species]() { return Promise; } 41 | 42 | constructor(executor = null) 43 | { 44 | let resolveFunction; 45 | let rejectFunction; 46 | super((resolve, reject) => { 47 | resolveFunction = resolve; 48 | rejectFunction = reject; 49 | if (typeof executor === 'function') 50 | return executor(resolve, reject); 51 | }); 52 | this[kResolve] = resolveFunction; 53 | this[kReject] = rejectFunction; 54 | } 55 | 56 | reject(reason) 57 | { 58 | this[kReject](reason); 59 | } 60 | 61 | resolve(value) 62 | { 63 | this[kResolve](value); 64 | } 65 | 66 | toPromise() 67 | { 68 | return Promise.resolve(this); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /web/runtime/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export default 34 | class Logger 35 | { 36 | get [Symbol.toStringTag]() { return 'Logger'; } 37 | 38 | constructor(fileName) 39 | { 40 | this._textEncoder = new TextEncoder(); 41 | this._stream = new FileStream(fileName, FileOp.Update); 42 | this._groups = []; 43 | 44 | let timestamp = new Date().toISOString(); 45 | let logLine = `LOG FILE OPENED: ${timestamp}`; 46 | this._stream.write(this._textEncoder.encode(`\n${logLine}\n`)); 47 | } 48 | 49 | beginGroup(text) 50 | { 51 | text = text.toString(); 52 | this.write(`BEGIN: ${text}`); 53 | this._groups.push(text); 54 | } 55 | 56 | endGroup() 57 | { 58 | let groupName = this._groups.pop(); 59 | this.write(`END: ${groupName}`); 60 | } 61 | 62 | write(text) 63 | { 64 | text = text.toString(); 65 | let timestamp = new Date().toISOString(); 66 | this._stream.write(this._textEncoder.encode(`${timestamp} .. `)); 67 | for (let i = 0; i < this._groups.length; ++i) 68 | this._stream.write(this._textEncoder.encode(" ")); 69 | this._stream.write(this._textEncoder.encode(`${text}\n`)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /web/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | align-items: center; 3 | background-color: #222; 4 | color: #CC7; 5 | display: flex; 6 | flex-direction: column; 7 | font-size: 11pt; 8 | height: 100vh; 9 | margin: 0; 10 | } 11 | 12 | #menu { 13 | align-items: center; 14 | background-color: #CCC; 15 | display: flex; 16 | color: #000; 17 | border-bottom-left-radius: 0.5rem; 18 | border-bottom-right-radius: 0.5rem; 19 | box-shadow: 2pt 2pt 4pt #000, -2pt -2pt 4pt #000; 20 | font-family: system-ui; 21 | flex-direction: row; 22 | padding: 0.25em 0.5em; 23 | } 24 | 25 | #menu > * { 26 | margin: 0.25rem; 27 | } 28 | 29 | #menu > #title { 30 | line-height: 1; 31 | padding-right: 1em; 32 | text-align: right; 33 | } 34 | 35 | #menu > #title > #name { 36 | color: #000; 37 | font-variant: small-caps; 38 | font-weight: bold; 39 | } 40 | 41 | #menu > #title > #copyright { 42 | font-size: 8pt; 43 | padding-right: 1em; 44 | } 45 | 46 | #menu > .game { 47 | cursor: pointer; 48 | filter: grayscale(100%); 49 | transition: filter 0.25s ease; 50 | } 51 | 52 | #menu > .game.running { 53 | filter: unset; 54 | } 55 | 56 | #menu > .game:hover { 57 | filter: unset; 58 | } 59 | 60 | #menu > .game:active { 61 | filter: brightness(150%); 62 | } 63 | 64 | #tv { 65 | align-items: center; 66 | background: #224; 67 | border-radius: 1rem; 68 | box-shadow: 4pt 4pt 8pt #000, -4pt -4pt 8pt #000; 69 | color: #CCC; 70 | display: flex; 71 | flex-direction: column; 72 | font-family: system-ui; 73 | margin: auto; 74 | padding: 1rem; 75 | text-align: center; 76 | } 77 | 78 | #tv > * { 79 | margin: 1rem; 80 | } 81 | 82 | #tv canvas { 83 | background-color: #000; 84 | border: 1px solid #000; 85 | flex: none; 86 | image-rendering: pixelated; 87 | } 88 | 89 | #tv hr { 90 | background-color: #448; 91 | border-color: #336; 92 | width: 100%; 93 | } 94 | 95 | #tv pre { 96 | background-color: #111; 97 | border: 1px solid #444; 98 | box-shadow: 4px 4px 8px #000, -4px -4px 8px #000; 99 | color: #CCC; 100 | margin: 0; 101 | overflow: auto; 102 | padding: 1em !important; 103 | text-align: left; 104 | } 105 | 106 | #tv ul { 107 | list-style-position: inside; 108 | padding: 0; 109 | } 110 | 111 | #tv #screen-container { 112 | position: relative; 113 | } 114 | 115 | #tv #overlay { 116 | position: absolute; 117 | pointer-events: none; 118 | transition: opacity 0.25s ease; 119 | left: 195px; 120 | top: 150px; 121 | width: 250px; 122 | height: 150px; 123 | } 124 | 125 | #tv #panel { 126 | align-items: flex-start; 127 | display: flex; 128 | flex-direction: row-reverse; 129 | justify-content: center; 130 | padding: 0.5rem; 131 | } 132 | 133 | #tv #panel > * { 134 | padding: 0; 135 | } 136 | 137 | #tv #readout { 138 | background-color: #300; 139 | border: 1px solid #700; 140 | box-shadow: 4px 4px 8px #200, -4px -4px 8px #200; 141 | color: #FCC; 142 | display: none; 143 | margin-right: 1rem; 144 | width: auto; 145 | } 146 | 147 | #tv #readout::first-line { 148 | font-weight: bold; 149 | } 150 | 151 | #tv #readout.visible { 152 | display: unset; 153 | } 154 | -------------------------------------------------------------------------------- /web/runtime/random.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | let normalVxW = NaN; 34 | 35 | export default 36 | class Random 37 | { 38 | constructor() 39 | { 40 | throw new TypeError(`'${new.target.name}' is static and cannot be instantiated`); 41 | } 42 | 43 | static chance(odds) 44 | { 45 | return odds > Math.random(); 46 | } 47 | 48 | static discrete(min, max) 49 | { 50 | min = Math.trunc(min); 51 | max = Math.trunc(max); 52 | let range = Math.abs(max - min) + 1; 53 | min = min < max ? min : max; 54 | return min + Math.floor(Math.random() * range); 55 | } 56 | 57 | static normal(mean, sigma) 58 | { 59 | // normal deviates are calculated in pairs. we return the first one 60 | // immediately, and save the second to be returned on the next call to 61 | // random.normal(). 62 | let x, u, v, w; 63 | if (Number.isNaN(normalVxW)) { 64 | do { 65 | u = 2.0 * Math.random() - 1.0; 66 | v = 2.0 * Math.random() - 1.0; 67 | w = u * u + v * v; 68 | } while (w >= 1.0); 69 | w = Math.sqrt(-2.0 * Math.log(w) / w); 70 | x = u * w; 71 | normalVxW = v * w; 72 | } 73 | else { 74 | x = normalVxW; 75 | normalVxW = NaN; 76 | } 77 | return mean + x * sigma; 78 | } 79 | 80 | static sample(array) 81 | { 82 | let index = this.discrete(0, array.length - 1); 83 | return array[index]; 84 | } 85 | 86 | static string(length = 10) 87 | { 88 | const CORPUS = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 89 | 90 | length = Math.floor(length); 91 | let string = ""; 92 | for (let i = 0; i < length; ++i) { 93 | let index = this.discrete(0, CORPUS.length - 1); 94 | string += CORPUS[index]; 95 | } 96 | return string; 97 | } 98 | 99 | static uniform(mean, variance) 100 | { 101 | let error = variance * 2.0 * (0.5 - Math.random()); 102 | return mean + error; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /web/scripts/package.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import { DataStream } from './data-stream.js'; 34 | import Fido from './fido.js'; 35 | 36 | export 37 | class Package 38 | { 39 | #dataStream; 40 | #toc = {}; 41 | 42 | static async fromFile(url) 43 | { 44 | const buffer = await Fido.fetchData(url); 45 | const dataStream = new DataStream(buffer); 46 | return new Package(dataStream); 47 | } 48 | 49 | constructor(dataStream) 50 | { 51 | const spkHeader = dataStream.readStruct({ 52 | signature: 'string/4', 53 | version: 'uint16-le', 54 | numFiles: 'uint32-le', 55 | tocOffset: 'uint32-le', 56 | reserved: 'reserve/2', 57 | }); 58 | if (spkHeader.signature != '.spk') 59 | throw RangeError("Not a valid Sphere package file"); 60 | if (spkHeader.version !== 1) 61 | throw RangeError(`Unsupported SPK format version '${spkHeader.version}'`); 62 | dataStream.position = spkHeader.tocOffset; 63 | for (let i = 0; i < spkHeader.numFiles; ++i) { 64 | const entry = dataStream.readStruct({ 65 | version: 'uint16-le', 66 | nameLength: 'uint16-le', 67 | byteOffset: 'uint32-le', 68 | fileSize: 'uint32-le', 69 | byteSize: 'uint32-le', 70 | }); 71 | if (entry.version !== 1) 72 | throw RangeError(`Unsupported SPK file record version '${entry.version}'`); 73 | const pathName = dataStream.readString(entry.nameLength); 74 | this.#toc[pathName] = { 75 | byteOffset: entry.byteOffset, 76 | byteLength: entry.byteLength, 77 | fileSize: entry.fileSize, 78 | }; 79 | } 80 | this.#dataStream = dataStream; 81 | } 82 | 83 | dataOf(pathName) 84 | { 85 | if (!(pathName in this.#toc)) 86 | throw Error(`File not found in Sphere package '${pathName}'`); 87 | const fileRecord = this.#toc[pathName]; 88 | if (fileRecord.data === undefined) { 89 | if (fileRecord.byteLength !== fileRecord.fileSize) 90 | throw RangeError(`Compressed packages are currently unsupported`); 91 | this.#dataStream.position = fileRecord.byteOffset; 92 | const compressedData = this.#dataStream.readBytes(fileRecord.byteLength); 93 | fileRecord.data = compressedData.buffer; 94 | } 95 | return fileRecord.data; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /web/scripts/fido.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | var jobs = []; 34 | 35 | export default 36 | class Fido 37 | { 38 | static get numJobs() 39 | { 40 | return jobs.length; 41 | } 42 | 43 | static get progress() 44 | { 45 | let bytesTotal = 0; 46 | let bytesDone = 0; 47 | for (const job of jobs) { 48 | if (job.totalSize === null) 49 | continue; 50 | bytesTotal += job.totalSize; 51 | bytesDone += job.bytesDone; 52 | } 53 | return bytesTotal > 0 ? bytesDone / bytesTotal : 1.0; 54 | } 55 | 56 | static async fetch(url) 57 | { 58 | const response = await fetch(url); 59 | if (!response.ok || response.body === null) 60 | throw Error(`Couldn't fetch the file '${url}'. (HTTP ${response.status})`); 61 | const job = { 62 | url, 63 | bytesDone: 0, 64 | totalSize: null, 65 | finished: false, 66 | }; 67 | jobs.push(job); 68 | const reader = response.body.getReader(); 69 | const length = response.headers.get('Content-Length'); 70 | if (length !== null) 71 | job.totalSize = parseInt(length, 10); 72 | const chunks = []; 73 | while (!job.finished) { 74 | const result = await reader.read(); 75 | if (!result.done) { 76 | chunks.push(result.value); 77 | job.bytesDone += result.value.length; 78 | } 79 | job.finished = result.done; 80 | } 81 | let allDone = true; 82 | for (const job of jobs) 83 | allDone = allDone && job.finished; 84 | if (allDone) 85 | jobs.length = 0; 86 | return new Blob(chunks); 87 | } 88 | 89 | static async fetchData(url) 90 | { 91 | const blob = await this.fetch(url); 92 | return blob.arrayBuffer(); 93 | } 94 | 95 | static async fetchImage(url) 96 | { 97 | const blob = await this.fetch(url); 98 | return new Promise((resolve, reject) => { 99 | const image = new Image(); 100 | image.onload = () => { 101 | resolve(image); 102 | URL.revokeObjectURL(image.src); 103 | }; 104 | image.onerror = () => { 105 | reject(Error(`Couldn't load image '${url}'`)); 106 | URL.revokeObjectURL(image.src); 107 | } 108 | image.src = URL.createObjectURL(blob); 109 | }); 110 | } 111 | 112 | static async fetchJSON(url) 113 | { 114 | const text = await this.fetchText(url); 115 | return JSON.parse(text); 116 | } 117 | 118 | static async fetchText(url) 119 | { 120 | const blob = await this.fetch(url); 121 | return blob.text(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /web/runtime/music.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Tween, { Easing } from './tween.js'; 34 | 35 | let adjuster = null; 36 | let currentSound = null; 37 | let haveOverride = false; 38 | let mixer = null; 39 | let oldSounds = []; 40 | let topmostSound = null; 41 | 42 | export default 43 | class Music extends null 44 | { 45 | static async adjustVolume(newVolume, fadeTime = 0) 46 | { 47 | appearifyMixer(); 48 | newVolume = Math.min(Math.max(newVolume, 0.0), 1.0); 49 | if (fadeTime > 0) 50 | await adjuster.easeIn({ volume: newVolume }, fadeTime); 51 | else 52 | mixer.volume = newVolume; 53 | } 54 | 55 | static async override(fileName, fadeTime = 0) 56 | { 57 | await crossfade(fileName, fadeTime, true); 58 | haveOverride = true; 59 | } 60 | 61 | static async play(fileName, fadeTime = 0) 62 | { 63 | topmostSound = await crossfade(fileName, fadeTime, false); 64 | } 65 | 66 | static pop(fadeTime = 0) 67 | { 68 | if (oldSounds.length === 0) 69 | return; 70 | currentSound.tween.easeIn({ volume: 0.0 }, fadeTime); 71 | topmostSound = oldSounds.pop(); 72 | currentSound = topmostSound; 73 | if (currentSound !== null) { 74 | currentSound.stream.volume = 0.0; 75 | currentSound.tween.easeIn({ volume: 1.0 }, fadeTime); 76 | } 77 | } 78 | 79 | static async push(fileName, fadeTime = 0) 80 | { 81 | let oldSound = topmostSound; 82 | await this.play(fileName, fadeTime); 83 | oldSounds.push(oldSound); 84 | } 85 | 86 | static reset(fadeTime = 0) 87 | { 88 | if (!haveOverride) 89 | return; 90 | haveOverride = false; 91 | 92 | currentSound.tween.easeIn({ volume: 0.0 }, fadeTime); 93 | currentSound = topmostSound; 94 | if (currentSound !== null) { 95 | currentSound.stream.volume = 0.0; 96 | currentSound.tween.easeIn({ volume: 1.0 }, fadeTime); 97 | } 98 | } 99 | } 100 | 101 | function appearifyMixer() 102 | { 103 | // lazy mixer creation, works around autoplay policy in Oozaru 104 | if (mixer === null) { 105 | mixer = new Mixer(44100, 16, 2); 106 | adjuster = new Tween(mixer, Easing.Linear); 107 | } 108 | } 109 | 110 | async function crossfade(fileName, frames = 0, forceChange) 111 | { 112 | appearifyMixer(); 113 | let allowChange = !haveOverride || forceChange; 114 | if (currentSound !== null && allowChange) 115 | currentSound.tween.easeIn({ volume: 0.0 }, frames); 116 | if (fileName !== null) { 117 | let stream = await Sound.fromFile(fileName); 118 | stream.repeat = true; 119 | stream.volume = 0.0; 120 | stream.play(mixer); 121 | let newSound = { stream, tween: new Tween(stream, Easing.Linear) }; 122 | if (allowChange) { 123 | newSound.tween.easeIn({ volume: 1.0 }, frames); 124 | currentSound = newSound; 125 | } 126 | return newSound; 127 | } 128 | else { 129 | return null; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /web/scripts/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Audialis from './audialis.js'; 34 | import Fido from './fido.js'; 35 | import Fontso from './fontso.js'; 36 | import Galileo from './galileo.js'; 37 | import InputEngine from './input-engine.js'; 38 | import Pegasus from './pegasus.js'; 39 | import Version from './version.js'; 40 | 41 | main(); 42 | 43 | async function main() 44 | { 45 | await Version.initialize(); 46 | 47 | const urlQuery = new URL(location.href).searchParams; 48 | const gameID = urlQuery.get('game'); 49 | 50 | // use event handling to intercept errors originating inside the Sphere sandbox, rather than a 51 | // try-catch. otherwise the debugger thinks the error is handled and doesn't do a breakpoint, 52 | // making diagnosing bugs in the engine harder than necessary. 53 | window.addEventListener('error', (e) => { 54 | reportException(e.error); 55 | }); 56 | window.addEventListener('unhandledrejection', (e) => { 57 | reportException(e.reason); 58 | }); 59 | 60 | const menu = document.getElementById('menu'); 61 | const engineNameSpan = document.getElementById('name'); 62 | const copyrightSpan = document.getElementById('copyright'); 63 | engineNameSpan.innerText = Version.engine; 64 | copyrightSpan.innerText = Version.copyright; 65 | let useDistDir = true; 66 | try { 67 | const gameList = await Fido.fetchJSON('games/index.json'); 68 | for (const entry of gameList) { 69 | const iconImage = document.createElement('img'); 70 | iconImage.src = `games/${entry.gameID}/icon.png`; 71 | iconImage.width = 48; 72 | iconImage.height = 48; 73 | const anchor = document.createElement('a'); 74 | anchor.className = 'game'; 75 | if (entry.gameID === gameID) 76 | anchor.classList.add('running'); 77 | anchor.title = entry.title; 78 | anchor.href = `${location.origin}${location.pathname}?game=${entry.gameID}`; 79 | anchor.appendChild(iconImage); 80 | menu.appendChild(anchor); 81 | } 82 | useDistDir = false; 83 | } 84 | catch { 85 | const iconImage = document.createElement('img'); 86 | iconImage.src = `dist/icon.png`; 87 | iconImage.width = 48; 88 | iconImage.height = 48; 89 | menu.appendChild(iconImage); 90 | } 91 | 92 | const canvas = document.getElementById('screen'); 93 | const overlay = document.getElementById('overlay'); 94 | canvas.focus(); 95 | canvas.onkeypress = canvas.onclick = async (e) => { 96 | if (gameID !== null || useDistDir) { 97 | if (overlay !== null) 98 | overlay.style.opacity = '0'; 99 | canvas.onclick = null; 100 | canvas.onkeypress = null; 101 | canvas.focus(); 102 | await Galileo.initialize(canvas); 103 | await Audialis.initialize(); 104 | await Fontso.initialize(); 105 | InputEngine.initialize(canvas); 106 | Pegasus.initialize(); 107 | await Pegasus.launchGame(gameID !== null ? `games/${gameID}` : 'dist'); 108 | } 109 | else { 110 | reportException("Please select a game from the top menu first."); 111 | } 112 | }; 113 | } 114 | 115 | function reportException(thrownValue) 116 | { 117 | let msg; 118 | if (thrownValue instanceof Error && thrownValue.stack !== undefined) 119 | msg = thrownValue.stack.replace(/\r?\n/g, '
'); 120 | else 121 | msg = String(thrownValue); 122 | const readout = document.getElementById('readout'); 123 | readout.classList.add('visible'); 124 | readout.innerHTML = `an error occurred.\r\n\r\n${msg}`; 125 | } 126 | -------------------------------------------------------------------------------- /web/scripts/deque.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | // this is based on an algorithm for dynamically-expanding ring buffers which 34 | // is described here: 35 | // https://blog.labix.org/2010/12/23/efficient-algorithm-for-expanding-circular-buffers 36 | 37 | export 38 | class Deque 39 | { 40 | #backPtr = 0; 41 | #entries; 42 | #frontPtr = 0; 43 | #overflowPtr; 44 | #stride; 45 | #vips = []; 46 | 47 | constructor(reserveSize = 8) 48 | { 49 | this.#stride = reserveSize + 1; 50 | this.#entries = new Array(this.#stride); 51 | this.#overflowPtr = this.#stride; 52 | } 53 | 54 | *[Symbol.iterator]() 55 | { 56 | while (!this.empty) 57 | yield this.shift(); 58 | } 59 | 60 | get empty() 61 | { 62 | return this.#backPtr === this.#frontPtr 63 | && this.#overflowPtr === this.#stride 64 | && this.#vips.length === 0; 65 | } 66 | 67 | get first() 68 | { 69 | return this.#vips.length > 0 ? this.#vips[this.#vips.length - 1] 70 | : this.#backPtr !== this.#frontPtr ? this.#entries[this.#frontPtr] 71 | : this.#entries[this.#stride]; 72 | } 73 | 74 | get last() 75 | { 76 | const ptr = this.#backPtr > 0 ? this.#backPtr - 1 77 | : this.#stride - 1; 78 | return this.#overflowPtr > this.#stride ? this.#entries[this.#overflowPtr - 1] 79 | : this.#frontPtr !== this.#backPtr ? this.#entries[ptr] 80 | : this.#vips[0]; 81 | } 82 | 83 | clear() 84 | { 85 | this.#entries.length = 0; 86 | this.#stride = 1; 87 | this.#overflowPtr = 1; 88 | this.#backPtr = 0; 89 | this.#frontPtr = 0; 90 | } 91 | 92 | pop() 93 | { 94 | if (this.#overflowPtr > this.#stride) { 95 | // take from the overflow area first 96 | return this.#entries[--this.#overflowPtr]; 97 | } 98 | else if (this.#frontPtr !== this.#backPtr) { 99 | if (--this.#backPtr < 0) 100 | this.#backPtr = this.#stride - 1; 101 | return this.#entries[this.#backPtr]; 102 | } 103 | else { 104 | // note: uses Array#shift so not O(1). i'll fix it eventually but 105 | // ultimately, I don't expect this case to be common. 106 | return this.#vips.shift(); 107 | } 108 | } 109 | 110 | push(value) 111 | { 112 | const ringFull = (this.#backPtr + 1) % this.#stride === this.#frontPtr; 113 | if (ringFull || this.#overflowPtr > this.#stride) { 114 | // if there's already an overflow area established, we need to keep 115 | // using it to maintain proper FIFO order. 116 | this.#entries[this.#overflowPtr++] = value; 117 | } 118 | else { 119 | this.#entries[this.#backPtr++] = value; 120 | if (this.#backPtr >= this.#stride) 121 | this.#backPtr = 0; 122 | } 123 | } 124 | 125 | shift() 126 | { 127 | if (this.#vips.length > 0) { 128 | return this.#vips.pop(); 129 | } 130 | else { 131 | const value = this.#entries[this.#frontPtr++]; 132 | if (this.#frontPtr >= this.#stride) 133 | this.#frontPtr = 0; 134 | if (this.#frontPtr === this.#backPtr) { 135 | // absorb the overflow area back into the ring 136 | this.#frontPtr = this.#stride % this.#overflowPtr; 137 | this.#backPtr = 0; 138 | this.#stride = this.#overflowPtr; 139 | } 140 | return value; 141 | } 142 | } 143 | 144 | unshift(value) 145 | { 146 | const ringFull = (this.#backPtr + 1) % this.#stride === this.#frontPtr; 147 | if (!ringFull) { 148 | if (--this.#frontPtr < 0) 149 | this.#frontPtr = this.#stride - 1; 150 | this.#entries[this.#frontPtr] = value; 151 | } 152 | else { 153 | this.#vips.push(value); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /web/runtime/tween.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | let activeTweens = []; 34 | let job = null; 35 | 36 | export 37 | const Easing = 38 | { 39 | Linear: 0, 40 | Back: 1, 41 | Bounce: 2, 42 | Circular: 3, 43 | Cubic: 4, 44 | Elastic: 5, 45 | Exponential: 6, 46 | Quadratic: 7, 47 | Quartic: 8, 48 | Quintic: 9, 49 | Sine: 10, 50 | }; 51 | 52 | export default 53 | class Tween 54 | { 55 | constructor(target, easing = Easing.Sine) 56 | { 57 | const easeIn = easing === Easing.Back ? easeInBack 58 | : easing === Easing.Bounce ? easeInBounce 59 | : easing === Easing.Circular ? easeInCircular 60 | : easing === Easing.Cubic ? easeInCubic 61 | : easing === Easing.Elastic ? easeInElastic 62 | : easing === Easing.Exponential ? easeInExponential 63 | : easing === Easing.Quadratic ? easeInQuadratic 64 | : easing === Easing.Quartic ? easeInQuartic 65 | : easing === Easing.Quintic ? easeInQuintic 66 | : easing === Easing.Sine ? easeInSine 67 | : easeLinear; 68 | const easeOut = (t) => 1.0 - easeIn(1.0 - t); 69 | const easeInOut = (t) => t < 0.5 ? 0.5 * easeIn(t * 2.0) : 0.5 + 0.5 * easeOut(t * 2.0 - 1.0); 70 | this.inEaser = easeIn; 71 | this.inOutEaser = easeInOut; 72 | this.outEaser = easeOut; 73 | this.target = target; 74 | } 75 | 76 | async easeIn(newValues, numFrames) 77 | { 78 | await runTween(this.target, newValues, this.inEaser, numFrames); 79 | } 80 | 81 | async easeInOut(newValues, numFrames) 82 | { 83 | await runTween(this.target, newValues, this.inOutEaser, numFrames); 84 | } 85 | 86 | async easeOut(newValues, numFrames) 87 | { 88 | await runTween(this.target, newValues, this.outEaser, numFrames); 89 | } 90 | } 91 | 92 | function appearifyUpdateJob() 93 | { 94 | if (job !== null) 95 | return; 96 | job = Dispatch.onUpdate(() => { 97 | let ptr = 0; 98 | for (let i = 0, len = activeTweens.length; i < len; ++i) { 99 | const tween = activeTweens[i]; 100 | const len = tween.endTime - tween.startTime; 101 | const t = (Sphere.now() - tween.startTime) / len; 102 | if (t < 1.0) { 103 | activeTweens[ptr++] = tween; 104 | for (const p of Object.keys(tween.targetValues)) { 105 | const base = tween.initialValues[p]; 106 | const delta = tween.targetValues[p] - base; 107 | tween.targetObject[p] = tween.easer(t) * delta + base; 108 | } 109 | } 110 | else { 111 | for (const p of Object.keys(tween.targetValues)) 112 | tween.targetObject[p] = tween.targetValues[p]; 113 | tween.resolve(); 114 | } 115 | } 116 | activeTweens.length = ptr; 117 | }, { 118 | inBackground: true, 119 | priority: Infinity, 120 | }); 121 | } 122 | 123 | function easeInBack(t) 124 | { 125 | return t * t * t - t * Math.sin(t * Math.PI); 126 | } 127 | 128 | function easeInBounce(t) 129 | { 130 | t = 1.0 - t; 131 | const p = t < (1.0 / 2.75) ? 7.5625 * t * t 132 | : t < (2.0 / 2.75) ? 7.5625 * (t -= 1.5 / 2.75) * t + 0.75 133 | : t < (2.5 / 2.75) ? 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375 134 | : 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; 135 | return 1.0 - p; 136 | } 137 | 138 | function easeInCircular(t) 139 | { 140 | return 1.0 - Math.sqrt(1.0 - t * t); 141 | } 142 | 143 | function easeInCubic(t) 144 | { 145 | return t * t * t; 146 | } 147 | 148 | function easeInElastic(t) 149 | { 150 | return Math.sin(t * 7.5 * Math.PI) * 2.0 ** (10 * (t - 1.0)); 151 | } 152 | 153 | function easeInExponential(t) 154 | { 155 | return t === 0.0 ? t : 2 ** (10 * (t - 1.0)); 156 | } 157 | 158 | function easeLinear(t) 159 | { 160 | return t; 161 | } 162 | 163 | function easeInQuadratic(t) 164 | { 165 | return t * t; 166 | } 167 | 168 | function easeInQuartic(t) 169 | { 170 | return t * t * t * t; 171 | } 172 | 173 | function easeInQuintic(t) 174 | { 175 | return t * t * t * t * t; 176 | } 177 | 178 | function easeInSine(t) 179 | { 180 | return Math.sin((t - 1.0) * Math.PI / 2) + 1.0; 181 | } 182 | 183 | function runTween(targetObject, newValues, easer, numFrames) 184 | { 185 | const initialValues = {}; 186 | const targetValues = {}; 187 | for (const p of Object.keys(newValues)) { 188 | initialValues[p] = targetObject[p]; 189 | targetValues[p] = newValues[p]; 190 | } 191 | const promise = new Promise(resolve => { 192 | activeTweens.push({ 193 | initialValues, 194 | targetValues, 195 | easer, 196 | resolve, 197 | targetObject, 198 | startTime: Sphere.now(), 199 | endTime: Sphere.now() + numFrames, 200 | }); 201 | }); 202 | appearifyUpdateJob(); 203 | return promise; 204 | } 205 | -------------------------------------------------------------------------------- /web/runtime/task.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import FocusTarget from './focus-target.js'; 34 | import Pact from './pact.js'; 35 | 36 | const canDispatchOnExit = 'onExit' in Dispatch; 37 | const canPauseJobs = 'pause' in Dispatch.now(() => {}); 38 | 39 | export default 40 | class Task 41 | { 42 | get [Symbol.toStringTag]() { return 'Task'; } 43 | 44 | static async join(...tasks) 45 | { 46 | const promises = tasks.map(it => it._onTaskStop); 47 | return Promise.all(promises); 48 | } 49 | 50 | constructor(options = {}) 51 | { 52 | if (new.target === Task) 53 | throw new Error(`'${new.target.name}' is abstract and cannot be instantiated`); 54 | 55 | options = Object.assign({}, { 56 | inBackground: false, 57 | priority: 0.0, 58 | }, options); 59 | 60 | this._bootstrapping = false; 61 | this._exitJob = null; 62 | this._focusTarget = new FocusTarget(options); 63 | this._inBackground = options.inBackground; 64 | this._onTaskStart = null; 65 | this._onTaskStop = Pact.resolve(); 66 | this._priority = options.priority; 67 | this._renderJob = null; 68 | this._started = false; 69 | this._updateJob = null; 70 | } 71 | 72 | get hasFocus() 73 | { 74 | return this._focusTarget.hasFocus; 75 | } 76 | 77 | get priority() 78 | { 79 | return this._priority; 80 | } 81 | 82 | get running() 83 | { 84 | return this._started; 85 | } 86 | 87 | on_startUp() {} 88 | on_shutDown() {} 89 | on_inputCheck() {} 90 | on_render() {} 91 | on_update() {} 92 | 93 | pause() 94 | { 95 | if (!canPauseJobs) 96 | throw new RangeError("Task#pause requires a newer Sphere version"); 97 | if (!this.running) 98 | throw new Error("Task is not running"); 99 | this._updateJob.pause(); 100 | } 101 | 102 | resume() 103 | { 104 | if (!canPauseJobs) 105 | throw new RangeError("Task#resume requires a newer Sphere version"); 106 | if (!this.running) 107 | throw new Error("Task is not running"); 108 | this._updateJob.resume(); 109 | this._renderJob.resume(); 110 | } 111 | 112 | async start() 113 | { 114 | if (this._started) 115 | return; 116 | 117 | this._bootstrapping = true; 118 | this._started = true; 119 | this._onTaskStart = new Pact(); 120 | this._onTaskStop = new Pact(); 121 | 122 | // Dispatch.onExit() ensures the shutdown handler is always called 123 | if (canDispatchOnExit) 124 | this._exitJob = Dispatch.onExit(() => this.on_shutDown()); 125 | 126 | // set up the update and render callbacks 127 | this._renderJob = Dispatch.onRender( 128 | () => { 129 | if (this._bootstrapping) 130 | return; 131 | this.on_render(); 132 | }, { 133 | inBackground: this._inBackground, 134 | priority: this._priority, 135 | }); 136 | this._updateJob = Dispatch.onUpdate( 137 | async () => { 138 | if (this._bootstrapping) { 139 | await this.on_startUp(); 140 | this._onTaskStart.resolve(); 141 | this._bootstrapping = false; 142 | } 143 | if (this.hasFocus) 144 | await this.on_inputCheck(); 145 | await this.on_update(); 146 | }, { 147 | inBackground: this._inBackground, 148 | priority: this._priority, 149 | }); 150 | 151 | // after task terminates, remove it from the input queue 152 | this._onTaskStop.then(() => { 153 | this._focusTarget.dispose(); 154 | }); 155 | 156 | return this._onTaskStart; 157 | } 158 | 159 | async stop() 160 | { 161 | if (!this._started) 162 | return; 163 | 164 | this.yieldFocus(); 165 | if (canDispatchOnExit) 166 | this._exitJob.cancel(); 167 | this._updateJob.cancel(); 168 | this._renderJob.cancel(); 169 | this._started = false; 170 | await this.on_shutDown(); 171 | this._onTaskStop.resolve(); 172 | } 173 | 174 | suspend() 175 | { 176 | if (!canPauseJobs) 177 | throw new RangeError("Task#suspend requires a newer Sphere version"); 178 | if (!this.running) 179 | throw new Error("Task is not running"); 180 | this._updateJob.pause(); 181 | this._renderJob.pause(); 182 | } 183 | 184 | takeFocus() 185 | { 186 | if (!this.running) 187 | throw new Error("Task is not running"); 188 | if (this.on_inputCheck === Task.on_inputCheck) 189 | throw new TypeError("Task is not enabled for user input"); 190 | 191 | this._focusTarget.takeFocus(); 192 | } 193 | 194 | yieldFocus() 195 | { 196 | if (!this.running) 197 | throw new Error("Task is not running"); 198 | if (this.on_inputCheck === Task.on_inputCheck) 199 | throw new TypeError("Task is not enabled for user input"); 200 | 201 | this._focusTarget.yield(); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /web/scripts/job-queue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Galileo from './galileo.js'; 34 | 35 | var frameCount = -1; 36 | var frameRate = 60; 37 | var jobSortNeeded = false; 38 | var jobs = []; 39 | var nextFrameTime = null; 40 | var nextJobID = 1; 41 | var rAFID = 0; 42 | 43 | export 44 | const JobType = 45 | { 46 | // in order of execution 47 | Render: 0, 48 | Update: 1, 49 | Immediate: 2, 50 | } 51 | 52 | export default 53 | class JobQueue 54 | { 55 | static now() 56 | { 57 | return Math.max(frameCount, 0); 58 | } 59 | 60 | static start() 61 | { 62 | if (rAFID !== 0) // already running? 63 | return; 64 | rAFID = requestAnimationFrame(animate); 65 | } 66 | 67 | static stop() 68 | { 69 | if (rAFID !== 0) 70 | cancelAnimationFrame(rAFID); 71 | frameCount = -1; 72 | jobs.length = 0; 73 | nextFrameTime = null; 74 | rAFID = 0; 75 | } 76 | } 77 | 78 | export 79 | class Dispatch 80 | { 81 | static cancelAll() 82 | { 83 | throw Error(`'Dispatch#cancelAll()' API is not implemented`); 84 | } 85 | 86 | static later(numFrames, callback) 87 | { 88 | const job = addJob(JobType.Update, callback, false, numFrames); 89 | return new JobToken(job); 90 | } 91 | 92 | static now(callback) 93 | { 94 | const job = addJob(JobType.Immediate, callback); 95 | return new JobToken(job); 96 | } 97 | 98 | static onRender(callback, options) 99 | { 100 | const job = addJob(JobType.Render, callback, true, 0, options); 101 | return new JobToken(job); 102 | } 103 | 104 | static onUpdate(callback, options) 105 | { 106 | const job = addJob(JobType.Update, callback, true, 0, options); 107 | return new JobToken(job); 108 | } 109 | } 110 | 111 | export 112 | class JobToken 113 | { 114 | #job; 115 | 116 | constructor(job) 117 | { 118 | this.#job = job; 119 | } 120 | 121 | cancel() 122 | { 123 | this.#job.cancelled = true; 124 | } 125 | 126 | pause() 127 | { 128 | this.#job.paused = true; 129 | } 130 | 131 | resume() 132 | { 133 | this.#job.paused = false; 134 | } 135 | } 136 | 137 | function addJob(type, callback, recurring = false, delay = 0, options) 138 | { 139 | // for render jobs, invert priority so the highest-priority render is done last 140 | let priority = options?.priority ?? 0.0; 141 | if (type === JobType.Render) 142 | priority = -(priority); 143 | 144 | const job = { 145 | jobID: nextJobID++, 146 | type, 147 | callback, 148 | cancelled: false, 149 | priority, 150 | recurring, 151 | busy: false, 152 | paused: false, 153 | timer: delay, 154 | }; 155 | jobs.push(job); 156 | jobSortNeeded = true; 157 | return job; 158 | } 159 | 160 | function animate(timestamp) 161 | { 162 | rAFID = requestAnimationFrame(animate); 163 | 164 | const oldFrameCount = frameCount; 165 | nextFrameTime ??= timestamp; 166 | while (timestamp >= nextFrameTime) { 167 | ++frameCount; 168 | nextFrameTime += 1000.0 / frameRate; 169 | 170 | // sort the Dispatch jobs for this frame 171 | if (jobSortNeeded) { 172 | // job queue sorting criteria, in order of key ranking: 173 | // 1. all recurring jobs first, followed by all one-offs 174 | // 2. renders, then updates, then immediates 175 | // 3. highest to lowest priority 176 | // 4. within the same priority bracket, maintain FIFO order 177 | jobs.sort((a, b) => { 178 | const recurDelta = +b.recurring - +a.recurring; 179 | const typeDelta = a.type - b.type; 180 | const priorityDelta = b.priority - a.priority; 181 | const fifoDelta = a.jobID - b.jobID; 182 | return recurDelta || typeDelta || priorityDelta || fifoDelta; 183 | }); 184 | jobSortNeeded = false; 185 | } 186 | 187 | // this is a bit tricky. Dispatch.now() is required to be processed in the same frame it's 188 | // issued, but we also want to avoid doing updates and renders out of turn. to that end, 189 | // the loop below is split into two phases. in phase one, we run through the sorted part of 190 | // the list. in phase two, we process all jobs added since the frame started, but skip over 191 | // the update and render jobs, leaving them for the next frame. conveniently for us, 192 | // Dispatch.now() jobs are not prioritized so they're guaranteed to be in the correct order 193 | // (FIFO) naturally! 194 | let ptr = 0; 195 | const initialLength = jobs.length; 196 | for (let i = 0; i < jobs.length; ++i) { 197 | const job = jobs[i]; 198 | if ((i < initialLength || job.type === JobType.Immediate) 199 | && (timestamp < nextFrameTime || job.type !== JobType.Render) 200 | && !job.busy && !job.cancelled && (job.recurring || job.timer-- <= 0) 201 | && !job.paused) 202 | { 203 | job.busy = true; 204 | Promise.resolve(job.callback()).then(() => { 205 | job.busy = false; 206 | }) 207 | .catch((error) => { 208 | jobs.length = 0; 209 | throw error; 210 | }); 211 | } 212 | if (job.cancelled || (!job.recurring && job.timer < 0)) 213 | continue; // delete it 214 | jobs[ptr++] = job; 215 | } 216 | jobs.length = ptr; 217 | } 218 | 219 | if (frameCount > oldFrameCount) 220 | Galileo.flip(); 221 | } 222 | -------------------------------------------------------------------------------- /web/scripts/game.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Fido from './fido.js'; 34 | import Galileo from './galileo.js'; 35 | import { fullURL, isConstructible } from './utilities.js'; 36 | import Version from './version.js'; 37 | 38 | export default 39 | class Game 40 | { 41 | static manifest; 42 | static rootPath; 43 | 44 | static async initialize(rootPath) 45 | { 46 | const manifest = await Manifest.fromFile(`${rootPath}/game.sgm`); 47 | 48 | if (manifest.apiVersion < 2) 49 | throw Error(`'${manifest.name}' is a Sphere 1.x game and won't run in Oozaru.`); 50 | if (manifest.apiLevel > Version.apiLevel) 51 | throw Error(`'${manifest.name}' requires API level '${manifest.apiLevel}' or higher.`); 52 | 53 | // API levels prior to level 4 had synchronous file system functions, which doesn't work in a 54 | // browser. let Oozaru launch the game, but log a warning to the console to let the user know 55 | // there might be an issue. 56 | if (manifest.apiLevel < 4) 57 | console.warn(`'${manifest.name}' targets deprecated Sphere API level ${manifest.apiLevel} and may not run correctly. If this is a problem, consider summoning Big Chungus to fix it.`); 58 | 59 | this.manifest = manifest; 60 | this.rootPath = rootPath; 61 | } 62 | 63 | static fullPath(pathName, baseDirName = '@/') 64 | { 65 | // canonicalizing the base path first ensures the first hop will always be a SphereFS prefix. 66 | // this makes things easier below. 67 | if (baseDirName !== '@/') 68 | baseDirName = this.fullPath(`${baseDirName}/`); 69 | 70 | // if `pathName` already starts with a SphereFS prefix, don't rebase it. 71 | const inputPath = /^[@#~$%](?:\\|\/)/.test(pathName) 72 | ? `${pathName}` 73 | : `${baseDirName}/${pathName}`; 74 | 75 | const input = inputPath.split(/[\\/]+/); 76 | if (input[0] === '$') { 77 | // '$/' aliases the directory containing the main module; it's not a root itself. 78 | input.splice(0, 1, ...this.manifest.mainPath.split(/[\\/]+/).slice(0, -1)); 79 | } 80 | const output = [ 81 | input[0], 82 | ]; 83 | for (let i = 1, len = input.length; i < len; ++i) { 84 | if (input[i] === '..') { 85 | if (output.length > 1) { 86 | output.pop(); // collapse it 87 | } 88 | else { 89 | // if collapsing a '../' would navigate past the SphereFS prefix, we've gone too far. 90 | throw RangeError(`SphereFS sandbox violation '${pathName}'`); 91 | } 92 | } 93 | else if (input[i] !== '.') { 94 | output.push(input[i]); 95 | } 96 | } 97 | return output.join('/'); 98 | } 99 | 100 | static async launch() 101 | { 102 | document.title = `${Game.manifest.name} - ${Version.engine}`; 103 | 104 | Galileo.rerez(Game.manifest.resolution.x, Game.manifest.resolution.y); 105 | 106 | // load and execute the game's main module. if it exports a startup 107 | // function or class, call it. 108 | const scriptURL = this.urlOf(this.manifest.mainPath); 109 | const main = await import(fullURL(scriptURL)); 110 | if (isConstructible(main.default)) { 111 | const mainObject = new main.default(); 112 | if (typeof mainObject.start === 'function') 113 | await mainObject.start(); 114 | } 115 | else { 116 | await main.default(); 117 | } 118 | } 119 | 120 | static urlOf(pathName) 121 | { 122 | const hops = pathName.split(/[\\/]+/); 123 | if (hops[0] !== '@' && hops[0] !== '#' && hops[0] !== '~' && hops[0] !== '$' && hops[0] !== '%') 124 | hops.unshift('@'); 125 | if (hops[0] === '@') { 126 | hops.splice(0, 1); 127 | return `${this.rootPath}/${hops.join('/')}`; 128 | } 129 | else if (hops[0] === '#') { 130 | hops.splice(0, 1); 131 | return `assets/${hops.join('/')}`; 132 | } 133 | else { 134 | throw RangeError(`Unsupported SphereFS prefix '${hops[0]}'`); 135 | } 136 | } 137 | } 138 | 139 | export 140 | class Manifest 141 | { 142 | apiLevel; 143 | apiVersion; 144 | author; 145 | description; 146 | mainPath; 147 | name; 148 | resolution = { x: 320, y: 240 }; 149 | saveID = ""; 150 | 151 | static async fromFile(url) 152 | { 153 | const content = await Fido.fetchText(url); 154 | const values = {}; 155 | for (const line of content.split(/\r?\n/)) { 156 | const lineParse = line.match(/(.*)=(.*)/); 157 | if (lineParse && lineParse.length === 3) { 158 | const key = lineParse[1]; 159 | const value = lineParse[2]; 160 | values[key] = value; 161 | } 162 | } 163 | return new this(values); 164 | } 165 | 166 | constructor(values) 167 | { 168 | this.name = values.name ?? "Untitled"; 169 | this.author = values.author ?? "Unknown"; 170 | this.description = values.description ?? ""; 171 | 172 | // `main` field implies Sphere v2, even if no API is specified 173 | this.apiVersion = parseInt(values.version ?? "1", 10); 174 | this.apiLevel = parseInt(values.api ?? "0", 10); 175 | this.mainPath = values.main ?? ""; 176 | if (this.apiLevel > 0 || this.mainPath != "") { 177 | this.apiVersion = Math.max(this.apiVersion, 2); 178 | this.apiLevel = Math.max(this.apiLevel, 1); 179 | } 180 | 181 | if (this.apiVersion >= 2) { 182 | this.saveID = values.saveID ?? ""; 183 | const resString = values.resolution ?? ""; 184 | const resParse = resString.match(/(\d+)x(\d+)/); 185 | if (resParse && resParse.length === 3) { 186 | this.resolution = { 187 | x: parseInt(resParse[1], 10), 188 | y: parseInt(resParse[2], 10), 189 | }; 190 | } 191 | } 192 | else { 193 | this.mainPath = values.script ?? ""; 194 | this.resolution = { 195 | x: parseInt(values.screen_width ?? "320", 10), 196 | y: parseInt(values.screen_height ?? "240", 10), 197 | } 198 | } 199 | 200 | if (this.mainPath === "") 201 | throw Error("No main script is specified in the game manifest."); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /web/runtime/prim.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export default 34 | class Prim extends null 35 | { 36 | static blit(surface, x, y, texture, mask) 37 | { 38 | Prim.blitSection(surface, x, y, texture, 0, 0, texture.width, texture.height, mask); 39 | } 40 | 41 | static blitSection(surface, x, y, texture, sx, sy, width, height, mask = Color.White) 42 | { 43 | let x1 = x; 44 | let y1 = y; 45 | let x2 = x1 + width; 46 | let y2 = y1 + height; 47 | let u1 = sx / texture.width; 48 | let v1 = 1.0 - sy / texture.height; 49 | let u2 = (sx + width) / texture.width; 50 | let v2 = 1.0 - (sy + height) / texture.height; 51 | drawTexturedShape(surface, ShapeType.TriStrip, texture, [ 52 | { x: x1, y: y1, u: u1, v: v1, color: mask }, 53 | { x: x2, y: y1, u: u2, v: v1, color: mask }, 54 | { x: x1, y: y2, u: u1, v: v2, color: mask }, 55 | { x: x2, y: y2, u: u2, v: v2, color: mask }, 56 | ]); 57 | } 58 | 59 | static drawCircle(surface, x, y, radius, color) 60 | { 61 | Prim.drawEllipse(surface, x, y, radius, radius, color); 62 | } 63 | 64 | static drawEllipse(surface, x, y, rx, ry, color) 65 | { 66 | let numSegments = Math.ceil(10 * Math.sqrt((rx + ry) / 2.0)); 67 | let vertices = []; 68 | let tau = 2 * Math.PI; 69 | let cos = Math.cos; 70 | let sin = Math.sin; 71 | for (let i = 0; i < numSegments - 1; ++i) { 72 | let phi = tau * i / numSegments; 73 | let c = cos(phi); 74 | let s = sin(phi); 75 | vertices.push({ 76 | x: x + c * rx, 77 | y: y - s * ry, 78 | color: color, 79 | }); 80 | } 81 | drawShape(surface, ShapeType.LineLoop, vertices); 82 | } 83 | 84 | static drawSolidCircle(surface, x, y, radius, color, color2) 85 | { 86 | Prim.drawSolidEllipse(surface, x, y, radius, radius, color, color2); 87 | } 88 | 89 | static drawSolidEllipse(surface, x, y, rx, ry, color, color2) 90 | { 91 | color2 = color2 || color; 92 | 93 | let numSegments = Math.ceil(10 * Math.sqrt((rx + ry) / 2.0)); 94 | let vertices = [ { x: x, y: y, color: color } ]; 95 | let tau = 2 * Math.PI; 96 | let cos = Math.cos; 97 | let sin = Math.sin; 98 | for (let i = 0; i < numSegments; ++i) { 99 | let phi = tau * i / numSegments; 100 | let c = cos(phi); 101 | let s = sin(phi); 102 | vertices[i + 1] = { 103 | x: x + c * rx, 104 | y: y - s * ry, 105 | color: color2, 106 | }; 107 | } 108 | vertices[numSegments + 1] = { 109 | x: x + rx, // cos(0) = 1.0 110 | y: y, // sin(0) = 0.0 111 | color: color2, 112 | }; 113 | 114 | drawShape(surface, ShapeType.Fan, vertices); 115 | } 116 | 117 | static drawSolidRectangle(surface, x, y, width, height, color_ul, color_ur, color_lr, color_ll) 118 | { 119 | color_ur = color_ur || color_ul; 120 | color_lr = color_lr || color_ul; 121 | color_ll = color_ll || color_ul; 122 | 123 | drawShape(surface, ShapeType.TriStrip, [ 124 | { x: x, y: y, color: color_ul }, 125 | { x: x + width, y: y, color: color_ur }, 126 | { x: x, y: y + height, color: color_ll }, 127 | { x: x + width, y: y + height, color: color_lr }, 128 | ]); 129 | } 130 | 131 | static drawSolidTriangle(surface, x1, y1, x2, y2, x3, y3, color1, color2, color3) 132 | { 133 | color2 = color2 || color1; 134 | color3 = color3 || color1; 135 | 136 | drawShape(surface, ShapeType.Triangles, [ 137 | { x: x1, y: y1, color: color1 }, 138 | { x: x2, y: y2, color: color2 }, 139 | { x: x3, y: y3, color: color3 }, 140 | ]); 141 | } 142 | 143 | static drawLine(surface, x1, y1, x2, y2, thickness, color1, color2) 144 | { 145 | color2 = color2 || color1; 146 | 147 | let xSize = x2 - x1; 148 | let ySize = y2 - y1; 149 | let length = Math.sqrt(xSize * xSize + ySize * ySize); 150 | if (length === 0.0) 151 | return; 152 | let tx = 0.5 * thickness * (y2 - y1) / length; 153 | let ty = 0.5 * thickness * -(x2 - x1) / length; 154 | drawShape(surface, ShapeType.Fan, [ 155 | { x: x1 + tx, y: y1 + ty, color: color1 }, 156 | { x: x1 - tx, y: y1 - ty, color: color1 }, 157 | { x: x2 - tx, y: y2 - ty, color: color2 }, 158 | { x: x2 + tx, y: y2 + ty, color: color2 }, 159 | ]); 160 | } 161 | 162 | static drawPoint(surface, x, y, color) 163 | { 164 | Prim.drawSolidRectangle(surface, x, y, 1, 1, color); 165 | } 166 | 167 | static drawRectangle(surface, x, y, width, height, thickness, color) 168 | { 169 | let t = 0.5 * thickness; 170 | let x1 = x + t; 171 | let y1 = y + t; 172 | let x2 = x1 + width - thickness; 173 | let y2 = y1 + height - thickness; 174 | drawShape(surface, ShapeType.TriStrip, [ 175 | { x: x1 - t, y: y1 - t, color: color }, 176 | { x: x1 + t, y: y1 + t, color: color }, 177 | { x: x2 + t, y: y1 - t, color: color }, 178 | { x: x2 - t, y: y1 + t, color: color }, 179 | { x: x2 + t, y: y2 + t, color: color }, 180 | { x: x2 - t, y: y2 - t, color: color }, 181 | { x: x1 - t, y: y2 + t, color: color }, 182 | { x: x1 + t, y: y2 - t, color: color }, 183 | { x: x1 - t, y: y1 - t, color: color }, 184 | { x: x1 + t, y: y1 + t, color: color }, 185 | ]); 186 | } 187 | 188 | static fill(surface, color) 189 | { 190 | Prim.drawSolidRectangle(surface, 0, 0, surface.width, surface.height, color); 191 | } 192 | } 193 | 194 | function drawShape(surface, type, vertices) 195 | { 196 | if (Sphere.APILevel >= 2) { 197 | Shape.drawImmediate(surface, type, vertices); 198 | } 199 | else { 200 | let vertexList = new VertexList(vertices); 201 | let shape = new Shape(type, vertexList); 202 | shape.draw(surface); 203 | } 204 | } 205 | 206 | function drawTexturedShape(surface, type, texture, vertices) 207 | { 208 | if (Sphere.APILevel >= 2) { 209 | Shape.drawImmediate(surface, type, texture, vertices); 210 | } 211 | else { 212 | let vertexList = new VertexList(vertices); 213 | let shape = new Shape(type, texture, vertexList); 214 | shape.draw(surface); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Oozaru Changelog 2 | ================ 3 | 4 | v0.7.1 - TBD 5 | ------------ 6 | 7 | * Fixes the Oozaru header bar to not show the name of the currently running 8 | game. 9 | 10 | v0.7.0 - December 18, 2024 11 | -------------------------- 12 | 13 | * Adds a `clipOp` parameter to `Surface#clipTo()` that lets games control how 14 | the clipping box is changed by the call. 15 | * Adds a `Surface#unclip()` method for undoing the previous clipping change. 16 | * Removes the dependency on `es-module-shims` in favor of using native import 17 | maps. 18 | 19 | v0.6.3 - December 22, 2023 20 | -------------------------- 21 | 22 | * Fixes several bugs in the `from` module where the key wasn't being passed to 23 | the reducer for object sources. 24 | 25 | v0.6.2 - April 7, 2023 26 | ---------------------- 27 | 28 | * Adds support for the `Color.CosmicLatte` predefined color. 29 | * Renames `Color.EatyPig` to `Color.EatyPink`. 30 | * Fixes a bug that caused texture magnification to be done using nearest- 31 | neighbor scaling instead of linear. 32 | 33 | v0.6.1 - March 30, 2023 34 | ----------------------- 35 | 36 | * Adds the new asynchronous `File` APIs. 37 | * Fixes an issue that caused the engine to run too fast on high-refresh-rate 38 | screens. 39 | * Fixes an issue that caused games that heavily use `Shape.drawImmediate` to 40 | stutter and drop frames due to high GC pressure. 41 | 42 | v0.6.0 - March 23, 2022 43 | ----------------------- 44 | 45 | * Adds support for the `Color.fromRGBA()`, `Color.EatyPig`, and 46 | `Transform#matrix` APIs. 47 | * Adds support for the `DataType.JSON` file reading mode. 48 | * Adds static methods to `Transform` for constructing basic matrices. 49 | * Removes support for the `global` binding. 50 | * Removes support for the `JSON.fromFile()` API. 51 | 52 | 53 | v0.5.1 - January 27, 2022 54 | ------------------------- 55 | 56 | * Adds support for games that use `Task` instead of `Thread`. 57 | * Removes background-loading support for assets and the corresponding APIs 58 | (e.g. `whenReady`) added in the previous release. 59 | 60 | v0.5.0 - January 5, 2022 61 | ------------------------ 62 | 63 | * Adds support for background-loading `Font`, `Shader`, `Sound`, and `Texture` 64 | assets via `new`, along with APIs to check when a background-loaded asset is 65 | ready for use. 66 | * Fixes a bug where HTTP error codes are ignored when fetching files, causing 67 | requests for nonexistent files to succeed instead of throwing an exception. 68 | 69 | 70 | v0.4.2 - December 23, 2021 71 | -------------------------- 72 | 73 | * Adds an overlay notifying the user they have to click to start the engine. 74 | * Adds the ability to click in the rendering area or press a key to start the 75 | engine. 76 | * Fixes an issue that prevented games using `sphere-runtime` from running in 77 | non-Chromium browsers. 78 | 79 | v0.4.1 - December 21, 2021 80 | -------------------------- 81 | 82 | * Fixes a bug where `Font` methods would throw errors when passed non-string 83 | values for `text`. 84 | 85 | v0.4.0 - December 18, 2021 86 | -------------------------- 87 | 88 | * Adds support for loading Sphere v2 games with only an SGM manifest. 89 | * Adds support for launching games targeting API level 3 or under, albeit with 90 | a warning message printed to the console. 91 | * Adds a metadata file, `oozaru.json`, that external tools can use to identify 92 | the engine. 93 | * Improves the file loader to show loading progress for more asset types. 94 | * Renames `BufferStream` to `DataStream` and updates the implementation to 95 | match the one currently used in neoSphere. 96 | 97 | 98 | v0.3.3 - July 6, 2021 99 | --------------------- 100 | 101 | * Adds support for `FS.readFile()` to the Core API implementation. 102 | * Adds `BufferStream` to the Sphere Runtime implementation. 103 | * Removes `DataStream` from the Sphere Runtime implementation. 104 | 105 | v0.3.2 - November 28, 2020 106 | -------------------------- 107 | 108 | * Adds a count of files currently being fetched to the Fido indicator. 109 | * Adds a fallback whereby Oozaru will load a game from `./dist` if no game 110 | index is present on the server. 111 | * Adds a placeholder `Math.random()`-based implementation for the `RNG` class. 112 | 113 | v0.3.1 - November 4, 2020 114 | ------------------------- 115 | 116 | * Adds a cinematic darkening effect for the page background when launching a 117 | game. 118 | * Adds full support for multiple games via a JSON index in `./games`. 119 | * Removes support for loading games from `./dist`. 120 | 121 | v0.3.0 - October 20, 2020 122 | ------------------------- 123 | 124 | * Adds the ability to load a game from `./dist` instead of having the game 125 | index hardcoded into the engine. 126 | * Removes support for synchronous loading of assets: `new Texture(filePath)`, 127 | `new Sound(filePath)`, etc. 128 | 129 | 130 | v0.2.9 - August 12, 2020 131 | ------------------------ 132 | 133 | * Adds support for importing the Sphere Runtime as `/lib/sphere-runtime.js`. 134 | 135 | v0.2.8 - June 11, 2020 136 | ---------------------- 137 | 138 | * Fixes a bug where `SoundStream` playback produces an annoying buzzing noise. 139 | 140 | v0.2.7 - May 28, 2020 141 | --------------------- 142 | 143 | * Fixes a bug where Fido shows incorrect progress for sequential fetches. 144 | * Fixes a bug where text drawn using `Font#drawText` can come out blurry when 145 | drawn at non-integer coordinates. 146 | 147 | v0.2.6 - May 24, 2020 148 | --------------------- 149 | 150 | * Fixes a bug where the Fido progress percentage could jump around erratically 151 | while fetching multiple files. 152 | 153 | v0.2.5 - February 24, 2020 154 | -------------------------- 155 | 156 | * Renames `FileStream.open()` to `FileStream.fromFile()` for API parity with 157 | miniSphere. 158 | * Fixes a bug where `from()` doesn't immediately check whether the source(s) 159 | passed are nullish, leading to more obtuse errors later. 160 | 161 | v0.2.4 - February 19, 2020 162 | -------------------------- 163 | 164 | * Improves performance of `IndexBuffer#upload()` on macOS. 165 | 166 | v0.2.3 - February 15, 2020 167 | -------------------------- 168 | 169 | * Improves performance when switching back to a shader whose previous matrices 170 | haven't changed since it was last active. 171 | * Fixes an issue where rendering performance is suboptimal in all browsers on 172 | macOS. 173 | 174 | v0.2.2 - February 9, 2020 175 | ------------------------- 176 | 177 | * Adds a `start` label under the Sphere logo before a game is launched to 178 | indicate where to click to start a game. 179 | 180 | v0.2.1 - February 8, 2020 181 | ------------------------- 182 | 183 | * Changes the Sphere logo into a Start button to work around browsers disabling 184 | audio playback due to no user interaction. 185 | * Fixes a bug where the Fido progress percentage slowly creeps out of sync with 186 | actual progress as more files are fetched. 187 | 188 | v0.2.0 - February 6, 2020 189 | ------------------------- 190 | 191 | * Adds preliminary support for selecting between multiple games. 192 | 193 | 194 | v0.1.3 - February 3, 2020 195 | ------------------------- 196 | 197 | * Adds support for `JobToken#pause()` and `JobToken#resume()`. 198 | 199 | v0.1.2 - January 29, 2020 200 | ------------------------- 201 | 202 | * Fixes a bug where `Esc` keypresses aren't recognized by the engine. 203 | 204 | v0.1.1 - January 13, 2020 205 | ------------------------- 206 | 207 | * Improves readability of crash messages by word-wrapping the exception text. 208 | * Fixes an issue where an uncaught exception thrown while executing Sphere code 209 | gets caught by the engine and thus may not be intercepted by JS debuggers. 210 | 211 | v0.1.0 - January 5, 2020 212 | ------------------------ 213 | 214 | First official Oozaru release. API parity with miniSphere is passable, but far 215 | from complete. A lot of Sphere v2 code will run as-is, with the caveat that 216 | the browser must support import maps in order to import from `sphere-runtime`. 217 | -------------------------------------------------------------------------------- /web/runtime/scene.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Task from './task.js'; 34 | 35 | let defaultPriority = 0.0; 36 | 37 | export default 38 | class Scene 39 | { 40 | get [Symbol.toStringTag]() { return 'Scene'; } 41 | 42 | static get defaultPriority() 43 | { 44 | return defaultPriority; 45 | } 46 | 47 | static set defaultPriority(value) 48 | { 49 | defaultPriority = value; 50 | } 51 | 52 | static defineOp(name, def) 53 | { 54 | if (name in this.prototype) 55 | throw new Error(`Scene op '${name}' is already defined`); 56 | this.prototype[name] = function (...args) { 57 | this.enqueue({ 58 | arguments: [ ...args ], 59 | start: def.start, 60 | getInput: def.getInput, 61 | update: def.update, 62 | render: def.render, 63 | finish: def.finish, 64 | }); 65 | return this; 66 | }; 67 | } 68 | 69 | constructor(options) 70 | { 71 | options = Object.assign({}, { 72 | inBackground: false, 73 | priority: defaultPriority, 74 | }, options); 75 | 76 | this.threadOptions = options; 77 | this.timeline = new Timeline(this, this.threadOptions); 78 | this.forkStack = []; 79 | this.jumpsToFix = []; 80 | this.openBlockTypes = []; 81 | } 82 | 83 | get running() 84 | { 85 | return this.timeline.running; 86 | } 87 | 88 | doIf(predicate) 89 | { 90 | let timeline = this.timeline; 91 | let jump = { ifFalse: null }; 92 | this.jumpsToFix.push(jump); 93 | let op = { 94 | arguments: [], 95 | start(scene) { 96 | if (predicate.call(scene)) 97 | return; 98 | timeline.goTo(jump.ifFalse); 99 | }, 100 | }; 101 | this.enqueue(op); 102 | this.openBlockTypes.push('branch'); 103 | return this; 104 | } 105 | 106 | doWhile(predicate) 107 | { 108 | let timeline = this.timeline; 109 | let jump = { 110 | loopStart: this.timeline.length, 111 | ifDone: null, 112 | }; 113 | this.jumpsToFix.push(jump); 114 | let op = { 115 | arguments: [], 116 | start(scene) { 117 | if (predicate.call(scene)) 118 | return; 119 | timeline.goTo(jump.ifDone); 120 | }, 121 | }; 122 | this.enqueue(op); 123 | this.openBlockTypes.push('loop'); 124 | return this; 125 | } 126 | 127 | end() 128 | { 129 | if (this.openBlockTypes.length === 0) 130 | throw new Error("extraneous end() in scene definition"); 131 | let blockType = this.openBlockTypes.pop(); 132 | switch (blockType) { 133 | case 'fork': { 134 | let forkedFrom = this.forkStack.pop(); 135 | let op = { 136 | arguments: [ this.timeline ], 137 | start(scene, timeline) { 138 | forkedFrom.children.push(timeline); 139 | timeline.goTo(0); 140 | timeline.start(); 141 | }, 142 | }; 143 | this.timeline = forkedFrom; 144 | this.enqueue(op); 145 | break; 146 | } 147 | case 'branch': { 148 | let jump = this.jumpsToFix.pop(); 149 | jump.ifFalse = this.timeline.length; 150 | break; 151 | } 152 | case 'loop': { 153 | let jump = this.jumpsToFix.pop(); 154 | let op = { 155 | arguments: [ jump, this.timeline ], 156 | start(scene, jump, timeline) { 157 | timeline.goTo(jump.loopStart); 158 | }, 159 | }; 160 | this.enqueue(op); 161 | jump.ifDone = this.timeline.length; 162 | break; 163 | } 164 | } 165 | return this; 166 | } 167 | 168 | enqueue(op) 169 | { 170 | this.timeline.enqueue(op); 171 | return this; 172 | } 173 | 174 | fork() 175 | { 176 | this.forkStack.push(this.timeline); 177 | this.timeline = new Timeline(this, this.threadOptions); 178 | this.openBlockTypes.push('fork'); 179 | return this; 180 | } 181 | 182 | resync() 183 | { 184 | let op = { 185 | arguments: [ this.timeline ], 186 | start(scene, timeline) { 187 | this.forks = timeline.children; 188 | }, 189 | update(scene) { 190 | return this.forks.some(it => it.running); 191 | }, 192 | }; 193 | this.enqueue(op); 194 | return this; 195 | } 196 | 197 | async run() 198 | { 199 | if (this.openBlockTypes.length > 0) 200 | throw new Error("missing end() in scene definition"); 201 | this.timeline.goTo(0); 202 | this.timeline.start(); 203 | return Task.join(this.timeline); 204 | } 205 | 206 | stop() 207 | { 208 | this.timeline.stop(); 209 | } 210 | } 211 | 212 | class Timeline extends Task 213 | { 214 | constructor(scene, threadOptions) 215 | { 216 | super(threadOptions); 217 | 218 | this.children = []; 219 | this.opThread = null; 220 | this.ops = []; 221 | this.pc = 0; 222 | this.scene = scene; 223 | this.threadOptions = threadOptions; 224 | } 225 | 226 | get length() 227 | { 228 | return this.ops.length; 229 | } 230 | 231 | enqueue(op) 232 | { 233 | if (this.running) 234 | throw new Error("Cannot enqueue while scene is running"); 235 | this.ops.push(op); 236 | } 237 | 238 | goTo(pc) 239 | { 240 | this.pc = pc; 241 | } 242 | 243 | stop() 244 | { 245 | if (this.opThread !== null) 246 | this.opThread.stop(); 247 | for (const child of this.children) 248 | child.stop(); 249 | super.stop(); 250 | } 251 | 252 | async on_update() 253 | { 254 | let op = this.ops[this.pc++]; 255 | let thread = new OpThread(this.scene, op, this.threadOptions); 256 | await thread.start(); 257 | await Task.join(thread); 258 | if (this.pc >= this.ops.length) { 259 | await Task.join(...this.children); 260 | this.stop(); 261 | } 262 | } 263 | } 264 | 265 | class OpThread extends Task 266 | { 267 | constructor(scene, op, threadOptions) 268 | { 269 | super(threadOptions); 270 | 271 | this.scene = scene; 272 | this.op = op; 273 | this.context = {}; 274 | } 275 | 276 | async start() 277 | { 278 | await this.op.start.call(this.context, this.scene, ...this.op.arguments); 279 | if (this.op.update !== undefined) { 280 | super.start(); 281 | if (this.op.getInput !== undefined) 282 | this.takeFocus(); 283 | } 284 | else { 285 | if (this.op.finish !== undefined) 286 | await this.op.finish.call(this.context, this.scene); 287 | } 288 | } 289 | 290 | async stop() 291 | { 292 | super.stop(); 293 | if (this.op.finish !== undefined) 294 | await this.op.finish.call(this.context, this.scene); 295 | } 296 | 297 | async on_inputCheck() 298 | { 299 | await this.op.getInput.call(this.context, this.scene); 300 | } 301 | 302 | on_render() 303 | { 304 | if (this.op.render !== undefined) 305 | this.op.render.call(this.context, this.scene); 306 | } 307 | 308 | async on_update() 309 | { 310 | if (!await this.op.update.call(this.context, this.scene)) 311 | await this.stop(); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /web/scripts/data-stream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export 34 | class DataStream 35 | { 36 | #bytes; 37 | #dataView; 38 | #ptr = 0; 39 | #textDecoder = new TextDecoder(); 40 | 41 | constructor(buffer) 42 | { 43 | if (ArrayBuffer.isView(buffer)) 44 | this.#bytes = new Uint8Array(buffer.buffer); 45 | else 46 | this.#bytes = new Uint8Array(buffer); 47 | this.#dataView = new DataView(this.#bytes.buffer); 48 | } 49 | 50 | get atEOF() 51 | { 52 | return this.#ptr >= this.#bytes.length; 53 | } 54 | 55 | get bufferSize() 56 | { 57 | return this.#bytes.length; 58 | } 59 | 60 | get position() 61 | { 62 | return this.#ptr; 63 | } 64 | 65 | set position(value) 66 | { 67 | if (value > this.#bytes.length) 68 | throw RangeError(`Stream position '${value}' is out of range`); 69 | this.#ptr = value; 70 | } 71 | 72 | readBytes(numBytes) 73 | { 74 | if (this.#ptr + numBytes > this.#bytes.length) 75 | throw Error(`Unable to read ${numBytes} bytes from stream`); 76 | const bytes = this.#bytes.slice(this.#ptr, this.#ptr + numBytes); 77 | this.#ptr += numBytes; 78 | return bytes; 79 | } 80 | 81 | readFloat32(littleEndian = false) 82 | { 83 | if (this.#ptr + 4 > this.#bytes.length) 84 | throw Error(`Unable to read 32-bit float from stream`); 85 | const value = this.#dataView.getFloat32(this.#ptr, littleEndian); 86 | this.#ptr += 4; 87 | return value; 88 | } 89 | 90 | readFloat64(littleEndian = false) 91 | { 92 | if (this.#ptr + 8 > this.#bytes.length) 93 | throw Error(`Unable to read 64-bit float from stream`); 94 | const value = this.#dataView.getFloat64(this.#ptr, littleEndian); 95 | this.#ptr += 8; 96 | return value; 97 | } 98 | 99 | readInt8() 100 | { 101 | if (this.#ptr + 1 > this.#bytes.length) 102 | throw Error(`Unable to read 8-bit signed integer from stream`); 103 | return this.#dataView.getInt8(this.#ptr++); 104 | } 105 | 106 | readInt16(littleEndian = false) 107 | { 108 | if (this.#ptr + 2 > this.#bytes.length) 109 | throw Error(`Unable to read 16-bit signed integer from stream`); 110 | const value = this.#dataView.getInt16(this.#ptr, littleEndian); 111 | this.#ptr += 2; 112 | return value; 113 | } 114 | 115 | readInt32(littleEndian = false) 116 | { 117 | if (this.#ptr + 4 > this.#bytes.length) 118 | throw Error(`Unable to read 32-bit signed integer from stream`); 119 | const value = this.#dataView.getInt32(this.#ptr, littleEndian); 120 | this.#ptr += 4; 121 | return value; 122 | } 123 | 124 | readString(numBytes) 125 | { 126 | if (this.#ptr + numBytes > this.#bytes.length) 127 | throw Error(`Unable to read ${numBytes}-byte string from stream`); 128 | const slice = this.#bytes.subarray(this.#ptr, this.#ptr + numBytes); 129 | this.#ptr += numBytes; 130 | return this.#textDecoder.decode(slice); 131 | } 132 | 133 | readStringU8() 134 | { 135 | const length = this.readUint8(); 136 | return this.readString(length); 137 | } 138 | 139 | readStringU16(littleEndian = false) 140 | { 141 | const length = this.readUint16(littleEndian); 142 | return this.readString(length); 143 | } 144 | 145 | readStringU32(littleEndian = false) 146 | { 147 | const length = this.readUint32(littleEndian); 148 | return this.readString(length); 149 | } 150 | 151 | readStruct(manifest) 152 | { 153 | let retval = {}; 154 | for (const key of Object.keys(manifest)) { 155 | const matches = manifest[key].match(/(string|reserve)\/([0-9]*)/); 156 | const valueType = matches !== null ? matches[1] : manifest[key]; 157 | const numBytes = matches !== null ? parseInt(matches[2], 10) : 0; 158 | switch (valueType) { 159 | case 'float32-be': 160 | retval[key] = this.readFloat32(); 161 | break; 162 | case 'float32-le': 163 | retval[key] = this.readFloat32(true); 164 | break; 165 | case 'float64-be': 166 | retval[key] = this.readFloat64(); 167 | break; 168 | case 'float64-le': 169 | retval[key] = this.readFloat64(true); 170 | break; 171 | case 'int8': case 'int8-be': case 'int8-le': 172 | retval[key] = this.readInt8(); 173 | break; 174 | case 'int16-be': 175 | retval[key] = this.readInt16(); 176 | break; 177 | case 'int16-le': 178 | retval[key] = this.readInt16(true); 179 | break; 180 | case 'int32-be': 181 | retval[key] = this.readInt32(); 182 | break; 183 | case 'int32-le': 184 | retval[key] = this.readInt32(true); 185 | break; 186 | case 'reserve': 187 | retval[key] = null; 188 | this.skipAhead(numBytes); 189 | break; 190 | case 'string': 191 | retval[key] = this.readString(numBytes); 192 | break; 193 | case 'string8': case 'string8-be': case 'string8-le': 194 | retval[key] = this.readStringU8(); 195 | break; 196 | case 'string16-be': 197 | retval[key] = this.readStringU16(); 198 | break; 199 | case 'string16-le': 200 | retval[key] = this.readStringU16(true); 201 | break; 202 | case 'string32-be': 203 | retval[key] = this.readStringU32(); 204 | break; 205 | case 'string32-le': 206 | retval[key] = this.readStringU32(true); 207 | break; 208 | case 'uint8': case 'uint8-be': case 'uint8-le': 209 | retval[key] = this.readUint8(); 210 | break; 211 | case 'uint16-be': 212 | retval[key] = this.readUint16(); 213 | break; 214 | case 'uint16-le': 215 | retval[key] = this.readUint16(true); 216 | break; 217 | case 'uint32-be': 218 | retval[key] = this.readUint32(); 219 | break; 220 | case 'uint32-le': 221 | retval[key] = this.readUint32(true); 222 | break; 223 | default: 224 | throw RangeError(`Unknown readStruct() value type '${valueType}'`); 225 | } 226 | } 227 | return retval; 228 | } 229 | 230 | readUint8() 231 | { 232 | if (this.#ptr + 1 > this.#bytes.length) 233 | throw Error(`Unable to read 8-bit unsigned integer from stream`); 234 | return this.#dataView.getUint8(this.#ptr++); 235 | } 236 | 237 | readUint16(littleEndian = false) 238 | { 239 | if (this.#ptr + 2 > this.#bytes.length) 240 | throw Error(`Unable to read 16-bit unsigned integer from stream`); 241 | const value = this.#dataView.getUint16(this.#ptr, littleEndian); 242 | this.#ptr += 2; 243 | return value; 244 | } 245 | 246 | readUint32(littleEndian = false) 247 | { 248 | if (this.#ptr + 4 > this.#bytes.length) 249 | throw Error(`Unable to read 32-bit unsigned integer from stream`); 250 | const value = this.#dataView.getUint32(this.#ptr, littleEndian); 251 | this.#ptr += 4; 252 | return value; 253 | } 254 | 255 | skipAhead(numBytes) 256 | { 257 | if (this.#ptr + numBytes > this.#bytes.length) 258 | throw Error(`Cannot read ${numBytes} bytes from stream`); 259 | this.#ptr += numBytes; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /web/scripts/audialis.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import { Deque } from './deque.js'; 34 | import Game from './game.js'; 35 | 36 | var defaultMixer = null; 37 | 38 | export default 39 | class Audialis 40 | { 41 | static async initialize() 42 | { 43 | defaultMixer = new Mixer(44100, 16, 2); 44 | } 45 | } 46 | 47 | export 48 | class Mixer 49 | { 50 | #audioContext; 51 | #gainNode; 52 | #panNode; 53 | 54 | static get Default() 55 | { 56 | return defaultMixer; 57 | } 58 | 59 | constructor(sampleRate, bits, numChannels = 2) 60 | { 61 | this.#audioContext = new AudioContext({ sampleRate }); 62 | this.#gainNode = this.#audioContext.createGain(); 63 | this.#panNode = this.#audioContext.createStereoPanner(); 64 | this.#gainNode.gain.value = 1.0; 65 | this.#gainNode 66 | .connect(this.#panNode) 67 | .connect(this.#audioContext.destination); 68 | } 69 | 70 | get pan() 71 | { 72 | return this.#panNode.pan.value; 73 | } 74 | 75 | get volume() 76 | { 77 | return this.#gainNode.gain.value; 78 | } 79 | 80 | set pan(value) 81 | { 82 | this.#panNode.pan.value = value; 83 | } 84 | 85 | set volume(value) 86 | { 87 | this.#gainNode.gain.value = value; 88 | } 89 | 90 | attachAudio(audioElement) 91 | { 92 | const audioNode = this.#audioContext.createMediaElementSource(audioElement); 93 | audioNode.connect(this.#gainNode); 94 | return audioNode; 95 | } 96 | 97 | attachScript(numChannels, callback) 98 | { 99 | const node = this.#audioContext.createScriptProcessor(0, 0, numChannels); 100 | node.onaudioprocess = (e) => callback(e.outputBuffer); 101 | node.connect(this.#gainNode); 102 | return node; 103 | } 104 | } 105 | 106 | export 107 | class Sound 108 | { 109 | #audioElement; 110 | #audioNode = null; 111 | #currentMixer = null; 112 | #fileName; 113 | 114 | static async fromFile(fileName) 115 | { 116 | const url = Game.urlOf(fileName); 117 | const audioElement = new Audio(); 118 | await new Promise((resolve, reject) => { 119 | audioElement.onloadedmetadata = () => { 120 | resolve(); 121 | } 122 | audioElement.onerror = () => { 123 | reject(Error(`Couldn't load audio file '${url}'.`)); 124 | }; 125 | audioElement.src = url; 126 | }); 127 | const sound = new Sound(audioElement); 128 | sound.#fileName = Game.fullPath(fileName); 129 | return sound; 130 | } 131 | 132 | constructor(source) 133 | { 134 | if (source instanceof HTMLAudioElement) { 135 | this.#audioElement = source; 136 | this.#audioElement.loop = true; 137 | } 138 | else if (typeof source === 'string') { 139 | throw Error("'new Sound' from filename is not supported"); 140 | } 141 | else { 142 | throw TypeError(`Invalid value '${source}' passed for 'Sound' source`); 143 | } 144 | } 145 | 146 | get fileName() 147 | { 148 | return this.#fileName; 149 | } 150 | 151 | get length() 152 | { 153 | return this.#audioElement.duration; 154 | } 155 | 156 | get playing() 157 | { 158 | return !this.#audioElement.paused; 159 | } 160 | 161 | get position() 162 | { 163 | return this.#audioElement.currentTime; 164 | } 165 | 166 | get repeat() 167 | { 168 | return this.#audioElement.loop; 169 | } 170 | 171 | get speed() 172 | { 173 | return this.#audioElement.playbackRate; 174 | } 175 | 176 | get volume() 177 | { 178 | return this.#audioElement.volume; 179 | } 180 | 181 | set position(value) 182 | { 183 | this.#audioElement.currentTime = value; 184 | } 185 | 186 | set repeat(value) 187 | { 188 | this.#audioElement.loop = value; 189 | } 190 | 191 | set speed(value) 192 | { 193 | this.#audioElement.playbackRate = value; 194 | } 195 | 196 | set volume(value) 197 | { 198 | this.#audioElement.volume = value; 199 | } 200 | 201 | pause() 202 | { 203 | this.#audioElement.pause(); 204 | } 205 | 206 | play(mixer = Mixer.Default) 207 | { 208 | if (mixer !== this.#currentMixer) { 209 | this.#currentMixer = mixer; 210 | if (this.#audioNode !== null) 211 | this.#audioNode.disconnect(); 212 | this.#audioNode = mixer.attachAudio(this.#audioElement); 213 | } 214 | this.#audioElement.play(); 215 | } 216 | 217 | stop() 218 | { 219 | this.#audioElement.pause(); 220 | this.#audioElement.currentTime = 0.0; 221 | } 222 | } 223 | 224 | export 225 | class SoundStream 226 | { 227 | #audioNode = null; 228 | #buffers = new Deque(); 229 | #currentMixer = null; 230 | #inputPtr = 0.0; 231 | #numChannels; 232 | #paused = true; 233 | #sampleRate; 234 | #timeBuffered = 0.0; 235 | 236 | constructor(frequency = 22050, bits = 8, numChannels = 1) 237 | { 238 | if (bits != 32) 239 | throw RangeError("SoundStream bit depth must be 32-bit under Oozaru"); 240 | this.#numChannels = numChannels; 241 | this.#sampleRate = frequency; 242 | } 243 | 244 | get length() 245 | { 246 | return this.#timeBuffered; 247 | } 248 | 249 | pause() 250 | { 251 | this.#paused = true; 252 | } 253 | 254 | play(mixer = Mixer.Default) 255 | { 256 | this.#paused = false; 257 | if (mixer !== this.#currentMixer) { 258 | if (this.#audioNode !== null) 259 | this.#audioNode.disconnect(); 260 | this.#audioNode = mixer.attachScript(this.#numChannels, (buffer) => { 261 | const outputs = []; 262 | for (let i = 0; i < this.#numChannels; ++i) 263 | outputs[i] = buffer.getChannelData(i); 264 | if (this.#paused || this.#timeBuffered < buffer.duration) { 265 | // not enough data buffered or stream is paused, fill with silence 266 | for (let i = 0; i < this.#numChannels; ++i) 267 | outputs[i].fill(0.0); 268 | return; 269 | } 270 | this.#timeBuffered -= buffer.duration; 271 | if (this.#timeBuffered < 0.0) 272 | this.#timeBuffered = 0.0; 273 | const step = this.#sampleRate / buffer.sampleRate; 274 | let input = this.#buffers.first; 275 | let inputPtr = this.#inputPtr; 276 | for (let i = 0, len = outputs[0].length; i < len; ++i) { 277 | const t1 = Math.floor(inputPtr) * this.#numChannels; 278 | let t2 = t1 + this.#numChannels; 279 | const frac = inputPtr % 1.0; 280 | 281 | // FIXME: if `t2` is past the end of the buffer, the first sample from the 282 | // NEXT buffer should be used, but actually doing that requires some 283 | // reorganization, so just skip the interpolation for now. 284 | if (t2 >= input.length) 285 | t2 = t1; 286 | 287 | for (let j = 0; j < this.#numChannels; ++j) { 288 | const a = input[t1 + j]; 289 | const b = input[t2 + j]; 290 | outputs[j][i] = a + frac * (b - a); 291 | } 292 | inputPtr += step; 293 | if (inputPtr >= Math.floor(input.length / this.#numChannels)) { 294 | this.#buffers.shift(); 295 | if (!this.#buffers.empty) { 296 | inputPtr -= Math.floor(input.length / this.#numChannels); 297 | input = this.#buffers.first; 298 | } 299 | else { 300 | // no more data, fill the rest with silence and return 301 | for (let j = 0; j < this.#numChannels; ++j) 302 | outputs[j].fill(0.0, i + 1); 303 | return; 304 | } 305 | } 306 | } 307 | this.#inputPtr = inputPtr; 308 | }); 309 | this.#currentMixer = mixer; 310 | } 311 | } 312 | 313 | stop() 314 | { 315 | if (this.#audioNode !== null) 316 | this.#audioNode.disconnect(); 317 | this.#buffers.clear(); 318 | this.#inputPtr = 0.0; 319 | this.#currentMixer = null; 320 | this.#audioNode = null; 321 | this.#paused = true; 322 | this.#timeBuffered = 0.0; 323 | } 324 | 325 | write(data) 326 | { 327 | this.#buffers.push(data); 328 | this.#timeBuffered += data.length / (this.#sampleRate * this.#numChannels); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /web/scripts/pegasus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import { Mixer, Sound, SoundStream } from './audialis.js'; 34 | import { DataStream } from './data-stream.js'; 35 | import Fido from './fido.js'; 36 | import { Font } from './fontso.js'; 37 | import Game from './game.js'; 38 | import Galileo, { BlendOp, Color, DepthOp, IndexList, Model, Shader, Shape, ShapeType, Surface, Texture, Transform, VertexList } from './galileo.js'; 39 | import InputEngine, { Joystick, Key, Keyboard, Mouse, MouseKey } from './input-engine.js'; 40 | import JobQueue, { Dispatch, JobToken, JobType } from './job-queue.js'; 41 | import Version from './version.js'; 42 | 43 | const DataType = 44 | { 45 | Bytes: 0, 46 | JSON: 1, 47 | Lines: 2, 48 | Raw: 3, 49 | Text: 4, 50 | }; 51 | 52 | const FileOp = 53 | { 54 | Read: 0, 55 | Update: 1, 56 | Write: 2, 57 | }; 58 | 59 | const console = globalThis.console; 60 | 61 | var mainObject; 62 | 63 | export default 64 | class Pegasus 65 | { 66 | static initialize() 67 | { 68 | // register Sphere v2 API globals 69 | Object.assign(globalThis, { 70 | // enumerations 71 | BlendOp, 72 | DataType, 73 | DepthOp, 74 | FileOp, 75 | JobType, 76 | Key, 77 | MouseKey, 78 | ShapeType, 79 | 80 | // classes and namespaces 81 | Sphere, 82 | Color, 83 | Dispatch, 84 | FS, 85 | File, 86 | FileStream, 87 | Font, 88 | IndexList, 89 | JobToken, 90 | Joystick, 91 | Keyboard, 92 | Mixer, 93 | Model, 94 | Mouse, 95 | SSj, 96 | Shader, 97 | Shape, 98 | Sound, 99 | SoundStream, 100 | Surface, 101 | Texture, 102 | Transform, 103 | VertexList, 104 | }); 105 | } 106 | 107 | static async launchGame(rootPath) 108 | { 109 | // load the game's JSON manifest 110 | await Game.initialize(rootPath); 111 | 112 | Dispatch.onRender(() => { 113 | if (Fido.numJobs === 0) 114 | return; 115 | const status = Fido.progress < 1.0 116 | ? `${Math.floor(100.0 * Fido.progress)}% - ${Fido.numJobs} files` 117 | : `loading ${Fido.numJobs} files`; 118 | const textSize = Font.Default.getTextSize(status); 119 | const x = Surface.Screen.width - textSize.width - 5; 120 | const y = Surface.Screen.height - textSize.height - 5; 121 | Font.Default.drawText(Surface.Screen, x + 1, y + 1, status, Color.Black); 122 | Font.Default.drawText(Surface.Screen, x, y, status, Color.Silver); 123 | }, { 124 | inBackground: true, 125 | priority: Infinity, 126 | }); 127 | 128 | // start the Sphere v2 event loop 129 | JobQueue.start(); 130 | 131 | await Game.launch(); 132 | } 133 | } 134 | 135 | class Sphere 136 | { 137 | static get APILevel() 138 | { 139 | return Version.apiLevel; 140 | } 141 | 142 | static get Compiler() 143 | { 144 | return undefined; 145 | } 146 | 147 | static get Engine() 148 | { 149 | return `${Version.engine} ${Version.version}`; 150 | } 151 | 152 | static get Game() 153 | { 154 | return Game.manifest; 155 | } 156 | 157 | static get Version() 158 | { 159 | return Version.apiVersion; 160 | } 161 | 162 | static get frameRate() 163 | { 164 | return 60; 165 | } 166 | 167 | static get frameSkip() 168 | { 169 | return 0; 170 | } 171 | 172 | static get fullScreen() 173 | { 174 | return false; 175 | } 176 | 177 | static set frameRate(value) 178 | { 179 | throw Error(`'Sphere.frameRate' cannot be set in Oozaru`); 180 | } 181 | 182 | static set frameSkip(value) 183 | { 184 | throw Error(`'Sphere.frameSkip' cannot be set in Oozaru`); 185 | } 186 | 187 | static set fullScreen(value) 188 | { 189 | if (value !== false) 190 | throw Error(`Full-screen mode is not implemented`); 191 | } 192 | 193 | static get main() 194 | { 195 | return mainObject; 196 | } 197 | 198 | static now() 199 | { 200 | return JobQueue.now(); 201 | } 202 | 203 | static sleep(numFrames) 204 | { 205 | return new Promise((resolve) => { 206 | Dispatch.later(numFrames, resolve); 207 | }); 208 | } 209 | 210 | static setResolution(width, height) 211 | { 212 | Galileo.rerez(width, height); 213 | } 214 | } 215 | 216 | class FS 217 | { 218 | static fullPath(pathName, baseDirName) 219 | { 220 | return Game.fullPath(pathName, baseDirName); 221 | } 222 | } 223 | 224 | class File 225 | { 226 | static async exists(fileName) 227 | { 228 | throw Error(`'File.exists' is not implemented`); 229 | } 230 | 231 | static async load(fileName, dataType = DataType.Text) 232 | { 233 | const url = Game.urlOf(fileName); 234 | switch (dataType) { 235 | case DataType.Bytes: 236 | const data = await Fido.fetchData(url); 237 | return new Uint8Array(data); 238 | case DataType.JSON: 239 | return Fido.fetchJSON(url); 240 | case DataType.Lines: 241 | const text = await Fido.fetchText(url); 242 | return text.split(/\r?\n/); 243 | case DataType.Raw: 244 | return Fido.fetchData(url); 245 | case DataType.Text: 246 | return Fido.fetchText(url); 247 | } 248 | } 249 | 250 | static async remove(fileName) 251 | { 252 | throw Error(`'File.remove' is not implemented`); 253 | } 254 | 255 | static async rename(fileName, newFileName) 256 | { 257 | throw Error(`'File.rename' is not implemented`) 258 | } 259 | 260 | static async run(fileName) 261 | { 262 | const url = Game.urlOf(fileName); 263 | return new Promise((resolve, reject) => { 264 | const script = document.createElement('script'); 265 | script.onload = () => { 266 | resolve(); 267 | script.remove(); 268 | } 269 | script.onerror = () => { 270 | reject(Error(`Couldn't load JS script '${url}'`)); 271 | script.remove(); 272 | } 273 | script.src = url; 274 | document.head.appendChild(script); 275 | }); 276 | } 277 | 278 | static async save(fileName, data) 279 | { 280 | throw Error(`'File.save' is not implemented`); 281 | } 282 | } 283 | 284 | class FileStream 285 | { 286 | #dataStream; 287 | #fullPath; 288 | 289 | static async fromFile(fileName, fileOp) 290 | { 291 | if (fileOp !== FileOp.Read) 292 | throw RangeError(`Opening files in write mode is not yet supported`); 293 | 294 | const url = Game.urlOf(fileName); 295 | const data = await Fido.fetchData(url); 296 | const fileStream = Object.create(this.prototype); 297 | fileStream.#fullPath = fileName; 298 | fileStream.#dataStream = new DataStream(data); 299 | return fileStream; 300 | } 301 | 302 | constructor() 303 | { 304 | throw RangeError(`'new FileStream()' is not supported`); 305 | } 306 | 307 | get fileName() 308 | { 309 | return this.#fullPath; 310 | } 311 | 312 | get fileSize() 313 | { 314 | if (this.#dataStream === null) 315 | throw Error(`The FileStream has already been disposed`); 316 | return this.#dataStream.bufferSize; 317 | } 318 | 319 | get position() 320 | { 321 | if (this.#dataStream === null) 322 | throw Error(`The FileStream has already been disposed`); 323 | return this.#dataStream.position; 324 | } 325 | 326 | set position(value) 327 | { 328 | if (this.#dataStream === null) 329 | throw Error(`The FileStream has already been disposed`); 330 | this.#dataStream.position = value; 331 | } 332 | 333 | dispose() 334 | { 335 | this.#dataStream = null; 336 | } 337 | 338 | read(numBytes) 339 | { 340 | if (this.#dataStream === null) 341 | throw Error(`The FileStream has already been disposed`); 342 | return this.#dataStream.readBytes(numBytes).buffer; 343 | } 344 | 345 | write(data) 346 | { 347 | if (this.#dataStream === null) 348 | throw Error(`The FileStream has already been disposed`); 349 | throw Error(`'FileStream#write' is not yet implemented.`); 350 | } 351 | } 352 | 353 | class SSj 354 | { 355 | static log(object) 356 | { 357 | console.log(object); 358 | } 359 | 360 | static now() 361 | { 362 | return performance.now() / 1000.0; 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /web/runtime/data-stream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export default 34 | class DataStream 35 | { 36 | static async fromFile(fileName) 37 | { 38 | const buffer = Sphere.APILevel >= 4 39 | ? await File.load(fileName, DataType.Raw) 40 | : FS.readFile(fileName, DataType.Raw); 41 | return new DataStream(buffer); 42 | } 43 | 44 | constructor(buffer) 45 | { 46 | this.textDec = new TextDecoder(); 47 | if (ArrayBuffer.isView(buffer)) 48 | this.bytes = new Uint8Array(buffer.buffer); 49 | else 50 | this.bytes = new Uint8Array(buffer); 51 | this.view = new DataView(this.bytes.buffer); 52 | this.ptr = 0; 53 | } 54 | 55 | get atEOF() 56 | { 57 | return this.ptr >= this.bytes.length; 58 | } 59 | 60 | get bufferSize() 61 | { 62 | return this.bytes.length; 63 | } 64 | 65 | get position() 66 | { 67 | return this.ptr; 68 | } 69 | 70 | set position(value) 71 | { 72 | if (value > this.bytes.length) 73 | throw RangeError(`Stream position '${value}' is out of range`); 74 | this.ptr = value; 75 | } 76 | 77 | readBytes(numBytes) 78 | { 79 | if (this.ptr + numBytes > this.bytes.length) 80 | throw Error(`Unable to read ${numBytes} bytes from stream`); 81 | const bytes = this.bytes.slice(this.ptr, this.ptr + numBytes); 82 | this.ptr += numBytes; 83 | return bytes; 84 | } 85 | 86 | readFloat32(littleEndian = false) 87 | { 88 | if (this.ptr + 4 > this.bytes.length) 89 | throw Error(`Unable to read 32-bit float from stream`); 90 | const value = this.view.getFloat32(this.ptr, littleEndian); 91 | this.ptr += 4; 92 | return value; 93 | } 94 | 95 | readFloat64(littleEndian = false) 96 | { 97 | if (this.ptr + 8 > this.bytes.length) 98 | throw Error(`Unable to read 64-bit float from stream`); 99 | const value = this.view.getFloat64(this.ptr, littleEndian); 100 | this.ptr += 8; 101 | return value; 102 | } 103 | 104 | readInt8() 105 | { 106 | if (this.ptr + 1 > this.bytes.length) 107 | throw Error(`Unable to read 8-bit signed integer from stream`); 108 | return this.view.getInt8(this.ptr++); 109 | } 110 | 111 | readInt16(littleEndian = false) 112 | { 113 | if (this.ptr + 2 > this.bytes.length) 114 | throw Error(`Unable to read 16-bit signed integer from stream`); 115 | const value = this.view.getInt16(this.ptr, littleEndian); 116 | this.ptr += 2; 117 | return value; 118 | } 119 | 120 | readInt32(littleEndian = false) 121 | { 122 | if (this.ptr + 4 > this.bytes.length) 123 | throw Error(`Unable to read 32-bit signed integer from stream`); 124 | const value = this.view.getInt32(this.ptr, littleEndian); 125 | this.ptr += 4; 126 | return value; 127 | } 128 | 129 | readString(numBytes, stripNUL = false) 130 | { 131 | if (this.ptr + numBytes > this.bytes.length) 132 | throw Error(`Unable to read ${numBytes}-byte string from stream`); 133 | const slice = this.bytes.subarray(this.ptr, this.ptr + numBytes); 134 | let value = this.textDec.decode(slice); 135 | if (stripNUL && value.endsWith('\0')) 136 | value = value.slice(0, -1); 137 | this.ptr += numBytes; 138 | return value; 139 | } 140 | 141 | readLPStr8(stripNUL = false) 142 | { 143 | const length = this.readUint8(); 144 | return this.readString(length, stripNUL); 145 | } 146 | 147 | readLPStr16(littleEndian = false, stripNUL = false) 148 | { 149 | const length = this.readUint16(littleEndian); 150 | return this.readString(length, stripNUL); 151 | } 152 | 153 | readLPStr32(littleEndian = false, stripNUL = false) 154 | { 155 | const length = this.readUint32(littleEndian); 156 | return this.readString(length, stripNUL); 157 | } 158 | 159 | readStruct(manifest) 160 | { 161 | let retval = {}; 162 | for (const key of Object.keys(manifest)) { 163 | const matches = manifest[key].match(/(string|cstring|raw|nil)\/([0-9]*)/); 164 | const valueType = matches !== null ? matches[1] : manifest[key]; 165 | const numBytes = matches !== null ? parseInt(matches[2], 10) : 0; 166 | switch (valueType) { 167 | case 'bool': 168 | retval[key] = this.readUint8() !== 0; 169 | case 'cstring': 170 | retval[key] = this.readString(numBytes, true); 171 | break; 172 | case 'cstr8': case 'cstr8-be': case 'cstr8-le': 173 | retval[key] = this.readLPStr8(true); 174 | break; 175 | case 'cstr16-be': 176 | retval[key] = this.readLPStr16(false, true); 177 | break; 178 | case 'cstr16-le': 179 | retval[key] = this.readLPStr16(true, true); 180 | break; 181 | case 'cstr32-be': 182 | retval[key] = this.readLPStr32(false, true); 183 | break; 184 | case 'cstr32-le': 185 | retval[key] = this.readLPStr32(true, true); 186 | break; 187 | case 'float32-be': 188 | retval[key] = this.readFloat32(); 189 | break; 190 | case 'float32-le': 191 | retval[key] = this.readFloat32(true); 192 | break; 193 | case 'float64-be': 194 | retval[key] = this.readFloat64(); 195 | break; 196 | case 'float64-le': 197 | retval[key] = this.readFloat64(true); 198 | break; 199 | case 'int8': case 'int8-be': case 'int8-le': 200 | retval[key] = this.readInt8(); 201 | break; 202 | case 'int16-be': 203 | retval[key] = this.readInt16(); 204 | break; 205 | case 'int16-le': 206 | retval[key] = this.readInt16(true); 207 | break; 208 | case 'int32-be': 209 | retval[key] = this.readInt32(); 210 | break; 211 | case 'int32-le': 212 | retval[key] = this.readInt32(true); 213 | break; 214 | case 'lpstr8': case 'lpstr8-be': case 'lpstr8-le': 215 | retval[key] = this.readLPStr8(); 216 | break; 217 | case 'lpstr16-be': 218 | retval[key] = this.readLPStr16(); 219 | break; 220 | case 'lpstr16-le': 221 | retval[key] = this.readLPStr16(true); 222 | break; 223 | case 'lpstr32-be': 224 | retval[key] = this.readLPStr32(); 225 | break; 226 | case 'lpstr32-le': 227 | retval[key] = this.readLPStr32(true); 228 | break; 229 | case 'nil': 230 | retval[key] = null; 231 | this.skipAhead(numBytes); 232 | break; 233 | case 'raw': 234 | retval[key] = this.readBytes(numBytes).buffer; 235 | break; 236 | case 'string': 237 | retval[key] = this.readString(numBytes); 238 | break; 239 | case 'uint8': case 'uint8-be': case 'uint8-le': 240 | retval[key] = this.readUint8(); 241 | break; 242 | case 'uint16-be': 243 | retval[key] = this.readUint16(); 244 | break; 245 | case 'uint16-le': 246 | retval[key] = this.readUint16(true); 247 | break; 248 | case 'uint32-be': 249 | retval[key] = this.readUint32(); 250 | break; 251 | case 'uint32-le': 252 | retval[key] = this.readUint32(true); 253 | break; 254 | default: 255 | throw RangeError(`Unknown readStruct() value type '${valueType}'`); 256 | } 257 | } 258 | return retval; 259 | } 260 | 261 | readUint8() 262 | { 263 | if (this.ptr + 1 > this.bytes.length) 264 | throw Error(`Unable to read 8-bit unsigned integer from stream`); 265 | return this.view.getUint8(this.ptr++); 266 | } 267 | 268 | readUint16(littleEndian = false) 269 | { 270 | if (this.ptr + 2 > this.bytes.length) 271 | throw Error(`Unable to read 16-bit unsigned integer from stream`); 272 | const value = this.view.getUint16(this.ptr, littleEndian); 273 | this.ptr += 2; 274 | return value; 275 | } 276 | 277 | readUint32(littleEndian = false) 278 | { 279 | if (this.ptr + 4 > this.bytes.length) 280 | throw Error(`Unable to read 32-bit unsigned integer from stream`); 281 | const value = this.view.getUint32(this.ptr, littleEndian); 282 | this.ptr += 4; 283 | return value; 284 | } 285 | 286 | skipAhead(numBytes) 287 | { 288 | if (this.ptr + numBytes > this.bytes.length) 289 | throw Error(`Cannot read ${numBytes} bytes from stream`); 290 | this.ptr += numBytes; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /web/scripts/fontso.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import { DataStream } from './data-stream.js'; 34 | import Fido from './fido.js'; 35 | import { Color, Shape, ShapeType, Texture } from './galileo.js'; 36 | import Game from './game.js'; 37 | 38 | var defaultFont; 39 | 40 | export default 41 | class Fontso 42 | { 43 | static async initialize() 44 | { 45 | defaultFont = await Font.fromFile('#/default.rfn'); 46 | } 47 | } 48 | 49 | export 50 | class Font 51 | { 52 | #fileName; 53 | #glyphAtlas; 54 | #glyphData = []; 55 | #lineHeight = 0; 56 | #maxWidth = 0; 57 | #numGlyphs = 0; 58 | #stride; 59 | 60 | static get Default() 61 | { 62 | return defaultFont; 63 | } 64 | 65 | static async fromFile(fileName) 66 | { 67 | const fontURL = Game.urlOf(fileName); 68 | const fileData = await Fido.fetchData(fontURL); 69 | const font = new Font(fileData); 70 | font.#fileName = Game.fullPath(fileName); 71 | return font; 72 | } 73 | 74 | constructor(...args) 75 | { 76 | if (typeof args[0] === 'string') { 77 | throw Error("'new Font' from filename is not supported"); 78 | } 79 | else if (args[0] instanceof ArrayBuffer) { 80 | let dataStream = new DataStream(args[0]); 81 | let rfnHeader = dataStream.readStruct({ 82 | signature: 'string/4', 83 | version: 'uint16-le', 84 | numGlyphs: 'uint16-le', 85 | reserved: 'reserve/248', 86 | }); 87 | if (rfnHeader.signature !== '.rfn') 88 | throw Error(`Unable to load RFN font file`); 89 | if (rfnHeader.version < 2 || rfnHeader.version > 2) 90 | throw Error(`Unsupported RFN version '${rfnHeader.version}'`); 91 | if (rfnHeader.numGlyphs <= 0) 92 | throw Error(`Malformed RFN font (no glyphs)`); 93 | const numAcross = Math.ceil(Math.sqrt(rfnHeader.numGlyphs)); 94 | this.#stride = 1.0 / numAcross; 95 | for (let i = 0; i < rfnHeader.numGlyphs; ++i) { 96 | let charInfo = dataStream.readStruct({ 97 | width: 'uint16-le', 98 | height: 'uint16-le', 99 | reserved: 'reserve/28', 100 | }); 101 | this.#lineHeight = Math.max(this.#lineHeight, charInfo.height); 102 | this.#maxWidth = Math.max(this.#maxWidth, charInfo.width); 103 | const pixelData = dataStream.readBytes(charInfo.width * charInfo.height * 4); 104 | this.#glyphData.push({ 105 | width: charInfo.width, 106 | height: charInfo.height, 107 | u: i % numAcross / numAcross, 108 | v: 1.0 - Math.floor(i / numAcross) / numAcross, 109 | pixelData, 110 | }); 111 | } 112 | this.#glyphAtlas = new Texture(numAcross * this.#maxWidth, numAcross * this.#lineHeight); 113 | this.#numGlyphs = rfnHeader.numGlyphs; 114 | for (let i = 0; i < this.#numGlyphs; ++i) { 115 | const glyph = this.#glyphData[i]; 116 | const x = i % numAcross * this.#maxWidth; 117 | const y = Math.floor(i / numAcross) * this.#lineHeight; 118 | this.#glyphAtlas.upload(glyph.pixelData, x, y, glyph.width, glyph.height); 119 | } 120 | } 121 | else { 122 | throw RangeError("Invalid argument(s) passed to 'new Font'."); 123 | } 124 | } 125 | 126 | get fileName() 127 | { 128 | return this.#fileName; 129 | } 130 | 131 | get height() 132 | { 133 | return this.#lineHeight; 134 | } 135 | 136 | drawText(surface, x, y, text, color = Color.White, wrapWidth) 137 | { 138 | text = text.toString(); 139 | const lines = wrapWidth !== undefined 140 | ? this.wordWrap(text, wrapWidth) 141 | : [ text ]; 142 | for (let i = 0, len = lines.length; i < len; ++i) 143 | this.#renderString(surface, x, y + i * this.#lineHeight, lines[i], color); 144 | } 145 | 146 | getTextSize(text, wrapWidth) 147 | { 148 | text = text.toString(); 149 | if (wrapWidth !== undefined) { 150 | const lines = this.wordWrap(text, wrapWidth); 151 | return { 152 | width: wrapWidth, 153 | height: lines.length * this.#lineHeight, 154 | }; 155 | } 156 | else { 157 | return { 158 | width: this.widthOf(text), 159 | height: this.#lineHeight, 160 | }; 161 | } 162 | } 163 | 164 | heightOf(text, wrapWidth) 165 | { 166 | return this.getTextSize(text, wrapWidth).height; 167 | } 168 | 169 | widthOf(text) 170 | { 171 | text = text.toString(); 172 | let cp; 173 | let ptr = 0; 174 | let width = 0; 175 | while ((cp = text.codePointAt(ptr++)) !== undefined) { 176 | if (cp > 0xFFFF) // surrogate pair? 177 | ++ptr; 178 | cp = toCP1252(cp); 179 | if (cp >= this.#numGlyphs) 180 | cp = 0x1A; 181 | width += this.#glyphData[cp].width; 182 | } 183 | return width; 184 | } 185 | 186 | wordWrap(text, wrapWidth) 187 | { 188 | text = text.toString(); 189 | const lines = []; 190 | const codepoints = []; 191 | let currentLine = ""; 192 | let lineWidth = 0; 193 | let lineFinished = false; 194 | let wordWidth = 0; 195 | let wordFinished = false; 196 | let cp; 197 | let ptr = 0; 198 | while ((cp = text.codePointAt(ptr++)) !== undefined) { 199 | if (cp > 0xFFFF) // surrogate pair? 200 | ++ptr; 201 | cp = toCP1252(cp); 202 | if (cp >= this.#numGlyphs) 203 | cp = 0x1A; 204 | const glyph = this.#glyphData[cp]; 205 | switch (cp) { 206 | case 13: case 10: // newline 207 | if (cp === 13 && text.codePointAt(ptr) == 10) 208 | ++ptr; // treat CRLF as a single newline 209 | lineFinished = true; 210 | break; 211 | case 8: // tab 212 | codepoints.push(cp); 213 | wordWidth += this.#glyphData[32].width * 3; 214 | wordFinished = true; 215 | break; 216 | case 32: // space 217 | codepoints.push(cp); 218 | wordWidth += glyph.width; 219 | wordFinished = true; 220 | break; 221 | default: 222 | codepoints.push(cp); 223 | wordWidth += glyph.width; 224 | break; 225 | } 226 | if (wordFinished || lineFinished) { 227 | currentLine += String.fromCodePoint(...codepoints); 228 | lineWidth += wordWidth; 229 | codepoints.length = 0; 230 | wordWidth = 0; 231 | wordFinished = false; 232 | } 233 | if (lineWidth + wordWidth > wrapWidth || lineFinished) { 234 | lines.push(currentLine); 235 | currentLine = ""; 236 | lineWidth = 0; 237 | lineFinished = false; 238 | } 239 | } 240 | currentLine += String.fromCodePoint(...codepoints); 241 | if (currentLine !== "") 242 | lines.push(currentLine); 243 | return lines; 244 | } 245 | 246 | #renderString(surface, x, y, text, color) 247 | { 248 | x = Math.trunc(x); 249 | y = Math.trunc(y); 250 | if (text === "") 251 | return; // empty string, nothing to render 252 | let cp; 253 | let ptr = 0; 254 | let xOffset = 0; 255 | const vertices = []; 256 | while ((cp = text.codePointAt(ptr++)) !== undefined) { 257 | if (cp > 0xFFFF) // surrogate pair? 258 | ++ptr; 259 | cp = toCP1252(cp); 260 | if (cp >= this.#numGlyphs) 261 | cp = 0x1A; 262 | const glyph = this.#glyphData[cp]; 263 | const x1 = x + xOffset, x2 = x1 + glyph.width; 264 | const y1 = y, y2 = y1 + glyph.height; 265 | const u1 = glyph.u; 266 | const u2 = u1 + glyph.width / this.#maxWidth * this.#stride; 267 | const v1 = glyph.v; 268 | const v2 = v1 - glyph.height / this.#lineHeight * this.#stride; 269 | vertices.push( 270 | { x: x1, y: y1, u: u1, v: v1, color }, 271 | { x: x2, y: y1, u: u2, v: v1, color }, 272 | { x: x1, y: y2, u: u1, v: v2, color }, 273 | { x: x2, y: y1, u: u2, v: v1, color }, 274 | { x: x1, y: y2, u: u1, v: v2, color }, 275 | { x: x2, y: y2, u: u2, v: v2, color }, 276 | ); 277 | xOffset += glyph.width; 278 | } 279 | Shape.drawImmediate(surface, ShapeType.Triangles, this.#glyphAtlas, vertices); 280 | } 281 | } 282 | 283 | function toCP1252(codepoint) 284 | { 285 | return codepoint == 0x20AC ? 128 286 | : codepoint == 0x201A ? 130 287 | : codepoint == 0x0192 ? 131 288 | : codepoint == 0x201E ? 132 289 | : codepoint == 0x2026 ? 133 290 | : codepoint == 0x2020 ? 134 291 | : codepoint == 0x2021 ? 135 292 | : codepoint == 0x02C6 ? 136 293 | : codepoint == 0x2030 ? 137 294 | : codepoint == 0x0160 ? 138 295 | : codepoint == 0x2039 ? 139 296 | : codepoint == 0x0152 ? 140 297 | : codepoint == 0x017D ? 142 298 | : codepoint == 0x2018 ? 145 299 | : codepoint == 0x2019 ? 146 300 | : codepoint == 0x201C ? 147 301 | : codepoint == 0x201D ? 148 302 | : codepoint == 0x2022 ? 149 303 | : codepoint == 0x2013 ? 150 304 | : codepoint == 0x2014 ? 151 305 | : codepoint == 0x02DC ? 152 306 | : codepoint == 0x2122 ? 153 307 | : codepoint == 0x0161 ? 154 308 | : codepoint == 0x203A ? 155 309 | : codepoint == 0x0153 ? 156 310 | : codepoint == 0x017E ? 158 311 | : codepoint == 0x0178 ? 159 312 | : codepoint; 313 | } 314 | -------------------------------------------------------------------------------- /web/runtime/console.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | import Logger from './logger.js'; 34 | import Prim from './prim.js'; 35 | import Task from './task.js'; 36 | import Tween, { Easing } from './tween.js'; 37 | 38 | export default 39 | class Console extends Task 40 | { 41 | constructor(options = {}) 42 | { 43 | options = Object.assign({ 44 | hotKey: Key.Tilde, 45 | inBackground: true, 46 | logFileName: null, 47 | mouseKey: MouseKey.Middle, 48 | prompt: ">", 49 | priority: Infinity, 50 | }, options); 51 | 52 | super(options); 53 | 54 | this.activationKey = options.hotKey; 55 | this.buffer = []; 56 | this.bufferSize = 1000; 57 | this.commands = []; 58 | this.cursorColor = Color.Gold; 59 | this.entry = ""; 60 | this.font = Font.Default; 61 | this.logger = options.logFileName !== null 62 | ? new Logger(options.logFileName) 63 | : null; 64 | this.keyboard = Keyboard.Default; 65 | this.mouse = Mouse.Default; 66 | this.mouseKey = options.mouseKey; 67 | this.nextLine = 0; 68 | this.numLines = Math.floor((Surface.Screen.height - 32) / this.font.height); 69 | this.prompt = options.prompt; 70 | this.view = { visible: false, fade: 0.0, line: 0.0 }; 71 | this.tween = new Tween(this.view, Easing.Exponential); 72 | this.cursorTween = new Tween(this.cursorColor, Easing.Sine); 73 | this.wasKeyDown = false; 74 | 75 | this.start(); 76 | } 77 | 78 | get visible() 79 | { 80 | return this.view.visible; 81 | } 82 | 83 | set visible(value) 84 | { 85 | if (value) 86 | showConsole(this); 87 | else 88 | hideConsole(this); 89 | } 90 | 91 | defineObject(name, that, methods) 92 | { 93 | for (const [ key, value ] of Object.entries(methods)) { 94 | this.commands.push({ 95 | entity: name, 96 | instruction: key, 97 | that: that, 98 | method: value, 99 | }); 100 | } 101 | } 102 | 103 | log(...texts) 104 | { 105 | let lineInBuffer = this.nextLine % this.bufferSize; 106 | this.buffer[lineInBuffer] = texts[0]; 107 | for (let i = 1; i < texts.length; ++i) 108 | this.buffer[lineInBuffer] += ` >>${texts[i]}`; 109 | ++this.nextLine; 110 | this.view.line = 0.0; 111 | SSj.log(this.buffer[lineInBuffer]); 112 | 113 | // if we have a logger, write the line to the log file 114 | if (this.logger !== null) 115 | this.logger.write(this.buffer[lineInBuffer]); 116 | } 117 | 118 | start() 119 | { 120 | if (this.running) 121 | throw new Error("the Console has already been started"); 122 | 123 | (async () => { 124 | const fps = Sphere.frameRate; 125 | while (true) { 126 | await this.cursorTween.easeInOut({ a: 1.0 }, 0.25 * fps); 127 | await this.cursorTween.easeInOut({ a: 0.5 }, 0.25 * fps); 128 | } 129 | })(); 130 | 131 | this.log(`initializing the Sphere Runtime Console`); 132 | this.log(` ${Sphere.Game.name} by ${Sphere.Game.author}`); 133 | this.log(` Sphere v${Sphere.Version} API level ${Sphere.APILevel} (${Sphere.Engine})`); 134 | this.log(""); 135 | 136 | super.start(); 137 | } 138 | 139 | undefineObject(name) 140 | { 141 | this.commands = this.commands.filter(it => it.entity !== name); 142 | } 143 | 144 | on_inputCheck() 145 | { 146 | if (this.view.visible) { 147 | let mouseEvent = this.mouse.getEvent(); 148 | let wheelUp = mouseEvent !== null && mouseEvent.key === MouseKey.WheelUp; 149 | let wheelDown = mouseEvent !== null && mouseEvent.key === MouseKey.WheelDown; 150 | let speed = (wheelUp || wheelDown) ? 1.0 : 0.5; 151 | if (this.keyboard.isPressed(Key.PageUp) || wheelUp) 152 | this.view.line = Math.min(this.view.line + speed, this.buffer.length - this.numLines); 153 | else if (this.keyboard.isPressed(Key.PageDown) || wheelDown) 154 | this.view.line = Math.max(this.view.line - speed, 0); 155 | let keycode = this.keyboard.getKey(); 156 | let fps = Sphere.frameRate; 157 | if (keycode === this.activationKey) 158 | return; 159 | switch (keycode) { 160 | case Key.Enter: { 161 | this.log(`executing command line '${this.entry}'`); 162 | executeCommand(this, this.entry); 163 | this.entry = ""; 164 | break; 165 | } 166 | case Key.Backspace: { 167 | this.entry = this.entry.slice(0, -1); 168 | break; 169 | } 170 | case Key.Home: { 171 | let newLine = this.buffer.length - this.numLines; 172 | this.tween.easeInOut({ line: newLine }, 0.125 * fps); 173 | break; 174 | } 175 | case Key.End: { 176 | this.tween.easeInOut({ line: 0.0 }, 0.125 * fps); 177 | break; 178 | } 179 | case Key.Tab: 180 | case null: { 181 | break; 182 | } 183 | default: { 184 | let isShifted = this.keyboard.isPressed(Key.LShift) || this.keyboard.isPressed(Key.RShift); 185 | let ch = this.keyboard.charOf(keycode, isShifted); 186 | ch = this.keyboard.capsLock ? ch.toUpperCase() : ch; 187 | this.entry += ch; 188 | } 189 | } 190 | } 191 | } 192 | 193 | on_render() 194 | { 195 | if (this.view.fade <= 0.0) 196 | return; 197 | 198 | // draw the command prompt... 199 | let promptWidth = this.font.getTextSize(`${this.prompt} `).width; 200 | let boxY = -22 * (1.0 - this.view.fade); 201 | Prim.drawSolidRectangle(Surface.Screen, 0, boxY, Surface.Screen.width, 22, Color.Black.fadeTo(this.view.fade * 0.875)); 202 | this.font.drawText(Surface.Screen, 6, 6 + boxY, this.prompt, Color.Black.fadeTo(this.view.fade * 0.75)); 203 | this.font.drawText(Surface.Screen, 5, 5 + boxY, this.prompt, Color.Gray.fadeTo(this.view.fade * 0.75)); 204 | this.font.drawText(Surface.Screen, 6 + promptWidth, 6 + boxY, this.entry, Color.Black.fadeTo(this.view.fade * 0.75)); 205 | this.font.drawText(Surface.Screen, 5 + promptWidth, 5 + boxY, this.entry, Color.Gold.fadeTo(this.view.fade * 0.75)); 206 | this.font.drawText(Surface.Screen, 5 + promptWidth + this.font.getTextSize(this.entry).width, 5 + boxY, "_", this.cursorColor); 207 | 208 | // ...then the console output 209 | let boxHeight = this.numLines * this.font.height + 10; 210 | boxY = Surface.Screen.height - boxHeight * this.view.fade; 211 | Prim.drawSolidRectangle(Surface.Screen, 0, boxY, Surface.Screen.width, boxHeight, Color.Black.fadeTo(this.view.fade * 0.75)); 212 | Surface.Screen.clipTo(5, boxY + 5, Surface.Screen.width - 10, boxHeight - 10); 213 | for (let i = -1; i < this.numLines + 1; ++i) { 214 | let lineToDraw = (this.nextLine - this.numLines) + i - Math.floor(this.view.line); 215 | let lineInBuffer = lineToDraw % this.bufferSize; 216 | if (lineToDraw >= 0 && this.buffer[lineInBuffer] !== undefined) { 217 | let y = boxY + 5 + i * this.font.height; 218 | y += (this.view.line - Math.floor(this.view.line)) * this.font.height; 219 | this.font.drawText(Surface.Screen, 6, y + 1, this.buffer[lineInBuffer], Color.Black.fadeTo(this.view.fade * 0.75)); 220 | this.font.drawText(Surface.Screen, 5, y, this.buffer[lineInBuffer], Color.White.fadeTo(this.view.fade * 0.75)); 221 | } 222 | } 223 | Surface.Screen.clipTo(0, 0, Surface.Screen.width, Surface.Screen.height); 224 | } 225 | 226 | on_update() 227 | { 228 | const hotKeyPressed = this.keyboard.isPressed(this.activationKey) 229 | || this.mouse.isPressed(this.mouseKey); 230 | if (hotKeyPressed && !this.wasKeyDown) { 231 | if (!this.view.visible) 232 | showConsole(this); 233 | else 234 | hideConsole(this); 235 | } 236 | this.wasKeyDown = hotKeyPressed; 237 | 238 | if (this.view.fade <= 0.0) 239 | this.view.line = 0.0; 240 | } 241 | } 242 | 243 | function executeCommand(console, command) 244 | { 245 | // tokenize the command string 246 | let tokens = command.match(/'.*?'|".*?"|\S+/g); 247 | if (tokens === null) 248 | return; 249 | for (let i = 0; i < tokens.length; ++i) { 250 | tokens[i] = tokens[i].replace(/'(.*)'/, "$1"); 251 | tokens[i] = tokens[i].replace(/"(.*)"/, "$1"); 252 | } 253 | let objectName = tokens[0]; 254 | let instruction = tokens[1]; 255 | 256 | // check that the instruction is valid 257 | if (!console.commands.some(it => it.entity === objectName)) { 258 | console.log(`unrecognized object name '${objectName}'`); 259 | return; 260 | } 261 | if (tokens.length < 2) { 262 | console.log(`missing instruction for '${objectName}'`); 263 | return; 264 | } 265 | if (!console.commands.some(it => it.entity === objectName && it.instruction === instruction)) { 266 | console.log(`instruction '${instruction}' not valid for '${objectName}'`); 267 | return; 268 | } 269 | 270 | // parse arguments 271 | for (let i = 2; i < tokens.length; ++i) { 272 | let maybeNumber = parseFloat(tokens[i]); 273 | tokens[i] = !isNaN(maybeNumber) ? maybeNumber : tokens[i]; 274 | } 275 | 276 | // execute the command 277 | let matches = console.commands.filter((it) => 278 | it.entity === objectName && it.instruction === instruction); 279 | for (const command of matches) { 280 | Dispatch.now(() => { 281 | try { 282 | command.method.apply(command.that, tokens.slice(2)); 283 | } 284 | catch (e) { 285 | console.log(`caught JS error '${e.toString()}'`); 286 | } 287 | }); 288 | } 289 | } 290 | 291 | async function hideConsole(console) 292 | { 293 | console.yieldFocus(); 294 | let fps = Sphere.frameRate; 295 | await console.tween.easeIn({ fade: 0.0 }, 0.25 * fps); 296 | console.view.visible = false; 297 | console.entry = ""; 298 | } 299 | 300 | async function showConsole(console) 301 | { 302 | console.keyboard.clearQueue(); 303 | console.mouse.clearQueue(); 304 | console.takeFocus(); 305 | let fps = Sphere.frameRate; 306 | await console.tween.easeOut({ fade: 1.0 }, 0.25 * fps); 307 | console.view.visible = true; 308 | } 309 | -------------------------------------------------------------------------------- /web/scripts/input-engine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oozaru: Sphere for the Web 3 | * Copyright (c) 2016-2025, Where'd She Go? LLC 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export 34 | const Key = 35 | { 36 | Alt: 0, 37 | AltGr: 1, 38 | Apostrophe: 2, 39 | Backslash: 3, 40 | Backspace: 4, 41 | CapsLock: 5, 42 | CloseBrace: 6, 43 | Comma: 7, 44 | Delete: 8, 45 | Down: 9, 46 | End: 10, 47 | Enter: 11, 48 | Equals: 12, 49 | Escape: 13, 50 | F1: 14, 51 | F2: 15, 52 | F3: 16, 53 | F4: 17, 54 | F5: 18, 55 | F6: 19, 56 | F7: 20, 57 | F8: 21, 58 | F9: 22, 59 | F10: 23, 60 | F11: 24, 61 | F12: 25, 62 | Home: 26, 63 | Hyphen: 27, 64 | Insert: 28, 65 | LCtrl: 29, 66 | LShift: 30, 67 | Left: 31, 68 | NumLock: 32, 69 | OpenBrace: 33, 70 | PageDown: 34, 71 | PageUp: 35, 72 | Period: 36, 73 | RCtrl: 37, 74 | RShift: 38, 75 | Right: 39, 76 | ScrollLock: 40, 77 | Semicolon: 41, 78 | Slash: 42, 79 | Space: 43, 80 | Tab: 44, 81 | Tilde: 45, 82 | Up: 46, 83 | A: 47, 84 | B: 48, 85 | C: 49, 86 | D: 50, 87 | E: 51, 88 | F: 52, 89 | G: 53, 90 | H: 54, 91 | I: 55, 92 | J: 56, 93 | K: 57, 94 | L: 58, 95 | M: 59, 96 | N: 60, 97 | O: 61, 98 | P: 62, 99 | Q: 63, 100 | R: 64, 101 | S: 65, 102 | T: 66, 103 | U: 67, 104 | V: 68, 105 | W: 69, 106 | X: 70, 107 | Y: 71, 108 | Z: 72, 109 | D1: 73, 110 | D2: 74, 111 | D3: 75, 112 | D4: 76, 113 | D5: 77, 114 | D6: 78, 115 | D7: 79, 116 | D8: 80, 117 | D9: 81, 118 | D0: 82, 119 | NumPad1: 83, 120 | NumPad2: 84, 121 | NumPad3: 85, 122 | NumPad4: 86, 123 | NumPad5: 87, 124 | NumPad6: 88, 125 | NumPad7: 89, 126 | NumPad8: 90, 127 | NumPad9: 91, 128 | NumPad0: 92, 129 | NumPadEnter: 93, 130 | Add: 94, 131 | Decimal: 95, 132 | Divide: 96, 133 | Multiply: 97, 134 | Subtract: 98, 135 | }; 136 | 137 | export 138 | const MouseKey = 139 | { 140 | Left: 0, 141 | Middle: 1, 142 | Right: 2, 143 | Back: 3, 144 | Forward: 4, 145 | WheelUp: 5, 146 | WheelDown: 6, 147 | }; 148 | 149 | var buttonStates = {}; 150 | var keyQueue = []; 151 | var keyStates = { '': false }; 152 | var lastMouseX = undefined; 153 | var lastMouseY = undefined; 154 | var mouseQueue = []; 155 | var nullJoystick; 156 | 157 | export default 158 | class InputEngine 159 | { 160 | static initialize(canvas) 161 | { 162 | canvas.addEventListener('contextmenu', (e) => { 163 | e.preventDefault(); 164 | }); 165 | 166 | canvas.addEventListener('keydown', (e) => { 167 | e.preventDefault(); 168 | keyStates[e.code] = true; 169 | switch (e.code) { 170 | case 'ArrowLeft': keyQueue.push(Key.Left); break; 171 | case 'ArrowRight': keyQueue.push(Key.Right); break; 172 | case 'ArrowDown': keyQueue.push(Key.Down); break; 173 | case 'ArrowUp': keyQueue.push(Key.Up); break; 174 | case 'Backquote': keyQueue.push(Key.Tilde); break; 175 | case 'Backslash': keyQueue.push(Key.Backslash); break; 176 | case 'Backspace': keyQueue.push(Key.Backspace); break; 177 | case 'BracketLeft': keyQueue.push(Key.OpenBrace); break; 178 | case 'BracketRight': keyQueue.push(Key.CloseBrace); break; 179 | case 'Comma': keyQueue.push(Key.Comma); break; 180 | case 'Delete': keyQueue.push(Key.Delete); break; 181 | case 'Digit0': keyQueue.push(Key.D0); break; 182 | case 'Digit1': keyQueue.push(Key.D1); break; 183 | case 'Digit2': keyQueue.push(Key.D2); break; 184 | case 'Digit3': keyQueue.push(Key.D3); break; 185 | case 'Digit4': keyQueue.push(Key.D4); break; 186 | case 'Digit5': keyQueue.push(Key.D5); break; 187 | case 'Digit6': keyQueue.push(Key.D6); break; 188 | case 'Digit7': keyQueue.push(Key.D7); break; 189 | case 'Digit8': keyQueue.push(Key.D8); break; 190 | case 'Digit9': keyQueue.push(Key.D9); break; 191 | case 'End': keyQueue.push(Key.End); break; 192 | case 'Enter': keyQueue.push(Key.Enter); break; 193 | case 'Equal': keyQueue.push(Key.Equals); break; 194 | case 'Escape': keyQueue.push(Key.Escape); break; 195 | case 'F1': keyQueue.push(Key.F1); break; 196 | case 'F2': keyQueue.push(Key.F2); break; 197 | case 'F3': keyQueue.push(Key.F3); break; 198 | case 'F4': keyQueue.push(Key.F4); break; 199 | case 'F5': keyQueue.push(Key.F5); break; 200 | case 'F6': keyQueue.push(Key.F6); break; 201 | case 'F7': keyQueue.push(Key.F7); break; 202 | case 'F8': keyQueue.push(Key.F8); break; 203 | case 'F9': keyQueue.push(Key.F9); break; 204 | case 'F10': keyQueue.push(Key.F10); break; 205 | case 'F11': keyQueue.push(Key.F11); break; 206 | case 'F12': keyQueue.push(Key.F12); break; 207 | case 'Home': keyQueue.push(Key.Home); break; 208 | case 'Insert': keyQueue.push(Key.Insert); break; 209 | case 'KeyA': keyQueue.push(Key.A); break; 210 | case 'KeyB': keyQueue.push(Key.B); break; 211 | case 'KeyC': keyQueue.push(Key.C); break; 212 | case 'KeyD': keyQueue.push(Key.D); break; 213 | case 'KeyE': keyQueue.push(Key.E); break; 214 | case 'KeyF': keyQueue.push(Key.F); break; 215 | case 'KeyG': keyQueue.push(Key.G); break; 216 | case 'KeyH': keyQueue.push(Key.H); break; 217 | case 'KeyI': keyQueue.push(Key.I); break; 218 | case 'KeyJ': keyQueue.push(Key.J); break; 219 | case 'KeyK': keyQueue.push(Key.K); break; 220 | case 'KeyL': keyQueue.push(Key.L); break; 221 | case 'KeyM': keyQueue.push(Key.M); break; 222 | case 'KeyN': keyQueue.push(Key.N); break; 223 | case 'KeyO': keyQueue.push(Key.O); break; 224 | case 'KeyP': keyQueue.push(Key.P); break; 225 | case 'KeyQ': keyQueue.push(Key.Q); break; 226 | case 'KeyR': keyQueue.push(Key.R); break; 227 | case 'KeyS': keyQueue.push(Key.S); break; 228 | case 'KeyT': keyQueue.push(Key.T); break; 229 | case 'KeyU': keyQueue.push(Key.U); break; 230 | case 'KeyV': keyQueue.push(Key.V); break; 231 | case 'KeyW': keyQueue.push(Key.W); break; 232 | case 'KeyX': keyQueue.push(Key.X); break; 233 | case 'KeyY': keyQueue.push(Key.Y); break; 234 | case 'KeyZ': keyQueue.push(Key.Z); break; 235 | case 'Minus': keyQueue.push(Key.Hyphen); break; 236 | case 'Numpad0': keyQueue.push(Key.NumPad0); break; 237 | case 'Numpad1': keyQueue.push(Key.NumPad1); break; 238 | case 'Numpad2': keyQueue.push(Key.NumPad2); break; 239 | case 'Numpad3': keyQueue.push(Key.NumPad3); break; 240 | case 'Numpad4': keyQueue.push(Key.NumPad4); break; 241 | case 'Numpad5': keyQueue.push(Key.NumPad5); break; 242 | case 'Numpad6': keyQueue.push(Key.NumPad6); break; 243 | case 'Numpad7': keyQueue.push(Key.NumPad7); break; 244 | case 'Numpad8': keyQueue.push(Key.NumPad8); break; 245 | case 'Numpad9': keyQueue.push(Key.NumPad9); break; 246 | case 'NumpadAdd': keyQueue.push(Key.Add); break; 247 | case 'NumpadDecimal': keyQueue.push(Key.Decimal); break; 248 | case 'NumpadDivide': keyQueue.push(Key.Divide); break; 249 | case 'NumpadEnter': keyQueue.push(Key.NumPadEnter); break; 250 | case 'NumpadMultiply': keyQueue.push(Key.Multiply); break; 251 | case 'NumpadSubtract': keyQueue.push(Key.Subtract); break; 252 | case 'PageDown': keyQueue.push(Key.PageDown); break; 253 | case 'PageUp': keyQueue.push(Key.PageUp); break; 254 | case 'Period': keyQueue.push(Key.Period); break; 255 | case 'Quote': keyQueue.push(Key.Apostrophe); break; 256 | case 'Semicolon': keyQueue.push(Key.Semicolon); break; 257 | case 'Slash': keyQueue.push(Key.Slash); break; 258 | case 'Space': keyQueue.push(Key.Space); break; 259 | case 'Tab': keyQueue.push(Key.Tab); break; 260 | } 261 | }); 262 | canvas.addEventListener('keyup', e => { 263 | e.preventDefault(); 264 | keyStates[e.code] = false; 265 | }); 266 | 267 | canvas.addEventListener('mousemove', (e) => { 268 | e.preventDefault(); 269 | lastMouseX = e.offsetX; 270 | lastMouseY = e.offsetY; 271 | }); 272 | canvas.addEventListener('mouseout', (e) => { 273 | e.preventDefault(); 274 | lastMouseX = undefined; 275 | lastMouseY = undefined; 276 | }); 277 | canvas.addEventListener('mousedown', (e) => { 278 | e.preventDefault(); 279 | canvas.focus(); 280 | const key = e.button === 1 ? MouseKey.Middle 281 | : e.button === 2 ? MouseKey.Right 282 | : e.button === 3 ? MouseKey.Back 283 | : e.button === 4 ? MouseKey.Forward 284 | : MouseKey.Left; 285 | buttonStates[key] = true; 286 | }); 287 | canvas.addEventListener('mouseup', (e) => { 288 | e.preventDefault(); 289 | const key = e.button === 1 ? MouseKey.Middle 290 | : e.button === 2 ? MouseKey.Right 291 | : e.button === 3 ? MouseKey.Back 292 | : e.button === 4 ? MouseKey.Forward 293 | : MouseKey.Left; 294 | buttonStates[key] = false; 295 | mouseQueue.push({ 296 | key, 297 | x: e.offsetX, 298 | y: e.offsetY, 299 | }); 300 | }); 301 | canvas.addEventListener('wheel', (e) => { 302 | e.preventDefault(); 303 | const key = e.deltaY < 0.0 ? MouseKey.WheelUp 304 | : MouseKey.WheelDown; 305 | mouseQueue.push({ 306 | key, 307 | delta: Math.abs(e.deltaY), 308 | x: e.offsetX, 309 | y: e.offsetY, 310 | }); 311 | }); 312 | 313 | nullJoystick = new Joystick(); 314 | } 315 | } 316 | 317 | export 318 | class Joystick 319 | { 320 | static get P1() { return nullJoystick; } 321 | static get P2() { return nullJoystick; } 322 | static get P3() { return nullJoystick; } 323 | static get P4() { return nullJoystick; } 324 | 325 | static getDevices() 326 | { 327 | return []; 328 | } 329 | 330 | constructor() 331 | { 332 | } 333 | 334 | get name() 335 | { 336 | return "Null Device"; 337 | } 338 | 339 | get numAxes() 340 | { 341 | return Infinity; 342 | } 343 | 344 | get numButtons() 345 | { 346 | return Infinity; 347 | } 348 | 349 | getPosition(axisID) 350 | { 351 | return 0.0; 352 | } 353 | 354 | isPressed(buttonID) 355 | { 356 | return false; 357 | } 358 | } 359 | 360 | export 361 | class Keyboard 362 | { 363 | static get Default() 364 | { 365 | return this; 366 | } 367 | 368 | static get capsLock() 369 | { 370 | return false; 371 | } 372 | 373 | static get numLock() 374 | { 375 | return false; 376 | } 377 | 378 | static get scrollLock() 379 | { 380 | return false; 381 | } 382 | 383 | static charOf(key, shifted = false) 384 | { 385 | return key === Key.Space ? " " 386 | : key === Key.Apostrophe ? shifted ? "\"" : "'" 387 | : key === Key.Backslash ? shifted ? "|" : "\\" 388 | : key === Key.Comma ? shifted ? "<" : "," 389 | : key === Key.CloseBrace ? shifted ? "}" : "]" 390 | : key === Key.Equals ? shifted ? "+" : "=" 391 | : key === Key.Hyphen ? shifted ? "_" : "-" 392 | : key === Key.OpenBrace ? shifted ? "{" : "[" 393 | : key === Key.Period ? shifted ? ">" : "." 394 | : key === Key.Semicolon ? shifted ? ":" : ";" 395 | : key === Key.Slash ? shifted ? "?" : "/" 396 | : key === Key.Tab ? "\t" 397 | : key === Key.Tilde ? shifted ? "~" : "`" 398 | : key === Key.D0 ? shifted ? ")" : "0" 399 | : key === Key.D1 ? shifted ? "!" : "1" 400 | : key === Key.D2 ? shifted ? "@" : "2" 401 | : key === Key.D3 ? shifted ? "#" : "3" 402 | : key === Key.D4 ? shifted ? "$" : "4" 403 | : key === Key.D5 ? shifted ? "%" : "5" 404 | : key === Key.D6 ? shifted ? "^" : "6" 405 | : key === Key.D7 ? shifted ? "&" : "7" 406 | : key === Key.D8 ? shifted ? "*" : "8" 407 | : key === Key.D9 ? shifted ? "(" : "9" 408 | : key === Key.A ? shifted ? "A" : "a" 409 | : key === Key.B ? shifted ? "B" : "b" 410 | : key === Key.C ? shifted ? "C" : "c" 411 | : key === Key.D ? shifted ? "D" : "d" 412 | : key === Key.E ? shifted ? "E" : "e" 413 | : key === Key.F ? shifted ? "F" : "f" 414 | : key === Key.G ? shifted ? "G" : "g" 415 | : key === Key.H ? shifted ? "H" : "h" 416 | : key === Key.I ? shifted ? "I" : "i" 417 | : key === Key.J ? shifted ? "J" : "j" 418 | : key === Key.K ? shifted ? "K" : "k" 419 | : key === Key.L ? shifted ? "L" : "l" 420 | : key === Key.M ? shifted ? "M" : "m" 421 | : key === Key.N ? shifted ? "N" : "n" 422 | : key === Key.O ? shifted ? "O" : "o" 423 | : key === Key.P ? shifted ? "P" : "p" 424 | : key === Key.Q ? shifted ? "Q" : "q" 425 | : key === Key.R ? shifted ? "R" : "r" 426 | : key === Key.S ? shifted ? "S" : "s" 427 | : key === Key.T ? shifted ? "T" : "t" 428 | : key === Key.U ? shifted ? "U" : "u" 429 | : key === Key.V ? shifted ? "V" : "v" 430 | : key === Key.W ? shifted ? "W" : "w" 431 | : key === Key.X ? shifted ? "X" : "x" 432 | : key === Key.Y ? shifted ? "Y" : "y" 433 | : key === Key.Z ? shifted ? "Z" : "z" 434 | : ""; 435 | } 436 | 437 | static clearQueue() 438 | { 439 | keyQueue.length = 0; 440 | } 441 | 442 | static getKey() 443 | { 444 | return keyQueue.pop() ?? null; 445 | } 446 | 447 | static isPressed(key) 448 | { 449 | const keySpec = key === Key.Tilde ? 'Backquote' 450 | : key === Key.D0 ? 'Digit0' 451 | : key === Key.D1 ? 'Digit1' 452 | : key === Key.D2 ? 'Digit2' 453 | : key === Key.D3 ? 'Digit3' 454 | : key === Key.D4 ? 'Digit4' 455 | : key === Key.D5 ? 'Digit5' 456 | : key === Key.D6 ? 'Digit6' 457 | : key === Key.D7 ? 'Digit7' 458 | : key === Key.D8 ? 'Digit8' 459 | : key === Key.D9 ? 'Digit9' 460 | : key === Key.A ? 'KeyA' 461 | : key === Key.B ? 'KeyB' 462 | : key === Key.C ? 'KeyC' 463 | : key === Key.D ? 'KeyD' 464 | : key === Key.E ? 'KeyE' 465 | : key === Key.F ? 'KeyF' 466 | : key === Key.G ? 'KeyG' 467 | : key === Key.H ? 'KeyH' 468 | : key === Key.I ? 'KeyI' 469 | : key === Key.J ? 'KeyJ' 470 | : key === Key.K ? 'KeyK' 471 | : key === Key.L ? 'KeyL' 472 | : key === Key.M ? 'KeyM' 473 | : key === Key.N ? 'KeyN' 474 | : key === Key.O ? 'KeyO' 475 | : key === Key.P ? 'KeyP' 476 | : key === Key.Q ? 'KeyQ' 477 | : key === Key.R ? 'KeyR' 478 | : key === Key.S ? 'KeyS' 479 | : key === Key.T ? 'KeyT' 480 | : key === Key.U ? 'KeyU' 481 | : key === Key.V ? 'KeyV' 482 | : key === Key.W ? 'KeyW' 483 | : key === Key.X ? 'KeyX' 484 | : key === Key.Y ? 'KeyY' 485 | : key === Key.Z ? 'KeyZ' 486 | : key === Key.PageDown ? 'PageDown' 487 | : key === Key.PageUp ? 'PageUp' 488 | : key === Key.LShift ? 'ShiftLeft' 489 | : key === Key.LCtrl ? 'ControlLeft' 490 | : key === Key.Alt ? 'AltLeft' 491 | : key === Key.RShift ? 'ShiftRight' 492 | : key === Key.RCtrl ? 'ControlRight' 493 | : key === Key.AltGr ? 'AltRight' 494 | : ''; 495 | return keyStates[keySpec]; 496 | } 497 | } 498 | 499 | export 500 | class Mouse 501 | { 502 | static get Default() 503 | { 504 | return this; 505 | } 506 | 507 | static get position() 508 | { 509 | return [ lastMouseX, lastMouseY ]; 510 | } 511 | 512 | static get x() 513 | { 514 | return lastMouseX; 515 | } 516 | 517 | static get y() 518 | { 519 | return lastMouseY; 520 | } 521 | 522 | static clearQueue() 523 | { 524 | mouseQueue.length = 0; 525 | } 526 | 527 | static getEvent() 528 | { 529 | return mouseQueue.pop() ?? { key: null }; 530 | } 531 | 532 | static isPressed(key) 533 | { 534 | return buttonStates[key] ?? false; 535 | } 536 | } 537 | -------------------------------------------------------------------------------- /web/runtime/from.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphere: the JavaScript game platform 3 | * Copyright (c) 2015-2022, Fat Cerberus 4 | * All rights reserved. 5 | * 6 | * Redistribution and use in source and binary forms, with or without 7 | * modification, are permitted provided that the following conditions are met: 8 | * 9 | * * Redistributions of source code must retain the above copyright notice, 10 | * this list of conditions and the following disclaimer. 11 | * 12 | * * Redistributions in binary form must reproduce the above copyright notice, 13 | * this list of conditions and the following disclaimer in the documentation 14 | * and/or other materials provided with the distribution. 15 | * 16 | * * Neither the name of Spherical nor the names of its contributors may be 17 | * used to endorse or promote products derived from this software without 18 | * specific prior written permission. 19 | * 20 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 23 | * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 24 | * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 25 | * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 26 | * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 27 | * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 28 | * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 29 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | * POSSIBILITY OF SUCH DAMAGE. 31 | **/ 32 | 33 | export default 34 | function from(...sources) 35 | { 36 | if (sources.some(it => it === null || it === undefined)) 37 | throw new TypeError("Query source is null or undefined"); 38 | let query = new Query(); 39 | query.sources = sources; 40 | return query; 41 | } 42 | 43 | export 44 | class Query 45 | { 46 | get [Symbol.toStringTag]() { return 'Query'; } 47 | 48 | constructor() 49 | { 50 | this.opcodes = []; 51 | this.firstOp = null; 52 | this.lastOp = null; 53 | } 54 | 55 | [Symbol.iterator]() 56 | { 57 | return this.toArray()[Symbol.iterator](); 58 | } 59 | 60 | addOp$(type, a, b) 61 | { 62 | const opcode = { type, a, b }; 63 | const newQuery = new Query(); 64 | newQuery.sources = this.sources; 65 | newQuery.opcodes = [ ...this.opcodes, opcode ]; 66 | return newQuery; 67 | } 68 | 69 | compile$() 70 | { 71 | if (this.firstOp !== null) 72 | return; 73 | this.firstOp = null; 74 | this.lastOp = null; 75 | for (let i = 0, len = this.opcodes.length; i < len; ++i) { 76 | const opcode = this.opcodes[i]; 77 | const op = new opcode.type(opcode.a, opcode.b); 78 | if (this.lastOp !== null) 79 | this.lastOp.nextOp = op; 80 | this.lastOp = op; 81 | if (this.firstOp === null) 82 | this.firstOp = this.lastOp; 83 | } 84 | return this.firstOp; 85 | } 86 | 87 | run$(reduceOp) 88 | { 89 | this.compile$(); 90 | let firstOp = this.firstOp; 91 | let lastOp = this.lastOp; 92 | const runQuery = (...sources) => { 93 | if (sources.some(it => it === null || it === undefined)) 94 | throw new TypeError("Query source is null or undefined"); 95 | if (lastOp !== null) 96 | lastOp.nextOp = reduceOp; 97 | else 98 | firstOp = reduceOp; 99 | firstOp.initialize(sources); 100 | for (let i = 0, len = sources.length; i < len; ++i) { 101 | const source = sources[i]; 102 | if (!feedMeSeymour(firstOp, source)) 103 | break; 104 | } 105 | return firstOp.flush(sources); 106 | }; 107 | return this.sources !== undefined 108 | ? runQuery(...this.sources) 109 | : runQuery; 110 | } 111 | 112 | aggregate(reducer, seedValue) 113 | { 114 | return this.run$(new AggregateOp(reducer, seedValue)); 115 | } 116 | 117 | all(predicate) 118 | { 119 | return this.run$(new FindOp((it, key, memo) => !predicate(it, key) ? (memo.value = false, true) : false, true)); 120 | } 121 | 122 | allIn(values) 123 | { 124 | const valueSet = new Set(values); 125 | return this.all(it => valueSet.has(it)); 126 | } 127 | 128 | any(predicate) 129 | { 130 | return this.run$(new FindOp((it, key, memo) => predicate(it, key) ? (memo.value = true, true) : false, false)); 131 | } 132 | 133 | anyIn(values) 134 | { 135 | const valueSet = new Set(values); 136 | return this.any(it => valueSet.has(it)); 137 | } 138 | 139 | anyIs(value) 140 | { 141 | const match = value !== value ? x => x !== x : x => x === value; 142 | return this.any(it => match(it)); 143 | } 144 | 145 | apply(values) 146 | { 147 | return this.selectMany(fn => from(values).select(fn)); 148 | } 149 | 150 | ascending(keySelector = identity) 151 | { 152 | return this.thru(all => { 153 | const pairs = all.map(it => ({ key: keySelector(it), value: it })); 154 | pairs.sort((a, b) => a.key < b.key ? -1 : b.key < a.key ? +1 : 0); 155 | return pairs.map(it => it.value); 156 | }); 157 | } 158 | 159 | besides(iteratee) 160 | { 161 | return this.select((it, k) => (iteratee(it, k), it)); 162 | } 163 | 164 | call(...args) 165 | { 166 | return this.select(it => it(...args)).toArray(); 167 | } 168 | 169 | concat(...sources) 170 | { 171 | return this.addOp$(ConcatOp, sources); 172 | } 173 | 174 | count() 175 | { 176 | return this.aggregate(n => n + 1, 0); 177 | } 178 | 179 | countBy(keySelector) 180 | { 181 | return this.aggregate((a, it) => { 182 | const key = keySelector(it); 183 | if (a[key] !== undefined) 184 | ++a[key]; 185 | else 186 | a[key] = 1; 187 | return a; 188 | }, Object.create(null)); 189 | } 190 | 191 | descending(keySelector = identity) 192 | { 193 | return this.thru(all => { 194 | const pairs = all.map(it => ({ key: keySelector(it), value: it })); 195 | pairs.sort((b, a) => a.key < b.key ? -1 : b.key < a.key ? +1 : 0); 196 | return pairs.map(it => it.value); 197 | }); 198 | } 199 | 200 | distinct(keySelector = identity) 201 | { 202 | return this.addOp$(DistinctOp, keySelector); 203 | } 204 | 205 | elementAt(index) 206 | { 207 | return this.skip(index).first(); 208 | } 209 | 210 | first(predicate = always) 211 | { 212 | return this.run$(new FindOp((it, key, memo) => predicate(it) ? (memo.value = it, true) : false)); 213 | } 214 | 215 | forEach(iteratee) 216 | { 217 | this.aggregate((a, it) => iteratee(it)); 218 | } 219 | 220 | groupBy(keySelector) 221 | { 222 | return this.run$(new GroupOp(keySelector)); 223 | } 224 | 225 | join(collection, predicate, selector) 226 | { 227 | return this.selectMany(outer => 228 | from(collection) 229 | .where(it => predicate(outer, it)) 230 | .select(it => selector(outer, it))); 231 | } 232 | 233 | last(predicate = always) 234 | { 235 | return this.run$(new LastOp(predicate)); 236 | } 237 | 238 | memoize() 239 | { 240 | return from(this.toArray()); 241 | } 242 | 243 | plus(...values) 244 | { 245 | return this.addOp$(ConcatOp, [ values ]); 246 | } 247 | 248 | pull(...values) 249 | { 250 | const valueSet = new Set(values); 251 | return this.remove(it => valueSet.has(it)); 252 | } 253 | 254 | random(count) 255 | { 256 | return this.thru(all => { 257 | let samples = []; 258 | for (let i = 0, len = all.length; i < count; ++i) { 259 | const index = Math.floor(Math.random() * len); 260 | samples.push(all[index]); 261 | } 262 | return samples; 263 | }); 264 | } 265 | 266 | remove(predicate) 267 | { 268 | return this.run$(new RemoveOp(predicate)); 269 | } 270 | 271 | reverse() 272 | { 273 | return this.addOp$(ReverseOp); 274 | } 275 | 276 | sample(count) 277 | { 278 | return this.thru(all => { 279 | const nSamples = Math.min(Math.max(count, 0), all.length); 280 | for (let i = 0, len = all.length; i < nSamples; ++i) { 281 | const pick = i + Math.floor(Math.random() * (len - i)); 282 | const value = all[pick]; 283 | all[pick] = all[i]; 284 | all[i] = value; 285 | } 286 | all.length = nSamples; 287 | return all; 288 | }); 289 | } 290 | 291 | select(selector) 292 | { 293 | return this.addOp$(SelectOp, selector); 294 | } 295 | 296 | selectMany(selector) 297 | { 298 | return this.addOp$(SelectManyOp, selector); 299 | } 300 | 301 | shuffle() 302 | { 303 | return this.thru(all => { 304 | for (let i = 0, len = all.length; i < len - 1; ++i) { 305 | const pick = i + Math.floor(Math.random() * (len - i)); 306 | const value = all[pick]; 307 | all[pick] = all[i]; 308 | all[i] = value; 309 | } 310 | return all; 311 | }); 312 | } 313 | 314 | single(predicate = always) 315 | { 316 | return this.run$(new SingleOp(predicate)); 317 | } 318 | 319 | skip(count) 320 | { 321 | return this.addOp$(SkipOp, count); 322 | } 323 | 324 | skipLast(count) 325 | { 326 | return this.addOp$(SkipLastOp, count); 327 | } 328 | 329 | skipWhile(predicate) 330 | { 331 | return this.addOp$(SkipWhileOp, predicate); 332 | } 333 | 334 | take(count) 335 | { 336 | return this.addOp$(TakeOp, count); 337 | } 338 | 339 | takeLast(count) 340 | { 341 | // takeLast can't be lazily evaluated because we don't know where to 342 | // start until we've seen the final result. 343 | return this.thru(values => (count > 0 ? values.slice(-count) : [])); 344 | } 345 | 346 | takeWhile(predicate) 347 | { 348 | return this.addOp$(TakeWhileOp, predicate); 349 | } 350 | 351 | thru(transformer) 352 | { 353 | return this.addOp$(ThruOp, transformer); 354 | } 355 | 356 | toArray() 357 | { 358 | return this.run$(new ToArrayOp()); 359 | } 360 | 361 | update(selector) 362 | { 363 | return this.run$(new UpdateOp(selector)); 364 | } 365 | 366 | where(predicate) 367 | { 368 | return this.addOp$(WhereOp, predicate); 369 | } 370 | 371 | without(...values) 372 | { 373 | return this.addOp$(WithoutOp, values); 374 | } 375 | 376 | zip(collection, selector = tupleify) 377 | { 378 | return this.addOp$(ZipOp, collection, selector); 379 | } 380 | } 381 | 382 | class QueryOp 383 | { 384 | initialize(sources) 385 | { 386 | // `initialize()` is called at the start of query execution, before the 387 | // first item is sent to `step()`. 388 | if (this.nextOp !== undefined) 389 | this.nextOp.initialize(sources); 390 | } 391 | 392 | flush(sources) 393 | { 394 | // `flush()` is called after all items from the source have been sent 395 | // into the pipeline. this provides for operations that need to see 396 | // all results before they can do their work, e.g. sorting. the 397 | // aggregator at the end of the chain should return its final result 398 | // from `flush()`. 399 | return this.nextOp !== undefined 400 | ? this.nextOp.flush(sources) 401 | : undefined; 402 | } 403 | 404 | step(value, source, key) 405 | { 406 | // `step()` should return `true` to continue execution, or `false` to 407 | // short-circuit. note that `flush()` will still be called even if the 408 | // query short-circuits. 409 | return this.nextOp !== undefined 410 | ? this.nextOp.step(value, source, key) 411 | : false; 412 | } 413 | } 414 | 415 | class ThruOp extends QueryOp 416 | { 417 | constructor(transformer) 418 | { 419 | super(); 420 | this.transformer = transformer; 421 | } 422 | 423 | initialize() 424 | { 425 | this.values = []; 426 | super.initialize(); 427 | } 428 | 429 | flush() 430 | { 431 | const newSource = this.transformer(this.values); 432 | if (this.nextOp instanceof ThruOp) { 433 | // if the next operator is a ThruOp, just give it our buffer since 434 | // we don't need it anymore. this greatly speeds up queries with 435 | // multiple consecutive ThruOps. 436 | this.nextOp.values = Array.isArray(newSource) 437 | ? newSource 438 | : Array.from(newSource); 439 | } 440 | else { 441 | feedMeSeymour(this.nextOp, newSource); 442 | } 443 | return this.nextOp.flush(); 444 | } 445 | 446 | step(value) 447 | { 448 | this.values.push(value); 449 | return true; 450 | } 451 | } 452 | 453 | class AggregateOp extends QueryOp 454 | { 455 | constructor(aggregator, seedValue) 456 | { 457 | super(); 458 | this.aggregator = aggregator; 459 | this.seedValue = seedValue; 460 | } 461 | 462 | initialize() 463 | { 464 | this.accumulator = this.seedValue; 465 | super.initialize(); 466 | } 467 | 468 | flush() 469 | { 470 | return this.accumulator; 471 | } 472 | 473 | step(value, source, key) 474 | { 475 | this.accumulator = this.aggregator(this.accumulator, value, key); 476 | return true; 477 | } 478 | } 479 | 480 | class ConcatOp extends QueryOp 481 | { 482 | constructor(sources) 483 | { 484 | super(); 485 | this.sources = sources; 486 | } 487 | 488 | flush() 489 | { 490 | for (let i = 0, len = this.sources.length; i < len; ++i) { 491 | if (!feedMeSeymour(this.nextOp, this.sources[i])) 492 | break; 493 | } 494 | return this.nextOp.flush(); 495 | } 496 | 497 | step(value, source, key) 498 | { 499 | return this.nextOp.step(value, source, key); 500 | } 501 | } 502 | 503 | class DistinctOp extends QueryOp 504 | { 505 | constructor(keySelector) 506 | { 507 | super(); 508 | this.keySelector = keySelector; 509 | } 510 | 511 | initialize() 512 | { 513 | this.keys = new Set(); 514 | super.initialize(); 515 | } 516 | 517 | step(value, source, key) 518 | { 519 | const uniqKey = this.keySelector(value); 520 | if (!this.keys.has(uniqKey)) { 521 | this.keys.add(uniqKey); 522 | return this.nextOp.step(value, source, key); 523 | } 524 | return true; 525 | } 526 | } 527 | 528 | class FindOp extends QueryOp 529 | { 530 | constructor(finder, defaultValue) 531 | { 532 | super(); 533 | this.defaultValue = defaultValue; 534 | this.finder = finder; 535 | } 536 | 537 | initialize(sources) 538 | { 539 | this.memo = { value: this.defaultValue }; 540 | } 541 | 542 | flush(sources) 543 | { 544 | return this.memo.value; 545 | } 546 | 547 | step(value, source, key) 548 | { 549 | // if the `finder` returns true, short-circuit the query. 550 | return !this.finder(value, key, this.memo); 551 | } 552 | } 553 | 554 | class GroupOp extends QueryOp 555 | { 556 | constructor(keySelector) 557 | { 558 | super(); 559 | this.keySelector = keySelector; 560 | } 561 | 562 | initialize() 563 | { 564 | this.groupMap = new Map(); 565 | } 566 | 567 | flush() 568 | { 569 | const groups = {}; 570 | for (const [ key, list ] of this.groupMap.entries()) 571 | groups[key] = list; 572 | return groups; 573 | } 574 | 575 | step(value) 576 | { 577 | const key = this.keySelector(value); 578 | let list = this.groupMap.get(key); 579 | if (list === undefined) 580 | this.groupMap.set(key, list = []); 581 | list.push(value); 582 | return true; 583 | } 584 | } 585 | 586 | class LastOp extends QueryOp 587 | { 588 | constructor(predicate) 589 | { 590 | super(); 591 | this.predicate = predicate; 592 | } 593 | 594 | initialize() 595 | { 596 | this.lastValue = undefined; 597 | } 598 | 599 | flush() 600 | { 601 | return this.lastValue; 602 | } 603 | 604 | step(value, source, key) 605 | { 606 | if (this.predicate(value, key)) 607 | this.lastValue = value; 608 | return true; 609 | } 610 | } 611 | 612 | class RemoveOp extends QueryOp 613 | { 614 | constructor(predicate) 615 | { 616 | super(); 617 | this.predicate = predicate; 618 | } 619 | 620 | initialize(sources) 621 | { 622 | if (sources === undefined) 623 | throw new TypeError("remove() cannot be used with transformations"); 624 | this.removals = []; 625 | } 626 | 627 | flush(sources) 628 | { 629 | let r = 0; 630 | for (let m = 0, len = sources.length; m < len; ++m) { 631 | const source = sources[m]; 632 | if (isArrayLike(source)) { 633 | let j = 0; 634 | for (let i = 0, len = source.length; i < len; ++i) { 635 | if (r < this.removals.length && i === this.removals[r].key) { 636 | // note: array length is adjusted after the loop. 637 | ++r; 638 | continue; 639 | } 640 | source[j++] = source[i]; 641 | } 642 | source.length = j; 643 | } 644 | else { 645 | for (let i = 0, len = this.removals.length; i < len; ++i) { 646 | if (this.removals[i].source === source) 647 | delete source[this.removals[i].key]; 648 | } 649 | } 650 | } 651 | } 652 | 653 | step(value, source, key) 654 | { 655 | if (this.predicate === undefined || this.predicate(value, key)) 656 | this.removals.push({ source, key }); 657 | return true; 658 | } 659 | } 660 | 661 | class ReverseOp extends ThruOp 662 | { 663 | constructor() 664 | { 665 | super(); 666 | } 667 | 668 | initialize() 669 | { 670 | this.values = []; 671 | super.initialize(); 672 | } 673 | 674 | flush() 675 | { 676 | if (this.nextOp instanceof ThruOp) { 677 | this.values.reverse(); 678 | this.nextOp.values = this.values; 679 | } 680 | else { 681 | const length = this.values.length; 682 | let start = length - 1; 683 | if (this.nextOp instanceof SkipOp) { 684 | start -= this.nextOp.left; 685 | this.nextOp.left = 0; 686 | } 687 | for (let i = start; i >= 0; --i) { 688 | if (!this.nextOp.step(this.values[i], this.values, i)) 689 | break; 690 | } 691 | } 692 | return this.nextOp.flush(); 693 | } 694 | 695 | step(value) 696 | { 697 | this.values.push(value); 698 | return true; 699 | } 700 | } 701 | 702 | class SelectOp extends QueryOp 703 | { 704 | constructor(selector) 705 | { 706 | super(); 707 | this.selector = selector; 708 | } 709 | 710 | step(value, source, key) 711 | { 712 | const newValue = this.selector(value, key); 713 | return this.nextOp.step(newValue, source, key); 714 | } 715 | } 716 | 717 | class SelectManyOp extends QueryOp 718 | { 719 | constructor(selector) 720 | { 721 | super(); 722 | this.selector = selector; 723 | } 724 | 725 | initialize() 726 | { 727 | // don't pass the sources through. OverOp is not implemented as a 728 | // ThruOp to avoid the creation of a temp array but it's still a 729 | // transformative operation so we don't want to allow use of remove() 730 | // or update() after this. 731 | super.initialize(); 732 | } 733 | 734 | step(value, source, key) 735 | { 736 | const itemSource = this.selector(value, key); 737 | return feedMeSeymour(this.nextOp, itemSource); 738 | } 739 | } 740 | 741 | class SingleOp extends QueryOp 742 | { 743 | constructor(predicate) 744 | { 745 | super(); 746 | this.predicate = predicate; 747 | } 748 | 749 | initialize() 750 | { 751 | this.lastValue = undefined; 752 | this.count = 0; 753 | } 754 | 755 | flush() 756 | { 757 | return this.lastValue; 758 | } 759 | 760 | step(value, source, key) 761 | { 762 | if (this.predicate(value, key)) { 763 | if (++this.count > 1) 764 | throw new Error("Query would return too many results"); 765 | this.lastValue = value; 766 | } 767 | return true; 768 | } 769 | } 770 | 771 | class SkipOp extends QueryOp 772 | { 773 | constructor(count) 774 | { 775 | super(); 776 | this.count = count; 777 | } 778 | 779 | initialize(sources) 780 | { 781 | this.left = this.count; 782 | super.initialize(sources); 783 | } 784 | 785 | step(value, source, key) 786 | { 787 | return this.left-- <= 0 788 | ? this.nextOp.step(value, source, key) 789 | : true; 790 | } 791 | } 792 | 793 | class SkipLastOp extends QueryOp 794 | { 795 | constructor(count) 796 | { 797 | super(); 798 | this.count = count; 799 | } 800 | 801 | initialize(sources) 802 | { 803 | this.buffer = new Array(this.count); 804 | this.ptr = 0; 805 | this.left = this.count; 806 | super.initialize(sources); 807 | } 808 | 809 | step(value, source, key) 810 | { 811 | const nextUp = this.buffer[this.ptr]; 812 | this.buffer[this.ptr] = { value, key, source }; 813 | this.ptr = (this.ptr + 1) % this.count; 814 | return this.left-- <= 0 815 | ? this.nextOp.step(nextUp.value, nextUp.source, nextUp.key) 816 | : true; 817 | } 818 | } 819 | 820 | class SkipWhileOp extends QueryOp 821 | { 822 | constructor(predicate) 823 | { 824 | super(); 825 | this.predicate = predicate; 826 | } 827 | 828 | initialize(sources) 829 | { 830 | this.onTheTake = false; 831 | super.initialize(sources); 832 | } 833 | 834 | step(value, source, key) 835 | { 836 | if (!this.onTheTake) 837 | this.onTheTake = !this.predicate(value, key); 838 | return this.onTheTake 839 | ? this.nextOp.step(value, source, key) 840 | : true; 841 | } 842 | } 843 | 844 | class TakeOp extends QueryOp 845 | { 846 | constructor(count) 847 | { 848 | super(); 849 | this.count = count; 850 | } 851 | 852 | initialize(sources) 853 | { 854 | this.left = this.count; 855 | super.initialize(sources); 856 | } 857 | 858 | step(value, source, key) 859 | { 860 | return this.left-- > 0 861 | ? this.nextOp.step(value, source, key) 862 | : false; 863 | } 864 | } 865 | 866 | class TakeWhileOp extends QueryOp 867 | { 868 | constructor(predicate) 869 | { 870 | super(); 871 | this.predicate = predicate; 872 | } 873 | 874 | initialize(sources) 875 | { 876 | this.onTheTake = true; 877 | super.initialize(sources); 878 | } 879 | 880 | step(value, source, key) 881 | { 882 | if (this.onTheTake) 883 | this.onTheTake = this.predicate(value, key); 884 | return this.onTheTake 885 | ? this.nextOp.step(value, source, key) 886 | : false; 887 | } 888 | } 889 | 890 | class ToArrayOp extends ThruOp 891 | { 892 | constructor() 893 | { 894 | super(); 895 | } 896 | 897 | initialize() 898 | { 899 | this.values = []; 900 | super.initialize(); 901 | } 902 | 903 | flush() 904 | { 905 | return this.values; 906 | } 907 | 908 | step(value) 909 | { 910 | this.values.push(value); 911 | return true; 912 | } 913 | } 914 | 915 | class UpdateOp extends QueryOp 916 | { 917 | constructor(selector) 918 | { 919 | super(); 920 | this.selector = selector; 921 | } 922 | 923 | initialize(sources) 924 | { 925 | if (sources === undefined) 926 | throw new TypeError("update() cannot be used with transformations"); 927 | } 928 | 929 | step(value, source, key) 930 | { 931 | source[key] = this.selector(value, key); 932 | return true; 933 | } 934 | } 935 | 936 | class WhereOp extends QueryOp 937 | { 938 | constructor(predicate) 939 | { 940 | super(); 941 | this.predicate = predicate; 942 | } 943 | 944 | step(value, source, key) 945 | { 946 | return this.predicate(value, key) 947 | ? this.nextOp.step(value, source, key) 948 | : true; 949 | } 950 | } 951 | 952 | class WithoutOp extends QueryOp 953 | { 954 | constructor(values) 955 | { 956 | super(); 957 | this.values = new Set(values); 958 | } 959 | 960 | step(value, source, key) 961 | { 962 | return (!this.values.has(value)) 963 | ? this.nextOp.step(value, source, key) 964 | : true; 965 | } 966 | } 967 | 968 | class ZipOp extends QueryOp 969 | { 970 | constructor(source, selector) 971 | { 972 | super(); 973 | this.source = source; 974 | this.selector = selector; 975 | } 976 | 977 | initialize(sources) 978 | { 979 | this.iterator = this.source[Symbol.iterator](); 980 | super.initialize(sources); 981 | } 982 | 983 | step(value, source, key) 984 | { 985 | const iterResult = this.iterator.next(); 986 | if (iterResult.done) 987 | return false; 988 | const newValue = this.selector(value, iterResult.value, key); 989 | return this.nextOp.step(newValue, source, key); 990 | } 991 | } 992 | 993 | function isArrayLike(value) 994 | { 995 | // note: strings have a numeric `.length`, but aren't usually treated as 996 | // collections, so this check is purposely designed to exclude them. 997 | return value !== null && typeof value === 'object' 998 | && typeof value.length === 'number'; 999 | } 1000 | 1001 | function isIterable(value) 1002 | { 1003 | return value !== null && value !== undefined 1004 | && typeof value[Symbol.iterator] === 'function'; 1005 | } 1006 | 1007 | function feedMeSeymour(queryOp, source) 1008 | { 1009 | if (isArrayLike(source)) { 1010 | let start = 0; 1011 | if (queryOp instanceof SkipOp) { 1012 | start = queryOp.left; 1013 | queryOp.left -= source.length; 1014 | if (queryOp.left < 0) 1015 | queryOp.left = 0; 1016 | } 1017 | for (let i = start, len = source.length; i < len; ++i) { 1018 | const value = source[i]; 1019 | if (!queryOp.step(value, source, i)) 1020 | return false; 1021 | } 1022 | } 1023 | else if (isIterable(source)) { 1024 | for (const value of source) { 1025 | if (!queryOp.step(value, source)) 1026 | return false; 1027 | } 1028 | } 1029 | else if (source instanceof Query) { 1030 | if (!source.all((it, k, s) => queryOp.step(it, s, k))) 1031 | return false; 1032 | } 1033 | else { 1034 | const keys = Object.keys(source); 1035 | let start = 0; 1036 | if (queryOp instanceof SkipOp) { 1037 | start = queryOp.left; 1038 | queryOp.left -= source.length; 1039 | if (queryOp.left < 0) 1040 | queryOp.left = 0; 1041 | } 1042 | for (let i = start, len = keys.length; i < len; ++i) { 1043 | const value = source[keys[i]]; 1044 | if (!queryOp.step(value, source, keys[i])) 1045 | return false; 1046 | } 1047 | } 1048 | return true; 1049 | } 1050 | 1051 | function always() 1052 | { 1053 | return true; 1054 | } 1055 | 1056 | function identity(value) 1057 | { 1058 | return value; 1059 | } 1060 | 1061 | function tupleify(lValue, rValue) 1062 | { 1063 | return [ lValue, rValue ]; 1064 | } 1065 | --------------------------------------------------------------------------------