frontispiece / packages / phaser-bgs / src / index.ts
index.ts
Raw
/*
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

import Phaser from "phaser"
import TransitionImage from "phaser3-rex-plugins/plugins/transitionimage.js"
import DissolvePipelinePlugin from "phaser3-rex-plugins/plugins/dissolvepipeline-plugin"

type BGMap = Record<string, string | Phaser.Types.Loader.FileTypes.CompressedTextureFileConfig> // id, url

type TransitionConfig = Partial<TransitionImage.ITransitConfig> & {
	key?: string
}
type TransitionGenerator = (scene: Phaser.Scene, duration: number) => TransitionConfig

class BGImage extends TransitionImage {
	constructor(scene, x, y, texture?, config?) {
		super(scene, x, y, texture, undefined, config)
		// ...
	}
}

interface SceneListener {
	onSceneReady?: () => void
}

class BG extends Phaser.Scene {
	protected bgMap: BGMap
	protected image: TransitionImage
	protected listener: SceneListener
	constructor(
		imagesToLoad: BGMap,
		listener?: SceneListener,
		sceneConfig: Partial<Phaser.Types.Scenes.SettingsConfig> = {}
	) {
		super({
			key: "backgrounds",
			plugins: {
				input: false,
			},
			...sceneConfig,
		})

		this.bgMap = imagesToLoad
		if (listener) {
			this.listener = listener
		}
	}

	preload() {
		// load all of the images in the bgMap
		for (let key in this.bgMap) {
			const val = this.bgMap[key]
			if (typeof val === "string") {
				this.load.image(key, val)
			} else {
				this.load.texture(key, val)
			}
		}
	}

	create() {
		this.image = new BGImage(this, this.getWidth() / 2, this.getHeight() / 2, "__WHITE")
		this.add.existing(this.image)
		this.autoResize()
		if (this.listener?.onSceneReady) {
			this.listener.onSceneReady()
		}
		this.image.on("complete", () => {
			this.autoResize()

			let anyImage = this.image as any
			anyImage.frontImage.tint = anyImage.backImage.tintTopLeft
		})
	}

	transition(to: string, config: TransitionConfig = {}) {
		this.image.transit({
			key: to,
			...config,
			onStart: (parent, currentImage, nextImage, t) => {
				if (config.onStart) {
					config.onStart(parent, currentImage, nextImage, t)
				}
				this.autoResize(currentImage)
				this.autoResize(nextImage)
			},
		})
	}

	getWidth() {
		return this.sys.renderer.width
	}

	getHeight() {
		return this.sys.renderer.height
	}

	getImage() {
		return this.image
	}

	autoResize(image?: Phaser.GameObjects.Container | Phaser.GameObjects.Image) {
		let targetImage = image ?? (this.image as any).frontImage
		targetImage.setX(this.getWidth() / 2).setY(this.getHeight() / 2)

		const containerWidth = this.getWidth()
		const containerHeight = this.getHeight()

		const imgRatio = targetImage.height / targetImage.width // original img ratio
		const containerRatio = containerHeight / containerWidth // container ratio

		let finalWidth
		let finalHeight

		if (containerRatio > imgRatio) {
			finalHeight = containerHeight
			finalWidth = containerHeight / imgRatio
		} else {
			finalWidth = containerWidth
			finalHeight = containerWidth * imgRatio
		}

		// doing the scaling
		targetImage.displayWidth = finalWidth
		targetImage.displayHeight = finalHeight
	}
}

type PhaserBGEngineConfig = Phaser.Types.Core.GameConfig & {
	autoResizeBGs?: boolean
	autoResizeGame?: boolean
	domElement?: HTMLElement // dom element to attach this entire game to.
	sceneConfig?: Partial<Phaser.Types.Scenes.SettingsConfig>
	onLoaded?: () => void // fired when the engine is loaded
}

const defaultBGEngineConfig = {
	type: Phaser.AUTO,
	scale: {
		mode: Phaser.Scale.RESIZE,
	},
	autoResizeBGs: true,
	autoResizeGame: false,
}

const defaultTransitionConfig: TransitionConfig = {
	duration: 500,
	onStart: () => {},
	onProgress: (parent, currentImage, nextImage, t) => {
		parent.setChildLocalAlpha(currentImage, 1 - t).setChildLocalAlpha(nextImage, t)
	},
	onComplete: (parent, currentImage, nextImage, t) => {
		parent.setChildLocalAlpha(currentImage, 1)
	},
	mask: undefined,
	dir: "out",
	ease: "Linear",
}

export class PhaserBGEngine implements SceneListener {
	protected game: Phaser.Game
	protected bg: BG
	protected isActive: boolean = false

	protected timeout: number = 500
	protected config: PhaserBGEngineConfig
	constructor(imagesToLoad: BGMap, config: PhaserBGEngineConfig = {}, existingGame?: Phaser.Game) {
		const mergedConfig = { ...defaultBGEngineConfig, ...config }

		this.config = mergedConfig
		this.bg = new BG(imagesToLoad, this, config.sceneConfig)
		if (existingGame) {
			this.game = existingGame
		} else {
			this.game = new Phaser.Game(mergedConfig)
		}

		if (mergedConfig.domElement) {
			this.addToDOM(mergedConfig.domElement)
		}

		this.game.scene.add("backgrounds", this.bg, true)
	}

	onSceneReady() {
		this.isActive = true
		if ((this.config.autoResizeGame || this.config.autoResizeBGs) && !this.config.domElement) {
			this.resize(window.innerWidth, window.innerHeight)
			window.addEventListener("resize", () => {
				this.resize(window.innerWidth, window.innerHeight)
			})
		}

		if (this.config.onLoaded) {
			this.config.onLoaded()
		}
	}

	/**
	 * Transitions to a new image for the background.
	 * @param to the key for the image, which should be taken from the imagesToLoad that was fed to the constructor
	 * @param config can be manually created, or generated from an imported TransitionGenerator to perform various shader effects.
	 */
	transition(to: string, config: TransitionConfig = {}) {
		const mergedConfig = {
			...defaultTransitionConfig,
			...config,
			onStart: function (parent, currentImage, nextImage, t) {
				// reset the color tint unless we're trying to transition colors...this is why we run this
				// before config.start
				nextImage.tint = 0xffffff
				if (config.onStart) {
					config.onStart(parent, currentImage, nextImage, t)
				}
			},
		}
		if (!this.isActive) {
			const self = this
			setTimeout(() => {
				if (!this.isActive) {
					// fail silently. We might want to instead try again later.
					console.warn(
						"Attempted to transition before the scene was ready. Please use a preloader if possible."
					)
					return
				}
				self.bg.transition(to, mergedConfig)
			}, this.timeout)
			return
		}

		this.bg.transition(to, mergedConfig)
	}

	transitionColor(color: number, config: TransitionConfig = {}) {
		const mergedConfig: TransitionConfig = {
			...config,
			onStart: function (parent, currentImage, nextImage, t) {
				if (config.onStart) {
					config.onStart(parent, currentImage, nextImage, t)
				}
				nextImage.tint = color
			},
		}
		this.transition("__WHITE", mergedConfig)
	}

	get width() {
		return this.bg.getWidth()
	}

	get height() {
		return this.bg.getHeight()
	}

	get phaser() {
		return this.game
	}

	get scene() {
		return this.bg
	}

	get image() {
		return this.bg.getImage()
	}

	resize(width: number, height: number) {
		if (this.config.autoResizeGame) {
			this.game.scale.resize(width, height)
		}
		if (this.config.autoResizeBGs) {
			this.bg.autoResize()
		}
	}

	addToDOM(element: HTMLElement) {
		element.appendChild(this.game.canvas)
	}
}

export const CrossfadeTransition: TransitionGenerator = (scene, duration) => {
	return {
		duration,
		ease: "Linear",
		dir: "out",

		onStart: function (parent, currentImage, nextImage, t) {},
		onProgress: function (parent, currentImage, nextImage, t) {
			parent.setChildLocalAlpha(currentImage, 1 - t).setChildLocalAlpha(nextImage, t)
		},
		onComplete: function (parent, currentImage, nextImage, t) {
			parent.setChildLocalAlpha(currentImage, 1)
		},
	}
}

export const DissolveTransition: TransitionGenerator = (scene, duration) => {
	let postFxPlugin = scene.plugins.get("rexdissolvepipelineplugin") as DissolvePipelinePlugin
	if (!postFxPlugin) {
		scene.plugins.install("rexdissolvepipelineplugin", DissolvePipelinePlugin)
		postFxPlugin = scene.plugins.get("rexdissolvepipelineplugin") as DissolvePipelinePlugin
	}

	return {
		duration,
		ease: "Linear",
		dir: "out",

		onStart: function (parent, currentImage, nextImage, t) {
			postFxPlugin.add(currentImage)
		},
		onProgress: function (parent, currentImage, nextImage, t) {
			postFxPlugin.get(currentImage)[0].setProgress(t)
		},
		onComplete: function (parent, currentImage, nextImage, t) {
			postFxPlugin.remove(currentImage)
		},
	}
}