diff options
Diffstat (limited to 'platform/web/js')
-rw-r--r-- | platform/web/js/engine/config.js | 358 | ||||
-rw-r--r-- | platform/web/js/engine/engine.externs.js | 4 | ||||
-rw-r--r-- | platform/web/js/engine/engine.js | 281 | ||||
-rw-r--r-- | platform/web/js/engine/preloader.js | 133 | ||||
-rw-r--r-- | platform/web/js/jsdoc2rst/publish.js | 354 | ||||
-rw-r--r-- | platform/web/js/libs/audio.worklet.js | 211 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_audio.js | 484 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_display.js | 754 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_fetch.js | 247 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_input.js | 549 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_javascript_singleton.js | 346 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_os.js | 427 | ||||
-rw-r--r-- | platform/web/js/libs/library_godot_runtime.js | 134 |
13 files changed, 4282 insertions, 0 deletions
diff --git a/platform/web/js/engine/config.js b/platform/web/js/engine/config.js new file mode 100644 index 0000000000..9c4b6c2012 --- /dev/null +++ b/platform/web/js/engine/config.js @@ -0,0 +1,358 @@ +/** + * An object used to configure the Engine instance based on godot export options, and to override those in custom HTML + * templates if needed. + * + * @header Engine configuration + * @summary The Engine configuration object. This is just a typedef, create it like a regular object, e.g.: + * + * ``const MyConfig = { executable: 'godot', unloadAfterInit: false }`` + * + * @typedef {Object} EngineConfig + */ +const EngineConfig = {}; // eslint-disable-line no-unused-vars + +/** + * @struct + * @constructor + * @ignore + */ +const InternalConfig = function (initConfig) { // eslint-disable-line no-unused-vars + const cfg = /** @lends {InternalConfig.prototype} */ { + /** + * Whether the unload the engine automatically after the instance is initialized. + * + * @memberof EngineConfig + * @default + * @type {boolean} + */ + unloadAfterInit: true, + /** + * The HTML DOM Canvas object to use. + * + * By default, the first canvas element in the document will be used is none is specified. + * + * @memberof EngineConfig + * @default + * @type {?HTMLCanvasElement} + */ + canvas: null, + /** + * The name of the WASM file without the extension. (Set by Godot Editor export process). + * + * @memberof EngineConfig + * @default + * @type {string} + */ + executable: '', + /** + * An alternative name for the game pck to load. The executable name is used otherwise. + * + * @memberof EngineConfig + * @default + * @type {?string} + */ + mainPack: null, + /** + * Specify a language code to select the proper localization for the game. + * + * The browser locale will be used if none is specified. See complete list of + * :ref:`supported locales <doc_locales>`. + * + * @memberof EngineConfig + * @type {?string} + * @default + */ + locale: null, + /** + * The canvas resize policy determines how the canvas should be resized by Godot. + * + * ``0`` means Godot won't do any resizing. This is useful if you want to control the canvas size from + * javascript code in your template. + * + * ``1`` means Godot will resize the canvas on start, and when changing window size via engine functions. + * + * ``2`` means Godot will adapt the canvas size to match the whole browser window. + * + * @memberof EngineConfig + * @type {number} + * @default + */ + canvasResizePolicy: 2, + /** + * The arguments to be passed as command line arguments on startup. + * + * See :ref:`command line tutorial <doc_command_line_tutorial>`. + * + * **Note**: :js:meth:`startGame <Engine.prototype.startGame>` will always add the ``--main-pack`` argument. + * + * @memberof EngineConfig + * @type {Array<string>} + * @default + */ + args: [], + /** + * When enabled, the game canvas will automatically grab the focus when the engine starts. + * + * @memberof EngineConfig + * @type {boolean} + * @default + */ + focusCanvas: true, + /** + * When enabled, this will turn on experimental virtual keyboard support on mobile. + * + * @memberof EngineConfig + * @type {boolean} + * @default + */ + experimentalVK: false, + /** + * The progressive web app service worker to install. + * @memberof EngineConfig + * @default + * @type {string} + */ + serviceWorker: '', + /** + * @ignore + * @type {Array.<string>} + */ + persistentPaths: ['/userfs'], + /** + * @ignore + * @type {boolean} + */ + persistentDrops: false, + /** + * @ignore + * @type {Array.<string>} + */ + gdnativeLibs: [], + /** + * @ignore + * @type {Array.<string>} + */ + fileSizes: [], + /** + * A callback function for handling Godot's ``OS.execute`` calls. + * + * This is for example used in the Web Editor template to switch between project manager and editor, and for running the game. + * + * @callback EngineConfig.onExecute + * @param {string} path The path that Godot's wants executed. + * @param {Array.<string>} args The arguments of the "command" to execute. + */ + /** + * @ignore + * @type {?function(string, Array.<string>)} + */ + onExecute: null, + /** + * A callback function for being notified when the Godot instance quits. + * + * **Note**: This function will not be called if the engine crashes or become unresponsive. + * + * @callback EngineConfig.onExit + * @param {number} status_code The status code returned by Godot on exit. + */ + /** + * @ignore + * @type {?function(number)} + */ + onExit: null, + /** + * A callback function for displaying download progress. + * + * The function is called once per frame while downloading files, so the usage of ``requestAnimationFrame()`` + * is not necessary. + * + * If the callback function receives a total amount of bytes as 0, this means that it is impossible to calculate. + * Possible reasons include: + * + * - Files are delivered with server-side chunked compression + * - Files are delivered with server-side compression on Chromium + * - Not all file downloads have started yet (usually on servers without multi-threading) + * + * @callback EngineConfig.onProgress + * @param {number} current The current amount of downloaded bytes so far. + * @param {number} total The total amount of bytes to be downloaded. + */ + /** + * @ignore + * @type {?function(number, number)} + */ + onProgress: null, + /** + * A callback function for handling the standard output stream. This method should usually only be used in debug pages. + * + * By default, ``console.log()`` is used. + * + * @callback EngineConfig.onPrint + * @param {...*} [var_args] A variadic number of arguments to be printed. + */ + /** + * @ignore + * @type {?function(...*)} + */ + onPrint: function () { + console.log.apply(console, Array.from(arguments)); // eslint-disable-line no-console + }, + /** + * A callback function for handling the standard error stream. This method should usually only be used in debug pages. + * + * By default, ``console.error()`` is used. + * + * @callback EngineConfig.onPrintError + * @param {...*} [var_args] A variadic number of arguments to be printed as errors. + */ + /** + * @ignore + * @type {?function(...*)} + */ + onPrintError: function (var_args) { + console.error.apply(console, Array.from(arguments)); // eslint-disable-line no-console + }, + }; + + /** + * @ignore + * @struct + * @constructor + * @param {EngineConfig} opts + */ + function Config(opts) { + this.update(opts); + } + + Config.prototype = cfg; + + /** + * @ignore + * @param {EngineConfig} opts + */ + Config.prototype.update = function (opts) { + const config = opts || {}; + // NOTE: We must explicitly pass the default, accessing it via + // the key will fail due to closure compiler renames. + function parse(key, def) { + if (typeof (config[key]) === 'undefined') { + return def; + } + return config[key]; + } + // Module config + this.unloadAfterInit = parse('unloadAfterInit', this.unloadAfterInit); + this.onPrintError = parse('onPrintError', this.onPrintError); + this.onPrint = parse('onPrint', this.onPrint); + this.onProgress = parse('onProgress', this.onProgress); + + // Godot config + this.canvas = parse('canvas', this.canvas); + this.executable = parse('executable', this.executable); + this.mainPack = parse('mainPack', this.mainPack); + this.locale = parse('locale', this.locale); + this.canvasResizePolicy = parse('canvasResizePolicy', this.canvasResizePolicy); + this.persistentPaths = parse('persistentPaths', this.persistentPaths); + this.persistentDrops = parse('persistentDrops', this.persistentDrops); + this.experimentalVK = parse('experimentalVK', this.experimentalVK); + this.focusCanvas = parse('focusCanvas', this.focusCanvas); + this.serviceWorker = parse('serviceWorker', this.serviceWorker); + this.gdnativeLibs = parse('gdnativeLibs', this.gdnativeLibs); + this.fileSizes = parse('fileSizes', this.fileSizes); + this.args = parse('args', this.args); + this.onExecute = parse('onExecute', this.onExecute); + this.onExit = parse('onExit', this.onExit); + }; + + /** + * @ignore + * @param {string} loadPath + * @param {Response} response + */ + Config.prototype.getModuleConfig = function (loadPath, response) { + let r = response; + return { + 'print': this.onPrint, + 'printErr': this.onPrintError, + 'thisProgram': this.executable, + 'noExitRuntime': true, + 'dynamicLibraries': [`${loadPath}.side.wasm`], + 'instantiateWasm': function (imports, onSuccess) { + function done(result) { + onSuccess(result['instance'], result['module']); + } + if (typeof (WebAssembly.instantiateStreaming) !== 'undefined') { + WebAssembly.instantiateStreaming(Promise.resolve(r), imports).then(done); + } else { + r.arrayBuffer().then(function (buffer) { + WebAssembly.instantiate(buffer, imports).then(done); + }); + } + r = null; + return {}; + }, + 'locateFile': function (path) { + if (path.endsWith('.worker.js')) { + return `${loadPath}.worker.js`; + } else if (path.endsWith('.audio.worklet.js')) { + return `${loadPath}.audio.worklet.js`; + } else if (path.endsWith('.js')) { + return `${loadPath}.js`; + } else if (path.endsWith('.side.wasm')) { + return `${loadPath}.side.wasm`; + } else if (path.endsWith('.wasm')) { + return `${loadPath}.wasm`; + } + return path; + }, + }; + }; + + /** + * @ignore + * @param {function()} cleanup + */ + Config.prototype.getGodotConfig = function (cleanup) { + // Try to find a canvas + if (!(this.canvas instanceof HTMLCanvasElement)) { + const nodes = document.getElementsByTagName('canvas'); + if (nodes.length && nodes[0] instanceof HTMLCanvasElement) { + this.canvas = nodes[0]; + } + if (!this.canvas) { + throw new Error('No canvas found in page'); + } + } + // Canvas can grab focus on click, or key events won't work. + if (this.canvas.tabIndex < 0) { + this.canvas.tabIndex = 0; + } + + // Browser locale, or custom one if defined. + let locale = this.locale; + if (!locale) { + locale = navigator.languages ? navigator.languages[0] : navigator.language; + locale = locale.split('.')[0]; + } + locale = locale.replace('-', '_'); + const onExit = this.onExit; + + // Godot configuration. + return { + 'canvas': this.canvas, + 'canvasResizePolicy': this.canvasResizePolicy, + 'locale': locale, + 'persistentDrops': this.persistentDrops, + 'virtualKeyboard': this.experimentalVK, + 'focusCanvas': this.focusCanvas, + 'onExecute': this.onExecute, + 'onExit': function (p_code) { + cleanup(); // We always need to call the cleanup callback to free memory. + if (typeof (onExit) === 'function') { + onExit(p_code); + } + }, + }; + }; + return new Config(initConfig); +}; diff --git a/platform/web/js/engine/engine.externs.js b/platform/web/js/engine/engine.externs.js new file mode 100644 index 0000000000..35a66a93ae --- /dev/null +++ b/platform/web/js/engine/engine.externs.js @@ -0,0 +1,4 @@ +var Godot; +var WebAssembly = {}; +WebAssembly.instantiate = function(buffer, imports) {}; +WebAssembly.instantiateStreaming = function(response, imports) {}; diff --git a/platform/web/js/engine/engine.js b/platform/web/js/engine/engine.js new file mode 100644 index 0000000000..6f0d51b2be --- /dev/null +++ b/platform/web/js/engine/engine.js @@ -0,0 +1,281 @@ +/** + * Projects exported for the Web expose the :js:class:`Engine` class to the JavaScript environment, that allows + * fine control over the engine's start-up process. + * + * This API is built in an asynchronous manner and requires basic understanding + * of `Promises <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises>`__. + * + * @module Engine + * @header Web export JavaScript reference + */ +const Engine = (function () { + const preloader = new Preloader(); + + let loadPromise = null; + let loadPath = ''; + let initPromise = null; + + /** + * @classdesc The ``Engine`` class provides methods for loading and starting exported projects on the Web. For default export + * settings, this is already part of the exported HTML page. To understand practical use of the ``Engine`` class, + * see :ref:`Custom HTML page for Web export <doc_customizing_html5_shell>`. + * + * @description Create a new Engine instance with the given configuration. + * + * @global + * @constructor + * @param {EngineConfig} initConfig The initial config for this instance. + */ + function Engine(initConfig) { // eslint-disable-line no-shadow + this.config = new InternalConfig(initConfig); + this.rtenv = null; + } + + /** + * Load the engine from the specified base path. + * + * @param {string} basePath Base path of the engine to load. + * @param {number=} [size=0] The file size if known. + * @returns {Promise} A Promise that resolves once the engine is loaded. + * + * @function Engine.load + */ + Engine.load = function (basePath, size) { + if (loadPromise == null) { + loadPath = basePath; + loadPromise = preloader.loadPromise(`${loadPath}.wasm`, size, true); + requestAnimationFrame(preloader.animateProgress); + } + return loadPromise; + }; + + /** + * Unload the engine to free memory. + * + * This method will be called automatically depending on the configuration. See :js:attr:`unloadAfterInit`. + * + * @function Engine.unload + */ + Engine.unload = function () { + loadPromise = null; + }; + + /** + * Check whether WebGL is available. Optionally, specify a particular version of WebGL to check for. + * + * @param {number=} [majorVersion=1] The major WebGL version to check for. + * @returns {boolean} If the given major version of WebGL is available. + * @function Engine.isWebGLAvailable + */ + Engine.isWebGLAvailable = function (majorVersion = 1) { + try { + return !!document.createElement('canvas').getContext(['webgl', 'webgl2'][majorVersion - 1]); + } catch (e) { /* Not available */ } + return false; + }; + + /** + * Safe Engine constructor, creates a new prototype for every new instance to avoid prototype pollution. + * @ignore + * @constructor + */ + function SafeEngine(initConfig) { + const proto = /** @lends Engine.prototype */ { + /** + * Initialize the engine instance. Optionally, pass the base path to the engine to load it, + * if it hasn't been loaded yet. See :js:meth:`Engine.load`. + * + * @param {string=} basePath Base path of the engine to load. + * @return {Promise} A ``Promise`` that resolves once the engine is loaded and initialized. + */ + init: function (basePath) { + if (initPromise) { + return initPromise; + } + if (loadPromise == null) { + if (!basePath) { + initPromise = Promise.reject(new Error('A base path must be provided when calling `init` and the engine is not loaded.')); + return initPromise; + } + Engine.load(basePath, this.config.fileSizes[`${basePath}.wasm`]); + } + const me = this; + function doInit(promise) { + // Care! Promise chaining is bogus with old emscripten versions. + // This caused a regression with the Mono build (which uses an older emscripten version). + // Make sure to test that when refactoring. + return new Promise(function (resolve, reject) { + promise.then(function (response) { + const cloned = new Response(response.clone().body, { 'headers': [['content-type', 'application/wasm']] }); + Godot(me.config.getModuleConfig(loadPath, cloned)).then(function (module) { + const paths = me.config.persistentPaths; + module['initFS'](paths).then(function (err) { + me.rtenv = module; + if (me.config.unloadAfterInit) { + Engine.unload(); + } + resolve(); + }); + }); + }); + }); + } + preloader.setProgressFunc(this.config.onProgress); + initPromise = doInit(loadPromise); + return initPromise; + }, + + /** + * Load a file so it is available in the instance's file system once it runs. Must be called **before** starting the + * instance. + * + * If not provided, the ``path`` is derived from the URL of the loaded file. + * + * @param {string|ArrayBuffer} file The file to preload. + * + * If a ``string`` the file will be loaded from that path. + * + * If an ``ArrayBuffer`` or a view on one, the buffer will used as the content of the file. + * + * @param {string=} path Path by which the file will be accessible. Required, if ``file`` is not a string. + * + * @returns {Promise} A Promise that resolves once the file is loaded. + */ + preloadFile: function (file, path) { + return preloader.preload(file, path, this.config.fileSizes[file]); + }, + + /** + * Start the engine instance using the given override configuration (if any). + * :js:meth:`startGame <Engine.prototype.startGame>` can be used in typical cases instead. + * + * This will initialize the instance if it is not initialized. For manual initialization, see :js:meth:`init <Engine.prototype.init>`. + * The engine must be loaded beforehand. + * + * Fails if a canvas cannot be found on the page, or not specified in the configuration. + * + * @param {EngineConfig} override An optional configuration override. + * @return {Promise} Promise that resolves once the engine started. + */ + start: function (override) { + this.config.update(override); + const me = this; + return me.init().then(function () { + if (!me.rtenv) { + return Promise.reject(new Error('The engine must be initialized before it can be started')); + } + + let config = {}; + try { + config = me.config.getGodotConfig(function () { + me.rtenv = null; + }); + } catch (e) { + return Promise.reject(e); + } + // Godot configuration. + me.rtenv['initConfig'](config); + + // Preload GDNative libraries. + const libs = []; + me.config.gdnativeLibs.forEach(function (lib) { + libs.push(me.rtenv['loadDynamicLibrary'](lib, { 'loadAsync': true })); + }); + return Promise.all(libs).then(function () { + return new Promise(function (resolve, reject) { + preloader.preloadedFiles.forEach(function (file) { + me.rtenv['copyToFS'](file.path, file.buffer); + }); + preloader.preloadedFiles.length = 0; // Clear memory + me.rtenv['callMain'](me.config.args); + initPromise = null; + if (me.config.serviceWorker && 'serviceWorker' in navigator) { + navigator.serviceWorker.register(me.config.serviceWorker); + } + resolve(); + }); + }); + }); + }, + + /** + * Start the game instance using the given configuration override (if any). + * + * This will initialize the instance if it is not initialized. For manual initialization, see :js:meth:`init <Engine.prototype.init>`. + * + * This will load the engine if it is not loaded, and preload the main pck. + * + * This method expects the initial config (or the override) to have both the :js:attr:`executable` and :js:attr:`mainPack` + * properties set (normally done by the editor during export). + * + * @param {EngineConfig} override An optional configuration override. + * @return {Promise} Promise that resolves once the game started. + */ + startGame: function (override) { + this.config.update(override); + // Add main-pack argument. + const exe = this.config.executable; + const pack = this.config.mainPack || `${exe}.pck`; + this.config.args = ['--main-pack', pack].concat(this.config.args); + // Start and init with execName as loadPath if not inited. + const me = this; + return Promise.all([ + this.init(exe), + this.preloadFile(pack, pack), + ]).then(function () { + return me.start.apply(me); + }); + }, + + /** + * Create a file at the specified ``path`` with the passed as ``buffer`` in the instance's file system. + * + * @param {string} path The location where the file will be created. + * @param {ArrayBuffer} buffer The content of the file. + */ + copyToFS: function (path, buffer) { + if (this.rtenv == null) { + throw new Error('Engine must be inited before copying files'); + } + this.rtenv['copyToFS'](path, buffer); + }, + + /** + * Request that the current instance quit. + * + * This is akin the user pressing the close button in the window manager, and will + * have no effect if the engine has crashed, or is stuck in a loop. + * + */ + requestQuit: function () { + if (this.rtenv) { + this.rtenv['request_quit'](); + } + }, + }; + + Engine.prototype = proto; + // Closure compiler exported instance methods. + Engine.prototype['init'] = Engine.prototype.init; + Engine.prototype['preloadFile'] = Engine.prototype.preloadFile; + Engine.prototype['start'] = Engine.prototype.start; + Engine.prototype['startGame'] = Engine.prototype.startGame; + Engine.prototype['copyToFS'] = Engine.prototype.copyToFS; + Engine.prototype['requestQuit'] = Engine.prototype.requestQuit; + // Also expose static methods as instance methods + Engine.prototype['load'] = Engine.load; + Engine.prototype['unload'] = Engine.unload; + Engine.prototype['isWebGLAvailable'] = Engine.isWebGLAvailable; + return new Engine(initConfig); + } + + // Closure compiler exported static methods. + SafeEngine['load'] = Engine.load; + SafeEngine['unload'] = Engine.unload; + SafeEngine['isWebGLAvailable'] = Engine.isWebGLAvailable; + + return SafeEngine; +}()); +if (typeof window !== 'undefined') { + window['Engine'] = Engine; +} diff --git a/platform/web/js/engine/preloader.js b/platform/web/js/engine/preloader.js new file mode 100644 index 0000000000..564c68d264 --- /dev/null +++ b/platform/web/js/engine/preloader.js @@ -0,0 +1,133 @@ +const Preloader = /** @constructor */ function () { // eslint-disable-line no-unused-vars + function getTrackedResponse(response, load_status) { + function onloadprogress(reader, controller) { + return reader.read().then(function (result) { + if (load_status.done) { + return Promise.resolve(); + } + if (result.value) { + controller.enqueue(result.value); + load_status.loaded += result.value.length; + } + if (!result.done) { + return onloadprogress(reader, controller); + } + load_status.done = true; + return Promise.resolve(); + }); + } + const reader = response.body.getReader(); + return new Response(new ReadableStream({ + start: function (controller) { + onloadprogress(reader, controller).then(function () { + controller.close(); + }); + }, + }), { headers: response.headers }); + } + + function loadFetch(file, tracker, fileSize, raw) { + tracker[file] = { + total: fileSize || 0, + loaded: 0, + done: false, + }; + return fetch(file).then(function (response) { + if (!response.ok) { + return Promise.reject(new Error(`Failed loading file '${file}'`)); + } + const tr = getTrackedResponse(response, tracker[file]); + if (raw) { + return Promise.resolve(tr); + } + return tr.arrayBuffer(); + }); + } + + function retry(func, attempts = 1) { + function onerror(err) { + if (attempts <= 1) { + return Promise.reject(err); + } + return new Promise(function (resolve, reject) { + setTimeout(function () { + retry(func, attempts - 1).then(resolve).catch(reject); + }, 1000); + }); + } + return func().catch(onerror); + } + + const DOWNLOAD_ATTEMPTS_MAX = 4; + const loadingFiles = {}; + const lastProgress = { loaded: 0, total: 0 }; + let progressFunc = null; + + const animateProgress = function () { + let loaded = 0; + let total = 0; + let totalIsValid = true; + let progressIsFinal = true; + + Object.keys(loadingFiles).forEach(function (file) { + const stat = loadingFiles[file]; + if (!stat.done) { + progressIsFinal = false; + } + if (!totalIsValid || stat.total === 0) { + totalIsValid = false; + total = 0; + } else { + total += stat.total; + } + loaded += stat.loaded; + }); + if (loaded !== lastProgress.loaded || total !== lastProgress.total) { + lastProgress.loaded = loaded; + lastProgress.total = total; + if (typeof progressFunc === 'function') { + progressFunc(loaded, total); + } + } + if (!progressIsFinal) { + requestAnimationFrame(animateProgress); + } + }; + + this.animateProgress = animateProgress; + + this.setProgressFunc = function (callback) { + progressFunc = callback; + }; + + this.loadPromise = function (file, fileSize, raw = false) { + return retry(loadFetch.bind(null, file, loadingFiles, fileSize, raw), DOWNLOAD_ATTEMPTS_MAX); + }; + + this.preloadedFiles = []; + this.preload = function (pathOrBuffer, destPath, fileSize) { + let buffer = null; + if (typeof pathOrBuffer === 'string') { + const me = this; + return this.loadPromise(pathOrBuffer, fileSize).then(function (buf) { + me.preloadedFiles.push({ + path: destPath || pathOrBuffer, + buffer: buf, + }); + return Promise.resolve(); + }); + } else if (pathOrBuffer instanceof ArrayBuffer) { + buffer = new Uint8Array(pathOrBuffer); + } else if (ArrayBuffer.isView(pathOrBuffer)) { + buffer = new Uint8Array(pathOrBuffer.buffer); + } + if (buffer) { + this.preloadedFiles.push({ + path: destPath, + buffer: pathOrBuffer, + }); + return Promise.resolve(); + } + return Promise.reject(new Error('Invalid object for preloading')); + }; +}; diff --git a/platform/web/js/jsdoc2rst/publish.js b/platform/web/js/jsdoc2rst/publish.js new file mode 100644 index 0000000000..ad9c0fbaaa --- /dev/null +++ b/platform/web/js/jsdoc2rst/publish.js @@ -0,0 +1,354 @@ +/* eslint-disable strict */ + +'use strict'; + +const fs = require('fs'); + +class JSDoclet { + constructor(doc) { + this.doc = doc; + this.description = doc['description'] || ''; + this.name = doc['name'] || 'unknown'; + this.longname = doc['longname'] || ''; + this.types = []; + if (doc['type'] && doc['type']['names']) { + this.types = doc['type']['names'].slice(); + } + this.type = this.types.length > 0 ? this.types.join('\\|') : '*'; + this.variable = doc['variable'] || false; + this.kind = doc['kind'] || ''; + this.memberof = doc['memberof'] || null; + this.scope = doc['scope'] || ''; + this.members = []; + this.optional = doc['optional'] || false; + this.defaultvalue = doc['defaultvalue']; + this.summary = doc['summary'] || null; + this.classdesc = doc['classdesc'] || null; + + // Parameters (functions) + this.params = []; + this.returns = doc['returns'] ? doc['returns'][0]['type']['names'][0] : 'void'; + this.returns_desc = doc['returns'] ? doc['returns'][0]['description'] : null; + + this.params = (doc['params'] || []).slice().map((p) => new JSDoclet(p)); + + // Custom tags + this.tags = doc['tags'] || []; + this.header = this.tags.filter((t) => t['title'] === 'header').map((t) => t['text']).pop() || null; + } + + add_member(obj) { + this.members.push(obj); + } + + is_static() { + return this.scope === 'static'; + } + + is_instance() { + return this.scope === 'instance'; + } + + is_object() { + return this.kind === 'Object' || (this.kind === 'typedef' && this.type === 'Object'); + } + + is_class() { + return this.kind === 'class'; + } + + is_function() { + return this.kind === 'function' || (this.kind === 'typedef' && this.type === 'function'); + } + + is_module() { + return this.kind === 'module'; + } +} + +function format_table(f, data, depth = 0) { + if (!data.length) { + return; + } + + const column_sizes = new Array(data[0].length).fill(0); + + data.forEach((row) => { + row.forEach((e, idx) => { + column_sizes[idx] = Math.max(e.length, column_sizes[idx]); + }); + }); + + const indent = ' '.repeat(depth); + let sep = indent; + column_sizes.forEach((size) => { + sep += '+'; + sep += '-'.repeat(size + 2); + }); + sep += '+\n'; + f.write(sep); + + data.forEach((row) => { + let row_text = `${indent}|`; + row.forEach((entry, idx) => { + row_text += ` ${entry.padEnd(column_sizes[idx])} |`; + }); + row_text += '\n'; + f.write(row_text); + f.write(sep); + }); + + f.write('\n'); +} + +function make_header(header, sep) { + return `${header}\n${sep.repeat(header.length)}\n\n`; +} + +function indent_multiline(text, depth) { + const indent = ' '.repeat(depth); + return text.split('\n').map((l) => (l === '' ? l : indent + l)).join('\n'); +} + +function make_rst_signature(obj, types = false, style = false) { + let out = ''; + const fmt = style ? '*' : ''; + obj.params.forEach((arg, idx) => { + if (idx > 0) { + if (arg.optional) { + out += ` ${fmt}[`; + } + out += ', '; + } else { + out += ' '; + if (arg.optional) { + out += `${fmt}[ `; + } + } + if (types) { + out += `${arg.type} `; + } + const variable = arg.variable ? '...' : ''; + const defval = arg.defaultvalue !== undefined ? `=${arg.defaultvalue}` : ''; + out += `${variable}${arg.name}${defval}`; + if (arg.optional) { + out += ` ]${fmt}`; + } + }); + out += ' '; + return out; +} + +function make_rst_param(f, obj, depth = 0) { + const indent = ' '.repeat(depth * 3); + f.write(indent); + f.write(`:param ${obj.type} ${obj.name}:\n`); + f.write(indent_multiline(obj.description, (depth + 1) * 3)); + f.write('\n\n'); +} + +function make_rst_attribute(f, obj, depth = 0, brief = false) { + const indent = ' '.repeat(depth * 3); + f.write(indent); + f.write(`.. js:attribute:: ${obj.name}\n\n`); + + if (brief) { + if (obj.summary) { + f.write(indent_multiline(obj.summary, (depth + 1) * 3)); + } + f.write('\n\n'); + return; + } + + f.write(indent_multiline(obj.description, (depth + 1) * 3)); + f.write('\n\n'); + + f.write(indent); + f.write(` :type: ${obj.type}\n\n`); + + if (obj.defaultvalue !== undefined) { + let defval = obj.defaultvalue; + if (defval === '') { + defval = '""'; + } + f.write(indent); + f.write(` :value: \`\`${defval}\`\`\n\n`); + } +} + +function make_rst_function(f, obj, depth = 0) { + let prefix = ''; + if (obj.is_instance()) { + prefix = 'prototype.'; + } + + const indent = ' '.repeat(depth * 3); + const sig = make_rst_signature(obj); + f.write(indent); + f.write(`.. js:function:: ${prefix}${obj.name}(${sig})\n`); + f.write('\n'); + + f.write(indent_multiline(obj.description, (depth + 1) * 3)); + f.write('\n\n'); + + obj.params.forEach((param) => { + make_rst_param(f, param, depth + 1); + }); + + if (obj.returns !== 'void') { + f.write(indent); + f.write(' :return:\n'); + f.write(indent_multiline(obj.returns_desc, (depth + 2) * 3)); + f.write('\n\n'); + f.write(indent); + f.write(` :rtype: ${obj.returns}\n\n`); + } +} + +function make_rst_object(f, obj) { + let brief = false; + // Our custom header flag. + if (obj.header !== null) { + f.write(make_header(obj.header, '-')); + f.write(`${obj.description}\n\n`); + brief = true; + } + + // Format members table and descriptions + const data = [['type', 'name']].concat(obj.members.map((m) => [m.type, `:js:attr:\`${m.name}\``])); + + f.write(make_header('Properties', '^')); + format_table(f, data, 0); + + make_rst_attribute(f, obj, 0, brief); + + if (!obj.members.length) { + return; + } + + f.write(' **Property Descriptions**\n\n'); + + // Properties first + obj.members.filter((m) => !m.is_function()).forEach((m) => { + make_rst_attribute(f, m, 1); + }); + + // Callbacks last + obj.members.filter((m) => m.is_function()).forEach((m) => { + make_rst_function(f, m, 1); + }); +} + +function make_rst_class(f, obj) { + const header = obj.header ? obj.header : obj.name; + f.write(make_header(header, '-')); + + if (obj.classdesc) { + f.write(`${obj.classdesc}\n\n`); + } + + const funcs = obj.members.filter((m) => m.is_function()); + function make_data(m) { + const base = m.is_static() ? obj.name : `${obj.name}.prototype`; + const params = make_rst_signature(m, true, true); + const sig = `:js:attr:\`${m.name} <${base}.${m.name}>\` **(**${params}**)**`; + return [m.returns, sig]; + } + const sfuncs = funcs.filter((m) => m.is_static()); + const ifuncs = funcs.filter((m) => !m.is_static()); + + f.write(make_header('Static Methods', '^')); + format_table(f, sfuncs.map((m) => make_data(m))); + + f.write(make_header('Instance Methods', '^')); + format_table(f, ifuncs.map((m) => make_data(m))); + + const sig = make_rst_signature(obj); + f.write(`.. js:class:: ${obj.name}(${sig})\n\n`); + f.write(indent_multiline(obj.description, 3)); + f.write('\n\n'); + + obj.params.forEach((p) => { + make_rst_param(f, p, 1); + }); + + f.write(' **Static Methods**\n\n'); + sfuncs.forEach((m) => { + make_rst_function(f, m, 1); + }); + + f.write(' **Instance Methods**\n\n'); + ifuncs.forEach((m) => { + make_rst_function(f, m, 1); + }); +} + +function make_rst_module(f, obj) { + const header = obj.header !== null ? obj.header : obj.name; + f.write(make_header(header, '=')); + f.write(obj.description); + f.write('\n\n'); +} + +function write_base_object(f, obj) { + if (obj.is_object()) { + make_rst_object(f, obj); + } else if (obj.is_function()) { + make_rst_function(f, obj); + } else if (obj.is_class()) { + make_rst_class(f, obj); + } else if (obj.is_module()) { + make_rst_module(f, obj); + } +} + +function generate(f, docs) { + const globs = []; + const SYMBOLS = {}; + docs.filter((d) => !d.ignore && d.kind !== 'package').forEach((d) => { + SYMBOLS[d.name] = d; + if (d.memberof) { + const up = SYMBOLS[d.memberof]; + if (up === undefined) { + console.log(d); // eslint-disable-line no-console + console.log(`Undefined symbol! ${d.memberof}`); // eslint-disable-line no-console + throw new Error('Undefined symbol!'); + } + SYMBOLS[d.memberof].add_member(d); + } else { + globs.push(d); + } + }); + + f.write('.. _doc_html5_shell_classref:\n\n'); + globs.forEach((obj) => write_base_object(f, obj)); +} + +/** + * Generate documentation output. + * + * @param {TAFFY} data - A TaffyDB collection representing + * all the symbols documented in your code. + * @param {object} opts - An object with options information. + */ +exports.publish = function (data, opts) { + const docs = data().get().filter((doc) => !doc.undocumented && !doc.ignore).map((doc) => new JSDoclet(doc)); + const dest = opts.destination; + if (dest === 'dry-run') { + process.stdout.write('Dry run... '); + generate({ + write: function () { /* noop */ }, + }, docs); + process.stdout.write('Okay!\n'); + return; + } + if (dest !== '' && !dest.endsWith('.rst')) { + throw new Error('Destination file must be either a ".rst" file, or an empty string (for printing to stdout)'); + } + if (dest !== '') { + const f = fs.createWriteStream(dest); + generate(f, docs); + } else { + generate(process.stdout, docs); + } +}; diff --git a/platform/web/js/libs/audio.worklet.js b/platform/web/js/libs/audio.worklet.js new file mode 100644 index 0000000000..ea4d8cb221 --- /dev/null +++ b/platform/web/js/libs/audio.worklet.js @@ -0,0 +1,211 @@ +/*************************************************************************/ +/* audio.worklet.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +class RingBuffer { + constructor(p_buffer, p_state, p_threads) { + this.buffer = p_buffer; + this.avail = p_state; + this.threads = p_threads; + this.rpos = 0; + this.wpos = 0; + } + + data_left() { + return this.threads ? Atomics.load(this.avail, 0) : this.avail; + } + + space_left() { + return this.buffer.length - this.data_left(); + } + + read(output) { + const size = this.buffer.length; + let from = 0; + let to_write = output.length; + if (this.rpos + to_write > size) { + const high = size - this.rpos; + output.set(this.buffer.subarray(this.rpos, size)); + from = high; + to_write -= high; + this.rpos = 0; + } + if (to_write) { + output.set(this.buffer.subarray(this.rpos, this.rpos + to_write), from); + } + this.rpos += to_write; + if (this.threads) { + Atomics.add(this.avail, 0, -output.length); + Atomics.notify(this.avail, 0); + } else { + this.avail -= output.length; + } + } + + write(p_buffer) { + const to_write = p_buffer.length; + const mw = this.buffer.length - this.wpos; + if (mw >= to_write) { + this.buffer.set(p_buffer, this.wpos); + this.wpos += to_write; + if (mw === to_write) { + this.wpos = 0; + } + } else { + const high = p_buffer.subarray(0, mw); + const low = p_buffer.subarray(mw); + this.buffer.set(high, this.wpos); + this.buffer.set(low); + this.wpos = low.length; + } + if (this.threads) { + Atomics.add(this.avail, 0, to_write); + Atomics.notify(this.avail, 0); + } else { + this.avail += to_write; + } + } +} + +class GodotProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.threads = false; + this.running = true; + this.lock = null; + this.notifier = null; + this.output = null; + this.output_buffer = new Float32Array(); + this.input = null; + this.input_buffer = new Float32Array(); + this.port.onmessage = (event) => { + const cmd = event.data['cmd']; + const data = event.data['data']; + this.parse_message(cmd, data); + }; + } + + process_notify() { + if (this.notifier) { + Atomics.add(this.notifier, 0, 1); + Atomics.notify(this.notifier, 0); + } + } + + parse_message(p_cmd, p_data) { + if (p_cmd === 'start' && p_data) { + const state = p_data[0]; + let idx = 0; + this.threads = true; + this.lock = state.subarray(idx, ++idx); + this.notifier = state.subarray(idx, ++idx); + const avail_in = state.subarray(idx, ++idx); + const avail_out = state.subarray(idx, ++idx); + this.input = new RingBuffer(p_data[1], avail_in, true); + this.output = new RingBuffer(p_data[2], avail_out, true); + } else if (p_cmd === 'stop') { + this.running = false; + this.output = null; + this.input = null; + } else if (p_cmd === 'start_nothreads') { + this.output = new RingBuffer(p_data[0], p_data[0].length, false); + } else if (p_cmd === 'chunk') { + this.output.write(p_data); + } + } + + static array_has_data(arr) { + return arr.length && arr[0].length && arr[0][0].length; + } + + process(inputs, outputs, parameters) { + if (!this.running) { + return false; // Stop processing. + } + if (this.output === null) { + return true; // Not ready yet, keep processing. + } + const process_input = GodotProcessor.array_has_data(inputs); + if (process_input) { + const input = inputs[0]; + const chunk = input[0].length * input.length; + if (this.input_buffer.length !== chunk) { + this.input_buffer = new Float32Array(chunk); + } + if (!this.threads) { + GodotProcessor.write_input(this.input_buffer, input); + this.port.postMessage({ 'cmd': 'input', 'data': this.input_buffer }); + } else if (this.input.space_left() >= chunk) { + GodotProcessor.write_input(this.input_buffer, input); + this.input.write(this.input_buffer); + } else { + this.port.postMessage('Input buffer is full! Skipping input frame.'); + } + } + const process_output = GodotProcessor.array_has_data(outputs); + if (process_output) { + const output = outputs[0]; + const chunk = output[0].length * output.length; + if (this.output_buffer.length !== chunk) { + this.output_buffer = new Float32Array(chunk); + } + if (this.output.data_left() >= chunk) { + this.output.read(this.output_buffer); + GodotProcessor.write_output(output, this.output_buffer); + if (!this.threads) { + this.port.postMessage({ 'cmd': 'read', 'data': chunk }); + } + } else { + this.port.postMessage('Output buffer has not enough frames! Skipping output frame.'); + } + } + this.process_notify(); + return true; + } + + static write_output(dest, source) { + const channels = dest.length; + for (let ch = 0; ch < channels; ch++) { + for (let sample = 0; sample < dest[ch].length; sample++) { + dest[ch][sample] = source[sample * channels + ch]; + } + } + } + + static write_input(dest, source) { + const channels = source.length; + for (let ch = 0; ch < channels; ch++) { + for (let sample = 0; sample < source[ch].length; sample++) { + dest[sample * channels + ch] = source[ch][sample]; + } + } + } +} + +registerProcessor('godot-processor', GodotProcessor); diff --git a/platform/web/js/libs/library_godot_audio.js b/platform/web/js/libs/library_godot_audio.js new file mode 100644 index 0000000000..756c1ac595 --- /dev/null +++ b/platform/web/js/libs/library_godot_audio.js @@ -0,0 +1,484 @@ +/*************************************************************************/ +/* library_godot_audio.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const GodotAudio = { + $GodotAudio__deps: ['$GodotRuntime', '$GodotOS'], + $GodotAudio: { + ctx: null, + input: null, + driver: null, + interval: 0, + + init: function (mix_rate, latency, onstatechange, onlatencyupdate) { + const opts = {}; + // If mix_rate is 0, let the browser choose. + if (mix_rate) { + opts['sampleRate'] = mix_rate; + } + // Do not specify, leave 'interactive' for good performance. + // opts['latencyHint'] = latency / 1000; + const ctx = new (window.AudioContext || window.webkitAudioContext)(opts); + GodotAudio.ctx = ctx; + ctx.onstatechange = function () { + let state = 0; + switch (ctx.state) { + case 'suspended': + state = 0; + break; + case 'running': + state = 1; + break; + case 'closed': + state = 2; + break; + + // no default + } + onstatechange(state); + }; + ctx.onstatechange(); // Immediately notify state. + // Update computed latency + GodotAudio.interval = setInterval(function () { + let computed_latency = 0; + if (ctx.baseLatency) { + computed_latency += GodotAudio.ctx.baseLatency; + } + if (ctx.outputLatency) { + computed_latency += GodotAudio.ctx.outputLatency; + } + onlatencyupdate(computed_latency); + }, 1000); + GodotOS.atexit(GodotAudio.close_async); + return ctx.destination.channelCount; + }, + + create_input: function (callback) { + if (GodotAudio.input) { + return 0; // Already started. + } + function gotMediaInput(stream) { + try { + GodotAudio.input = GodotAudio.ctx.createMediaStreamSource(stream); + callback(GodotAudio.input); + } catch (e) { + GodotRuntime.error('Failed creaating input.', e); + } + } + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + navigator.mediaDevices.getUserMedia({ + 'audio': true, + }).then(gotMediaInput, function (e) { + GodotRuntime.error('Error getting user media.', e); + }); + } else { + if (!navigator.getUserMedia) { + navigator.getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; + } + if (!navigator.getUserMedia) { + GodotRuntime.error('getUserMedia not available.'); + return 1; + } + navigator.getUserMedia({ + 'audio': true, + }, gotMediaInput, function (e) { + GodotRuntime.print(e); + }); + } + return 0; + }, + + close_async: function (resolve, reject) { + const ctx = GodotAudio.ctx; + GodotAudio.ctx = null; + // Audio was not initialized. + if (!ctx) { + resolve(); + return; + } + // Remove latency callback + if (GodotAudio.interval) { + clearInterval(GodotAudio.interval); + GodotAudio.interval = 0; + } + // Disconnect input, if it was started. + if (GodotAudio.input) { + GodotAudio.input.disconnect(); + GodotAudio.input = null; + } + // Disconnect output + let closed = Promise.resolve(); + if (GodotAudio.driver) { + closed = GodotAudio.driver.close(); + } + closed.then(function () { + return ctx.close(); + }).then(function () { + ctx.onstatechange = null; + resolve(); + }).catch(function (e) { + ctx.onstatechange = null; + GodotRuntime.error('Error closing AudioContext', e); + resolve(); + }); + }, + }, + + godot_audio_is_available__sig: 'i', + godot_audio_is_available__proxy: 'sync', + godot_audio_is_available: function () { + if (!(window.AudioContext || window.webkitAudioContext)) { + return 0; + } + return 1; + }, + + godot_audio_has_worklet__sig: 'i', + godot_audio_has_worklet: function () { + return (GodotAudio.ctx && GodotAudio.ctx.audioWorklet) ? 1 : 0; + }, + + godot_audio_has_script_processor__sig: 'i', + godot_audio_has_script_processor: function () { + return (GodotAudio.ctx && GodotAudio.ctx.createScriptProcessor) ? 1 : 0; + }, + + godot_audio_init__sig: 'iiiii', + godot_audio_init: function (p_mix_rate, p_latency, p_state_change, p_latency_update) { + const statechange = GodotRuntime.get_func(p_state_change); + const latencyupdate = GodotRuntime.get_func(p_latency_update); + const mix_rate = GodotRuntime.getHeapValue(p_mix_rate, 'i32'); + const channels = GodotAudio.init(mix_rate, p_latency, statechange, latencyupdate); + GodotRuntime.setHeapValue(p_mix_rate, GodotAudio.ctx.sampleRate, 'i32'); + return channels; + }, + + godot_audio_resume__sig: 'v', + godot_audio_resume: function () { + if (GodotAudio.ctx && GodotAudio.ctx.state !== 'running') { + GodotAudio.ctx.resume(); + } + }, + + godot_audio_capture_start__proxy: 'sync', + godot_audio_capture_start__sig: 'i', + godot_audio_capture_start: function () { + return GodotAudio.create_input(function (input) { + input.connect(GodotAudio.driver.get_node()); + }); + }, + + godot_audio_capture_stop__proxy: 'sync', + godot_audio_capture_stop__sig: 'v', + godot_audio_capture_stop: function () { + if (GodotAudio.input) { + const tracks = GodotAudio.input['mediaStream']['getTracks'](); + for (let i = 0; i < tracks.length; i++) { + tracks[i]['stop'](); + } + GodotAudio.input.disconnect(); + GodotAudio.input = null; + } + }, +}; + +autoAddDeps(GodotAudio, '$GodotAudio'); +mergeInto(LibraryManager.library, GodotAudio); + +/** + * The AudioWorklet API driver, used when threads are available. + */ +const GodotAudioWorklet = { + $GodotAudioWorklet__deps: ['$GodotAudio', '$GodotConfig'], + $GodotAudioWorklet: { + promise: null, + worklet: null, + ring_buffer: null, + + create: function (channels) { + const path = GodotConfig.locate_file('godot.audio.worklet.js'); + GodotAudioWorklet.promise = GodotAudio.ctx.audioWorklet.addModule(path).then(function () { + GodotAudioWorklet.worklet = new AudioWorkletNode( + GodotAudio.ctx, + 'godot-processor', + { + 'outputChannelCount': [channels], + } + ); + return Promise.resolve(); + }); + GodotAudio.driver = GodotAudioWorklet; + }, + + start: function (in_buf, out_buf, state) { + GodotAudioWorklet.promise.then(function () { + const node = GodotAudioWorklet.worklet; + node.connect(GodotAudio.ctx.destination); + node.port.postMessage({ + 'cmd': 'start', + 'data': [state, in_buf, out_buf], + }); + node.port.onmessage = function (event) { + GodotRuntime.error(event.data); + }; + }); + }, + + start_no_threads: function (p_out_buf, p_out_size, out_callback, p_in_buf, p_in_size, in_callback) { + function RingBuffer() { + let wpos = 0; + let rpos = 0; + let pending_samples = 0; + const wbuf = new Float32Array(p_out_size); + + function send(port) { + if (pending_samples === 0) { + return; + } + const buffer = GodotRuntime.heapSub(HEAPF32, p_out_buf, p_out_size); + const size = buffer.length; + const tot_sent = pending_samples; + out_callback(wpos, pending_samples); + if (wpos + pending_samples >= size) { + const high = size - wpos; + wbuf.set(buffer.subarray(wpos, size)); + pending_samples -= high; + wpos = 0; + } + if (pending_samples > 0) { + wbuf.set(buffer.subarray(wpos, wpos + pending_samples), tot_sent - pending_samples); + } + port.postMessage({ 'cmd': 'chunk', 'data': wbuf.subarray(0, tot_sent) }); + wpos += pending_samples; + pending_samples = 0; + } + this.receive = function (recv_buf) { + const buffer = GodotRuntime.heapSub(HEAPF32, p_in_buf, p_in_size); + const from = rpos; + let to_write = recv_buf.length; + let high = 0; + if (rpos + to_write >= p_in_size) { + high = p_in_size - rpos; + buffer.set(recv_buf.subarray(0, high), rpos); + to_write -= high; + rpos = 0; + } + if (to_write) { + buffer.set(recv_buf.subarray(high, to_write), rpos); + } + in_callback(from, recv_buf.length); + rpos += to_write; + }; + this.consumed = function (size, port) { + pending_samples += size; + send(port); + }; + } + GodotAudioWorklet.ring_buffer = new RingBuffer(); + GodotAudioWorklet.promise.then(function () { + const node = GodotAudioWorklet.worklet; + const buffer = GodotRuntime.heapSlice(HEAPF32, p_out_buf, p_out_size); + node.connect(GodotAudio.ctx.destination); + node.port.postMessage({ + 'cmd': 'start_nothreads', + 'data': [buffer, p_in_size], + }); + node.port.onmessage = function (event) { + if (!GodotAudioWorklet.worklet) { + return; + } + if (event.data['cmd'] === 'read') { + const read = event.data['data']; + GodotAudioWorklet.ring_buffer.consumed(read, GodotAudioWorklet.worklet.port); + } else if (event.data['cmd'] === 'input') { + const buf = event.data['data']; + if (buf.length > p_in_size) { + GodotRuntime.error('Input chunk is too big'); + return; + } + GodotAudioWorklet.ring_buffer.receive(buf); + } else { + GodotRuntime.error(event.data); + } + }; + }); + }, + + get_node: function () { + return GodotAudioWorklet.worklet; + }, + + close: function () { + return new Promise(function (resolve, reject) { + if (GodotAudioWorklet.promise === null) { + return; + } + GodotAudioWorklet.promise.then(function () { + GodotAudioWorklet.worklet.port.postMessage({ + 'cmd': 'stop', + 'data': null, + }); + GodotAudioWorklet.worklet.disconnect(); + GodotAudioWorklet.worklet = null; + GodotAudioWorklet.promise = null; + resolve(); + }).catch(function (err) { /* aborted? */ }); + }); + }, + }, + + godot_audio_worklet_create__sig: 'ii', + godot_audio_worklet_create: function (channels) { + try { + GodotAudioWorklet.create(channels); + } catch (e) { + GodotRuntime.error('Error starting AudioDriverWorklet', e); + return 1; + } + return 0; + }, + + godot_audio_worklet_start__sig: 'viiiii', + godot_audio_worklet_start: function (p_in_buf, p_in_size, p_out_buf, p_out_size, p_state) { + const out_buffer = GodotRuntime.heapSub(HEAPF32, p_out_buf, p_out_size); + const in_buffer = GodotRuntime.heapSub(HEAPF32, p_in_buf, p_in_size); + const state = GodotRuntime.heapSub(HEAP32, p_state, 4); + GodotAudioWorklet.start(in_buffer, out_buffer, state); + }, + + godot_audio_worklet_start_no_threads__sig: 'viiiiii', + godot_audio_worklet_start_no_threads: function (p_out_buf, p_out_size, p_out_callback, p_in_buf, p_in_size, p_in_callback) { + const out_callback = GodotRuntime.get_func(p_out_callback); + const in_callback = GodotRuntime.get_func(p_in_callback); + GodotAudioWorklet.start_no_threads(p_out_buf, p_out_size, out_callback, p_in_buf, p_in_size, in_callback); + }, + + godot_audio_worklet_state_wait__sig: 'iiii', + godot_audio_worklet_state_wait: function (p_state, p_idx, p_expected, p_timeout) { + Atomics.wait(HEAP32, (p_state >> 2) + p_idx, p_expected, p_timeout); + return Atomics.load(HEAP32, (p_state >> 2) + p_idx); + }, + + godot_audio_worklet_state_add__sig: 'iiii', + godot_audio_worklet_state_add: function (p_state, p_idx, p_value) { + return Atomics.add(HEAP32, (p_state >> 2) + p_idx, p_value); + }, + + godot_audio_worklet_state_get__sig: 'iii', + godot_audio_worklet_state_get: function (p_state, p_idx) { + return Atomics.load(HEAP32, (p_state >> 2) + p_idx); + }, +}; + +autoAddDeps(GodotAudioWorklet, '$GodotAudioWorklet'); +mergeInto(LibraryManager.library, GodotAudioWorklet); + +/* + * The deprecated ScriptProcessorNode API, used when threads are disabled. + */ +const GodotAudioScript = { + $GodotAudioScript__deps: ['$GodotAudio'], + $GodotAudioScript: { + script: null, + + create: function (buffer_length, channel_count) { + GodotAudioScript.script = GodotAudio.ctx.createScriptProcessor(buffer_length, 2, channel_count); + GodotAudio.driver = GodotAudioScript; + return GodotAudioScript.script.bufferSize; + }, + + start: function (p_in_buf, p_in_size, p_out_buf, p_out_size, onprocess) { + GodotAudioScript.script.onaudioprocess = function (event) { + // Read input + const inb = GodotRuntime.heapSub(HEAPF32, p_in_buf, p_in_size); + const input = event.inputBuffer; + if (GodotAudio.input) { + const inlen = input.getChannelData(0).length; + for (let ch = 0; ch < 2; ch++) { + const data = input.getChannelData(ch); + for (let s = 0; s < inlen; s++) { + inb[s * 2 + ch] = data[s]; + } + } + } + + // Let Godot process the input/output. + onprocess(); + + // Write the output. + const outb = GodotRuntime.heapSub(HEAPF32, p_out_buf, p_out_size); + const output = event.outputBuffer; + const channels = output.numberOfChannels; + for (let ch = 0; ch < channels; ch++) { + const data = output.getChannelData(ch); + // Loop through samples and assign computed values. + for (let sample = 0; sample < data.length; sample++) { + data[sample] = outb[sample * channels + ch]; + } + } + }; + GodotAudioScript.script.connect(GodotAudio.ctx.destination); + }, + + get_node: function () { + return GodotAudioScript.script; + }, + + close: function () { + return new Promise(function (resolve, reject) { + GodotAudioScript.script.disconnect(); + GodotAudioScript.script.onaudioprocess = null; + GodotAudioScript.script = null; + resolve(); + }); + }, + }, + + godot_audio_script_create__sig: 'iii', + godot_audio_script_create: function (buffer_length, channel_count) { + const buf_len = GodotRuntime.getHeapValue(buffer_length, 'i32'); + try { + const out_len = GodotAudioScript.create(buf_len, channel_count); + GodotRuntime.setHeapValue(buffer_length, out_len, 'i32'); + } catch (e) { + GodotRuntime.error('Error starting AudioDriverScriptProcessor', e); + return 1; + } + return 0; + }, + + godot_audio_script_start__sig: 'viiiii', + godot_audio_script_start: function (p_in_buf, p_in_size, p_out_buf, p_out_size, p_cb) { + const onprocess = GodotRuntime.get_func(p_cb); + GodotAudioScript.start(p_in_buf, p_in_size, p_out_buf, p_out_size, onprocess); + }, +}; + +autoAddDeps(GodotAudioScript, '$GodotAudioScript'); +mergeInto(LibraryManager.library, GodotAudioScript); diff --git a/platform/web/js/libs/library_godot_display.js b/platform/web/js/libs/library_godot_display.js new file mode 100644 index 0000000000..91cb8e728a --- /dev/null +++ b/platform/web/js/libs/library_godot_display.js @@ -0,0 +1,754 @@ +/*************************************************************************/ +/* library_godot_display.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const GodotDisplayVK = { + + $GodotDisplayVK__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners'], + $GodotDisplayVK__postset: 'GodotOS.atexit(function(resolve, reject) { GodotDisplayVK.clear(); resolve(); });', + $GodotDisplayVK: { + textinput: null, + textarea: null, + + available: function () { + return GodotConfig.virtual_keyboard && 'ontouchstart' in window; + }, + + init: function (input_cb) { + function create(what) { + const elem = document.createElement(what); + elem.style.display = 'none'; + elem.style.position = 'absolute'; + elem.style.zIndex = '-1'; + elem.style.background = 'transparent'; + elem.style.padding = '0px'; + elem.style.margin = '0px'; + elem.style.overflow = 'hidden'; + elem.style.width = '0px'; + elem.style.height = '0px'; + elem.style.border = '0px'; + elem.style.outline = 'none'; + elem.readonly = true; + elem.disabled = true; + GodotEventListeners.add(elem, 'input', function (evt) { + const c_str = GodotRuntime.allocString(elem.value); + input_cb(c_str, elem.selectionEnd); + GodotRuntime.free(c_str); + }, false); + GodotEventListeners.add(elem, 'blur', function (evt) { + elem.style.display = 'none'; + elem.readonly = true; + elem.disabled = true; + }, false); + GodotConfig.canvas.insertAdjacentElement('beforebegin', elem); + return elem; + } + GodotDisplayVK.textinput = create('input'); + GodotDisplayVK.textarea = create('textarea'); + GodotDisplayVK.updateSize(); + }, + show: function (text, type, start, end) { + if (!GodotDisplayVK.textinput || !GodotDisplayVK.textarea) { + return; + } + if (GodotDisplayVK.textinput.style.display !== '' || GodotDisplayVK.textarea.style.display !== '') { + GodotDisplayVK.hide(); + } + GodotDisplayVK.updateSize(); + + let elem = GodotDisplayVK.textinput; + switch (type) { + case 0: // KEYBOARD_TYPE_DEFAULT + elem.type = 'text'; + elem.inputmode = ''; + break; + case 1: // KEYBOARD_TYPE_MULTILINE + elem = GodotDisplayVK.textarea; + break; + case 2: // KEYBOARD_TYPE_NUMBER + elem.type = 'text'; + elem.inputmode = 'numeric'; + break; + case 3: // KEYBOARD_TYPE_NUMBER_DECIMAL + elem.type = 'text'; + elem.inputmode = 'decimal'; + break; + case 4: // KEYBOARD_TYPE_PHONE + elem.type = 'tel'; + elem.inputmode = ''; + break; + case 5: // KEYBOARD_TYPE_EMAIL_ADDRESS + elem.type = 'email'; + elem.inputmode = ''; + break; + case 6: // KEYBOARD_TYPE_PASSWORD + elem.type = 'password'; + elem.inputmode = ''; + break; + case 7: // KEYBOARD_TYPE_URL + elem.type = 'url'; + elem.inputmode = ''; + break; + default: + elem.type = 'text'; + elem.inputmode = ''; + break; + } + + elem.readonly = false; + elem.disabled = false; + elem.value = text; + elem.style.display = 'block'; + elem.focus(); + elem.setSelectionRange(start, end); + }, + hide: function () { + if (!GodotDisplayVK.textinput || !GodotDisplayVK.textarea) { + return; + } + [GodotDisplayVK.textinput, GodotDisplayVK.textarea].forEach(function (elem) { + elem.blur(); + elem.style.display = 'none'; + elem.value = ''; + }); + }, + updateSize: function () { + if (!GodotDisplayVK.textinput || !GodotDisplayVK.textarea) { + return; + } + const rect = GodotConfig.canvas.getBoundingClientRect(); + function update(elem) { + elem.style.left = `${rect.left}px`; + elem.style.top = `${rect.top}px`; + elem.style.width = `${rect.width}px`; + elem.style.height = `${rect.height}px`; + } + update(GodotDisplayVK.textinput); + update(GodotDisplayVK.textarea); + }, + clear: function () { + if (GodotDisplayVK.textinput) { + GodotDisplayVK.textinput.remove(); + GodotDisplayVK.textinput = null; + } + if (GodotDisplayVK.textarea) { + GodotDisplayVK.textarea.remove(); + GodotDisplayVK.textarea = null; + } + }, + }, +}; +mergeInto(LibraryManager.library, GodotDisplayVK); + +/* + * Display server cursor helper. + * Keeps track of cursor status and custom shapes. + */ +const GodotDisplayCursor = { + $GodotDisplayCursor__deps: ['$GodotOS', '$GodotConfig'], + $GodotDisplayCursor__postset: 'GodotOS.atexit(function(resolve, reject) { GodotDisplayCursor.clear(); resolve(); });', + $GodotDisplayCursor: { + shape: 'auto', + visible: true, + cursors: {}, + set_style: function (style) { + GodotConfig.canvas.style.cursor = style; + }, + set_shape: function (shape) { + GodotDisplayCursor.shape = shape; + let css = shape; + if (shape in GodotDisplayCursor.cursors) { + const c = GodotDisplayCursor.cursors[shape]; + css = `url("${c.url}") ${c.x} ${c.y}, auto`; + } + if (GodotDisplayCursor.visible) { + GodotDisplayCursor.set_style(css); + } + }, + clear: function () { + GodotDisplayCursor.set_style(''); + GodotDisplayCursor.shape = 'auto'; + GodotDisplayCursor.visible = true; + Object.keys(GodotDisplayCursor.cursors).forEach(function (key) { + URL.revokeObjectURL(GodotDisplayCursor.cursors[key]); + delete GodotDisplayCursor.cursors[key]; + }); + }, + lockPointer: function () { + const canvas = GodotConfig.canvas; + if (canvas.requestPointerLock) { + canvas.requestPointerLock(); + } + }, + releasePointer: function () { + if (document.exitPointerLock) { + document.exitPointerLock(); + } + }, + isPointerLocked: function () { + return document.pointerLockElement === GodotConfig.canvas; + }, + }, +}; +mergeInto(LibraryManager.library, GodotDisplayCursor); + +const GodotDisplayScreen = { + $GodotDisplayScreen__deps: ['$GodotConfig', '$GodotOS', '$GL', 'emscripten_webgl_get_current_context'], + $GodotDisplayScreen: { + desired_size: [0, 0], + hidpi: true, + getPixelRatio: function () { + return GodotDisplayScreen.hidpi ? window.devicePixelRatio || 1 : 1; + }, + isFullscreen: function () { + const elem = document.fullscreenElement || document.mozFullscreenElement + || document.webkitFullscreenElement || document.msFullscreenElement; + if (elem) { + return elem === GodotConfig.canvas; + } + // But maybe knowing the element is not supported. + return document.fullscreen || document.mozFullScreen + || document.webkitIsFullscreen; + }, + hasFullscreen: function () { + return document.fullscreenEnabled || document.mozFullScreenEnabled + || document.webkitFullscreenEnabled; + }, + requestFullscreen: function () { + if (!GodotDisplayScreen.hasFullscreen()) { + return 1; + } + const canvas = GodotConfig.canvas; + try { + const promise = (canvas.requestFullscreen || canvas.msRequestFullscreen + || canvas.mozRequestFullScreen || canvas.mozRequestFullscreen + || canvas.webkitRequestFullscreen + ).call(canvas); + // Some browsers (Safari) return undefined. + // For the standard ones, we need to catch it. + if (promise) { + promise.catch(function () { + // nothing to do. + }); + } + } catch (e) { + return 1; + } + return 0; + }, + exitFullscreen: function () { + if (!GodotDisplayScreen.isFullscreen()) { + return 0; + } + try { + const promise = document.exitFullscreen(); + if (promise) { + promise.catch(function () { + // nothing to do. + }); + } + } catch (e) { + return 1; + } + return 0; + }, + _updateGL: function () { + const gl_context_handle = _emscripten_webgl_get_current_context(); // eslint-disable-line no-undef + const gl = GL.getContext(gl_context_handle); + if (gl) { + GL.resizeOffscreenFramebuffer(gl); + } + }, + updateSize: function () { + const isFullscreen = GodotDisplayScreen.isFullscreen(); + const wantsFullWindow = GodotConfig.canvas_resize_policy === 2; + const noResize = GodotConfig.canvas_resize_policy === 0; + const wwidth = GodotDisplayScreen.desired_size[0]; + const wheight = GodotDisplayScreen.desired_size[1]; + const canvas = GodotConfig.canvas; + let width = wwidth; + let height = wheight; + if (noResize) { + // Don't resize canvas, just update GL if needed. + if (canvas.width !== width || canvas.height !== height) { + GodotDisplayScreen.desired_size = [canvas.width, canvas.height]; + GodotDisplayScreen._updateGL(); + return 1; + } + return 0; + } + const scale = GodotDisplayScreen.getPixelRatio(); + if (isFullscreen || wantsFullWindow) { + // We need to match screen size. + width = window.innerWidth * scale; + height = window.innerHeight * scale; + } + const csw = `${width / scale}px`; + const csh = `${height / scale}px`; + if (canvas.style.width !== csw || canvas.style.height !== csh || canvas.width !== width || canvas.height !== height) { + // Size doesn't match. + // Resize canvas, set correct CSS pixel size, update GL. + canvas.width = width; + canvas.height = height; + canvas.style.width = csw; + canvas.style.height = csh; + GodotDisplayScreen._updateGL(); + return 1; + } + return 0; + }, + }, +}; +mergeInto(LibraryManager.library, GodotDisplayScreen); + +/** + * Display server interface. + * + * Exposes all the functions needed by DisplayServer implementation. + */ +const GodotDisplay = { + $GodotDisplay__deps: ['$GodotConfig', '$GodotRuntime', '$GodotDisplayCursor', '$GodotEventListeners', '$GodotDisplayScreen', '$GodotDisplayVK'], + $GodotDisplay: { + window_icon: '', + getDPI: function () { + // devicePixelRatio is given in dppx + // https://drafts.csswg.org/css-values/#resolution + // > due to the 1:96 fixed ratio of CSS *in* to CSS *px*, 1dppx is equivalent to 96dpi. + const dpi = Math.round(window.devicePixelRatio * 96); + return dpi >= 96 ? dpi : 96; + }, + }, + + godot_js_display_is_swap_ok_cancel__sig: 'i', + godot_js_display_is_swap_ok_cancel: function () { + const win = (['Windows', 'Win64', 'Win32', 'WinCE']); + const plat = navigator.platform || ''; + if (win.indexOf(plat) !== -1) { + return 1; + } + return 0; + }, + + godot_js_tts_is_speaking__sig: 'i', + godot_js_tts_is_speaking: function () { + return window.speechSynthesis.speaking; + }, + + godot_js_tts_is_paused__sig: 'i', + godot_js_tts_is_paused: function () { + return window.speechSynthesis.paused; + }, + + godot_js_tts_get_voices__sig: 'vi', + godot_js_tts_get_voices: function (p_callback) { + const func = GodotRuntime.get_func(p_callback); + try { + const arr = []; + const voices = window.speechSynthesis.getVoices(); + for (let i = 0; i < voices.length; i++) { + arr.push(`${voices[i].lang};${voices[i].name}`); + } + const c_ptr = GodotRuntime.allocStringArray(arr); + func(arr.length, c_ptr); + GodotRuntime.freeStringArray(c_ptr, arr.length); + } catch (e) { + // Fail graciously. + } + }, + + godot_js_tts_speak__sig: 'viiiffii', + godot_js_tts_speak: function (p_text, p_voice, p_volume, p_pitch, p_rate, p_utterance_id, p_callback) { + const func = GodotRuntime.get_func(p_callback); + + function listener_end(evt) { + evt.currentTarget.cb(1 /*TTS_UTTERANCE_ENDED*/, evt.currentTarget.id, 0); + } + + function listener_start(evt) { + evt.currentTarget.cb(0 /*TTS_UTTERANCE_STARTED*/, evt.currentTarget.id, 0); + } + + function listener_error(evt) { + evt.currentTarget.cb(2 /*TTS_UTTERANCE_CANCELED*/, evt.currentTarget.id, 0); + } + + function listener_bound(evt) { + evt.currentTarget.cb(3 /*TTS_UTTERANCE_BOUNDARY*/, evt.currentTarget.id, evt.charIndex); + } + + const utterance = new SpeechSynthesisUtterance(GodotRuntime.parseString(p_text)); + utterance.rate = p_rate; + utterance.pitch = p_pitch; + utterance.volume = p_volume / 100.0; + utterance.addEventListener('end', listener_end); + utterance.addEventListener('start', listener_start); + utterance.addEventListener('error', listener_error); + utterance.addEventListener('boundary', listener_bound); + utterance.id = p_utterance_id; + utterance.cb = func; + const voice = GodotRuntime.parseString(p_voice); + const voices = window.speechSynthesis.getVoices(); + for (let i = 0; i < voices.length; i++) { + if (voices[i].name === voice) { + utterance.voice = voices[i]; + break; + } + } + window.speechSynthesis.resume(); + window.speechSynthesis.speak(utterance); + }, + + godot_js_tts_pause__sig: 'v', + godot_js_tts_pause: function () { + window.speechSynthesis.pause(); + }, + + godot_js_tts_resume__sig: 'v', + godot_js_tts_resume: function () { + window.speechSynthesis.resume(); + }, + + godot_js_tts_stop__sig: 'v', + godot_js_tts_stop: function () { + window.speechSynthesis.cancel(); + window.speechSynthesis.resume(); + }, + + godot_js_display_alert__sig: 'vi', + godot_js_display_alert: function (p_text) { + window.alert(GodotRuntime.parseString(p_text)); // eslint-disable-line no-alert + }, + + godot_js_display_screen_dpi_get__sig: 'i', + godot_js_display_screen_dpi_get: function () { + return GodotDisplay.getDPI(); + }, + + godot_js_display_pixel_ratio_get__sig: 'f', + godot_js_display_pixel_ratio_get: function () { + return GodotDisplayScreen.getPixelRatio(); + }, + + godot_js_display_fullscreen_request__sig: 'i', + godot_js_display_fullscreen_request: function () { + return GodotDisplayScreen.requestFullscreen(); + }, + + godot_js_display_fullscreen_exit__sig: 'i', + godot_js_display_fullscreen_exit: function () { + return GodotDisplayScreen.exitFullscreen(); + }, + + godot_js_display_desired_size_set__sig: 'vii', + godot_js_display_desired_size_set: function (width, height) { + GodotDisplayScreen.desired_size = [width, height]; + GodotDisplayScreen.updateSize(); + }, + + godot_js_display_size_update__sig: 'i', + godot_js_display_size_update: function () { + const updated = GodotDisplayScreen.updateSize(); + if (updated) { + GodotDisplayVK.updateSize(); + } + return updated; + }, + + godot_js_display_screen_size_get__sig: 'vii', + godot_js_display_screen_size_get: function (width, height) { + const scale = GodotDisplayScreen.getPixelRatio(); + GodotRuntime.setHeapValue(width, window.screen.width * scale, 'i32'); + GodotRuntime.setHeapValue(height, window.screen.height * scale, 'i32'); + }, + + godot_js_display_window_size_get__sig: 'vii', + godot_js_display_window_size_get: function (p_width, p_height) { + GodotRuntime.setHeapValue(p_width, GodotConfig.canvas.width, 'i32'); + GodotRuntime.setHeapValue(p_height, GodotConfig.canvas.height, 'i32'); + }, + + godot_js_display_has_webgl__sig: 'ii', + godot_js_display_has_webgl: function (p_version) { + if (p_version !== 1 && p_version !== 2) { + return false; + } + try { + return !!document.createElement('canvas').getContext(p_version === 2 ? 'webgl2' : 'webgl'); + } catch (e) { /* Not available */ } + return false; + }, + + /* + * Canvas + */ + godot_js_display_canvas_focus__sig: 'v', + godot_js_display_canvas_focus: function () { + GodotConfig.canvas.focus(); + }, + + godot_js_display_canvas_is_focused__sig: 'i', + godot_js_display_canvas_is_focused: function () { + return document.activeElement === GodotConfig.canvas; + }, + + /* + * Touchscreen + */ + godot_js_display_touchscreen_is_available__sig: 'i', + godot_js_display_touchscreen_is_available: function () { + return 'ontouchstart' in window; + }, + + /* + * Clipboard + */ + godot_js_display_clipboard_set__sig: 'ii', + godot_js_display_clipboard_set: function (p_text) { + const text = GodotRuntime.parseString(p_text); + if (!navigator.clipboard || !navigator.clipboard.writeText) { + return 1; + } + navigator.clipboard.writeText(text).catch(function (e) { + // Setting OS clipboard is only possible from an input callback. + GodotRuntime.error('Setting OS clipboard is only possible from an input callback for the Web plafrom. Exception:', e); + }); + return 0; + }, + + godot_js_display_clipboard_get__sig: 'ii', + godot_js_display_clipboard_get: function (callback) { + const func = GodotRuntime.get_func(callback); + try { + navigator.clipboard.readText().then(function (result) { + const ptr = GodotRuntime.allocString(result); + func(ptr); + GodotRuntime.free(ptr); + }).catch(function (e) { + // Fail graciously. + }); + } catch (e) { + // Fail graciously. + } + }, + + /* + * Window + */ + godot_js_display_window_title_set__sig: 'vi', + godot_js_display_window_title_set: function (p_data) { + document.title = GodotRuntime.parseString(p_data); + }, + + godot_js_display_window_icon_set__sig: 'vii', + godot_js_display_window_icon_set: function (p_ptr, p_len) { + let link = document.getElementById('-gd-engine-icon'); + if (link === null) { + link = document.createElement('link'); + link.rel = 'icon'; + link.id = '-gd-engine-icon'; + document.head.appendChild(link); + } + const old_icon = GodotDisplay.window_icon; + const png = new Blob([GodotRuntime.heapSlice(HEAPU8, p_ptr, p_len)], { type: 'image/png' }); + GodotDisplay.window_icon = URL.createObjectURL(png); + link.href = GodotDisplay.window_icon; + if (old_icon) { + URL.revokeObjectURL(old_icon); + } + }, + + /* + * Cursor + */ + godot_js_display_cursor_set_visible__sig: 'vi', + godot_js_display_cursor_set_visible: function (p_visible) { + const visible = p_visible !== 0; + if (visible === GodotDisplayCursor.visible) { + return; + } + GodotDisplayCursor.visible = visible; + if (visible) { + GodotDisplayCursor.set_shape(GodotDisplayCursor.shape); + } else { + GodotDisplayCursor.set_style('none'); + } + }, + + godot_js_display_cursor_is_hidden__sig: 'i', + godot_js_display_cursor_is_hidden: function () { + return !GodotDisplayCursor.visible; + }, + + godot_js_display_cursor_set_shape__sig: 'vi', + godot_js_display_cursor_set_shape: function (p_string) { + GodotDisplayCursor.set_shape(GodotRuntime.parseString(p_string)); + }, + + godot_js_display_cursor_set_custom_shape__sig: 'viiiii', + godot_js_display_cursor_set_custom_shape: function (p_shape, p_ptr, p_len, p_hotspot_x, p_hotspot_y) { + const shape = GodotRuntime.parseString(p_shape); + const old_shape = GodotDisplayCursor.cursors[shape]; + if (p_len > 0) { + const png = new Blob([GodotRuntime.heapSlice(HEAPU8, p_ptr, p_len)], { type: 'image/png' }); + const url = URL.createObjectURL(png); + GodotDisplayCursor.cursors[shape] = { + url: url, + x: p_hotspot_x, + y: p_hotspot_y, + }; + } else { + delete GodotDisplayCursor.cursors[shape]; + } + if (shape === GodotDisplayCursor.shape) { + GodotDisplayCursor.set_shape(GodotDisplayCursor.shape); + } + if (old_shape) { + URL.revokeObjectURL(old_shape.url); + } + }, + + godot_js_display_cursor_lock_set__sig: 'vi', + godot_js_display_cursor_lock_set: function (p_lock) { + if (p_lock) { + GodotDisplayCursor.lockPointer(); + } else { + GodotDisplayCursor.releasePointer(); + } + }, + + godot_js_display_cursor_is_locked__sig: 'i', + godot_js_display_cursor_is_locked: function () { + return GodotDisplayCursor.isPointerLocked() ? 1 : 0; + }, + + /* + * Listeners + */ + godot_js_display_fullscreen_cb__sig: 'vi', + godot_js_display_fullscreen_cb: function (callback) { + const canvas = GodotConfig.canvas; + const func = GodotRuntime.get_func(callback); + function change_cb(evt) { + if (evt.target === canvas) { + func(GodotDisplayScreen.isFullscreen()); + } + } + GodotEventListeners.add(document, 'fullscreenchange', change_cb, false); + GodotEventListeners.add(document, 'mozfullscreenchange', change_cb, false); + GodotEventListeners.add(document, 'webkitfullscreenchange', change_cb, false); + }, + + godot_js_display_window_blur_cb__sig: 'vi', + godot_js_display_window_blur_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + GodotEventListeners.add(window, 'blur', function () { + func(); + }, false); + }, + + godot_js_display_notification_cb__sig: 'viiiii', + godot_js_display_notification_cb: function (callback, p_enter, p_exit, p_in, p_out) { + const canvas = GodotConfig.canvas; + const func = GodotRuntime.get_func(callback); + const notif = [p_enter, p_exit, p_in, p_out]; + ['mouseover', 'mouseleave', 'focus', 'blur'].forEach(function (evt_name, idx) { + GodotEventListeners.add(canvas, evt_name, function () { + func(notif[idx]); + }, true); + }); + }, + + godot_js_display_setup_canvas__sig: 'viiii', + godot_js_display_setup_canvas: function (p_width, p_height, p_fullscreen, p_hidpi) { + const canvas = GodotConfig.canvas; + GodotEventListeners.add(canvas, 'contextmenu', function (ev) { + ev.preventDefault(); + }, false); + GodotEventListeners.add(canvas, 'webglcontextlost', function (ev) { + alert('WebGL context lost, please reload the page'); // eslint-disable-line no-alert + ev.preventDefault(); + }, false); + GodotDisplayScreen.hidpi = !!p_hidpi; + switch (GodotConfig.canvas_resize_policy) { + case 0: // None + GodotDisplayScreen.desired_size = [canvas.width, canvas.height]; + break; + case 1: // Project + GodotDisplayScreen.desired_size = [p_width, p_height]; + break; + default: // Full window + // Ensure we display in the right place, the size will be handled by updateSize + canvas.style.position = 'absolute'; + canvas.style.top = 0; + canvas.style.left = 0; + break; + } + GodotDisplayScreen.updateSize(); + if (p_fullscreen) { + GodotDisplayScreen.requestFullscreen(); + } + }, + + /* + * Virtual Keyboard + */ + godot_js_display_vk_show__sig: 'viiii', + godot_js_display_vk_show: function (p_text, p_type, p_start, p_end) { + const text = GodotRuntime.parseString(p_text); + const start = p_start > 0 ? p_start : 0; + const end = p_end > 0 ? p_end : start; + GodotDisplayVK.show(text, p_type, start, end); + }, + + godot_js_display_vk_hide__sig: 'v', + godot_js_display_vk_hide: function () { + GodotDisplayVK.hide(); + }, + + godot_js_display_vk_available__sig: 'i', + godot_js_display_vk_available: function () { + return GodotDisplayVK.available(); + }, + + godot_js_display_tts_available__sig: 'i', + godot_js_display_tts_available: function () { + return 'speechSynthesis' in window; + }, + + godot_js_display_vk_cb__sig: 'vi', + godot_js_display_vk_cb: function (p_input_cb) { + const input_cb = GodotRuntime.get_func(p_input_cb); + if (GodotDisplayVK.available()) { + GodotDisplayVK.init(input_cb); + } + }, +}; + +autoAddDeps(GodotDisplay, '$GodotDisplay'); +mergeInto(LibraryManager.library, GodotDisplay); diff --git a/platform/web/js/libs/library_godot_fetch.js b/platform/web/js/libs/library_godot_fetch.js new file mode 100644 index 0000000000..285e50a035 --- /dev/null +++ b/platform/web/js/libs/library_godot_fetch.js @@ -0,0 +1,247 @@ +/*************************************************************************/ +/* library_godot_fetch.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const GodotFetch = { + $GodotFetch__deps: ['$IDHandler', '$GodotRuntime'], + $GodotFetch: { + + onread: function (id, result) { + const obj = IDHandler.get(id); + if (!obj) { + return; + } + if (result.value) { + obj.chunks.push(result.value); + } + obj.reading = false; + obj.done = result.done; + }, + + onresponse: function (id, response) { + const obj = IDHandler.get(id); + if (!obj) { + return; + } + let chunked = false; + response.headers.forEach(function (value, header) { + const v = value.toLowerCase().trim(); + const h = header.toLowerCase().trim(); + if (h === 'transfer-encoding' && v === 'chunked') { + chunked = true; + } + }); + obj.status = response.status; + obj.response = response; + obj.reader = response.body.getReader(); + obj.chunked = chunked; + }, + + onerror: function (id, err) { + GodotRuntime.error(err); + const obj = IDHandler.get(id); + if (!obj) { + return; + } + obj.error = err; + }, + + create: function (method, url, headers, body) { + const obj = { + request: null, + response: null, + reader: null, + error: null, + done: false, + reading: false, + status: 0, + chunks: [], + bodySize: -1, + }; + const id = IDHandler.add(obj); + const init = { + method: method, + headers: headers, + body: body, + }; + obj.request = fetch(url, init); + obj.request.then(GodotFetch.onresponse.bind(null, id)).catch(GodotFetch.onerror.bind(null, id)); + return id; + }, + + free: function (id) { + const obj = IDHandler.get(id); + if (!obj) { + return; + } + IDHandler.remove(id); + if (!obj.request) { + return; + } + // Try to abort + obj.request.then(function (response) { + response.abort(); + }).catch(function (e) { /* nothing to do */ }); + }, + + read: function (id) { + const obj = IDHandler.get(id); + if (!obj) { + return; + } + if (obj.reader && !obj.reading) { + if (obj.done) { + obj.reader = null; + return; + } + obj.reading = true; + obj.reader.read().then(GodotFetch.onread.bind(null, id)).catch(GodotFetch.onerror.bind(null, id)); + } + }, + }, + + godot_js_fetch_create__sig: 'iiiiiii', + godot_js_fetch_create: function (p_method, p_url, p_headers, p_headers_size, p_body, p_body_size) { + const method = GodotRuntime.parseString(p_method); + const url = GodotRuntime.parseString(p_url); + const headers = GodotRuntime.parseStringArray(p_headers, p_headers_size); + const body = p_body_size ? GodotRuntime.heapSlice(HEAP8, p_body, p_body_size) : null; + return GodotFetch.create(method, url, headers.map(function (hv) { + const idx = hv.indexOf(':'); + if (idx <= 0) { + return []; + } + return [ + hv.slice(0, idx).trim(), + hv.slice(idx + 1).trim(), + ]; + }).filter(function (v) { + return v.length === 2; + }), body); + }, + + godot_js_fetch_state_get__sig: 'ii', + godot_js_fetch_state_get: function (p_id) { + const obj = IDHandler.get(p_id); + if (!obj) { + return -1; + } + if (obj.error) { + return -1; + } + if (!obj.response) { + return 0; + } + if (obj.reader) { + return 1; + } + if (obj.done) { + return 2; + } + return -1; + }, + + godot_js_fetch_http_status_get__sig: 'ii', + godot_js_fetch_http_status_get: function (p_id) { + const obj = IDHandler.get(p_id); + if (!obj || !obj.response) { + return 0; + } + return obj.status; + }, + + godot_js_fetch_read_headers__sig: 'iiii', + godot_js_fetch_read_headers: function (p_id, p_parse_cb, p_ref) { + const obj = IDHandler.get(p_id); + if (!obj || !obj.response) { + return 1; + } + const cb = GodotRuntime.get_func(p_parse_cb); + const arr = []; + obj.response.headers.forEach(function (v, h) { + arr.push(`${h}:${v}`); + }); + const c_ptr = GodotRuntime.allocStringArray(arr); + cb(arr.length, c_ptr, p_ref); + GodotRuntime.freeStringArray(c_ptr, arr.length); + return 0; + }, + + godot_js_fetch_read_chunk__sig: 'iiii', + godot_js_fetch_read_chunk: function (p_id, p_buf, p_buf_size) { + const obj = IDHandler.get(p_id); + if (!obj || !obj.response) { + return 0; + } + let to_read = p_buf_size; + const chunks = obj.chunks; + while (to_read && chunks.length) { + const chunk = obj.chunks[0]; + if (chunk.length > to_read) { + GodotRuntime.heapCopy(HEAP8, chunk.slice(0, to_read), p_buf); + chunks[0] = chunk.slice(to_read); + to_read = 0; + } else { + GodotRuntime.heapCopy(HEAP8, chunk, p_buf); + to_read -= chunk.length; + chunks.pop(); + } + } + if (!chunks.length) { + GodotFetch.read(p_id); + } + return p_buf_size - to_read; + }, + + godot_js_fetch_body_length_get__sig: 'ii', + godot_js_fetch_body_length_get: function (p_id) { + const obj = IDHandler.get(p_id); + if (!obj || !obj.response) { + return -1; + } + return obj.bodySize; + }, + + godot_js_fetch_is_chunked__sig: 'ii', + godot_js_fetch_is_chunked: function (p_id) { + const obj = IDHandler.get(p_id); + if (!obj || !obj.response) { + return -1; + } + return obj.chunked ? 1 : 0; + }, + + godot_js_fetch_free__sig: 'vi', + godot_js_fetch_free: function (id) { + GodotFetch.free(id); + }, +}; + +autoAddDeps(GodotFetch, '$GodotFetch'); +mergeInto(LibraryManager.library, GodotFetch); diff --git a/platform/web/js/libs/library_godot_input.js b/platform/web/js/libs/library_godot_input.js new file mode 100644 index 0000000000..51571d64a2 --- /dev/null +++ b/platform/web/js/libs/library_godot_input.js @@ -0,0 +1,549 @@ +/*************************************************************************/ +/* library_godot_input.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +/* + * Gamepad API helper. + */ +const GodotInputGamepads = { + $GodotInputGamepads__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotInputGamepads: { + samples: [], + + get_pads: function () { + try { + // Will throw in iframe when permission is denied. + // Will throw/warn in the future for insecure contexts. + // See https://github.com/w3c/gamepad/pull/120 + const pads = navigator.getGamepads(); + if (pads) { + return pads; + } + return []; + } catch (e) { + return []; + } + }, + + get_samples: function () { + return GodotInputGamepads.samples; + }, + + get_sample: function (index) { + const samples = GodotInputGamepads.samples; + return index < samples.length ? samples[index] : null; + }, + + sample: function () { + const pads = GodotInputGamepads.get_pads(); + const samples = []; + for (let i = 0; i < pads.length; i++) { + const pad = pads[i]; + if (!pad) { + samples.push(null); + continue; + } + const s = { + standard: pad.mapping === 'standard', + buttons: [], + axes: [], + connected: pad.connected, + }; + for (let b = 0; b < pad.buttons.length; b++) { + s.buttons.push(pad.buttons[b].value); + } + for (let a = 0; a < pad.axes.length; a++) { + s.axes.push(pad.axes[a]); + } + samples.push(s); + } + GodotInputGamepads.samples = samples; + }, + + init: function (onchange) { + GodotInputGamepads.samples = []; + function add(pad) { + const guid = GodotInputGamepads.get_guid(pad); + const c_id = GodotRuntime.allocString(pad.id); + const c_guid = GodotRuntime.allocString(guid); + onchange(pad.index, 1, c_id, c_guid); + GodotRuntime.free(c_id); + GodotRuntime.free(c_guid); + } + const pads = GodotInputGamepads.get_pads(); + for (let i = 0; i < pads.length; i++) { + // Might be reserved space. + if (pads[i]) { + add(pads[i]); + } + } + GodotEventListeners.add(window, 'gamepadconnected', function (evt) { + if (evt.gamepad) { + add(evt.gamepad); + } + }, false); + GodotEventListeners.add(window, 'gamepaddisconnected', function (evt) { + if (evt.gamepad) { + onchange(evt.gamepad.index, 0); + } + }, false); + }, + + get_guid: function (pad) { + if (pad.mapping) { + return pad.mapping; + } + const ua = navigator.userAgent; + let os = 'Unknown'; + if (ua.indexOf('Android') >= 0) { + os = 'Android'; + } else if (ua.indexOf('Linux') >= 0) { + os = 'Linux'; + } else if (ua.indexOf('iPhone') >= 0) { + os = 'iOS'; + } else if (ua.indexOf('Macintosh') >= 0) { + // Updated iPads will fall into this category. + os = 'MacOSX'; + } else if (ua.indexOf('Windows') >= 0) { + os = 'Windows'; + } + + const id = pad.id; + // Chrom* style: NAME (Vendor: xxxx Product: xxxx) + const exp1 = /vendor: ([0-9a-f]{4}) product: ([0-9a-f]{4})/i; + // Firefox/Safari style (safari may remove leading zeores) + const exp2 = /^([0-9a-f]+)-([0-9a-f]+)-/i; + let vendor = ''; + let product = ''; + if (exp1.test(id)) { + const match = exp1.exec(id); + vendor = match[1].padStart(4, '0'); + product = match[2].padStart(4, '0'); + } else if (exp2.test(id)) { + const match = exp2.exec(id); + vendor = match[1].padStart(4, '0'); + product = match[2].padStart(4, '0'); + } + if (!vendor || !product) { + return `${os}Unknown`; + } + return os + vendor + product; + }, + }, +}; +mergeInto(LibraryManager.library, GodotInputGamepads); + +/* + * Drag and drop helper. + * This is pretty big, but basically detect dropped files on GodotConfig.canvas, + * process them one by one (recursively for directories), and copies them to + * the temporary FS path '/tmp/drop-[random]/' so it can be emitted as a godot + * event (that requires a string array of paths). + * + * NOTE: The temporary files are removed after the callback. This means that + * deferred callbacks won't be able to access the files. + */ +const GodotInputDragDrop = { + $GodotInputDragDrop__deps: ['$FS', '$GodotFS'], + $GodotInputDragDrop: { + promises: [], + pending_files: [], + + add_entry: function (entry) { + if (entry.isDirectory) { + GodotInputDragDrop.add_dir(entry); + } else if (entry.isFile) { + GodotInputDragDrop.add_file(entry); + } else { + GodotRuntime.error('Unrecognized entry...', entry); + } + }, + + add_dir: function (entry) { + GodotInputDragDrop.promises.push(new Promise(function (resolve, reject) { + const reader = entry.createReader(); + reader.readEntries(function (entries) { + for (let i = 0; i < entries.length; i++) { + GodotInputDragDrop.add_entry(entries[i]); + } + resolve(); + }); + })); + }, + + add_file: function (entry) { + GodotInputDragDrop.promises.push(new Promise(function (resolve, reject) { + entry.file(function (file) { + const reader = new FileReader(); + reader.onload = function () { + const f = { + 'path': file.relativePath || file.webkitRelativePath, + 'name': file.name, + 'type': file.type, + 'size': file.size, + 'data': reader.result, + }; + if (!f['path']) { + f['path'] = f['name']; + } + GodotInputDragDrop.pending_files.push(f); + resolve(); + }; + reader.onerror = function () { + GodotRuntime.print('Error reading file'); + reject(); + }; + reader.readAsArrayBuffer(file); + }, function (err) { + GodotRuntime.print('Error!'); + reject(); + }); + })); + }, + + process: function (resolve, reject) { + if (GodotInputDragDrop.promises.length === 0) { + resolve(); + return; + } + GodotInputDragDrop.promises.pop().then(function () { + setTimeout(function () { + GodotInputDragDrop.process(resolve, reject); + }, 0); + }); + }, + + _process_event: function (ev, callback) { + ev.preventDefault(); + if (ev.dataTransfer.items) { + // Use DataTransferItemList interface to access the file(s) + for (let i = 0; i < ev.dataTransfer.items.length; i++) { + const item = ev.dataTransfer.items[i]; + let entry = null; + if ('getAsEntry' in item) { + entry = item.getAsEntry(); + } else if ('webkitGetAsEntry' in item) { + entry = item.webkitGetAsEntry(); + } + if (entry) { + GodotInputDragDrop.add_entry(entry); + } + } + } else { + GodotRuntime.error('File upload not supported'); + } + new Promise(GodotInputDragDrop.process).then(function () { + const DROP = `/tmp/drop-${parseInt(Math.random() * (1 << 30), 10)}/`; + const drops = []; + const files = []; + FS.mkdir(DROP.slice(0, -1)); // Without trailing slash + GodotInputDragDrop.pending_files.forEach((elem) => { + const path = elem['path']; + GodotFS.copy_to_fs(DROP + path, elem['data']); + let idx = path.indexOf('/'); + if (idx === -1) { + // Root file + drops.push(DROP + path); + } else { + // Subdir + const sub = path.substr(0, idx); + idx = sub.indexOf('/'); + if (idx < 0 && drops.indexOf(DROP + sub) === -1) { + drops.push(DROP + sub); + } + } + files.push(DROP + path); + }); + GodotInputDragDrop.promises = []; + GodotInputDragDrop.pending_files = []; + callback(drops); + if (GodotConfig.persistent_drops) { + // Delay removal at exit. + GodotOS.atexit(function (resolve, reject) { + GodotInputDragDrop.remove_drop(files, DROP); + resolve(); + }); + } else { + GodotInputDragDrop.remove_drop(files, DROP); + } + }); + }, + + remove_drop: function (files, drop_path) { + const dirs = [drop_path.substr(0, drop_path.length - 1)]; + // Remove temporary files + files.forEach(function (file) { + FS.unlink(file); + let dir = file.replace(drop_path, ''); + let idx = dir.lastIndexOf('/'); + while (idx > 0) { + dir = dir.substr(0, idx); + if (dirs.indexOf(drop_path + dir) === -1) { + dirs.push(drop_path + dir); + } + idx = dir.lastIndexOf('/'); + } + }); + // Remove dirs. + dirs.sort(function (a, b) { + const al = (a.match(/\//g) || []).length; + const bl = (b.match(/\//g) || []).length; + if (al > bl) { + return -1; + } else if (al < bl) { + return 1; + } + return 0; + }).forEach(function (dir) { + FS.rmdir(dir); + }); + }, + + handler: function (callback) { + return function (ev) { + GodotInputDragDrop._process_event(ev, callback); + }; + }, + }, +}; +mergeInto(LibraryManager.library, GodotInputDragDrop); + +/* + * Godot exposed input functions. + */ +const GodotInput = { + $GodotInput__deps: ['$GodotRuntime', '$GodotConfig', '$GodotEventListeners', '$GodotInputGamepads', '$GodotInputDragDrop'], + $GodotInput: { + getModifiers: function (evt) { + return (evt.shiftKey + 0) + ((evt.altKey + 0) << 1) + ((evt.ctrlKey + 0) << 2) + ((evt.metaKey + 0) << 3); + }, + computePosition: function (evt, rect) { + const canvas = GodotConfig.canvas; + const rw = canvas.width / rect.width; + const rh = canvas.height / rect.height; + const x = (evt.clientX - rect.x) * rw; + const y = (evt.clientY - rect.y) * rh; + return [x, y]; + }, + }, + + /* + * Mouse API + */ + godot_js_input_mouse_move_cb__sig: 'vi', + godot_js_input_mouse_move_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + const canvas = GodotConfig.canvas; + function move_cb(evt) { + const rect = canvas.getBoundingClientRect(); + const pos = GodotInput.computePosition(evt, rect); + // Scale movement + const rw = canvas.width / rect.width; + const rh = canvas.height / rect.height; + const rel_pos_x = evt.movementX * rw; + const rel_pos_y = evt.movementY * rh; + const modifiers = GodotInput.getModifiers(evt); + func(pos[0], pos[1], rel_pos_x, rel_pos_y, modifiers); + } + GodotEventListeners.add(window, 'mousemove', move_cb, false); + }, + + godot_js_input_mouse_wheel_cb__sig: 'vi', + godot_js_input_mouse_wheel_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + function wheel_cb(evt) { + if (func(evt['deltaX'] || 0, evt['deltaY'] || 0)) { + evt.preventDefault(); + } + } + GodotEventListeners.add(GodotConfig.canvas, 'wheel', wheel_cb, false); + }, + + godot_js_input_mouse_button_cb__sig: 'vi', + godot_js_input_mouse_button_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + const canvas = GodotConfig.canvas; + function button_cb(p_pressed, evt) { + const rect = canvas.getBoundingClientRect(); + const pos = GodotInput.computePosition(evt, rect); + const modifiers = GodotInput.getModifiers(evt); + // Since the event is consumed, focus manually. + // NOTE: The iframe container may not have focus yet, so focus even when already active. + if (p_pressed) { + GodotConfig.canvas.focus(); + } + if (func(p_pressed, evt.button, pos[0], pos[1], modifiers)) { + evt.preventDefault(); + } + } + GodotEventListeners.add(canvas, 'mousedown', button_cb.bind(null, 1), false); + GodotEventListeners.add(window, 'mouseup', button_cb.bind(null, 0), false); + }, + + /* + * Touch API + */ + godot_js_input_touch_cb__sig: 'viii', + godot_js_input_touch_cb: function (callback, ids, coords) { + const func = GodotRuntime.get_func(callback); + const canvas = GodotConfig.canvas; + function touch_cb(type, evt) { + // Since the event is consumed, focus manually. + // NOTE: The iframe container may not have focus yet, so focus even when already active. + if (type === 0) { + GodotConfig.canvas.focus(); + } + const rect = canvas.getBoundingClientRect(); + const touches = evt.changedTouches; + for (let i = 0; i < touches.length; i++) { + const touch = touches[i]; + const pos = GodotInput.computePosition(touch, rect); + GodotRuntime.setHeapValue(coords + (i * 2) * 8, pos[0], 'double'); + GodotRuntime.setHeapValue(coords + (i * 2 + 1) * 8, pos[1], 'double'); + GodotRuntime.setHeapValue(ids + i * 4, touch.identifier, 'i32'); + } + func(type, touches.length); + if (evt.cancelable) { + evt.preventDefault(); + } + } + GodotEventListeners.add(canvas, 'touchstart', touch_cb.bind(null, 0), false); + GodotEventListeners.add(canvas, 'touchend', touch_cb.bind(null, 1), false); + GodotEventListeners.add(canvas, 'touchcancel', touch_cb.bind(null, 1), false); + GodotEventListeners.add(canvas, 'touchmove', touch_cb.bind(null, 2), false); + }, + + /* + * Key API + */ + godot_js_input_key_cb__sig: 'viii', + godot_js_input_key_cb: function (callback, code, key) { + const func = GodotRuntime.get_func(callback); + function key_cb(pressed, evt) { + const modifiers = GodotInput.getModifiers(evt); + GodotRuntime.stringToHeap(evt.code, code, 32); + GodotRuntime.stringToHeap(evt.key, key, 32); + func(pressed, evt.repeat, modifiers); + evt.preventDefault(); + } + GodotEventListeners.add(GodotConfig.canvas, 'keydown', key_cb.bind(null, 1), false); + GodotEventListeners.add(GodotConfig.canvas, 'keyup', key_cb.bind(null, 0), false); + }, + + /* + * Gamepad API + */ + godot_js_input_gamepad_cb__sig: 'vi', + godot_js_input_gamepad_cb: function (change_cb) { + const onchange = GodotRuntime.get_func(change_cb); + GodotInputGamepads.init(onchange); + }, + + godot_js_input_gamepad_sample_count__sig: 'i', + godot_js_input_gamepad_sample_count: function () { + return GodotInputGamepads.get_samples().length; + }, + + godot_js_input_gamepad_sample__sig: 'i', + godot_js_input_gamepad_sample: function () { + GodotInputGamepads.sample(); + return 0; + }, + + godot_js_input_gamepad_sample_get__sig: 'iiiiiii', + godot_js_input_gamepad_sample_get: function (p_index, r_btns, r_btns_num, r_axes, r_axes_num, r_standard) { + const sample = GodotInputGamepads.get_sample(p_index); + if (!sample || !sample.connected) { + return 1; + } + const btns = sample.buttons; + const btns_len = btns.length < 16 ? btns.length : 16; + for (let i = 0; i < btns_len; i++) { + GodotRuntime.setHeapValue(r_btns + (i << 2), btns[i], 'float'); + } + GodotRuntime.setHeapValue(r_btns_num, btns_len, 'i32'); + const axes = sample.axes; + const axes_len = axes.length < 10 ? axes.length : 10; + for (let i = 0; i < axes_len; i++) { + GodotRuntime.setHeapValue(r_axes + (i << 2), axes[i], 'float'); + } + GodotRuntime.setHeapValue(r_axes_num, axes_len, 'i32'); + const is_standard = sample.standard ? 1 : 0; + GodotRuntime.setHeapValue(r_standard, is_standard, 'i32'); + return 0; + }, + + /* + * Drag/Drop API + */ + godot_js_input_drop_files_cb__sig: 'vi', + godot_js_input_drop_files_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + const dropFiles = function (files) { + const args = files || []; + if (!args.length) { + return; + } + const argc = args.length; + const argv = GodotRuntime.allocStringArray(args); + func(argv, argc); + GodotRuntime.freeStringArray(argv, argc); + }; + const canvas = GodotConfig.canvas; + GodotEventListeners.add(canvas, 'dragover', function (ev) { + // Prevent default behavior (which would try to open the file(s)) + ev.preventDefault(); + }, false); + GodotEventListeners.add(canvas, 'drop', GodotInputDragDrop.handler(dropFiles)); + }, + + /* Paste API */ + godot_js_input_paste_cb__sig: 'vi', + godot_js_input_paste_cb: function (callback) { + const func = GodotRuntime.get_func(callback); + GodotEventListeners.add(window, 'paste', function (evt) { + const text = evt.clipboardData.getData('text'); + const ptr = GodotRuntime.allocString(text); + func(ptr); + GodotRuntime.free(ptr); + }, false); + }, + + godot_js_input_vibrate_handheld__sig: 'vi', + godot_js_input_vibrate_handheld: function (p_duration_ms) { + if (typeof navigator.vibrate !== 'function') { + GodotRuntime.print('This browser does not support vibration.'); + } else { + navigator.vibrate(p_duration_ms); + } + }, +}; + +autoAddDeps(GodotInput, '$GodotInput'); +mergeInto(LibraryManager.library, GodotInput); diff --git a/platform/web/js/libs/library_godot_javascript_singleton.js b/platform/web/js/libs/library_godot_javascript_singleton.js new file mode 100644 index 0000000000..692f27676a --- /dev/null +++ b/platform/web/js/libs/library_godot_javascript_singleton.js @@ -0,0 +1,346 @@ +/*************************************************************************/ +/* library_godot_eval.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const GodotJSWrapper = { + + $GodotJSWrapper__deps: ['$GodotRuntime', '$IDHandler'], + $GodotJSWrapper__postset: 'GodotJSWrapper.proxies = new Map();', + $GodotJSWrapper: { + proxies: null, + cb_ret: null, + + MyProxy: function (val) { + const id = IDHandler.add(this); + GodotJSWrapper.proxies.set(val, id); + let refs = 1; + this.ref = function () { + refs++; + }; + this.unref = function () { + refs--; + if (refs === 0) { + IDHandler.remove(id); + GodotJSWrapper.proxies.delete(val); + } + }; + this.get_val = function () { + return val; + }; + this.get_id = function () { + return id; + }; + }, + + get_proxied: function (val) { + const id = GodotJSWrapper.proxies.get(val); + if (id === undefined) { + const proxy = new GodotJSWrapper.MyProxy(val); + return proxy.get_id(); + } + IDHandler.get(id).ref(); + return id; + }, + + get_proxied_value: function (id) { + const proxy = IDHandler.get(id); + if (proxy === undefined) { + return undefined; + } + return proxy.get_val(); + }, + + variant2js: function (type, val) { + switch (type) { + case 0: + return null; + case 1: + return !!GodotRuntime.getHeapValue(val, 'i64'); + case 2: + return GodotRuntime.getHeapValue(val, 'i64'); + case 3: + return GodotRuntime.getHeapValue(val, 'double'); + case 4: + return GodotRuntime.parseString(GodotRuntime.getHeapValue(val, '*')); + case 21: // OBJECT + return GodotJSWrapper.get_proxied_value(GodotRuntime.getHeapValue(val, 'i64')); + default: + return undefined; + } + }, + + js2variant: function (p_val, p_exchange) { + if (p_val === undefined || p_val === null) { + return 0; // NIL + } + const type = typeof (p_val); + if (type === 'boolean') { + GodotRuntime.setHeapValue(p_exchange, p_val, 'i64'); + return 1; // BOOL + } else if (type === 'number') { + if (Number.isInteger(p_val)) { + GodotRuntime.setHeapValue(p_exchange, p_val, 'i64'); + return 2; // INT + } + GodotRuntime.setHeapValue(p_exchange, p_val, 'double'); + return 3; // REAL + } else if (type === 'string') { + const c_str = GodotRuntime.allocString(p_val); + GodotRuntime.setHeapValue(p_exchange, c_str, '*'); + return 4; // STRING + } + const id = GodotJSWrapper.get_proxied(p_val); + GodotRuntime.setHeapValue(p_exchange, id, 'i64'); + return 21; + }, + }, + + godot_js_wrapper_interface_get__sig: 'ii', + godot_js_wrapper_interface_get: function (p_name) { + const name = GodotRuntime.parseString(p_name); + if (typeof (window[name]) !== 'undefined') { + return GodotJSWrapper.get_proxied(window[name]); + } + return 0; + }, + + godot_js_wrapper_object_get__sig: 'iiii', + godot_js_wrapper_object_get: function (p_id, p_exchange, p_prop) { + const obj = GodotJSWrapper.get_proxied_value(p_id); + if (obj === undefined) { + return 0; + } + if (p_prop) { + const prop = GodotRuntime.parseString(p_prop); + try { + return GodotJSWrapper.js2variant(obj[prop], p_exchange); + } catch (e) { + GodotRuntime.error(`Error getting variable ${prop} on object`, obj); + return 0; // NIL + } + } + return GodotJSWrapper.js2variant(obj, p_exchange); + }, + + godot_js_wrapper_object_set__sig: 'viiii', + godot_js_wrapper_object_set: function (p_id, p_name, p_type, p_exchange) { + const obj = GodotJSWrapper.get_proxied_value(p_id); + if (obj === undefined) { + return; + } + const name = GodotRuntime.parseString(p_name); + try { + obj[name] = GodotJSWrapper.variant2js(p_type, p_exchange); + } catch (e) { + GodotRuntime.error(`Error setting variable ${name} on object`, obj); + } + }, + + godot_js_wrapper_object_call__sig: 'iiiiiiiii', + godot_js_wrapper_object_call: function (p_id, p_method, p_args, p_argc, p_convert_callback, p_exchange, p_lock, p_free_lock_callback) { + const obj = GodotJSWrapper.get_proxied_value(p_id); + if (obj === undefined) { + return -1; + } + const method = GodotRuntime.parseString(p_method); + const convert = GodotRuntime.get_func(p_convert_callback); + const freeLock = GodotRuntime.get_func(p_free_lock_callback); + const args = new Array(p_argc); + for (let i = 0; i < p_argc; i++) { + const type = convert(p_args, i, p_exchange, p_lock); + const lock = GodotRuntime.getHeapValue(p_lock, '*'); + args[i] = GodotJSWrapper.variant2js(type, p_exchange); + if (lock) { + freeLock(p_lock, type); + } + } + try { + const res = obj[method](...args); + return GodotJSWrapper.js2variant(res, p_exchange); + } catch (e) { + GodotRuntime.error(`Error calling method ${method} on:`, obj, 'error:', e); + return -1; + } + }, + + godot_js_wrapper_object_unref__sig: 'vi', + godot_js_wrapper_object_unref: function (p_id) { + const proxy = IDHandler.get(p_id); + if (proxy !== undefined) { + proxy.unref(); + } + }, + + godot_js_wrapper_create_cb__sig: 'iii', + godot_js_wrapper_create_cb: function (p_ref, p_func) { + const func = GodotRuntime.get_func(p_func); + let id = 0; + const cb = function () { + if (!GodotJSWrapper.get_proxied_value(id)) { + return undefined; + } + // The callback will store the returned value in this variable via + // "godot_js_wrapper_object_set_cb_ret" upon calling the user function. + // This is safe! JavaScript is single threaded (and using it in threads is not a good idea anyway). + GodotJSWrapper.cb_ret = null; + const args = Array.from(arguments); + func(p_ref, GodotJSWrapper.get_proxied(args), args.length); + const ret = GodotJSWrapper.cb_ret; + GodotJSWrapper.cb_ret = null; + return ret; + }; + id = GodotJSWrapper.get_proxied(cb); + return id; + }, + + godot_js_wrapper_object_set_cb_ret__sig: 'vii', + godot_js_wrapper_object_set_cb_ret: function (p_val_type, p_val_ex) { + GodotJSWrapper.cb_ret = GodotJSWrapper.variant2js(p_val_type, p_val_ex); + }, + + godot_js_wrapper_object_getvar__sig: 'iiii', + godot_js_wrapper_object_getvar: function (p_id, p_type, p_exchange) { + const obj = GodotJSWrapper.get_proxied_value(p_id); + if (obj === undefined) { + return -1; + } + const prop = GodotJSWrapper.variant2js(p_type, p_exchange); + if (prop === undefined || prop === null) { + return -1; + } + try { + return GodotJSWrapper.js2variant(obj[prop], p_exchange); + } catch (e) { + GodotRuntime.error(`Error getting variable ${prop} on object`, obj, e); + return -1; + } + }, + + godot_js_wrapper_object_setvar__sig: 'iiiiii', + godot_js_wrapper_object_setvar: function (p_id, p_key_type, p_key_ex, p_val_type, p_val_ex) { + const obj = GodotJSWrapper.get_proxied_value(p_id); + if (obj === undefined) { + return -1; + } + const key = GodotJSWrapper.variant2js(p_key_type, p_key_ex); + try { + obj[key] = GodotJSWrapper.variant2js(p_val_type, p_val_ex); + return 0; + } catch (e) { + GodotRuntime.error(`Error setting variable ${key} on object`, obj); + return -1; + } + }, + + godot_js_wrapper_create_object__sig: 'iiiiiiii', + godot_js_wrapper_create_object: function (p_object, p_args, p_argc, p_convert_callback, p_exchange, p_lock, p_free_lock_callback) { + const name = GodotRuntime.parseString(p_object); + if (typeof (window[name]) === 'undefined') { + return -1; + } + const convert = GodotRuntime.get_func(p_convert_callback); + const freeLock = GodotRuntime.get_func(p_free_lock_callback); + const args = new Array(p_argc); + for (let i = 0; i < p_argc; i++) { + const type = convert(p_args, i, p_exchange, p_lock); + const lock = GodotRuntime.getHeapValue(p_lock, '*'); + args[i] = GodotJSWrapper.variant2js(type, p_exchange); + if (lock) { + freeLock(p_lock, type); + } + } + try { + const res = new window[name](...args); + return GodotJSWrapper.js2variant(res, p_exchange); + } catch (e) { + GodotRuntime.error(`Error calling constructor ${name} with args:`, args, 'error:', e); + return -1; + } + }, +}; + +autoAddDeps(GodotJSWrapper, '$GodotJSWrapper'); +mergeInto(LibraryManager.library, GodotJSWrapper); + +const GodotEval = { + godot_js_eval__deps: ['$GodotRuntime'], + godot_js_eval__sig: 'iiiiiii', + godot_js_eval: function (p_js, p_use_global_ctx, p_union_ptr, p_byte_arr, p_byte_arr_write, p_callback) { + const js_code = GodotRuntime.parseString(p_js); + let eval_ret = null; + try { + if (p_use_global_ctx) { + // indirect eval call grants global execution context + const global_eval = eval; // eslint-disable-line no-eval + eval_ret = global_eval(js_code); + } else { + eval_ret = eval(js_code); // eslint-disable-line no-eval + } + } catch (e) { + GodotRuntime.error(e); + } + + switch (typeof eval_ret) { + case 'boolean': + GodotRuntime.setHeapValue(p_union_ptr, eval_ret, 'i32'); + return 1; // BOOL + + case 'number': + GodotRuntime.setHeapValue(p_union_ptr, eval_ret, 'double'); + return 3; // REAL + + case 'string': + GodotRuntime.setHeapValue(p_union_ptr, GodotRuntime.allocString(eval_ret), '*'); + return 4; // STRING + + case 'object': + if (eval_ret === null) { + break; + } + + if (ArrayBuffer.isView(eval_ret) && !(eval_ret instanceof Uint8Array)) { + eval_ret = new Uint8Array(eval_ret.buffer); + } else if (eval_ret instanceof ArrayBuffer) { + eval_ret = new Uint8Array(eval_ret); + } + if (eval_ret instanceof Uint8Array) { + const func = GodotRuntime.get_func(p_callback); + const bytes_ptr = func(p_byte_arr, p_byte_arr_write, eval_ret.length); + HEAPU8.set(eval_ret, bytes_ptr); + return 20; // POOL_BYTE_ARRAY + } + break; + + // no default + } + return 0; // NIL + }, +}; + +mergeInto(LibraryManager.library, GodotEval); diff --git a/platform/web/js/libs/library_godot_os.js b/platform/web/js/libs/library_godot_os.js new file mode 100644 index 0000000000..377eec3234 --- /dev/null +++ b/platform/web/js/libs/library_godot_os.js @@ -0,0 +1,427 @@ +/*************************************************************************/ +/* library_godot_os.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const IDHandler = { + $IDHandler: { + _last_id: 0, + _references: {}, + + get: function (p_id) { + return IDHandler._references[p_id]; + }, + + add: function (p_data) { + const id = ++IDHandler._last_id; + IDHandler._references[id] = p_data; + return id; + }, + + remove: function (p_id) { + delete IDHandler._references[p_id]; + }, + }, +}; + +autoAddDeps(IDHandler, '$IDHandler'); +mergeInto(LibraryManager.library, IDHandler); + +const GodotConfig = { + $GodotConfig__postset: 'Module["initConfig"] = GodotConfig.init_config;', + $GodotConfig__deps: ['$GodotRuntime'], + $GodotConfig: { + canvas: null, + locale: 'en', + canvas_resize_policy: 2, // Adaptive + virtual_keyboard: false, + persistent_drops: false, + on_execute: null, + on_exit: null, + + init_config: function (p_opts) { + GodotConfig.canvas_resize_policy = p_opts['canvasResizePolicy']; + GodotConfig.canvas = p_opts['canvas']; + GodotConfig.locale = p_opts['locale'] || GodotConfig.locale; + GodotConfig.virtual_keyboard = p_opts['virtualKeyboard']; + GodotConfig.persistent_drops = !!p_opts['persistentDrops']; + GodotConfig.on_execute = p_opts['onExecute']; + GodotConfig.on_exit = p_opts['onExit']; + if (p_opts['focusCanvas']) { + GodotConfig.canvas.focus(); + } + }, + + locate_file: function (file) { + return Module['locateFile'](file); // eslint-disable-line no-undef + }, + clear: function () { + GodotConfig.canvas = null; + GodotConfig.locale = 'en'; + GodotConfig.canvas_resize_policy = 2; + GodotConfig.virtual_keyboard = false; + GodotConfig.persistent_drops = false; + GodotConfig.on_execute = null; + GodotConfig.on_exit = null; + }, + }, + + godot_js_config_canvas_id_get__sig: 'vii', + godot_js_config_canvas_id_get: function (p_ptr, p_ptr_max) { + GodotRuntime.stringToHeap(`#${GodotConfig.canvas.id}`, p_ptr, p_ptr_max); + }, + + godot_js_config_locale_get__sig: 'vii', + godot_js_config_locale_get: function (p_ptr, p_ptr_max) { + GodotRuntime.stringToHeap(GodotConfig.locale, p_ptr, p_ptr_max); + }, +}; + +autoAddDeps(GodotConfig, '$GodotConfig'); +mergeInto(LibraryManager.library, GodotConfig); + +const GodotFS = { + $GodotFS__deps: ['$ERRNO_CODES', '$FS', '$IDBFS', '$GodotRuntime'], + $GodotFS__postset: [ + 'Module["initFS"] = GodotFS.init;', + 'Module["copyToFS"] = GodotFS.copy_to_fs;', + ].join(''), + $GodotFS: { + _idbfs: false, + _syncing: false, + _mount_points: [], + + is_persistent: function () { + return GodotFS._idbfs ? 1 : 0; + }, + + // Initialize godot file system, setting up persistent paths. + // Returns a promise that resolves when the FS is ready. + // We keep track of mount_points, so that we can properly close the IDBFS + // since emscripten is not doing it by itself. (emscripten GH#12516). + init: function (persistentPaths) { + GodotFS._idbfs = false; + if (!Array.isArray(persistentPaths)) { + return Promise.reject(new Error('Persistent paths must be an array')); + } + if (!persistentPaths.length) { + return Promise.resolve(); + } + GodotFS._mount_points = persistentPaths.slice(); + + function createRecursive(dir) { + try { + FS.stat(dir); + } catch (e) { + if (e.errno !== ERRNO_CODES.ENOENT) { + throw e; + } + FS.mkdirTree(dir); + } + } + + GodotFS._mount_points.forEach(function (path) { + createRecursive(path); + FS.mount(IDBFS, {}, path); + }); + return new Promise(function (resolve, reject) { + FS.syncfs(true, function (err) { + if (err) { + GodotFS._mount_points = []; + GodotFS._idbfs = false; + GodotRuntime.print(`IndexedDB not available: ${err.message}`); + } else { + GodotFS._idbfs = true; + } + resolve(err); + }); + }); + }, + + // Deinit godot file system, making sure to unmount file systems, and close IDBFS(s). + deinit: function () { + GodotFS._mount_points.forEach(function (path) { + try { + FS.unmount(path); + } catch (e) { + GodotRuntime.print('Already unmounted', e); + } + if (GodotFS._idbfs && IDBFS.dbs[path]) { + IDBFS.dbs[path].close(); + delete IDBFS.dbs[path]; + } + }); + GodotFS._mount_points = []; + GodotFS._idbfs = false; + GodotFS._syncing = false; + }, + + sync: function () { + if (GodotFS._syncing) { + GodotRuntime.error('Already syncing!'); + return Promise.resolve(); + } + GodotFS._syncing = true; + return new Promise(function (resolve, reject) { + FS.syncfs(false, function (error) { + if (error) { + GodotRuntime.error(`Failed to save IDB file system: ${error.message}`); + } + GodotFS._syncing = false; + resolve(error); + }); + }); + }, + + // Copies a buffer to the internal file system. Creating directories recursively. + copy_to_fs: function (path, buffer) { + const idx = path.lastIndexOf('/'); + let dir = '/'; + if (idx > 0) { + dir = path.slice(0, idx); + } + try { + FS.stat(dir); + } catch (e) { + if (e.errno !== ERRNO_CODES.ENOENT) { + throw e; + } + FS.mkdirTree(dir); + } + FS.writeFile(path, new Uint8Array(buffer)); + }, + }, +}; +mergeInto(LibraryManager.library, GodotFS); + +const GodotOS = { + $GodotOS__deps: ['$GodotRuntime', '$GodotConfig', '$GodotFS'], + $GodotOS__postset: [ + 'Module["request_quit"] = function() { GodotOS.request_quit() };', + 'Module["onExit"] = GodotOS.cleanup;', + 'GodotOS._fs_sync_promise = Promise.resolve();', + ].join(''), + $GodotOS: { + request_quit: function () {}, + _async_cbs: [], + _fs_sync_promise: null, + + atexit: function (p_promise_cb) { + GodotOS._async_cbs.push(p_promise_cb); + }, + + cleanup: function (exit_code) { + const cb = GodotConfig.on_exit; + GodotFS.deinit(); + GodotConfig.clear(); + if (cb) { + cb(exit_code); + } + }, + + finish_async: function (callback) { + GodotOS._fs_sync_promise.then(function (err) { + const promises = []; + GodotOS._async_cbs.forEach(function (cb) { + promises.push(new Promise(cb)); + }); + return Promise.all(promises); + }).then(function () { + return GodotFS.sync(); // Final FS sync. + }).then(function (err) { + // Always deferred. + setTimeout(function () { + callback(); + }, 0); + }); + }, + }, + + godot_js_os_finish_async__sig: 'vi', + godot_js_os_finish_async: function (p_callback) { + const func = GodotRuntime.get_func(p_callback); + GodotOS.finish_async(func); + }, + + godot_js_os_request_quit_cb__sig: 'vi', + godot_js_os_request_quit_cb: function (p_callback) { + GodotOS.request_quit = GodotRuntime.get_func(p_callback); + }, + + godot_js_os_fs_is_persistent__sig: 'i', + godot_js_os_fs_is_persistent: function () { + return GodotFS.is_persistent(); + }, + + godot_js_os_fs_sync__sig: 'vi', + godot_js_os_fs_sync: function (callback) { + const func = GodotRuntime.get_func(callback); + GodotOS._fs_sync_promise = GodotFS.sync(); + GodotOS._fs_sync_promise.then(function (err) { + func(); + }); + }, + + godot_js_os_execute__sig: 'ii', + godot_js_os_execute: function (p_json) { + const json_args = GodotRuntime.parseString(p_json); + const args = JSON.parse(json_args); + if (GodotConfig.on_execute) { + GodotConfig.on_execute(args); + return 0; + } + return 1; + }, + + godot_js_os_shell_open__sig: 'vi', + godot_js_os_shell_open: function (p_uri) { + window.open(GodotRuntime.parseString(p_uri), '_blank'); + }, + + godot_js_os_hw_concurrency_get__sig: 'i', + godot_js_os_hw_concurrency_get: function () { + // TODO Godot core needs fixing to avoid spawning too many threads (> 24). + const concurrency = navigator.hardwareConcurrency || 1; + return concurrency < 2 ? concurrency : 2; + }, + + godot_js_os_download_buffer__sig: 'viiii', + godot_js_os_download_buffer: function (p_ptr, p_size, p_name, p_mime) { + const buf = GodotRuntime.heapSlice(HEAP8, p_ptr, p_size); + const name = GodotRuntime.parseString(p_name); + const mime = GodotRuntime.parseString(p_mime); + const blob = new Blob([buf], { type: mime }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + }, +}; + +autoAddDeps(GodotOS, '$GodotOS'); +mergeInto(LibraryManager.library, GodotOS); + +/* + * Godot event listeners. + * Keeps track of registered event listeners so it can remove them on shutdown. + */ +const GodotEventListeners = { + $GodotEventListeners__deps: ['$GodotOS'], + $GodotEventListeners__postset: 'GodotOS.atexit(function(resolve, reject) { GodotEventListeners.clear(); resolve(); });', + $GodotEventListeners: { + handlers: [], + + has: function (target, event, method, capture) { + return GodotEventListeners.handlers.findIndex(function (e) { + return e.target === target && e.event === event && e.method === method && e.capture === capture; + }) !== -1; + }, + + add: function (target, event, method, capture) { + if (GodotEventListeners.has(target, event, method, capture)) { + return; + } + function Handler(p_target, p_event, p_method, p_capture) { + this.target = p_target; + this.event = p_event; + this.method = p_method; + this.capture = p_capture; + } + GodotEventListeners.handlers.push(new Handler(target, event, method, capture)); + target.addEventListener(event, method, capture); + }, + + clear: function () { + GodotEventListeners.handlers.forEach(function (h) { + h.target.removeEventListener(h.event, h.method, h.capture); + }); + GodotEventListeners.handlers.length = 0; + }, + }, +}; +mergeInto(LibraryManager.library, GodotEventListeners); + +const GodotPWA = { + + $GodotPWA__deps: ['$GodotRuntime', '$GodotEventListeners'], + $GodotPWA: { + hasUpdate: false, + + updateState: function (cb, reg) { + if (!reg) { + return; + } + if (!reg.active) { + return; + } + if (reg.waiting) { + GodotPWA.hasUpdate = true; + cb(); + } + GodotEventListeners.add(reg, 'updatefound', function () { + const installing = reg.installing; + GodotEventListeners.add(installing, 'statechange', function () { + if (installing.state === 'installed') { + GodotPWA.hasUpdate = true; + cb(); + } + }); + }); + }, + }, + + godot_js_pwa_cb__sig: 'vi', + godot_js_pwa_cb: function (p_update_cb) { + if ('serviceWorker' in navigator) { + const cb = GodotRuntime.get_func(p_update_cb); + navigator.serviceWorker.getRegistration().then(GodotPWA.updateState.bind(null, cb)); + } + }, + + godot_js_pwa_update__sig: 'i', + godot_js_pwa_update: function () { + if ('serviceWorker' in navigator && GodotPWA.hasUpdate) { + navigator.serviceWorker.getRegistration().then(function (reg) { + if (!reg || !reg.waiting) { + return; + } + reg.waiting.postMessage('update'); + }); + return 0; + } + return 1; + }, +}; + +autoAddDeps(GodotPWA, '$GodotPWA'); +mergeInto(LibraryManager.library, GodotPWA); diff --git a/platform/web/js/libs/library_godot_runtime.js b/platform/web/js/libs/library_godot_runtime.js new file mode 100644 index 0000000000..e2f7c8dca6 --- /dev/null +++ b/platform/web/js/libs/library_godot_runtime.js @@ -0,0 +1,134 @@ +/*************************************************************************/ +/* library_godot_runtime.js */ +/*************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/*************************************************************************/ +/* Copyright (c) 2007-2022 Juan Linietsky, Ariel Manzur. */ +/* Copyright (c) 2014-2022 Godot Engine contributors (cf. AUTHORS.md). */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.*/ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/*************************************************************************/ + +const GodotRuntime = { + $GodotRuntime: { + /* + * Functions + */ + get_func: function (ptr) { + return wasmTable.get(ptr); // eslint-disable-line no-undef + }, + + /* + * Prints + */ + error: function () { + err.apply(null, Array.from(arguments)); // eslint-disable-line no-undef + }, + + print: function () { + out.apply(null, Array.from(arguments)); // eslint-disable-line no-undef + }, + + /* + * Memory + */ + malloc: function (p_size) { + return _malloc(p_size); // eslint-disable-line no-undef + }, + + free: function (p_ptr) { + _free(p_ptr); // eslint-disable-line no-undef + }, + + getHeapValue: function (p_ptr, p_type) { + return getValue(p_ptr, p_type); // eslint-disable-line no-undef + }, + + setHeapValue: function (p_ptr, p_value, p_type) { + setValue(p_ptr, p_value, p_type); // eslint-disable-line no-undef + }, + + heapSub: function (p_heap, p_ptr, p_len) { + const bytes = p_heap.BYTES_PER_ELEMENT; + return p_heap.subarray(p_ptr / bytes, p_ptr / bytes + p_len); + }, + + heapSlice: function (p_heap, p_ptr, p_len) { + const bytes = p_heap.BYTES_PER_ELEMENT; + return p_heap.slice(p_ptr / bytes, p_ptr / bytes + p_len); + }, + + heapCopy: function (p_dst, p_src, p_ptr) { + const bytes = p_src.BYTES_PER_ELEMENT; + return p_dst.set(p_src, p_ptr / bytes); + }, + + /* + * Strings + */ + parseString: function (p_ptr) { + return UTF8ToString(p_ptr); // eslint-disable-line no-undef + }, + + parseStringArray: function (p_ptr, p_size) { + const strings = []; + const ptrs = GodotRuntime.heapSub(HEAP32, p_ptr, p_size); // TODO wasm64 + ptrs.forEach(function (ptr) { + strings.push(GodotRuntime.parseString(ptr)); + }); + return strings; + }, + + strlen: function (p_str) { + return lengthBytesUTF8(p_str); // eslint-disable-line no-undef + }, + + allocString: function (p_str) { + const length = GodotRuntime.strlen(p_str) + 1; + const c_str = GodotRuntime.malloc(length); + stringToUTF8(p_str, c_str, length); // eslint-disable-line no-undef + return c_str; + }, + + allocStringArray: function (p_strings) { + const size = p_strings.length; + const c_ptr = GodotRuntime.malloc(size * 4); + for (let i = 0; i < size; i++) { + HEAP32[(c_ptr >> 2) + i] = GodotRuntime.allocString(p_strings[i]); + } + return c_ptr; + }, + + freeStringArray: function (p_ptr, p_len) { + for (let i = 0; i < p_len; i++) { + GodotRuntime.free(HEAP32[(p_ptr >> 2) + i]); + } + GodotRuntime.free(p_ptr); + }, + + stringToHeap: function (p_str, p_ptr, p_len) { + return stringToUTF8Array(p_str, HEAP8, p_ptr, p_len); // eslint-disable-line no-undef + }, + }, +}; +autoAddDeps(GodotRuntime, '$GodotRuntime'); +mergeInto(LibraryManager.library, GodotRuntime); |