diff --git a/README.md b/README.md index 503cfc5..0b716d4 100644 --- a/README.md +++ b/README.md @@ -181,3 +181,57 @@ export async function fetchLoaderConfig( ``` You can then use it to construct a `UnityContext` and pass this context to your `UnityRenderer` via the `context` prop. + +## Module augmentation + +Take the following example: + +```typescript +// create some context +const ctx = new UnityContext({ ... }); + +// handles some "info" event with one parameter of type string +ctx.on('info', (message: string) => { + console.log(message); +}); +``` + +The parameter `message` has to be explicitly defined as `string` each time a handler of for the event name `info` would be registered. +In order to make use of TypeScript to its fullest extent, you can augment an Interface of the library to get autocompletion and type-safety features here. + +Put this either in a file importing `react-unity-renderer` or create a new `unity.d.ts` somewhere in your `src` or (if you have that) `typings` directory: + +```typescript +// must be imported, else the module will be redefined, +// and this causes all sorts of errors. +import 'react-unity-renderer'; + +// module augmentation +declare module 'react-unity-renderer' { + // this is the interface providing autocompletion + interface EventSignatures { + // "info" is the event name + // the type on the right side is anything that would match TypeScript's + // Parameters<> helper type + info: [message: string]; + + // also possible: + info: [string]; + 'some-event': [number, debug: string]; + // note that all parametrs names are just labels, so they are fully optional. + } +} +``` + +Now, any defined event will be auto-completed with its types for `UnityContext.on(...)`: + +```typescript +// create some context +const ctx = new UnityContext({ ... }); + +// "info" will be suggested by your IDE +// "message" is now of type string +ctx.on('info', (message) => { + console.log(message); +}); +``` diff --git a/package.json b/package.json index e1b02da..3544a40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-unity-renderer", - "version": "2020.0.1", + "version": "2020.0.2", "description": "React Unity Renderer allows to interactively embed Unity WebGL builds into a React powered project.", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -27,10 +27,10 @@ "prepare": "yarn build" }, "lint-staged": { - "{src,typings}/**/*.{js,jsx,ts,tsx,json}": [ + "{src,typings}/**/*.{ts,tsx,json}": [ "prettier --write" ], - "{src,typings}/**/*.{js,jsx,ts,tsx}": [ + "{src,typings}/**/*.{ts,tsx}": [ "eslint --fix" ], "src/**/*.{ts,tsx}": [ diff --git a/src/components/UnityRenderer.ts b/src/components/UnityRenderer.ts index 0a6f40c..86ef9dd 100644 --- a/src/components/UnityRenderer.ts +++ b/src/components/UnityRenderer.ts @@ -8,7 +8,7 @@ export type UnityRendererProps = Omit< HTMLAttributes, 'ref' > & { - context: UnityContext; + context?: UnityContext; onUnityProgressChange?: (progress: number) => void; onUnityReadyStateChange?: (ready: boolean) => void; onUnityError?: (error: Error) => void; @@ -31,8 +31,8 @@ export const UnityRenderer: VFC = ({ onUnityError, ...canvasProps }: UnityRendererProps): JSX.Element | null => { - const [loader, setLoader] = useState(); - const [ctx, setCtx] = useState(context); + const [loader] = useState(new UnityLoaderService()); + const [ctx, setCtx] = useState(context); // We cannot actually render the `HTMLCanvasElement`, so we need the `ref` // for Unity and a `JSX.Element` for React rendering. @@ -70,18 +70,20 @@ export const UnityRenderer: VFC = ({ * after the unmounting has completed. */ function unmount(onComplete?: () => void) { - ctx.shutdown(() => { - // remove the loader script from the DOM - if (loader) loader.unmount(); - + ctx?.shutdown(() => { // reset progress / ready state if (onUnityProgressChange) onUnityProgressChange(0); if (onUnityReadyStateChange) onUnityReadyStateChange(false); - setLastReadyState(false); // callbck if (onComplete) onComplete(); }); + + setLastReadyState(false); + setCtx(undefined); + + // remove the loader script from the DOM + loader.unmount(); } /** @@ -92,56 +94,53 @@ export const UnityRenderer: VFC = ({ * Unity instance. */ async function mount(): Promise { - try { - // get the current loader configuration from the UnityContext - const c = ctx.getConfig(); - - // attach Unity's native JavaScript loader - await loader!.execute(c.loaderUrl); - - const instance = await window.createUnityInstance( - renderer!, - { - dataUrl: c.dataUrl, - frameworkUrl: c.frameworkUrl, - codeUrl: c.codeUrl, - streamingAssetsUrl: c.streamingAssetsUrl, - companyName: c.companyName, - productName: c.productName, - productVersion: c.productVersion, - }, - (p) => onUnityProgress(p) + if (!ctx || !renderer) + throw new Error( + 'cannot mount unity instance without a context or renderer' ); - // set the instance for further JavaScript <--> Unity communication - ctx.setInstance(instance); - } catch (e) { - unmount(() => { - if (onUnityError) onUnityError(e); - }); - } + // get the current loader configuration from the UnityContext + const c = ctx.getConfig(); + + // attach Unity's native JavaScript loader + await loader.execute(c.loaderUrl); + + const instance = await window.createUnityInstance( + renderer, + { + dataUrl: c.dataUrl, + frameworkUrl: c.frameworkUrl, + codeUrl: c.codeUrl, + streamingAssetsUrl: c.streamingAssetsUrl, + companyName: c.companyName, + productName: c.productName, + productVersion: c.productVersion, + }, + (p) => onUnityProgress(p) + ); + + // set the instance for further JavaScript <--> Unity communication + ctx.setInstance(instance); } // on loader + renderer ready useEffect(() => { - if (!loader || !renderer) return; + if (!ctx || !renderer) return; mount().catch((e) => { if (onUnityError) onUnityError(e); - ctx.shutdown(); + ctx?.shutdown(); }); - }, [loader, renderer, ctx]); + }, [ctx, renderer]); // on context change useEffect(() => { - unmount(() => setCtx(context)); + if (context) setCtx(context); + else unmount(); }, [context]); // on mount useEffect(() => { - // create the loader service - setLoader(new UnityLoaderService()); - // create the renderer and let the ref callback set its handle setCanvas( createElement('canvas', { diff --git a/src/components/__tests__/UnityRenderer.test.tsx b/src/components/__tests__/UnityRenderer.test.tsx index 42f64c7..1912311 100644 --- a/src/components/__tests__/UnityRenderer.test.tsx +++ b/src/components/__tests__/UnityRenderer.test.tsx @@ -4,15 +4,6 @@ import { UnityContext } from '../../lib/context'; import { UnityRenderer } from '../UnityRenderer'; describe(' (unconfigured)', () => { - const loaderUrl = 'http://example.com/script.js'; - - const ctx = new UnityContext({ - loaderUrl: loaderUrl, - codeUrl: '', - dataUrl: '', - frameworkUrl: '', - }); - let renderer: ReactWrapper; let progress = 0; let ready = false; @@ -21,7 +12,6 @@ describe(' (unconfigured)', () => { beforeEach(() => { renderer = mount( (progress = p)} onUnityReadyStateChange={(r) => (ready = r)} onUnityError={() => (error = true)} @@ -34,10 +24,6 @@ describe(' (unconfigured)', () => { expect(renderer).toBeDefined(); }); - it('uses the context prop', async () => { - expect(renderer.prop('context')).toBe(ctx); - }); - it('uses the className prop', async () => { expect(renderer.prop('className')).toBe('test'); }); diff --git a/src/index.ts b/src/index.ts index c72cf51..e09c6b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,6 @@ export { UnityContext, UnityLoaderConfig, UnityInstanceConfig, + EventSignatures, } from './lib/context'; export { UnityRenderer, UnityRendererProps } from './components/UnityRenderer'; diff --git a/src/lib/context.ts b/src/lib/context.ts index 3a4f47c..7c433cb 100644 --- a/src/lib/context.ts +++ b/src/lib/context.ts @@ -1,6 +1,6 @@ export interface UnityInstanceConfig { - frameworkUrl: string; codeUrl: string; + frameworkUrl: string; dataUrl: string; memoryUrl?: string; symbolsUrl?: string; @@ -15,7 +15,22 @@ export interface UnityLoaderConfig extends UnityInstanceConfig { loaderUrl: string; } -type UnityEventCallback = (...params: any) => void; +/** + * An interface containing event names and their handler parameter signatures. + * This interface is supposed to be augmented via module augmentation by the + * user. + */ +export interface EventSignatures {} + +/** + * Refers to a callback function with any parameters. + */ +type EventCallback = (...params: any) => void; + +/** + * Defines a weak union type, which can fallback to another type. + */ +type WeakUnion = T | (F & {}); /** * Defines a Unity WebGL context. @@ -29,7 +44,7 @@ export class UnityContext { private instance?: UnityInstance; - private eventCallbacks: { [name: string]: UnityEventCallback } = {}; + private eventCallbacks: { [name: string]: EventCallback } = {}; /** * Creates a new `UnityContext` and registers the global event callback. @@ -92,7 +107,7 @@ export class UnityContext { } /** - * Emits a remote procedure call towards the running Unity instance. + * Emits a message to the running Unity instance. * * @param {string} objectName The `GameObject` on which to call the method. * @param {string} methodName The name of the method which should be invoked. @@ -118,7 +133,12 @@ export class UnityContext { * @param {UnityEventCallback} callback The callback which should be invoked * upon the occurence of this event. */ - public on(name: string, callback: T): void { + public on>( + name: WeakUnion, + callback: ( + ...params: T extends keyof EventSignatures ? EventSignatures[T] : any + ) => void + ): void { this.eventCallbacks[name] = callback; } @@ -141,7 +161,7 @@ export class UnityContext { * @returns {UnityEventCallback} The callback which should * handle the event. */ - private bridgeCallback(name: string): UnityEventCallback { + private bridgeCallback(name: string): EventCallback { if (this.eventCallbacks && this.eventCallbacks[name]) return this.eventCallbacks[name]; diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 9b28ceb..034c779 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -1,5 +1,5 @@ export class UnityLoaderService { - private documentHead: HTMLHeadElement = document.querySelector('head')!; + private head: HTMLHeadElement = document.querySelector('head')!; private script?: HTMLScriptElement; @@ -19,7 +19,10 @@ export class UnityLoaderService { return resolve(); // another script is currently loaded - if (this.script) this.script.remove(); + if (this.script) { + this.script.remove(); + this.script = undefined; + } // create script node this.script = document.createElement('script'); @@ -31,7 +34,7 @@ export class UnityLoaderService { reject(new Error(`cannot download unity loader from: ${url}`)); // attach - this.documentHead.appendChild(this.script); + this.head.appendChild(this.script); }); } @@ -40,5 +43,6 @@ export class UnityLoaderService { */ public unmount(): void { this.script?.remove(); + this.script = undefined; } } diff --git a/typings/unity.d.ts b/typings/unity.d.ts index 3b36f92..fa39f9a 100644 --- a/typings/unity.d.ts +++ b/typings/unity.d.ts @@ -13,11 +13,40 @@ declare class UnityInstance { } declare interface Window { - UnityBridge: (name: string) => (...params: any) => void; + /** + * Global function returning a callback for the requested event. + * Will `console.warn()` and return a dummy callback in case the specified event + * name has no registered handler. + * + * @param {string} name name of the event + */ + UnityBridge(name: string): (...params: any) => void; + /** + * Mapper to the native JavaScript function from Unity's loader script, + * which loads and renders a WebGL build inside a `` element. + * + * @param {HTMLCanvasElement} canvas The `` object to which the game + * should be rendered. + * @param {UnityInstanceConfig} parameters The configuration containing all + * required information to load a WebGL build. + * @param {(progress: number) => void} [onProgress] Callback function + * for Unity loading progress changes. Ranges from `0` to `1.0`. + */ createUnityInstance( - element: HTMLCanvasElement, - parameters: UnityInstanceConfig, + canvas: HTMLCanvasElement, + config: { + codeUrl: string; + frameworkUrl: string; + dataUrl: string; + memoryUrl?: string; + symbolsUrl?: string; + streamingAssetsUrl?: string; + companyName?: string; + productName?: string; + productVersion?: string; + modules?: { [key: string]: any }; + }, onProgress?: (progress: number) => void ): Promise; }