UNPKG

sparkar-volts

Version:

An extensive non-reactive Typescript framework that eases the development experience in Spark AR

1,380 lines (1,275 loc) 81.2 kB
//#region imports import Scene from 'Scene'; import Diagnostics from 'Diagnostics'; import Reactive from 'Reactive'; import Time from 'Time'; import Blocks from 'Blocks'; import CameraInfo from 'CameraInfo'; import Materials from 'Materials'; // 👇 may be dynamically imported using `require` let Persistence: { userScope: { get: (s: string) => Promise<object>; set: (s: string, o: Object) => Promise<boolean>; remove: (s: string) => Promise<boolean>; }; }, Multipeer: {}; /** * Plugins are stored on `_plugins` * `plugins` is a creator-facing interface */ const _plugins: { [key: string]: VoltsPlugin } = {}; export const plugins: { oimo: typeof import('./oimo.plugin'); [key: string]: VoltsPlugin; } = Object.defineProperties({} as any, { oimo: { get: () => safeImportPlugins('oimo') }, }); /** * @description Allows the dynamic import of Volts' plugins * @see https://github.com/facebook/react-native/issues/6391#issuecomment-194581270 */ function safeImportPlugins(name: string, version?: number | string): VoltsPlugin { if (!_plugins[name]) { const fileName = `${name}.plugin.js`; try { switch (name) { case 'oimo': _plugins['oimo'] = require('./oimo.plugin'); break; default: throw new Error(`Plugin name is undefined`); } if (version && version !== _plugins[name].VERSION) report( `Plugin versions for "${name}" do not match. Expected version: ${version}, but received "${_plugins[name].VERSION}". Please make sure you include a compatible version of "${name}" in your project.`, ).asIssue('error'); if (_plugins[name].onImport && !_plugins[name].onImport()) report(`Plugin "${name} onImport function failed. Please check with the Plugin's creator"`); } catch (error) { report( `Could not find module "${name}". Please make sure you include the "${fileName}" file in your project.\n${error}`, ).asIssue('error'); } } return _plugins[name]; } //#endregion //#region types export type PublicOnly<T> = Pick<T, keyof T>; // https://github.com/microsoft/TypeScript/issues/26223#issuecomment-410642988 interface FixedLengthArray<T, L extends number> extends Array<T> { '0': T; length: L; } // https://stackoverflow.com/a/53808212 type IfEquals<T, U, Y = unknown, N = never> = (<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? Y : N; interface VoltsPlugin { VERSION: number | string; onImport?: () => boolean; [key: string]: any; } type Snapshot = { [key: string]: | ScalarSignal | Vec2Signal | VectorSignal | PointSignal | Vec4Signal | StringSignal | BoolSignal | QuaternionSignal; }; type getDimsOfSignal<S> = S extends Vec4Signal ? 'x4' | 'y4' | 'z4' | 'w4' : S extends VectorSignal ? 'x3' | 'y3' | 'z3' : S extends PointSignal ? 'x3' | 'y3' | 'z3' : S extends Vec2Signal ? 'x2' | 'y2' : S extends ScalarSignal ? 'x1' : never; type ObjectToSnapshotable<Obj> = { [Property in keyof Obj as `${Obj[Property] extends ISignal ? `CONVERTED::${Property extends string ? Property : never}::${getDimsOfSignal<Obj[Property]>}::UUID` & string : never}`]: Obj[Property] extends Vec2Signal | VectorSignal | PointSignal | Vec4Signal | QuaternionSignal ? ScalarSignal : Obj[Property]; }; type SnapshotToVanilla<Obj> = { [Property in keyof Obj]: Obj[Property] extends Vec2Signal ? Vector<2> : Obj[Property] extends VectorSignal ? Vector<3> : Obj[Property] extends PointSignal ? Vector<3> : Obj[Property] extends Vec4Signal ? Vector<4> : Obj[Property] extends ScalarSignal ? number : Obj[Property] extends StringSignal ? string : Obj[Property] extends BoolSignal ? boolean : Obj[Property] extends QuaternionSignal ? Quaternion : Obj[Property]; }; type ReactiveToVanilla<T> = T extends ScalarSignal ? number : T extends StringSignal ? string : T extends BoolSignal ? boolean : any; interface onFramePerformanceData { fps: number; delta: number; frameCount: number; } /** * @param elapsedTime The time elapsed since the timeout/interval was created * @param count The amount of times the timeout/interval has been called. * Note this is incremented after the function is called, so it will always be 0 for any timeout * @param lastCall The last time the function was called. `let deltaBetweenCalls = elapsedTime-lastCall;` * @param created The time (in elapsed Volts.World time, not UNIX) when the function was created * @param onFramePerformanceData onFramePerformanceData corresponding to the frame when the function was called */ type TimedEventFunction = ( this: any, timedEventFunctionArguments: { elapsedTime: number; count: number; lastCall: number; created: number }, onFramePerformanceData: onFramePerformanceData, ) => void; interface TimedEvent { recurring: boolean; delay: number; created: number; lastCall: number; cb: TimedEventFunction; count: number; onNext?: number; } //#endregion //#region constants const PI = 3.14159265359; const TWO_PI = 6.28318530718; //#endregion //#region utils //#region getUUIDv4 /** * @see https://stackoverflow.com/a/2117523/14899497 */ function getUUIDv4(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, v = c == 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } //#endregion //#region promiseAllConcurrent /** @author atolkachiov */ /** @see https://gist.github.com/jcouyang/632709f30e12a7879a73e9e132c0d56b#gistcomment-3591045 */ const pAll = async (queue: Promise<any>[], concurrency: number, areFn: boolean) => { let index = 0; const results: any[] = []; // Run a pseudo-thread const execThread = async () => { while (index < queue.length) { const curIndex = index++; // Use of `curIndex` is important because `index` may change after await is resolved // @ts-expect-error results[curIndex] = await (areFn ? queue[curIndex]() : queue[curIndex]); } }; // Start threads const threads = []; for (let thread = 0; thread < concurrency; thread++) { threads.push(execThread()); } await Promise.all(threads); return results; }; const promiseAllConcurrent = (n: number, areFn: boolean) => (list: Promise<any>[]): Promise<any[]> => pAll(list, n, areFn); //#endregion //#region report type LogLevels = 'log' | 'warn' | 'error' | 'throw'; interface Reporters { asIssue: (lvl?: LogLevels) => void; asBackwardsCompatibleDiagnosticsError: () => void; } type reportFn = ((...msg: string[] | [object]) => Reporters) & { getSceneInfo: ({ getMaterials, getTextures, getIdentifiers, getPositions, }?: { getMaterials?: boolean; getTextures?: boolean; getIdentifiers?: boolean; getPositions?: boolean; }) => Promise<string>; }; const prettifyJSON = (obj: Object, spacing = 2) => JSON.stringify(obj, null, spacing); // (!) doesn't get hoisted up export const report: reportFn = function report(...msg: string[] | [object]): Reporters { let message: any; // provides a bit of backwards compatibility, keeps support at (112, 121] const toLogLevel = (lvl: LogLevels, msg: string | object) => { if (lvl === 'throw') { throw msg; } else { Diagnostics[lvl] ? Diagnostics[lvl](msg) : Diagnostics.warn(msg + `\n\n[[logger not found: ${lvl}]]`); } }; if (msg.length > 1) { message = msg.join('\n'); } else { message = msg[0]; } return { asIssue: (lvl: LogLevels = 'warn') => { message = new Error(`${message}`); const info = `This issue arose during execution.\nIf you believe it's related to VOLTS itself, please report it as a Github issue here: https://github.com/tomaspietravallo/sparkar-volts/issues\nPlease make your report detailed (include this message too!), and if possible, include a package of your current project`; message = `Message: ${message.message ? message.message : message}\n\nInfo: ${info}\n\nStack: ${ message.stack ? message.stack : undefined }`; toLogLevel(lvl, message); }, asBackwardsCompatibleDiagnosticsError: () => { Diagnostics.error ? Diagnostics.error(message) : Diagnostics.warn ? Diagnostics.warn(message) : Diagnostics.log(message); }, }; } as any; report.getSceneInfo = async function ( { getMaterials, getTextures, getIdentifiers, getPositions } = { getMaterials: true, getTextures: true, getIdentifiers: true, getPositions: true, }, ): Promise<string> { const Instance = World.getInstance(false); const info: { [key: string]: any } = {}; if (Instance && Instance.loaded) { const sceneData: { [key: string]: any } = {}; const keys = Object.keys(Instance.assets); // loop over all assets, may include scene objects/textures/materials/others for (let index = 0; index < keys.length; index++) { const key = keys[index]; const element = Instance.assets[key]; const getElementData = async (e: any) => { if (!e) return { warning: 'no-element-was-found' }; const data: { [key: string]: any } = {}; let mat, tex; data['name'] = e.name; data['hidden'] = e.hidden.pinLastValue(); if (getIdentifiers) data['identifier'] = e.identifier; if (getPositions) { data['position'] = Vector.fromSignal(e.transform.position).toString(5); } if (getMaterials || getTextures) { mat = e.getMaterial ? (await e.getMaterial()) || {} : {}; if (getMaterials) data['material'] = mat.name || 'undefined'; if (getMaterials && getIdentifiers) data['material-id'] = mat.identifier || 'undefined'; } if (getTextures) { tex = mat && mat.getDiffuse ? (await mat.getDiffuse()) || {} : {}; data['texture'] = tex.name || 'undefined'; if (getIdentifiers) data['texture-id'] = tex.identifier || 'undefined'; } return data; }; if (Array.isArray(element) && element.length > 1) { sceneData[key] = await promiseAllConcurrent(10, true)(element.map((e) => getElementData.bind(this, e))); } else if (element) { sceneData[key] = await getElementData(element[0]); } else { sceneData[key] = `obj[key] is possibly undefined. key: ${key}`; } } info['scene'] = sceneData; } else { info['scene'] = 'no instance was found, or the current instance has not loaded yet'; } info['modules'] = { Persistence: !!Persistence, Multipeer: !!Multipeer, dynamicInstancing: !!Scene.create, writableSignals: !!Reactive.scalarSignalSource, }; return prettifyJSON(info); }; //#endregion //#region transformAcrossSpaces /** * @param vec The 3D VectorSignal to be transformed * @param vecSpace The parent space in which `vec` is located * @param targetSpace The parent space into which `vec` should be transformed into * @returns A signal in the `targetSpace`, which in the absolute frame of reference, is equivalent to `vec` in it's `vecSpace` * * @example ```ts * let firstObj: SceneObjectBase, secondarySceneObj: SceneObjectBase; * secondarySceneObj.transform.position = * transformAcrossSpaces( * firstObj.transform.position, * firstObj.parentWorldTransform, * secondarySceneObj.parentWorldTransform * ) * ``` */ export function transformAcrossSpaces( vec: VectorSignal | PointSignal, vecParentSpace: TransformSignal, targetParentSpace: TransformSignal, ): PointSignal { if (!(vec && vec.z && vec.pinLastValue)) throw new Error(`@ transformAcrossSpaces: Argument vec is not defined, or is not a VectorSignal`); if (!(vecParentSpace && vecParentSpace.inverse && vecParentSpace.pinLastValue)) throw new Error(`@ transformAcrossSpaces: Argument vecParentSpace is not defined, or is not a TransformSignal`); if (!(targetParentSpace && targetParentSpace.inverse && targetParentSpace.pinLastValue)) throw new Error(`@ transformAcrossSpaces: Argument targetParentSpace is not defined, or is not a TransformSignal`); return targetParentSpace.inverse().applyToPoint(vecParentSpace.applyToPoint(vec)); } //#endregion //#region randomBetween export const randomBetween = (min: number, max: number): number => { return Math.random() * (max - min) + min; }; //#endregion //#region HSVtoRGB /** * @see https://stackoverflow.com/a/54024653 */ export function hsv2rgb(h: number, s: number, v: number): [number, number, number] { h *= 360; /* istanbul ignore next */ const f = (n: number, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); return [f(5), f(3), f(1)]; } //#endregion //#region allBinaryOptions export function allBinaryOptions(len: number, a: number, b: number): (typeof a | typeof b)[][] { const binary: (typeof a | typeof b)[][] = []; for (let index = 0; index < 2 ** len; index++) { const binaryString = index.toString(2); binary.push( ( Array(len - binaryString.length) .fill('0') .join('') + binaryString ) .split('') .map((n) => (Number(n) ? a : b)), ); } return binary; } //#endregion //#endregion //#region World export enum PRODUCTION_MODES { 'PRODUCTION' = 'PRODUCTION', 'DEV' = 'DEV', 'NO_AUTO' = 'NO_AUTO', } interface InternalSignals { __volts__internal__time: number; __volts__internal__focalDistance: number; __volts__internal__screen: Vector<3>; __volts__internal__screenSizePixels: Vector<2>; } interface Events<S extends Snapshot> { load: (snapshot?: SnapshotToVanilla<S>) => void; frameUpdate: (snapshot?: SnapshotToVanilla<S>, data?: onFramePerformanceData) => void; [event: string]: (...args: any) => void; } interface InternalWorldData { initPromise: () => Promise<void>; loaded: boolean; running: boolean; events: Partial<{ [E in keyof Events<Snapshot>]: Events<Snapshot>[E][] }>; timedEvents: TimedEvent[]; elapsedTime: number; frameCount: number; FLAGS: { stopTimeout: boolean; lockInternalSnapshotOverride: boolean }; formattedValuesToSnapshot: ObjectToSnapshotable<Snapshot>; userFriendlySnapshot: SnapshotToVanilla<Snapshot> & InternalSignals; quaternions: Map<string, boolean>; Camera: Camera; } interface WorldConfig { mode: keyof typeof PRODUCTION_MODES | `${number}x${number}`; assets?: { [key: string]: Promise<any | any[]> }; snapshot?: Snapshot; loadStates?: State<any> | State<any>[]; } class VoltsWorld<WorldConfigParams extends WorldConfig> { private static instance: VoltsWorld<any>; private static userConfig: WorldConfig; static subscriptions: Function[]; protected internalData: InternalWorldData; public assets: { [Prop in keyof WorldConfigParams['assets']]: WorldConfigParams['assets'][Prop] extends PromiseLike<infer C> ? C : never; }; public mode: keyof typeof PRODUCTION_MODES | `${number}x${number}`; private constructor() { this.mode = VoltsWorld.userConfig.mode; // @ts-expect-error this.assets = {}; this.internalData = { initPromise: this.init.bind(this, VoltsWorld.userConfig.assets, VoltsWorld.userConfig.loadStates), running: false, loaded: false, events: {}, elapsedTime: 0, frameCount: 0, timedEvents: [], // @ts-ignore missing props are assigned at runtime userFriendlySnapshot: {}, formattedValuesToSnapshot: {}, FLAGS: { stopTimeout: false, lockInternalSnapshotOverride: false, }, quaternions: new Map<string, boolean>(), Camera: null, }; // Quaternion support needs the internalData.quaternion map this.internalData.formattedValuesToSnapshot = this.signalsToSnapshot_able(VoltsWorld.userConfig.snapshot); // Making the promise public makes it easier to test with Jest // Using Object.define so it doesn't show on the type def & doesn't raise ts errors Object.defineProperty(this, 'rawInitPromise', { value: this.internalData.initPromise(), enumerable: false, writable: false, configurable: false, }); for (let index = 0; index < VoltsWorld.subscriptions.length; index++) { VoltsWorld.subscriptions[index](); } } // @todo add uglier but user-friendlier long-form type static getInstance<WorldConfigParams extends WorldConfig>( config?: WorldConfigParams | boolean, ): VoltsWorld<WorldConfigParams> { if (config === false) return VoltsWorld.instance; if (!VoltsWorld.instance) { if (typeof config !== 'object' || config === null) throw new Error( `@ VoltsWorld.getInstance: 'config' was not provided, but is required when creating the first instance`, ); if (!config.mode) throw new Error( `@ VoltsWorld.getInstance: 'config.mode' was not provided, but is required when creating the first instance`, ); // @ts-expect-error if (!Object.values(PRODUCTION_MODES).includes(config.mode) && config.mode.indexOf('x') === -1) throw new Error( `@ VoltsWorld.getInstance: 'config.mode' was provided, but was not valid.\n\nAvailable modes are: ${Object.values( PRODUCTION_MODES, )}`, ); config.loadStates = config.loadStates || []; Array.isArray(config.loadStates) ? config.loadStates : [config.loadStates]; config.assets = config.assets || {}; config.snapshot = config.snapshot || {}; VoltsWorld.userConfig = config; VoltsWorld.instance = new VoltsWorld(); } else if (config) { Diagnostics.warn( `@ VoltsWorld.getInstance: 'config' was provided (attempted to create new instance) but there's already an instance running`, ); } return VoltsWorld.instance; } /** @description Use this function to run a fn when a new Instance gets created */ static subscribeToInstance(cb: () => void): boolean { if (typeof cb === 'function') return !!VoltsWorld.subscriptions.push(cb); return false; } static devClear() { // const Instance = VoltsWorld.getInstance(false); VoltsWorld.userConfig = undefined; VoltsWorld.instance = undefined; VoltsWorld.subscriptions = []; } private async init(assets: WorldConfig['assets'], states: State<any>[]): Promise<void> { this.internalData.Camera = (await Scene.root.findFirst('Camera')) as Camera; if (!this.internalData.FLAGS.lockInternalSnapshotOverride) this.addToSnapshot({ __volts__internal__focalDistance: this.internalData.Camera.focalPlane.distance, __volts__internal__time: Time.ms, __volts__internal__screen: Scene.unprojectToFocalPlane(Reactive.point2d(0, 0)), __volts__internal__screenSizePixels: CameraInfo.previewSize, }); // (three internal keys are manually deleted on load) this.internalData.FLAGS.lockInternalSnapshotOverride = true; // load states // States are automatically loaded when created // @ts-ignore loadState is purposely not part of the type const loadStateArr = await promiseAllConcurrent(10, true)(states.map((s: State<any>) => s.loadState)); const keys = Object.keys(assets); const getAssets: any[] = await promiseAllConcurrent(10, false)(keys.map((n) => assets[n])); for (let k = 0; k < keys.length; k++) { if (!getAssets[k]) throw new Error(`@ Volts.World.init: Object(s) not found. Key: "${keys[k]}"`); // @ts-ignore // To be properly typed out. Unfortunately, i think loading everything at once with an array ([...keys.map((n) =>...) would make it very challenging... // Might be best to ts-ignore or `as unknown` in this case this.assets[keys[k]] = Array.isArray(getAssets[k]) ? getAssets[k].sort((a, b) => { return a.name.localeCompare(b.name); }) : getAssets[k]; // .map(objBody=>{ return new Object3D(objBody) }); } this.internalData.loaded = true; if (this.mode !== PRODUCTION_MODES.NO_AUTO) this.run(); } public run(): boolean { if (this.internalData.running) return false; this.internalData.FLAGS.stopTimeout = false; this.internalData.running = true; // Fun fact: Time.setTimeoutWithSnapshot will run even if the Studio is paused // Meaning this would keep executing, along with any onFrame function // For DEV purposes, the function will not execute if it detects the studio is on pause // This won't be the case when the mode is set to PROD, in case some device has undocumented behavior within the margin of error (3 frames) const lastThreeFrames: number[] = []; let offset = 0; const loop = () => { Time.setTimeoutWithSnapshot( this.internalData.formattedValuesToSnapshot as { [key: string]: any }, (_: number, snapshot: any) => { //#region Snapshot snapshot = this.formattedSnapshotToUserFriendly(snapshot); this.internalData.userFriendlySnapshot = { ...this.internalData.userFriendlySnapshot, ...snapshot }; //#endregion //#region Capture data & analytics if (!lastThreeFrames[0]) offset = this.internalData.userFriendlySnapshot.__volts__internal__time || 0; const delta = (this.internalData.userFriendlySnapshot.__volts__internal__time || 0) - offset - this.internalData.elapsedTime; const fps = Math.round((1000 / delta) * 10) / 10; this.internalData.elapsedTime += delta; if (lastThreeFrames.length > 2) { lastThreeFrames[0] = lastThreeFrames[1]; lastThreeFrames[1] = lastThreeFrames[2]; lastThreeFrames[2] = this.internalData.userFriendlySnapshot.__volts__internal__time; } else { lastThreeFrames.push(this.internalData.userFriendlySnapshot.__volts__internal__time); } //#endregion // For DEV purposes, the function will not execute if it detects the studio is on pause if ( lastThreeFrames[0] === lastThreeFrames[1] && lastThreeFrames[1] === lastThreeFrames[2] && VoltsWorld.userConfig.mode !== PRODUCTION_MODES.PRODUCTION ) return loop(); const run = () => { const onFramePerformanceData = { fps, delta, frameCount: this.internalData.frameCount }; this.runTimedEvents(onFramePerformanceData); this.emitEvent('frameUpdate', this.internalData.userFriendlySnapshot, onFramePerformanceData); this.internalData.frameCount += 1; if (!this.internalData.FLAGS.stopTimeout) return loop(); }; if (this.frameCount === 0) { let loadReturn; if (VoltsWorld.userConfig.mode !== PRODUCTION_MODES.NO_AUTO) { // @ts-expect-error delete this.internalData.formattedValuesToSnapshot['__volts__internal__screen']; // @ts-expect-error delete this.internalData.formattedValuesToSnapshot['__volts__internal__screenSizePixels']; // @ts-expect-error delete this.internalData.formattedValuesToSnapshot['__volts__internal__focalDistance']; if (this.mode.indexOf('x') !== -1) { this.mode = this.internalData.userFriendlySnapshot.__volts__internal__screenSizePixels.equals( new Vector(this.mode.split('x').map((n) => Number(n))), ) ? 'DEV' : 'PRODUCTION'; } this.emitEvent('load', this.internalData.userFriendlySnapshot); } if (loadReturn && loadReturn.then) { loadReturn.then(run); } else { run(); } } else { run(); } }, 0, ); }; loop(); return true; } get loaded(): boolean { return this.internalData.loaded; } get running(): boolean { return this.internalData.running; } get frameCount(): number { return this.internalData.frameCount; } get snapshot(): SnapshotToVanilla<WorldConfigParams['snapshot']> & { [key: string]: any } { return this.internalData.userFriendlySnapshot; } /** * @description Runs World.init. **This is NOT RECOMMENDED**. This function will not load new assets or states. */ public forceAssetReload(): Promise<void> { return this.internalData.initPromise(); } /** * @description Freezes the World instance in time. * @returns */ public stop({ clearTimedEvents } = { clearTimedEvents: false }): boolean { if (!this.internalData.running) return false; this.internalData.running = false; if (clearTimedEvents) this.internalData.timedEvents = []; this.internalData.FLAGS.stopTimeout = true; return true; } /** * @author Andrey Sitnik * @param event The event name. * @param args The arguments for listeners. * @see https://github.com/ai/nanoevents */ public emitEvent(event: string, ...args: any[]): void { const shouldBind = ['load', 'frameUpdate', 'internal'].some((e) => e === event); const evts = this.internalData.events[event] || []; for (let index = 0; index < evts.length; index++) { const event = evts[index]; if (shouldBind) { event.bind(this)(...args); } else { event(...args); } } } /** * @author Andrey Sitnik * @param event The event name. * @param cb The listener function. * @returns Unbind listener from event. * @see https://github.com/ai/nanoevents */ public onEvent<K extends keyof Events<SnapshotToVanilla<WorldConfigParams['snapshot']> & { [key: string]: any }>>( event: K, cb: Events<SnapshotToVanilla<WorldConfigParams['snapshot']> & { [key: string]: any }>[K], ): () => void { (this.internalData.events[event] = this.internalData.events[event] || []).push(cb); return () => (this.internalData.events[event] = (this.internalData.events[event] || []).filter((i) => i !== cb)); } public onNextTick(cb: () => void): { clear: () => void } { return this.setTimedEvent(cb, { ms: 0, recurring: false, onNext: this.frameCount }); } /** * @description Creates a timeout that executes the function after a given number of milliseconds * @param cb The function to be executed * @param ms The amount of milliseconds to wait before calling the function * @returns The `clear` function, with which you can clear the timeout, preventing any future executions */ public setTimeout(cb: TimedEventFunction, ms: number): { clear: () => void } { // if (!this.internalData.running) // Diagnostics.warn('Warning @ Volts.World.setTimeout: created a timeout while the current instance is not running'); return this.setTimedEvent(cb, { ms, recurring: false }); } /** * @description Creates an interval that executes the function every [X] milliseconds * @param cb The function to be executed * @param ms The amount of milliseconds to wait before calling the function * @returns The `clear` function, with which you can clear the interval, preventing any future executions */ public setInterval(cb: TimedEventFunction, ms: number): { clear: () => void } { // if (!this.internalData.running) // Diagnostics.warn( 'Warning @ Volts.World.setInterval: created an interval while the current instance is not running', ); return this.setTimedEvent(cb, { ms, recurring: true }); } /** * @param cb The function to be called * @param ms The amount of ms * @param trailing Whether the debounce should trail or lead. False means the debounce will lead * @see http://demo.nimius.net/debounce_throttle/ */ public setDebounce<argTypes extends Array<any>>( cb: (...args: argTypes) => void, ms: number, trailing = false, ): (...args: argTypes) => void { // if (!this.internalData.running) // Diagnostics.warn('Warning @ Volts.World.setDebounce: created a debounce while the current instance is not running', ); let timer: { clear: () => void }; if (trailing) // trailing return (...args: argTypes): void => { timer && timer.clear(); timer = this.setTimeout(() => { cb.apply(this, args); }, ms); }; // leading return (...args: argTypes): void => { if (!timer) { cb.apply(this, args); } timer && timer.clear(); timer = this.setTimeout(() => { timer = undefined; }, ms); }; } protected setTimedEvent( cb: TimedEventFunction, { ms, recurring, onNext }: { ms?: number; recurring?: boolean; onNext?: number }, ): { clear: () => void } { const event: TimedEvent = { created: this.internalData.elapsedTime, lastCall: this.internalData.elapsedTime, count: 0, delay: ms, recurring, cb, onNext, }; this.internalData.timedEvents.push(event); return { clear: () => (this.internalData.timedEvents = (this.internalData.timedEvents || []).filter((i) => i !== event)), }; } private runTimedEvents(onFramePerformanceData: onFramePerformanceData) { this.internalData.timedEvents = this.internalData.timedEvents.sort( (e1, e2) => e1.lastCall + e1.delay - (e2.lastCall + e2.delay), ); let i = this.internalData.timedEvents.length; while (i--) { const event = this.internalData.timedEvents[i]; if ( (event.onNext !== undefined && event.onNext !== this.frameCount) || (event.onNext === undefined && event.lastCall + event.delay < this.internalData.elapsedTime) ) { event.cb.apply(this, [ this.internalData.elapsedTime - event.created, event.count, event.lastCall, event.created, onFramePerformanceData, ]); this.internalData.timedEvents[i].count++; if (event.recurring) { this.internalData.timedEvents[i].lastCall = this.internalData.elapsedTime; } else { this.internalData.timedEvents.splice(i, 1); } } } } protected signalsToSnapshot_able<values extends Snapshot>(values: values): ObjectToSnapshotable<values> { // The purpose of the prefix & suffix is to ensure any signal values added to the snapshot don't collide. // Eg. were vec3 'V1' to be broken up into 'V1x' 'V1y' 'V1z', it'd collide with any signals named 'V1x' 'V1y' 'V1z' // Here the names would get converted to 'CONVERTED::V1::x|y|z|w:[UUID]', later pieced back together into a number[] // Hopefully reducing any possible error that might arise from the accordion needed to work together with subscribeWithSnapshot const prefix = 'CONVERTED'; const suffix = getUUIDv4(); const getKey = (k: string, e: string) => `${prefix}::${k}::${e}::${suffix}`; // @ts-ignore const tmp: { [key: string]: any } = {}; const keys = Object.keys(values); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const signal: any = values[key]; // any used instead of 14 separate @ts-ignore s if (!signal) throw new Error(`@ (static) signalsToSnapshot_able: value[key] is not defined. Key: "${key}"`); if (signal.w) { // vec4 tmp[getKey(key, 'w4')] = signal.w; tmp[getKey(key, 'z4')] = signal.z; tmp[getKey(key, 'y4')] = signal.y; tmp[getKey(key, 'x4')] = signal.x; if (signal.eulerAngles) this.internalData.quaternions.set(key, true); } else if (signal.z) { // vec3 tmp[getKey(key, 'z3')] = signal.z; tmp[getKey(key, 'y3')] = signal.y; tmp[getKey(key, 'x3')] = signal.x; } else if (signal.y) { // vec2 tmp[getKey(key, 'y2')] = signal.y; tmp[getKey(key, 'x2')] = signal.x; } else if (signal.xor || signal.concat || signal.pinLastValue) { // bool // string // scalar, this very likely unintentionally catches any and all other signal types, even the ones that can't be snapshot'ed tmp[getKey(key, 'x1')] = signal; } else { throw new Error( `@ (static) signalsToSnapshot_able: The provided Signal is not defined or is not supported. Key: "${key}"\n\nPlease consider opening an issue/PR: https://github.com/tomaspietravallo/sparkar-volts/issues`, ); } } // @ts-ignore return tmp; } protected formattedSnapshotToUserFriendly(snapshot: ObjectToSnapshotable<Snapshot>): SnapshotToVanilla<Snapshot> { let keys = Object.keys(snapshot); const signals: { [key: string]: [number, string] } = {}; // name, dimension for (let i = 0; i < keys.length; i++) { const key = keys[i]; const parts: string[] = key.split('::'); if (parts.length !== 4 || parts[0] !== 'CONVERTED') throw new Error( `@ Volts.World.formattedSnapshotToUserFriendly: Signal is missing the correct prefix, or is missing parts. Key: ${key}. Parts: ${parts}`, ); const name = parts[1]; // eslint-disable-line no-alert const [component, dimension] = parts[2].split(''); const uuid = parts[3]; signals[name] = [Number(dimension), uuid]; } keys = Object.keys(signals); const result: { [key: string]: any } = {}; for (let i = 0; i < keys.length; i++) { const name = keys[i]; const [dim, uuid] = signals[name]; if (!Number.isFinite(dim) || dim == 0 || dim > 4) report( `@ Volts.World.formattedSnapshotToUserFriendly: dimension of signals[name] not 1|2|3|4. Dim: ${dim}. Name: ${name}.\n\nKeys: ${keys}`, ).asIssue('throw'); const arr: any[] = []; for (let index = 0; index < dim; index++) { arr.push(snapshot[`CONVERTED::${name}::${Vector.components[index]}${dim}::${uuid}`]); } if (this.internalData.quaternions.has(name)) { result[name] = new Quaternion(arr[3], arr[0], arr[1], arr[2]); } else { result[name] = dim >= 2 ? new Vector(arr) : arr[0]; } } return result; } public addToSnapshot(obj: Snapshot = {}): void { if ( this.internalData.FLAGS.lockInternalSnapshotOverride && !Object.keys(obj).every((k) => k.indexOf('__volts__internal') === -1) ) throw new Error('Cannot override internal key after the internal snapshot override has been locked'); this.internalData.formattedValuesToSnapshot = Object.assign( this.internalData.formattedValuesToSnapshot, this.signalsToSnapshot_able(obj), ); } public removeFromSnapshot(keys: string | string[]): void { const keysToRemove = Array.isArray(keys) ? keys : [keys]; if ( this.internalData.FLAGS.lockInternalSnapshotOverride && !keysToRemove.every((k) => k.indexOf('__volts__internal') === -1) ) throw new Error('Cannot remove internal key after the internal snapshot override has been locked'); const snapKeys = Object.keys(this.internalData.formattedValuesToSnapshot); const matches = snapKeys.filter((k) => keysToRemove.indexOf(k.split('::')[1]) !== -1); for (let index = 0; index < matches.length; index++) { const match = matches[index]; delete this.internalData.formattedValuesToSnapshot[match]; } } /** * @description Returns a 2D Vector representing the bottom right of the screen, in world space coordinates */ public getWorldSpaceScreenBounds(): Vector<3> { if (!this.internalData.running) { throw new Error( `Vector.getWorldSpaceScreenBounds can only be called when there's a Volts.World instance running`, ); } // ask the spark team about this :D, at the time of writing (v119), this didn't output consistent results return this.internalData.userFriendlySnapshot.__volts__internal__screen.copy().abs().mul(1, -1, 0); } } VoltsWorld.subscriptions = []; //#endregion //#region Vector type VectorArgRest<D extends number = any> = [number] | [number[]] | number[] | [Vector<D>]; interface NDVectorInstance<D extends number> { values: number[]; readonly dimension: number; add(...args: VectorArgRest): Vector<D>; sub(...args: VectorArgRest): Vector<D>; mul(...args: VectorArgRest): Vector<D>; div(...args: VectorArgRest): Vector<D>; dot(...args: VectorArgRest): number; distance(...other: VectorArgRest): number; magSq(): number; mag(): number; setMag(newMag: number): Vector<D>; abs(): Vector<D>; copy(): Vector<D>; normalize(): Vector<D>; equals(b: Vector<any>): boolean; toString(toFixed?: number): string; toArray(): number[]; get x(): number; set x(x: number); get signal(): D extends 2 ? Vec2Signal : D extends 3 ? PointSignal : D extends 4 ? Vec4Signal : ScalarSignal; setSignalComponents(): void; disposeSignalResources(): void; } interface Vector2DInstance { get x(): number; set x(x: number); get y(): number; set y(y: number); heading(): number; rotate(a: number): Vector<2>; } interface Vector3DInstance { get x(): number; set x(x: number); get y(): number; set y(y: number); get z(): number; set z(z: number); get pointSignal(): PointSignal; cross(...args: VectorArgRest<3>): Vector<3>; applyQuaternion(q: Quaternion): Vector<3>; } interface Vector4DInstance { get x(): number; set x(x: number); get y(): number; set y(y: number); get z(): number; set z(z: number); get w(): number; set w(w: number); } interface NDVector { new <uD extends number, args extends VectorArgRest = FixedLengthArray<number, uD> | [number[]]>( ...args: args ): args extends undefined[] ? Vector<3> : args extends [number[]] ? Vector<uD> : args extends FixedLengthArray<number, infer D> ? // typeof args[0] extends Array<any> ? Vector<uD> : D extends uD ? Vector<D> : never : args extends [Vector<infer D>] ? D extends uD ? Vector<D> : never : never; convertToSameDimVector<D extends number>(dim: D, ...args: VectorArgRest): Vector<D>; screenToWorld(x: number, y: number, focalPlane: boolean): Vector<3>; fromSignal<sT extends ScalarSignal | Vec2Signal | VectorSignal | PointSignal | Vec4Signal>( s: sT, ): Vector< sT extends ScalarSignal ? 1 : sT extends Vec2Signal ? 2 : sT extends VectorSignal ? 3 : sT extends PointSignal ? 3 : sT extends Vec4Signal ? 4 : number >; random2D(): Vector<2>; random3D(): Vector<3>; components: ['x', 'y', 'z', 'w']; } type getVecTypeForD<D extends number> = D extends 1 ? {} : D extends 2 ? Vector2DInstance : D extends 3 ? Vector3DInstance : D extends 4 ? Vector4DInstance : Vector2DInstance & Vector3DInstance & Vector4DInstance; export type Vector<D extends number> = NDVectorInstance<D> & getVecTypeForD<D>; /** * @classdesc A flexible, easy to use, N-D vector class * * Note: this is not optimized for incredible performance, but it provides a lot of flexibility to users of the framework/lib */ export const Vector = function <D extends number>(this: Vector<number>, ...args: VectorArgRest): Vector<D> { if (args[0] instanceof Vector) { // @ts-ignore return args[0].copy(); } else if (Array.isArray(args[0])) { this.values = args[0]; } else if (args.length === 1) { this.values = [args[0], args[0], args[0]]; } else if (args[0] == undefined) { this.values = [0, 0, 0]; } else { this.values = args as number[]; } let e = this.values.length === 0; for (let i = 0; i < this.values.length; i++) { e = e || typeof this.values[i] !== 'number'; } if (e) throw new Error(`@ Vector.constructor: Values provided are not valid. args: ${args}. this.values: ${this.values}`); // @ts-expect-error this.dimension = this.values.length; // @ts-expect-error return this; } as unknown as NDVector; Object.defineProperties(Vector.prototype, { x: { get: function () { return this.values[0]; }, set: function (x) { this.values[0] = x; }, }, y: { get: function () { if (this.dimension < 2) throw new Error(`Cannot get Vector.y, vector is a scalar`); return this.values[1]; }, set: function (y) { if (this.dimension < 2) throw new Error(`Cannot get Vector.y, vector is a scalar`); this.values[1] = y; }, }, z: { get: function () { if (this.dimension < 3) throw new Error(`Cannot get Vector.z, vector is not 3D`); return this.values[2]; }, set: function (z) { if (this.dimension < 3) throw new Error(`Cannot get Vector.z, vector is not 3D`); this.values[2] = z; }, }, w: { get: function () { if (this.dimension < 4) throw new Error(`Cannot get Vector.w, vector is not 4D`); return this.values[3]; }, set: function (w) { if (this.dimension < 4) throw new Error(`Cannot get Vector.w, vector is not 4D`); this.values[3] = w; }, }, signal: { get: function () { if (this.rs) return this.rs; const uuid = getUUIDv4(), vals = this.values; for (let index = 0; index < this.dimension; index++) { const c = Vector.components[index]; this[`r${c}`] = Reactive.scalarSignalSource(`v${this.dimension}-${c}-${uuid}`); this[`r${c}`].set(vals[index]); } if (this.dimension === 1) { this.rs = this.rx.signal; } else if (this.dimension === 2) { this.rs = Reactive.point2d(this.rx.signal, this.ry.signal); } else if (this.dimension === 3) { this.rs = Reactive.vector(this.rx.signal, this.ry.signal, this.rz.signal); } else if (this.dimension === 4) { this.rs = Reactive.pack4(this.rx.signal, this.ry.signal, this.rz.signal, this.rw.signal); } else { throw new Error( `Tried to get the Signal of a N>4 Vector instance. Signals are only available for Vectors with up to 4 dimensions`, ); } return this.rs; }, }, pointSignal: { get: function () { // reactive point signal if (this.rps) return this.rps; if (this.dimension !== 3) throw new Error(`@Vector.pointSignal accessor only available on 3D Vectors. Please use Vector.signal instead`); const uuid = getUUIDv4(), vals = this.values; for (let index = 0; index < 3; index++) { const c = Vector.components[index]; this[`r${c}`] = Reactive.scalarSignalSource(`v${this.dimension}-${c}-${uuid}`); this[`r${c}`].set(vals[index]); } this.rps = Reactive.point(this.rx.signal, this.ry.signal, this.rz.signal); return this.rps; }, }, }); //#region static Vector.convertToSameDimVector = function <D extends number>(dim: D, ...args: VectorArgRest): Vector<D> { if (!args) throw new Error('@ Vector.convertToSameDimVector: No values provided'); if (args.length == 1) { if (args[0] instanceof Vector) { // @ts-ignore if (args[0].dimension == dim) return args[0]; // returns the same vector that was provided if (args[0].dimension > dim) return new Vector(args[0].values.slice(0, dim)); // returns a vector that's swizzled to match throw new Error( `@ Vector.convertToVector: values provided are not valid. Dimensions do not match. dim: ${dim}. args(s): ${args}`, ); } else if (Array.isArray(args[0])) { if (args[0].length == dim) return new Vector(args[0]); // returns a vector with the given array as components if (args[0].length > dim) return new Vector(args[0].slice(0, dim)); // returns a vector with the given array as components (swizzled) throw new Error( `@ Vector.convertToVector: values provided are not valid. Dimensions do not match. dim: ${dim}. args(s): ${args}`, ); } else if (typeof args[0] == 'number') { return new Vector(new Array(dim).fill(args[0])); // returns a vector filled with the given number } else { throw new Error(`@ Vector.convertToVector: values provided are not valid. dim: ${dim}. args(s): ${args}`); } } else { if (!(Array.isArray(args) && (args as any[]).every((a) => typeof a === 'number')) || args.length < dim) throw new Error(`@ Vector.convertToVector: values provided are not valid. dim: ${dim}. args(s): ${args}`); return new Vector(args.splice(0, dim) as unknown as number[]); } }; Vector.screenToWorld = function (x: number, y: number, focalPlane = true): Vector<3> { const Instance = VoltsWorld.getInstance(false); if (!(Instance && Instance.running)) { throw new Error(`Vector.screenToWorld can only be called when there's a Volts.World instance running`); } if (!(typeof x == 'number' && typeof y == 'number')) { throw new Error(`@ Vector.screenToWorld: values provided are not valid. Values: x: ${x}, y: ${y}`); } x = (x - 0.5) * 2; y = (y - 0.5) * 2; const bounds = Instance.getWorldSpaceScreenBounds(); return new Vector( bounds.values[0] * x, bounds.values[1] * y, focalPlane ? (Instance.snapshot.__volts__internal__focalDistance as unknown as number) : 0, ); }; Vector.fromSignal = function <sT extends ScalarSignal | Vec2Signal | VectorSignal | PointSignal | Vec4Signal>( s: any, ): Vector< sT extends ScalarSignal ? 1 : sT extends Vec2Signal ? 2 : sT extends VectorSignal ? 3 : sT extends PointSignal ? 3 : sT extends Vec4Signal ? 4 : number > { if (!s) throw new Error(`@ Volts.Vector.fromSignal: s is not defined`); const tmp = []; // returns a scalar if (!s.x) return new Vector([s.pinLastValue()]); for (let index = 0; index < Vector.components.length; index++) { const e = s[Vector.components[index]]; if (!e) continue; tmp.push(e.pinLastValue()); } return new Vector(tmp); }; Vector.random2D = function random2D(magnitude = 1): Vector<2> { const angle = Math.random(); return new Vector(Math.cos(angle) * magnitude, Math.sin(angle) * magnitude); }; Vector.random3D = function random3D(magnitude = 1): Vector<3> { const angle = Math.random() * TWO_PI; const vz = Math.random() * 2 - 1; const vzBase = Math.sqrt(1 - vz * vz); const vx = vzBase * Math.cos(angle); const vy = vzBase * Math.sin(angle); return new Vector(vx * magnitude, vy * magnitude, vz * magnitude); }; Vector.components = ['x', 'y', 'z', 'w']; //#endregion //#region common Vector.prototype.add = function <D extends number>(this: Vector<D>, ...args: VectorArgRest): Vector<D> { const b = Vector.convertToSameDimVector(this.dimension, ...args).values; this.values = this.values.map((v, i) => v + b[i]); return this; }; Vector.prototype.sub = function <D extends number>(this: Vector<D>, ...args: VectorArgRest): Vector<D> { const b = Vector.convertToSameDimVector(this.dimension, ...args).values; this.values = this.values.map((v, i) => v - b[i]); return this; }; Vector.prototype.mul = function <D extends number>(this: Vector<D>, ...args: VectorArgRest): Vector<D> { const b = Vector.convertToSameDimVector(this.dimension, ...args).values; this.values = this.values.map((v, i) => v * b[i]); return this; }; Vector.prototype.div = function <D extends number>(this: Vector<D>, ...args: VectorArgRest): Vector<D> { const b = Vector.convertToSameDimVector(this.dimension, ...args).values; if (!([...this.values, ...b].every((v) => typeof v === 'number' && Number.isFinite(v)) && b.every((v) => v !== 0))) { throw new Error(`@ Vector.div: values provided are not valid. this value(s): ${this.values}\n\nb value(s): ${b}`); } this.values = this.values.map((v, i) => v / b[i]); return this; }; Vector.prototype.dot = function <D extends number>(this: Vector<D>, ...args: VectorArgRest): number { const b = Vector.convertToSameDimVector(this.dimension, ...args).values; return this.values.map((x, i) => this.values[i] * b[i]).reduce((acc, val) => acc + val); }; Vector.prototype.distance = function <D extends number>(this: Vector<D>, ...other: VectorArgRest): number { const b = Vector.convertToSameDimVector(this.dimension, ...other); return b.copy().sub(this).mag(); }; Vector.prototype.magSq = function <D extends number>(this: Vector<D>): number { return this.values.reduce((acc, val) => acc + val * val); }; Vector.prototype.mag = function <D extends number>(this: Vector<D>): number { return this.values.map((v) => v * v).reduce((acc, val) => acc + val) ** 0.5; }; Vector.prototype.setMag = function <D extends number>(this: Vector<D>, newMag: number) { return this.normalize().mul(newMag); }; Vector.prototype.abs = function <D extends number>(this: Vector<D>): Vector<D> { this.values = this.values.map((v) => (v < 0 ? -v : v)); return this; }; Vector.prototype.normalize = function <D extends number>(this: Vector<D>): Vector<D> { const len = this.mag(); len !== 0 && this.mul(1 / len); return this; }; Vector.prototype.copy = function <D extends number>(this: Vector<D>): Vector<D> { return new Vector([...this.values]); }; /** @description Test whether two Vectors are equal to each other. This does NOT test whether they are the same instance of Volts.Vector */ Vector.prototype.equals = function <D extends number>(this: Vector<D>, b: Vector<number>): boolean { return !!b && this.dimension === b.dimension && this.values.every((v, i) => v === b.values[i]); }; Vector.prototype.toString = function <D extends number>(this: Vector<D>, toFixed = 5): string { // Writeable Reactive Signal // @ts-expect-error return `Vector<${this.dimension}>${this.rs ? ' (WRS)' : ''} [${(toFixed ? this.values.map((v) => v.toFixed(toFixed)) : this.values ).toString()}]`; }; Vector.prototype.toArray = function () { return [...this.values]; }; Vector.prototype.setSignalComponents = function (): void {