├── .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 |
14 |
--------------------------------------------------------------------------------
/web/logo-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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 | [](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 |
--------------------------------------------------------------------------------