diff --git a/app/react-components/pixi/pixi.jsx b/app/react-components/pixi/pixi.jsx index 4c10e8a..bb9320c 100644 --- a/app/react-components/pixi/pixi.jsx +++ b/app/react-components/pixi/pixi.jsx @@ -1,6 +1,3 @@ -import { - Stage as PixiStage, -} from '@pixi/react'; import {SCALE_MODES} from '@pixi/constants'; import {BaseTexture} from '@pixi/core'; import {createElement, useContext} from 'react'; @@ -15,6 +12,7 @@ import RadiansContext from '@/context/radians.js'; import Ecs from './ecs.jsx'; import styles from './pixi.module.css'; +import PixiStage from './stage.jsx'; BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST; diff --git a/app/react-components/pixi/stage.jsx b/app/react-components/pixi/stage.jsx new file mode 100644 index 0000000..49aee3d --- /dev/null +++ b/app/react-components/pixi/stage.jsx @@ -0,0 +1,339 @@ +import { Application } from '@pixi/app'; +import { Stage as LayerStage } from '@pixi/layers'; +import { Ticker } from '@pixi/ticker'; +import { AppProvider, PixiFiber } from '@pixi/react'; + +import PropTypes from 'prop-types'; +import React from 'react'; + +const PROPS_DISPLAY_OBJECT = { + alpha: 1, + buttonMode: false, + cacheAsBitmap: false, + cursor: null, + filterArea: null, + filters: null, + hitArea: null, + interactive: false, + mask: null, + pivot: 0, + position: 0, + renderable: true, + rotation: 0, + scale: 1, + skew: 0, + transform: null, + visible: true, + x: 0, + y: 0, +}; + +const noop = () => {}; + +/** + * ------------------------------------------- + * Stage React Component (use this in react-dom) + * + * @usage + * + * const App = () => ( + * { + * console.log('PIXI renderer: ', renderer) + * console.log('Canvas element: ', canvas) + * }}> + * ); + * + * ------------------------------------------- + */ + +const propTypes = { + // dimensions + width: PropTypes.number, + height: PropTypes.number, + + // will return renderer + onMount: PropTypes.func, + onUnmount: PropTypes.func, + + // run ticker at start? + raf: PropTypes.bool, + + // render component on component lifecycle changes? + renderOnComponentChange: PropTypes.bool, + + children: PropTypes.node, + + // PIXI options, see https://pixijs.download/v7.x/docs/PIXI.Application.html + options: PropTypes.shape({ + autoStart: PropTypes.bool, + width: PropTypes.number, + height: PropTypes.number, + useContextAlpha: PropTypes.bool, + backgroundAlpha: PropTypes.number, + autoDensity: PropTypes.bool, + antialias: PropTypes.bool, + preserveDrawingBuffer: PropTypes.bool, + resolution: PropTypes.number, + forceCanvas: PropTypes.bool, + backgroundColor: PropTypes.number, + clearBeforeRender: PropTypes.bool, + powerPreference: PropTypes.string, + sharedTicker: PropTypes.bool, + sharedLoader: PropTypes.bool, + }), +}; + +const defaultProps = { + width: 800, + height: 600, + onMount: noop, + onUnmount: noop, + raf: true, + renderOnComponentChange: true, +}; + +export function getCanvasProps(props) +{ + const reserved = [ + ...Object.keys(propTypes), + ...Object.keys(PROPS_DISPLAY_OBJECT), + ]; + + return Object.keys(props) + .filter((p) => !reserved.includes(p)) + .reduce((all, prop) => ({ ...all, [prop]: props[prop] }), {}); +} + +class Stage extends React.Component +{ + _canvas = null; + _mediaQuery = null; + _ticker = null; + _needsUpdate = true; + app = null; + + componentDidMount() + { + const { + onMount, + width, + height, + options, + raf, + renderOnComponentChange, + } = this.props; + + this.app = new Application({ + width, + height, + view: this._canvas, + ...options, + autoDensity: options?.autoDensity !== false, + }); + this.app.stage = new LayerStage(); + + if (process.env.NODE_ENV === 'development') + { + // workaround for React 18 Strict Mode unmount causing + // webgl canvas context to be lost + if (this.app.renderer.context?.extensions) + { + this.app.renderer.context.extensions.loseContext = null; + } + } + + this.app.ticker.autoStart = false; + this.app.ticker[raf ? 'start' : 'stop'](); + + this.app.stage.__reactpixi = { root: this.app.stage }; + this.mountNode = PixiFiber.createContainer(this.app.stage); + PixiFiber.updateContainer(this.getChildren(), this.mountNode, this); + + onMount(this.app); + + // update size on media query resolution change? + // only if autoDensity = true + if ( + options?.autoDensity + && window.matchMedia + && options?.resolution === undefined + ) + { + this._mediaQuery = window.matchMedia( + `(-webkit-min-device-pixel-ratio: 1.3), (min-resolution: 120dpi)` + ); + this._mediaQuery.addListener(this.updateSize); + } + + // listen for reconciler changes + if (renderOnComponentChange && !raf) + { + this._ticker = new Ticker(); + this._ticker.autoStart = true; + this._ticker.add(this.renderStage); + this.app.stage.on( + '__REACT_PIXI_REQUEST_RENDER__', + this.needsRenderUpdate + ); + } + + this.updateSize(); + this.renderStage(); + } + + componentDidUpdate(prevProps) + { + const { width, height, raf, renderOnComponentChange, options } + = this.props; + + // update resolution + if ( + options?.resolution !== undefined + && prevProps?.options.resolution !== options?.resolution + ) + { + this.app.renderer.resolution = options.resolution; + this.resetInteractionManager(); + } + + // update size + if ( + prevProps.height !== height + || prevProps.width !== width + || prevProps.options?.resolution !== options?.resolution + ) + { + this.updateSize(); + } + + // handle raf change + if (prevProps.raf !== raf) + { + this.app.ticker[raf ? 'start' : 'stop'](); + } + + // flush fiber + PixiFiber.updateContainer(this.getChildren(), this.mountNode, this); + + if ( + prevProps.width !== width + || prevProps.height !== height + || prevProps.raf !== raf + || prevProps.renderOnComponentChange !== renderOnComponentChange + || prevProps.options !== options + ) + { + this._needsUpdate = true; + this.renderStage(); + } + } + + updateSize = () => + { + const { width, height, options } = this.props; + + if (!options?.resolution) + { + this.app.renderer.resolution = window.devicePixelRatio; + this.resetInteractionManager(); + } + + this.app.renderer.resize(width, height); + }; + + needsRenderUpdate = () => + { + this._needsUpdate = true; + }; + + renderStage = () => + { + const { renderOnComponentChange, raf } = this.props; + + if (!raf && renderOnComponentChange && this._needsUpdate) + { + this._needsUpdate = false; + this.app.renderer.render(this.app.stage); + } + }; + + // provide support for Pixi v6 still + resetInteractionManager() + { + // `interaction` property is absent in Pixi v7 and in v6 if user has installed Federated Events API plugin. + // https://api.pixijs.io/@pixi/events.html + // in v7 however, there's a stub object which displays a deprecation warning, so also check the resolution property: + const { interaction: maybeInteraction } = this.app.renderer.plugins; + + if (maybeInteraction?.resolution) + { + maybeInteraction.resolution = this.app.renderer.resolution; + } + } + + getChildren() + { + const { children } = this.props; + + return {children}; + } + + componentDidCatch(error, errorInfo) + { + console.error(`Error occurred in \`Stage\`.`); + console.error(error); + console.error(errorInfo); + } + + componentWillUnmount() + { + this.props.onUnmount(this.app); + + if (this._ticker) + { + this._ticker.remove(this.renderStage); + this._ticker.destroy(); + } + + this.app.stage.off( + '__REACT_PIXI_REQUEST_RENDER__', + this.needsRenderUpdate + ); + + PixiFiber.updateContainer(null, this.mountNode, this); + + if (this._mediaQuery) + { + this._mediaQuery.removeListener(this.updateSize); + this._mediaQuery = null; + } + + this.app.destroy(); + } + + render() + { + const { options } = this.props; + + if (options && options.view) + { + return null; + } + + return ( + (this._canvas = c)} + /> + ); + } +} + +Stage.propTypes = propTypes; +Stage.defaultProps = defaultProps; + +export default Stage; diff --git a/package-lock.json b/package-lock.json index 6de9b7d..54b858d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "@pixi/filter-adjustment": "^5.1.1", "@pixi/filter-color-matrix": "^7.4.2", "@pixi/filter-glow": "^5.2.1", + "@pixi/layers": "^2.1.0", "@pixi/particle-emitter": "^5.0.8", "@pixi/react": "^7.1.2", "@pixi/spritesheet": "^7.4.2", @@ -3554,6 +3555,15 @@ "@pixi/core": "7.4.2" } }, + "node_modules/@pixi/canvas-renderer": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@pixi/canvas-renderer/-/canvas-renderer-7.4.2.tgz", + "integrity": "sha512-5qBrr4hJ3hQYMJwxIkKCnvMxL9m7aL26h4zbacK0KH7SQ0i+RCcZS2NzvPa451FtnhquIUjwuuWQyjPFdM5R7g==", + "peer": true, + "peerDependencies": { + "@pixi/core": "7.4.2" + } + }, "node_modules/@pixi/color": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.2.tgz", @@ -3704,6 +3714,16 @@ "@pixi/sprite": "7.4.2" } }, + "node_modules/@pixi/layers": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@pixi/layers/-/layers-2.1.0.tgz", + "integrity": "sha512-e3p3xaHSkVF1jLTfiTEsFGNzGRPffTO0UkKbwA9pfBzRHIBOFM2mCbVqcdhgHSMXRFYr2FoOp1+zK9jJifEUug==", + "peerDependencies": { + "@pixi/canvas-renderer": "^7.0.0", + "@pixi/core": "^7.0.0", + "@pixi/display": "^7.0.0" + } + }, "node_modules/@pixi/math": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.2.tgz", diff --git a/package.json b/package.json index 348fb9c..32fd766 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@pixi/filter-adjustment": "^5.1.1", "@pixi/filter-color-matrix": "^7.4.2", "@pixi/filter-glow": "^5.2.1", + "@pixi/layers": "^2.1.0", "@pixi/particle-emitter": "^5.0.8", "@pixi/react": "^7.1.2", "@pixi/spritesheet": "^7.4.2",