diff --git a/README.md b/README.md index a0f0e7b..e0f21cb 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,44 @@ poki.runWhenInitialized((poki) => { }) ``` +#### Extended SDK Surface + +The plugin now exposes most Poki SDK methods directly on +`scene.plugins.get('poki')`, including ad helpers, user/auth helpers, share URL +helpers, error reporting, playtest capture controls, and misc utility methods. + +The raw `PokiSDK.init(options)` method is intentionally **not** exposed on the +plugin instance because Phaser already uses `plugin.init(data)` for plugin +bootstrapping. The plugin continues to load and initialize the Poki SDK for you +automatically. + +Async passthrough methods such as `getUser()`, `getToken()`, `login()`, and +`shareableURL()` wait until the SDK initialization attempt finishes. If the SDK +is unavailable, they reject with an error instead of hanging forever. Use +`runWhenInitialized()` as the readiness gate. `getURLParam()` falls back to +`window.location.search` before the SDK is ready, and `getLanguage()` returns +an empty string until the SDK can answer. + +```javascript +import { RewardedBreakSize } from '@poki/phaser-3' + +const poki = scene.plugins.get('poki') + +poki.runWhenInitialized(async (poki) => { + const rewarded = await poki.rewardedBreak({ + onStart: () => { + console.log('Rewarded break started') + }, + size: RewardedBreakSize.MEDIUM + }) + + if (rewarded) { + const shareUrl = await poki.shareableURL({ level: 3, score: 1200 }) + console.log('Share URL:', shareUrl) + } +}) +``` + ## Example diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..d831071 --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,119 @@ +import Phaser = require('phaser') + +export declare const EVENT_INITIALIZED: 'poki:initialized' + +export declare const RewardedBreakSize: { + readonly SMALL: 'small' + readonly MEDIUM: 'medium' + readonly LARGE: 'large' +} + +export type RewardedBreakSize = typeof RewardedBreakSize[keyof typeof RewardedBreakSize] + +export interface InitOptions { + debug?: boolean + logging?: boolean +} + +export interface User { + username: string + avatarUrl: string +} + +export interface RewardedBreakParams { + onStart?: () => void + size?: RewardedBreakSize +} + +export interface PokiPluginConfig { + loadingSceneKey?: string + gameplaySceneKey?: string + autoCommercialBreak?: boolean +} + +export interface PokiSDKGlobal { + init: (options?: InitOptions) => Promise + rewardedBreak: (onStartOrParams?: (() => void) | RewardedBreakParams) => Promise + commercialBreak: (onStart?: () => void) => Promise + displayAd: ( + container: HTMLElement, + size?: string, + onCanDestroy?: () => void, + onDisplayRendered?: (isEmpty: boolean) => void + ) => void + destroyAd: (container: HTMLElement) => void + shareableURL: (params?: object) => Promise + getURLParam: (key: string) => string + getLanguage: () => string + getUser: () => Promise + getToken: () => Promise + login: () => Promise + captureError: (error: string | Error) => void + gameLoadingStart: () => void + gameLoadingFinished: () => void + gameplayStart: () => void + gameplayStop: () => void + setDebug: (toggle: boolean) => void + setLogging: (toggle: boolean) => void + enableEventTracking: (cmpIndex: number | undefined) => void + openExternalLink: (url: string) => void + playtestSetCanvas: (canvas: HTMLCanvasElement | HTMLCanvasElement[] | null) => void + playtestCaptureHtmlOnce: () => void + playtestCaptureHtmlForce: () => void + playtestCaptureHtmlOn: () => void + playtestCaptureHtmlOff: () => void + movePill: (topPercent: number, topPx: number) => void + measure: (category: string, what: string, action: string) => void +} + +export declare class PokiPlugin extends Phaser.Plugins.BasePlugin { + sdk?: PokiSDKGlobal + initialized: boolean + hasAdblock: boolean + + init(config?: PokiPluginConfig): void + runWhenInitialized(callback: (poki: PokiPlugin) => void): void + start(): void + stop(): void + + gameLoadingStart(): void + gameLoadingFinished(): void + gameplayStart(): void + gameplayStop(): void + + commercialBreak(onStart?: () => void): Promise + rewardedBreak(onStartOrParams?: (() => void) | RewardedBreakParams): Promise + + displayAd( + container: HTMLElement, + size?: string, + onCanDestroy?: () => void, + onDisplayRendered?: (isEmpty: boolean) => void + ): void + destroyAd(container: HTMLElement): void + + shareableURL(params?: object): Promise + getURLParam(key: string): string + getLanguage(): string + getUser(): Promise + getToken(): Promise + login(): Promise + captureError(error: string | Error): void + setDebug(toggle: boolean): void + setLogging(toggle: boolean): void + enableEventTracking(cmpIndex: number | undefined): void + openExternalLink(url: string): void + playtestSetCanvas(canvas: HTMLCanvasElement | HTMLCanvasElement[] | null): void + playtestCaptureHtmlOnce(): void + playtestCaptureHtmlForce(): void + playtestCaptureHtmlOn(): void + playtestCaptureHtmlOff(): void + movePill(topPercent: number, topPx: number): void + measure(category: string, what: string, action: string): void +} + +declare global { + interface Window { + PokiSDK?: PokiSDKGlobal + } +} diff --git a/lib/poki.js b/lib/poki.js index a1ae65d..d37311e 100644 --- a/lib/poki.js +++ b/lib/poki.js @@ -1,16 +1,31 @@ import { Plugins } from 'phaser' export const EVENT_INITIALIZED = 'poki:initialized' +export const RewardedBreakSize = Object.freeze({ + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large' +}) + +function createSdkUnavailableError (methodName) { + return new Error(`PokiSDK.${methodName} is unavailable. Wait for runWhenInitialized() and ensure hasAdblock is false before calling it.`) +} export class PokiPlugin extends Plugins.BasePlugin { - init ({ loadingSceneKey, gameplaySceneKey, autoCommercialBreak }) { + init ({ loadingSceneKey, gameplaySceneKey, autoCommercialBreak } = {}) { this._loadingSceneKey = loadingSceneKey this._gameplaySceneKey = gameplaySceneKey this._autoCommercialBreak = autoCommercialBreak this._scriptLoaded = false + this._sdkInitFinished = false + this._sdkReady = false this._initializeHooks = [] this._queue = [] + this._initializationError = null + this._initializationPromise = new Promise((resolve) => { + this._resolveInitialization = resolve + }) this.initialized = false this.hasAdblock = true @@ -19,30 +34,10 @@ export class PokiPlugin extends Plugins.BasePlugin { script.setAttribute('type', 'text/javascript') script.setAttribute('src', 'https://game-cdn.poki.com/scripts/v2/poki-sdk.js') script.addEventListener('load', () => { - this.sdk = window.PokiSDK - - this._scriptLoaded = true - this._queue.forEach(f => f()) - - this.sdk.init().then(() => { - this.initialized = true - this.hasAdblock = false - - this.game.events.emit(EVENT_INITIALIZED, this) - this._initializeHooks.forEach(f => f(this)) - this._initializeHooks = undefined - }).catch(err => { - console.error('PokiSDK failed', err) - this.initialized = true - this.hasAdblock = true - - this.game.events.emit(EVENT_INITIALIZED, this) - this._initializeHooks.forEach(f => f(this)) - this._initializeHooks = undefined - }) + this._handleScriptLoad() }) - script.addEventListener('error', (e) => { - console.error('failed to load PokiSDK', e) + script.addEventListener('error', (event) => { + this._handleScriptError(event) }) document.head.appendChild(script) @@ -67,6 +62,150 @@ export class PokiPlugin extends Plugins.BasePlugin { this.game.events.off('step', this._update) } + _handleScriptLoad () { + this.sdk = window.PokiSDK + this._scriptLoaded = true + this._flushQueue() + + if (!this._hasSdkMethod('init')) { + const error = createSdkUnavailableError('init') + console.error('PokiSDK failed', error) + this._finishInitialization(error) + return + } + + this.sdk.init().then(() => { + this._sdkReady = true + this.hasAdblock = false + this._finishInitialization() + }).catch((error) => { + console.error('PokiSDK failed', error) + this._finishInitialization(error) + }) + } + + _handleScriptError (event) { + console.error('failed to load PokiSDK', event) + this._queue = [] + this._finishInitialization(new Error('PokiSDK script failed to load')) + } + + _finishInitialization (error) { + if (this._sdkInitFinished) { + return + } + + this._sdkInitFinished = true + this.initialized = true + this.hasAdblock = !this._sdkReady + this._initializationError = error || null + + this.game.events.emit(EVENT_INITIALIZED, this) + this._initializeHooks.forEach(f => f(this)) + this._initializeHooks = [] + + if (this._resolveInitialization) { + this._resolveInitialization(this) + this._resolveInitialization = undefined + } + } + + _flushQueue () { + const queue = this._queue + this._queue = [] + queue.forEach(f => f()) + } + + _hasSdkMethod (methodName) { + return Boolean(this.sdk) && typeof this.sdk[methodName] === 'function' + } + + _hasReadySdkMethod (methodName) { + return this._sdkReady && this._hasSdkMethod(methodName) + } + + _runWhenScriptLoaded (callback) { + if (this._scriptLoaded) { + callback() + return + } + + if (!this._sdkInitFinished) { + this._queue.push(callback) + } + } + + _callWhenScriptLoaded (methodName, args = []) { + this._runWhenScriptLoaded(() => { + if (this._hasSdkMethod(methodName)) { + this.sdk[methodName](...args) + } + }) + } + + _waitForInitialization () { + if (this._sdkInitFinished) { + return Promise.resolve(this) + } + + return this._initializationPromise + } + + _callSdkAsync (methodName, args = []) { + return this._waitForInitialization().then(() => { + if (!this._hasReadySdkMethod(methodName)) { + throw createSdkUnavailableError(methodName) + } + + return this.sdk[methodName](...args) + }) + } + + _runDuringBreak (callback) { + const keyboard = this.game.input && this.game.input.keyboard + const canToggleKeyboard = Boolean(keyboard) && typeof keyboard.enabled === 'boolean' + const wasKeyboardEnabled = canToggleKeyboard ? keyboard.enabled : false + + if (canToggleKeyboard) { + keyboard.enabled = false + } + + const sound = this.game.sound + const canMuteSound = Boolean(sound) && typeof sound.mute === 'boolean' + const wasMuted = canMuteSound ? sound.mute : false + + if (canMuteSound) { + sound.mute = true + } + + const restore = () => { + if (canToggleKeyboard) { + keyboard.enabled = wasKeyboardEnabled + } + + if (canMuteSound) { + sound.mute = wasMuted + } + } + + let result + + try { + result = callback() + } catch (error) { + restore() + throw error + } + + return Promise.resolve(result).then((value) => { + restore() + return value + }, (error) => { + restore() + throw error + }) + } + _update () { // Detect if new actives scenes are added or removed: const names = this.game.scene.getScenes(true).map(s => s.constructor.name) @@ -103,89 +242,138 @@ export class PokiPlugin extends Plugins.BasePlugin { // Manually call the gameLoadedStart event in the PokiSDK, this is done // automatically if you've set the loadingSceneKey in the plugin data. gameLoadingStart () { - if (this._scriptLoaded) { - this.sdk.gameLoadingStart() - } else { - this._queue.push(() => { - this.sdk.gameLoadingStart() - }) - } + this._callWhenScriptLoaded('gameLoadingStart') } // Manually call the gameLoadingFinished event in the PokiSDK, this is done // automatically if you've set the loadingSceneKey in the plugin data. gameLoadingFinished () { - if (this._scriptLoaded) { - this.sdk.gameLoadingFinished() - } else { - this._queue.push(() => { - this.sdk.gameLoadingFinished() - }) - } + this._callWhenScriptLoaded('gameLoadingFinished') } // Manually call the gameplayStart event in the PokiSDK, this is done // automatically if you've set the gameplaySceneKey in the plugin data. gameplayStart () { - if (this._scriptLoaded) { - this.sdk.gameplayStart() - } else { - this._queue.push(() => { - this.sdk.gameplayStart() - }) - } + this._callWhenScriptLoaded('gameplayStart') } // Manually call the gameplayStop event in the PokiSDK, this is done // automatically if you've set the gameplaySceneKey in the plugin data. gameplayStop () { - if (this._scriptLoaded) { - this.sdk.gameplayStop() - } else { - this._queue.push(() => { - this.sdk.gameplayStop() - }) - } + this._callWhenScriptLoaded('gameplayStop') } // Manually request a commercialBreak via the PokiSDK, this is done // automatically if you've set autoCommercialBreak to true in the plugin data // and the configured gameplayScene started/resumed. - commercialBreak () { - return this._break('commercial') + commercialBreak (onStart) { + return this._waitForInitialization().then(() => { + if (!this._hasReadySdkMethod('commercialBreak')) { + return undefined + } + + return this._runDuringBreak(() => this.sdk.commercialBreak(onStart)) + }) } // Trigger a rewardedBreak via the PokiSDK when called. - rewardedBreak () { - return this._break('rewarded') + rewardedBreak (onStartOrParams) { + return this._waitForInitialization().then(() => { + if (!this._hasReadySdkMethod('rewardedBreak')) { + return false + } + + return this._runDuringBreak(() => this.sdk.rewardedBreak(onStartOrParams)) + }) } - _break (type) { - if (type !== 'commercial' && type !== 'rewarded') { - throw new Error('type must be "commercial" or "rewarded"') - } + displayAd (container, size, onCanDestroy, onDisplayRendered) { + this._callWhenScriptLoaded('displayAd', [container, size, onCanDestroy, onDisplayRendered]) + } - if (this.initialized && !this.hasAdblock) { - return new Promise((resolve) => { - const wasKeyboardEnbaled = this.game.input.keyboard.enabled - this.game.input.keyboard.enabled = false + destroyAd (container) { + this._callWhenScriptLoaded('destroyAd', [container]) + } - const wasMuted = this.game.sound.mute - this.game.sound.mute = true + shareableURL (params = {}) { + return this._callSdkAsync('shareableURL', [params]) + } - this.sdk[`${type}Break`]().then((success) => { - if (wasKeyboardEnbaled) { - this.game.input.keyboard.enabled = true - } + getURLParam (key) { + if (this._hasReadySdkMethod('getURLParam')) { + return this.sdk.getURLParam(key) + } - if (!wasMuted) { - this.game.sound.mute = false - } + const params = new URLSearchParams(window.location.search) + const value = params.get(key) + return value === null ? '' : value + } - resolve(success) - }) - }) + getLanguage () { + if (this._hasReadySdkMethod('getLanguage')) { + return this.sdk.getLanguage() } - return Promise.resolve(false) + + return '' + } + + getUser () { + return this._callSdkAsync('getUser') + } + + getToken () { + return this._callSdkAsync('getToken') + } + + login () { + return this._callSdkAsync('login') + } + + captureError (error) { + this._callWhenScriptLoaded('captureError', [error]) + } + + setDebug (toggle) { + this._callWhenScriptLoaded('setDebug', [toggle]) + } + + setLogging (toggle) { + this._callWhenScriptLoaded('setLogging', [toggle]) + } + + enableEventTracking (cmpIndex) { + this._callWhenScriptLoaded('enableEventTracking', [cmpIndex]) + } + + openExternalLink (url) { + this._callWhenScriptLoaded('openExternalLink', [url]) + } + + playtestSetCanvas (canvas) { + this._callWhenScriptLoaded('playtestSetCanvas', [canvas]) + } + + playtestCaptureHtmlOnce () { + this._callWhenScriptLoaded('playtestCaptureHtmlOnce') + } + + playtestCaptureHtmlForce () { + this._callWhenScriptLoaded('playtestCaptureHtmlForce') + } + + playtestCaptureHtmlOn () { + this._callWhenScriptLoaded('playtestCaptureHtmlOn') + } + + playtestCaptureHtmlOff () { + this._callWhenScriptLoaded('playtestCaptureHtmlOff') + } + + movePill (topPercent, topPx) { + this._callWhenScriptLoaded('movePill', [topPercent, topPx]) + } + + measure (category, what, action) { + this._callWhenScriptLoaded('measure', [category, what, action]) } } diff --git a/package.json b/package.json index 95853af..366e9c2 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "version": "0.0.5", "source": "lib/index.js", "main": "dist/phaser-poki.js", + "types": "lib/index.d.ts", "license": "ISC", "scripts": { "watch": "parcel ./example/index.html",