├── .babelrc ├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── adapter.d.ts ├── components │ ├── index.d.ts │ ├── relative-position.d.ts │ ├── relative-scale.d.ts │ └── texture.d.ts ├── context │ └── scene.d.ts ├── hooks │ ├── index.d.ts │ ├── use-click-outside.d.ts │ ├── use-click.d.ts │ ├── use-current-scene.d.ts │ ├── use-event-value.d.ts │ ├── use-event.d.ts │ ├── use-game.d.ts │ ├── use-interaction.d.ts │ ├── use-match-media.d.ts │ ├── use-mobile-platform.d.ts │ ├── use-relative-position.d.ts │ ├── use-relative-scale.d.ts │ ├── use-scene-update.d.ts │ ├── use-scene.d.ts │ └── use-texture.d.ts ├── index.d.ts ├── index.js ├── render.d.ts ├── types │ ├── relative-position.d.ts │ ├── relative-scale.d.ts │ └── texture.d.ts └── utils │ ├── get-modified.d.ts │ └── index.d.ts ├── package.json ├── src ├── adapter.ts ├── components │ ├── index.ts │ ├── relative-position.tsx │ ├── relative-scale.tsx │ └── texture.tsx ├── context │ └── scene.ts ├── hooks │ ├── index.ts │ ├── use-click-outside.ts │ ├── use-click.ts │ ├── use-current-scene.ts │ ├── use-event-value.ts │ ├── use-event.ts │ ├── use-game.ts │ ├── use-interaction.ts │ ├── use-match-media.ts │ ├── use-mobile-platform.ts │ ├── use-relative-position.ts │ ├── use-relative-scale.ts │ ├── use-scene-update.ts │ ├── use-scene.ts │ └── use-texture.ts ├── index.ts ├── render.tsx ├── types │ ├── relative-position.ts │ ├── relative-scale.ts │ └── texture.ts └── utils │ ├── get-modified.ts │ └── index.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets":[ 3 | "@babel/preset-react", 4 | "@babel/preset-env" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | rules: { 12 | // Basic 13 | 'quotes': ['error', 'single'], 14 | 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }], 15 | 'no-multi-spaces': 'error', 16 | 'object-curly-spacing': ['error', 'always'], 17 | 'array-bracket-spacing': ['error', 'never'], 18 | 'computed-property-spacing': ['error', 'never'], 19 | 'comma-dangle': ['error', 'always-multiline'], 20 | 'eol-last': ['error', 'always'], 21 | 'no-trailing-spaces': 'error', 22 | 'brace-style': ['error', '1tbs', { 'allowSingleLine': true }], 23 | 'indent': ['error', 2, { 'SwitchCase': 1 }], 24 | 'keyword-spacing': ['error', { before: true, after: true }], 25 | 'padded-blocks': ['error', 'never'], 26 | 'comma-spacing': ['error', { 'before': false, 'after': true }], 27 | 'space-in-parens': ['error', 'never'], 28 | 'semi': ['error', 'always'], 29 | // TypeScript 30 | '@typescript-eslint/consistent-type-imports': 'error', 31 | '@typescript-eslint/no-unused-vars': 'error', 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | '@typescript-eslint/ban-ts-comment': 'off', 34 | '@typescript-eslint/no-empty-object-type': 'off', 35 | '@typescript-eslint/no-namespace': 'off', 36 | }, 37 | }; -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: '20.x' 11 | - name: Installing 12 | run: yarn install 13 | - name: Building 14 | run: yarn build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git 2 | .idea 3 | node_modules/ 4 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nikita Galadiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⚡ Phaser React UI 2 | [![Npm package version](https://badgen.net/npm/v/phaser-react-ui)](https://npmjs.com/package/phaser-react-ui) 3 | [![Small size](https://img.badgesize.io/neki-dev/phaser-react-ui/main/dist/index.js)](https://github.com/neki-dev/phaser-react-ui/blob/main/dist/index.js) 4 | [![Building](https://github.com/neki-dev/phaser-react-ui/actions/workflows/build.yml/badge.svg)](https://github.com/neki-dev/phaser-react-ui/actions/workflows/build.yml) 5 | 6 | Library for render relative game interface using React, connecting it with Phaser through events and context. 7 | 8 | Use special hooks for access to game and scenes. 9 | 10 | For each scene can be create one interface instance, which is container for all components. 11 | 12 | . 13 | 14 | Documentation 15 | 16 | * [Install](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#install) 17 | * [Integration](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#integration) 18 | * [Hooks](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#hooks) 19 | * [Components](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#components) 20 | * [Utils](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#utils) 21 | * [Example](https://github.com/neki-dev/phaser-react-ui?tab=readme-ov-file#example) 22 | 23 | . 24 | 25 | 26 | ## Install 27 | 28 | ```sh 29 | npm i phaser-react-ui 30 | ``` 31 | 32 | . 33 | 34 | ## Integration 35 | #### Add interface to scene 36 | ```ts 37 | const ui = new Interface(scene: Phaser.Scene) 38 | 39 | console.log(ui.container) // HTMLDivElement 40 | ``` 41 | 42 | #### Interface render 43 | ```ts 44 | ui.render( 45 | component: React.FC, 46 | props?: object 47 | ) 48 | ``` 49 | 50 | #### Interface events 51 | ```ts 52 | scene.events.on(Phaser.Interface.Events.MOUNT, () => { 53 | console.log('component mounted'); 54 | }) 55 | ``` 56 | 57 | #### Toggle interface interactive 58 | ```ts 59 | ui.setInteractive(state: boolean) 60 | ``` 61 | * Default: `true` 62 | * You can toggle interactive for certain interface elements by CSS-property `pointer-events` 63 | 64 | #### Remove interface from scene 65 | ```ts 66 | ui.destroy() 67 | ``` 68 | * When scene is closed, the interface is destroyed automatically 69 | 70 | . 71 | 72 | ## Hooks 73 | #### Get game instance 74 | ```ts 75 | useGame(): Phaser.Game 76 | ``` 77 | 78 | #### Get scene 79 | * Get scene in which interface was created 80 | ```ts 81 | useCurrentScene(): Phaser.Scene 82 | ``` 83 | * Get scene by key 84 | ```ts 85 | useScene(key: string): Phaser.Scene 86 | ``` 87 | 88 | #### Subscribe to scene update 89 | ```ts 90 | useSceneUpdate( 91 | scene: Phaser.Scene, 92 | callback: () => void, 93 | depends: any[] 94 | ) 95 | ``` 96 | 97 | #### Subscribe to event 98 | ```ts 99 | useEvent( 100 | emitter: Phaser.Events.EventEmitter, 101 | event: string, 102 | callback: () => void, 103 | depends: any[] 104 | ) 105 | ``` 106 | 107 | #### Get actual value from event 108 | ```ts 109 | useEventValue( 110 | emitter: Phaser.Events.EventEmitter, 111 | event: string, 112 | defaultValue: T 113 | ) 114 | ``` 115 | 116 | #### Position relative to camera 117 | ```ts 118 | useRelativePosition(params: { 119 | x: number, 120 | y: number, 121 | camera?: Phaser.Cameras.Scene2D.Camera 122 | }): React.MutableRefObject 123 | ``` 124 | 125 | #### Scale relative to canvas size 126 | ```ts 127 | useRelativeScale(params: { 128 | target: number, 129 | min?: number, 130 | max?: number, 131 | round?: boolean 132 | }): React.MutableRefObject 133 | ``` 134 | 135 | #### Get texture source image 136 | ```ts 137 | useTexture(key: string): HTMLImageElement 138 | ``` 139 | 140 | #### Get actual media query result 141 | ```ts 142 | useMatchMedia(query: string): boolean 143 | ``` 144 | 145 | #### Check if platform is mobile 146 | ```ts 147 | useMobilePlatform(): boolean 148 | ``` 149 | 150 | #### Use adaptive click event on target element 151 | ```ts 152 | useClick( 153 | ref: React.RefObject, 154 | type: 'up' | 'down', 155 | callback: Function, 156 | depends: any[] 157 | ) 158 | ``` 159 | 160 | #### Use adaptive click event outside target element 161 | ```ts 162 | useClickOutside( 163 | ref: React.RefObject, 164 | callback: Function, 165 | depends: any[] 166 | ) 167 | ``` 168 | 169 | #### Use adaptive interaction flow 170 | ```ts 171 | useInteraction( 172 | ref: React.RefObject, 173 | callback?: Function, 174 | depends?: any[] 175 | ): boolean 176 | ``` 177 | 178 | . 179 | 180 | 181 | ## Components 182 | #### Position relative to camera 183 | ```ts 184 | 189 | ... 190 | 191 | ``` 192 | 193 | #### Scale relative to canvas size 194 | ```ts 195 | 201 | ... 202 | 203 | ``` 204 | 205 | #### Render texture image 206 | ```ts 207 | 208 | ``` 209 | 210 | . 211 | 212 | ## Utils 213 | #### Safe rerender utils 214 | ```ts 215 | ifModifiedObject( 216 | newValue: T, 217 | keys?: (keyof T)[] 218 | ): (currentValue: T) => T 219 | ``` 220 | ```ts 221 | ifModifiedArray( 222 | newValue: T[], 223 | keys?: (keyof T)[] 224 | ): (currentValue: T[]) => T[] 225 | ``` 226 | ```ts 227 | const Component: React.FC = () => { 228 | const scene = useCurrentScene(); 229 | const [data, setData] = useState({}); 230 | 231 | useSceneUpdate(scene, () => { 232 | const newData = scene.getSomeData(); 233 | // Rerender only if newData is different by data 234 | setList(ifModifiedObject(newData)) 235 | }, []); 236 | }; 237 | ``` 238 | 239 | . 240 | 241 | ## Example 242 | #### Create interface component 243 | ```ts 244 | import { useScene, useSceneUpdate, useEvent } from 'phaser-react-ui'; 245 | 246 | const PlayerHealth: React.FC = () => { 247 | const world = useScene('world'); 248 | 249 | const [health, setHealth] = useState(0); 250 | const [isAlive, setAlive] = useState(true); 251 | 252 | useSceneUpdate(world, () => { 253 | if (isAlive) { 254 | setHealth(world.player.health); 255 | } 256 | }, [isAlive]); 257 | 258 | useEvent(world.player, Phaser.GameObjects.Events.DESTROY, () => { 259 | setAlive(false); 260 | }, []); 261 | 262 | return isAlive && ( 263 |
264 | {health} HP 265 |
266 | ); 267 | }; 268 | ``` 269 | 270 | #### Create components container 271 | ```ts 272 | import { useRelativeScale } from 'phaser-react-ui'; 273 | import { PlayerHealth } from './PlayerHealth'; 274 | 275 | const ScreenUI: React.FC = () => { 276 | const ref = useRelativeScale({ 277 | target: 1280, 278 | min: 0.6, 279 | max: 1.2, 280 | }); 281 | 282 | return ( 283 |
284 | 285 | ... 286 |
287 | ); 288 | }; 289 | 290 | ScreenUI.displayName = 'ScreenUI'; 291 | ``` 292 | 293 | #### Add interface to scene 294 | ```ts 295 | import { Interface } from 'phaser-react-ui'; 296 | import { ScreenUI } from './ScreenUI'; 297 | 298 | class Screen extends Phaser.Scene { 299 | private ui: Interface; 300 | 301 | create() { 302 | this.ui = new Interface(this); 303 | this.ui.render(ScreenUI); 304 | } 305 | } 306 | ``` -------------------------------------------------------------------------------- /dist/adapter.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/components/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './relative-scale'; 2 | export * from './relative-position'; 3 | export * from './texture'; 4 | -------------------------------------------------------------------------------- /dist/components/relative-position.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { RelativePositionProps } from '../types/relative-position'; 3 | type Props = RelativePositionProps & { 4 | children: React.ReactNode; 5 | }; 6 | export declare const RelativePosition: React.FC; 7 | export {}; 8 | -------------------------------------------------------------------------------- /dist/components/relative-scale.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { RelativeScaleProps } from '../types/relative-scale'; 3 | type Props = RelativeScaleProps & { 4 | children: React.ReactNode; 5 | }; 6 | export declare const RelativeScale: React.FC; 7 | export {}; 8 | -------------------------------------------------------------------------------- /dist/components/texture.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | type Props = { 3 | name: string; 4 | }; 5 | export declare const Texture: React.FC; 6 | export {}; 7 | -------------------------------------------------------------------------------- /dist/context/scene.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type Phaser from 'phaser'; 3 | export declare const SceneContext: import("react").Context; 4 | export declare const SceneProvider: import("react").Provider; 5 | -------------------------------------------------------------------------------- /dist/hooks/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './use-game'; 2 | export * from './use-current-scene'; 3 | export * from './use-scene'; 4 | export * from './use-scene-update'; 5 | export * from './use-relative-position'; 6 | export * from './use-relative-scale'; 7 | export * from './use-texture'; 8 | export * from './use-match-media'; 9 | export * from './use-mobile-platform'; 10 | export * from './use-click'; 11 | export * from './use-click-outside'; 12 | export * from './use-event'; 13 | export * from './use-event-value'; 14 | export * from './use-interaction'; 15 | -------------------------------------------------------------------------------- /dist/hooks/use-click-outside.d.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | /** 3 | * Use adaptive click event outside target element. 4 | * 5 | * @param ref - Target ref 6 | * @param callback - Event callback 7 | * @param depends - Callback dependencies 8 | */ 9 | export declare function useClickOutside(ref: React.RefObject, callback: () => void, depends: any[]): void; 10 | -------------------------------------------------------------------------------- /dist/hooks/use-click.d.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | /** 3 | * Use adaptive click event on target element. 4 | * 5 | * @param ref - Target ref 6 | * @param type - Event type 7 | * @param callback - Event callback 8 | * @param depends - Callback dependencies 9 | */ 10 | export declare function useClick(ref: React.RefObject, type: 'up' | 'down', callback: (event: MouseEvent | TouchEvent) => void, depends: any[]): void; 11 | -------------------------------------------------------------------------------- /dist/hooks/use-current-scene.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Get scene in which interface was created. 4 | */ 5 | export declare function useCurrentScene(): T; 6 | -------------------------------------------------------------------------------- /dist/hooks/use-event-value.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Get actual value from event. 4 | * 5 | * @param emitter - Events emitter 6 | * @param event - Event 7 | * @param defaultValue - Default value 8 | */ 9 | export declare function useEventValue(emitter: Phaser.Events.EventEmitter | null, event: string, defaultValue: T): T; 10 | -------------------------------------------------------------------------------- /dist/hooks/use-event.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Subscribe to event. 4 | * 5 | * @param emitter - Events emitter 6 | * @param event - Event 7 | * @param callback - Event callback 8 | * @param depends - Callback dependencies 9 | */ 10 | export declare function useEvent(emitter: Phaser.Events.EventEmitter | null, event: string, callback: (...args: any[]) => void, depends: any[]): void; 11 | -------------------------------------------------------------------------------- /dist/hooks/use-game.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Get game instance. 4 | */ 5 | export declare function useGame(): T; 6 | -------------------------------------------------------------------------------- /dist/hooks/use-interaction.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /** 3 | * Use adaptive interaction flow. 4 | * 5 | * Desktop: 6 | * * [ mouse enter = hover ] --> [ click = use ] --> [ mouse leave = unhover ] 7 | * 8 | * Mobile: 9 | * * [ click = hover ] --> [ second click = use ] --> [ auto unhover ] 10 | * * [ click = hover ] --> [ outside click = unhover ] 11 | * 12 | * @param ref - Target ref 13 | * @param callback - Event callback 14 | * @param depends - Callback dependencies 15 | */ 16 | export declare function useInteraction(ref: React.RefObject, callback?: () => void, depends?: any[]): boolean; 17 | -------------------------------------------------------------------------------- /dist/hooks/use-match-media.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get actual media query result. 3 | * 4 | * @param query - Media query 5 | */ 6 | export declare function useMatchMedia(query: string): boolean | null; 7 | -------------------------------------------------------------------------------- /dist/hooks/use-mobile-platform.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if platform is mobile. 3 | */ 4 | export declare function useMobilePlatform(): boolean; 5 | -------------------------------------------------------------------------------- /dist/hooks/use-relative-position.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type { RelativePositionProps } from '../types/relative-position'; 3 | /** 4 | * Position relative to camera. 5 | * 6 | * @param props 7 | * @param props.x - World position X 8 | * @param props.y - World position Y 9 | * @param props.camera - Camera 10 | */ 11 | export declare function useRelativePosition({ x, y, camera, }: RelativePositionProps): import("react").RefObject; 12 | -------------------------------------------------------------------------------- /dist/hooks/use-relative-scale.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type { RelativeScaleProps } from '../types/relative-scale'; 3 | /** 4 | * Scale relative to canvas size. 5 | * 6 | * @param props 7 | * @param props.target - Target value 8 | * @param props.min - Min scale 9 | * @param props.max - Max scale 10 | * @param props.round - Rounding 11 | */ 12 | export declare function useRelativeScale({ target, min, max, round, }: RelativeScaleProps): import("react").RefObject; 13 | -------------------------------------------------------------------------------- /dist/hooks/use-scene-update.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Subscribe to scene update. 4 | * 5 | * @param scene - Scene 6 | * @param callback - Update callback 7 | * @param depends - Callback dependencies 8 | */ 9 | export declare function useSceneUpdate(scene: Phaser.Scene, callback: () => void, depends: any[]): void; 10 | -------------------------------------------------------------------------------- /dist/hooks/use-scene.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | /** 3 | * Get scene by key. 4 | * 5 | * @param key - Scene key 6 | */ 7 | export declare function useScene(key: string): T; 8 | -------------------------------------------------------------------------------- /dist/hooks/use-texture.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get texture source image. 3 | * 4 | * @param key - Texture key 5 | */ 6 | export declare function useTexture(key: string): string | null; 7 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import './adapter'; 2 | export * from './hooks'; 3 | export * from './render'; 4 | export * from './utils'; 5 | export * from './components'; 6 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={460:function(e,t,r){var n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});var o=n(r(969)),i=o.default.Loader.File.createObjectURL;o.default.Loader.File.createObjectURL=function(e,t,r){e.originBlob=t,i.call(this,e,t,r)}},610:function(e,t,r){var n=this&&this.__createBinding||(Object.create?function(e,t,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(t,r);o&&!("get"in o?!t.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,n,o)}:function(e,t,r,n){void 0===n&&(n=r),e[n]=t[r]}),o=this&&this.__exportStar||function(e,t){for(var r in e)"default"===r||Object.prototype.hasOwnProperty.call(t,r)||n(t,e,r)};Object.defineProperty(t,"__esModule",{value:!0}),o(r(159),t),o(r(644),t),o(r(418),t)},644:function(e,t,r){var n=this&&this.__createBinding||(Object.create?function(e,t,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(t,r);o&&!("get"in o?!t.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,n,o)}:function(e,t,r,n){void 0===n&&(n=r),e[n]=t[r]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),i=this&&this.__importStar||function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var r in e)"default"!==r&&Object.prototype.hasOwnProperty.call(e,r)&&n(t,e,r);return o(t,e),t},u=this&&this.__rest||function(e,t){var r={};for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&t.indexOf(n)<0&&(r[n]=e[n]);if(null!=e&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(n=Object.getOwnPropertySymbols(e);o{Object.defineProperty(t,"__esModule",{value:!0}),t.SceneProvider=t.SceneContext=void 0;var n=r(497);t.SceneContext=(0,n.createContext)(null),t.SceneProvider=t.SceneContext.Provider},362:function(e,t,r){var n=this&&this.__createBinding||(Object.create?function(e,t,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(t,r);o&&!("get"in o?!t.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,n,o)}:function(e,t,r,n){void 0===n&&(n=r),e[n]=t[r]}),o=this&&this.__exportStar||function(e,t){for(var r in e)"default"===r||Object.prototype.hasOwnProperty.call(t,r)||n(t,e,r)};Object.defineProperty(t,"__esModule",{value:!0}),o(r(386),t),o(r(664),t),o(r(531),t),o(r(692),t),o(r(465),t),o(r(870),t),o(r(666),t),o(r(457),t),o(r(13),t),o(r(90),t),o(r(253),t),o(r(711),t),o(r(133),t),o(r(519),t)},253:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useClickOutside=void 0;var n=r(497),o=r(13);t.useClickOutside=function(e,t,r){var i=(0,o.useMobilePlatform)(),u=(0,n.useCallback)((function(r){e.current&&(r.composedPath().includes(e.current)||t())}),r);(0,n.useEffect)((function(){var e=i?"touchend":"mouseup";return document.addEventListener(e,u),function(){document.removeEventListener(e,u)}}),[i,u])}},90:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useClick=void 0;var n=r(497),o=r(13);t.useClick=function(e,t,r,i){var u=(0,o.useMobilePlatform)(),a=(0,n.useCallback)((function(e){r(e),e.stopPropagation(),e.preventDefault()}),i);(0,n.useEffect)((function(){var r,n=e.current;if(n)return r=u?"up"===t?"touchend":"touchstart":"up"===t?"mouseup":"mousedown",n.addEventListener(r,a),function(){n.removeEventListener(r,a)}}),[t,u,a])}},664:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useCurrentScene=void 0;var n=r(497),o=r(794);t.useCurrentScene=function(){var e=(0,n.useContext)(o.SceneContext);if(!e)throw Error("Undefined scene context");return e}},133:(e,t,r)=>{function n(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r{function n(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r{Object.defineProperty(t,"__esModule",{value:!0}),t.useGame=void 0;var n=r(497),o=r(794);t.useGame=function(){var e=(0,n.useContext)(o.SceneContext);if(!e)throw Error("Undefined scene context");return e.game}},519:(e,t,r)=>{function n(e,t){if(e){if("string"==typeof e)return o(e,t);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?o(e,t):void 0}}function o(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r{function n(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r{Object.defineProperty(t,"__esModule",{value:!0}),t.useMobilePlatform=void 0;var n=r(386);t.useMobilePlatform=function(){return!(0,n.useGame)().device.os.desktop}},465:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useRelativePosition=void 0;var n=r(497),o=r(664),i=r(692);t.useRelativePosition=function(e){var t=e.x,r=e.y,u=e.camera,a=(0,o.useCurrentScene)(),c=null!=u?u:a.cameras.main,l=(0,n.useRef)(null);return(0,i.useSceneUpdate)(a,(function(){if(l.current){var e=Math.round((t-c.worldView.x)*c.zoom),n=Math.round((r-c.worldView.y)*c.zoom);l.current.style.transform="translate(".concat(e,"px, ").concat(n,"px)")}}),[t,r,c]),l}},870:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useRelativeScale=void 0;var n=r(497),o=r(386);t.useRelativeScale=function(e){var t=e.target,r=e.min,i=e.max,u=e.round,a=(0,o.useGame)(),c=(0,n.useRef)(null),l=(0,n.useCallback)((function(){if(c.current){var e=a.canvas.parentElement;if(e){var n=e.clientWidth/t;"number"==typeof i&&(n=Math.min(n,i)),"number"==typeof r&&(n=Math.max(n,r)),u&&(n=Math.round(10*n)/10),c.current.style.removeProperty("transform"),c.current.style.removeProperty("transformOrigin"),c.current.style.removeProperty("width"),c.current.style.removeProperty("height");var o=c.current.clientWidth,l=c.current.clientHeight;c.current.style.transform="scale(".concat(n,")"),c.current.style.transformOrigin="0 0",c.current.style.width="".concat(o/n,"px"),c.current.style.height="".concat(l/n,"px")}}}),[t,r,i,u]);return(0,n.useEffect)((function(){return l(),window.addEventListener("resize",l),function(){window.removeEventListener("resize",l)}}),[l]),c}},692:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useSceneUpdate=void 0;var n=r(497);t.useSceneUpdate=function(e,t,r){(0,n.useEffect)((function(){return t(),e.events.on("update",t),function(){e.events.off("update",t)}}),r)}},531:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useScene=void 0;var n=r(497),o=r(386);t.useScene=function(e){var t=(0,o.useGame)();return(0,n.useMemo)((function(){return t.scene.getScene(e)}),[e])}},666:(e,t,r)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.useTexture=void 0;var n=r(497),o=r(386);t.useTexture=function(e){var t=(0,o.useGame)();return(0,n.useMemo)((function(){var r=t.textures.get(e).getSourceImage();return r.originBlob?URL.createObjectURL(r.originBlob):null}),[e])}},820:function(e,t,r){var n=this&&this.__createBinding||(Object.create?function(e,t,r,n){void 0===n&&(n=r);var o=Object.getOwnPropertyDescriptor(t,r);o&&!("get"in o?!t.__esModule:o.writable||o.configurable)||(o={enumerable:!0,get:function(){return t[r]}}),Object.defineProperty(e,n,o)}:function(e,t,r,n){void 0===n&&(n=r),e[n]=t[r]}),o=this&&this.__exportStar||function(e,t){for(var r in e)"default"===r||Object.prototype.hasOwnProperty.call(t,r)||n(t,e,r)};Object.defineProperty(t,"__esModule",{value:!0}),r(460),o(r(362),t),o(r(130),t),o(r(904),t),o(r(610),t)},130:function(e,t,r){function n(e){return n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},n(e)}function o(e,t){for(var r=0;r1&&void 0!==arguments[1]?arguments[1]:{},r=this.scene;e.displayName&&this.container.setAttribute("data-component",e.displayName),this.root.render(s.default.createElement(v.SceneProvider,{value:r},s.default.createElement((function(){return(0,s.useEffect)((function(){r.events.emit(f.default.Interface.Events.MOUNT)}),[]),s.default.createElement(e,Object.assign({},t))}),null)))}},{key:"destroy",value:function(){this.scene.events.emit(f.default.Interface.Events.UNMOUNT),delete this.scene.interface,this.root.unmount(),this.container.remove()}},{key:"configureContainer",value:function(){this.container.className="phaser-scene-interface",this.container.style.position="absolute",this.container.style.left="0",this.container.style.right="0",this.container.style.top="0",this.container.style.bottom="0"}}],t&&o(e.prototype,t),Object.defineProperty(e,"prototype",{writable:!1}),e;var e,t}();t.Interface=y,f.default.Interface={Events:{MOUNT:"interface_mount",UNMOUNT:"interface_unmount"}}},898:(e,t)=>{function r(e,t){var r="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!r){if(Array.isArray(e)||(r=function(e,t){if(e){if("string"==typeof e)return n(e,t);var r={}.toString.call(e).slice(8,-1);return"Object"===r&&e.constructor&&(r=e.constructor.name),"Map"===r||"Set"===r?Array.from(e):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?n(e,t):void 0}}(e))||t&&e&&"number"==typeof e.length){r&&(e=r);var o=0,i=function(){};return{s:i,n:function(){return o>=e.length?{done:!0}:{done:!1,value:e[o++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var u,a=!0,c=!1;return{s:function(){r=r.call(e)},n:function(){var e=r.next();return a=e.done,e},e:function(e){c=!0,u=e},f:function(){try{a||null==r.return||r.return()}finally{if(c)throw u}}}}function n(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r{e.exports=require("phaser")},497:e=>{e.exports=require("react")},183:e=>{e.exports=require("react-dom/client")}},t={},r=function r(n){var o=t[n];if(void 0!==o)return o.exports;var i=t[n]={exports:{}};return e[n].call(i.exports,i,i.exports,r),i.exports}(820);module.exports=r})(); -------------------------------------------------------------------------------- /dist/render.d.ts: -------------------------------------------------------------------------------- 1 | import Phaser from 'phaser'; 2 | import React from 'react'; 3 | import type { Root } from 'react-dom/client'; 4 | export declare class Interface { 5 | readonly container: HTMLDivElement; 6 | readonly root: Root; 7 | readonly scene: Phaser.Scene; 8 | constructor(scene: Phaser.Scene); 9 | setInteractive(state: boolean): void; 10 | render

(Component: React.FC

, props?: P): void; 11 | destroy(): void; 12 | private configureContainer; 13 | } 14 | declare global { 15 | namespace Phaser { 16 | interface Scene { 17 | interface?: Interface; 18 | } 19 | namespace Interface { 20 | enum Events { 21 | MOUNT = "interface_mount", 22 | UNMOUNT = "interface_unmount" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /dist/types/relative-position.d.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | export type RelativePositionProps = { 3 | x: number; 4 | y: number; 5 | camera?: Phaser.Cameras.Scene2D.Camera; 6 | }; 7 | -------------------------------------------------------------------------------- /dist/types/relative-scale.d.ts: -------------------------------------------------------------------------------- 1 | export type RelativeScaleProps = { 2 | target: number; 3 | min?: number; 4 | max?: number; 5 | round?: boolean; 6 | }; 7 | -------------------------------------------------------------------------------- /dist/types/texture.d.ts: -------------------------------------------------------------------------------- 1 | export type HTMLTextureElement = HTMLImageElement & { 2 | originBlob: Blob; 3 | }; 4 | -------------------------------------------------------------------------------- /dist/utils/get-modified.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get modified array between current and target value. 3 | * 4 | * @param current - Current array 5 | * @param target - New array 6 | * @param keys - Keys to compare 7 | */ 8 | export declare function getModifiedArray(current: T[], target: T[], keys?: (keyof T)[]): T[]; 9 | /** 10 | * Return callback for safe update state. 11 | * 12 | * @param target - New array 13 | * @param keys - Keys to compare 14 | */ 15 | export declare function ifModifiedArray(value: T[], keys?: (keyof T)[]): (currentValue: T[]) => T[]; 16 | /** 17 | * Get modified object between current and target value. 18 | * 19 | * @param current - Current object 20 | * @param target - New object 21 | * @param keys - Keys to compare 22 | */ 23 | export declare function getModifiedObject(current: T, target: T, keys?: (keyof T)[]): T; 24 | /** 25 | * Return callback for safe update state. 26 | * 27 | * @param target - New object 28 | * @param keys - Keys to compare 29 | */ 30 | export declare function ifModifiedObject(value: T, keys?: (keyof T)[]): (currentValue: T) => T; 31 | -------------------------------------------------------------------------------- /dist/utils/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './get-modified'; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phaser-react-ui", 3 | "description": "React interface render for Phaser engine", 4 | "version": "1.16.0", 5 | "keywords": [ 6 | "phaser", 7 | "interface", 8 | "react", 9 | "ui", 10 | "library", 11 | "render", 12 | "plugin" 13 | ], 14 | "license": "MIT", 15 | "author": { 16 | "name": "Nikita Galadiy", 17 | "email": "dev@neki.guru", 18 | "url": "https://neki.guru/" 19 | }, 20 | "main": "./dist/index.js", 21 | "scripts": { 22 | "build": "webpack --mode production", 23 | "lint": "eslint \"./src/**/*.{ts,tsx}\" --ignore-path .gitignore --fix" 24 | }, 25 | "peerDependencies": { 26 | "phaser": "^3.60.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0" 29 | }, 30 | "devDependencies": { 31 | "@typescript-eslint/eslint-plugin": "8.24.0", 32 | "@typescript-eslint/parser": "8.24.0", 33 | "@babel/core": "7.23.6", 34 | "@babel/preset-env": "7.23.6", 35 | "@babel/preset-react": "7.23.3", 36 | "@types/node": "20.10.5", 37 | "@types/react": "18.2.46", 38 | "@types/react-dom": "18.2.18", 39 | "babel-loader": "9.1.3", 40 | "eslint": "8.56.0", 41 | "phaser": "3.60.0", 42 | "react": "18.2.0", 43 | "react-dom": "18.2.0", 44 | "ts-loader": "9.5.1", 45 | "typescript": "5.3.3", 46 | "webpack": "5.89.0", 47 | "webpack-cli": "5.1.4" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "git+https://github.com/neki-dev/phaser-react-ui.git" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/adapter.ts: -------------------------------------------------------------------------------- 1 | import Phaser from 'phaser'; 2 | import type { HTMLTextureElement } from './types/texture'; 3 | 4 | const defaultFunction = Phaser.Loader.File.createObjectURL; 5 | 6 | Phaser.Loader.File.createObjectURL = function createObjectURL( 7 | image: HTMLTextureElement, 8 | blob: Blob, 9 | defaultType: string, 10 | ) { 11 | // eslint-disable-next-line no-param-reassign 12 | image.originBlob = blob; 13 | defaultFunction.call(this, image, blob, defaultType); 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './relative-scale'; 2 | export * from './relative-position'; 3 | export * from './texture'; 4 | -------------------------------------------------------------------------------- /src/components/relative-position.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from 'react'; 2 | import { useRelativePosition } from '../hooks'; 3 | import type { RelativePositionProps } from '../types/relative-position'; 4 | 5 | type Props = RelativePositionProps & { 6 | children: React.ReactNode 7 | }; 8 | 9 | export const RelativePosition: React.FC = ({ children, ...props }) => { 10 | const ref = useRelativePosition(props); 11 | 12 | useLayoutEffect(() => { 13 | if (ref.current) { 14 | ref.current.style.position = 'absolute'; 15 | } 16 | }, []); 17 | 18 | return

{children}
; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/relative-scale.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from 'react'; 2 | import { useRelativeScale } from '../hooks'; 3 | import type { RelativeScaleProps } from '../types/relative-scale'; 4 | 5 | type Props = RelativeScaleProps & { 6 | children: React.ReactNode 7 | }; 8 | 9 | export const RelativeScale: React.FC = ({ children, ...props }) => { 10 | const ref = useRelativeScale(props); 11 | 12 | useLayoutEffect(() => { 13 | if (ref.current) { 14 | ref.current.style.position = 'absolute'; 15 | } 16 | }, []); 17 | 18 | return
{children}
; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/texture.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTexture } from '../hooks'; 3 | 4 | type Props = { 5 | name: string 6 | }; 7 | 8 | export const Texture: React.FC = ({ name }) => { 9 | const imageSrc = useTexture(name); 10 | 11 | return imageSrc ? {name} : null; 12 | }; 13 | -------------------------------------------------------------------------------- /src/context/scene.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { createContext } from 'react'; 3 | 4 | export const SceneContext = createContext(null); 5 | export const SceneProvider = SceneContext.Provider; 6 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-game'; 2 | export * from './use-current-scene'; 3 | export * from './use-scene'; 4 | export * from './use-scene-update'; 5 | export * from './use-relative-position'; 6 | export * from './use-relative-scale'; 7 | export * from './use-texture'; 8 | export * from './use-match-media'; 9 | export * from './use-mobile-platform'; 10 | export * from './use-click'; 11 | export * from './use-click-outside'; 12 | export * from './use-event'; 13 | export * from './use-event-value'; 14 | export * from './use-interaction'; 15 | -------------------------------------------------------------------------------- /src/hooks/use-click-outside.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useCallback, useEffect } from 'react'; 3 | import { useMobilePlatform } from './use-mobile-platform'; 4 | 5 | /** 6 | * Use adaptive click event outside target element. 7 | * 8 | * @param ref - Target ref 9 | * @param callback - Event callback 10 | * @param depends - Callback dependencies 11 | */ 12 | export function useClickOutside( 13 | ref: React.RefObject, 14 | callback: () => void, 15 | depends: any[], 16 | ) { 17 | const isMobile = useMobilePlatform(); 18 | 19 | const onClick = useCallback( 20 | (event: MouseEvent | TouchEvent) => { 21 | if (!ref.current) { 22 | return; 23 | } 24 | 25 | const isInside = event.composedPath().includes(ref.current); 26 | 27 | if (!isInside) { 28 | callback(); 29 | } 30 | }, 31 | depends, 32 | ); 33 | 34 | useEffect(() => { 35 | const event = isMobile ? 'touchend' : 'mouseup'; 36 | 37 | document.addEventListener(event, onClick); 38 | 39 | return () => { 40 | document.removeEventListener(event, onClick); 41 | }; 42 | }, [isMobile, onClick]); 43 | } 44 | -------------------------------------------------------------------------------- /src/hooks/use-click.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useCallback, useEffect } from 'react'; 3 | import { useMobilePlatform } from './use-mobile-platform'; 4 | 5 | /** 6 | * Use adaptive click event on target element. 7 | * 8 | * @param ref - Target ref 9 | * @param type - Event type 10 | * @param callback - Event callback 11 | * @param depends - Callback dependencies 12 | */ 13 | export function useClick( 14 | ref: React.RefObject, 15 | type: 'up' | 'down', 16 | callback: (event: MouseEvent | TouchEvent) => void, 17 | depends: any[], 18 | ) { 19 | const isMobile = useMobilePlatform(); 20 | 21 | const onClick = useCallback((event: MouseEvent | TouchEvent) => { 22 | callback(event); 23 | event.stopPropagation(); 24 | event.preventDefault(); 25 | }, depends); 26 | 27 | useEffect(() => { 28 | const element = ref.current; 29 | 30 | if (!element) { 31 | return; 32 | } 33 | 34 | let event: string; 35 | 36 | if (isMobile) { 37 | event = (type === 'up') ? 'touchend' : 'touchstart'; 38 | } else { 39 | event = (type === 'up') ? 'mouseup' : 'mousedown'; 40 | } 41 | 42 | // @ts-ignore 43 | element.addEventListener(event, onClick); 44 | 45 | return () => { 46 | // @ts-ignore 47 | element.removeEventListener(event, onClick); 48 | }; 49 | }, [type, isMobile, onClick]); 50 | } 51 | -------------------------------------------------------------------------------- /src/hooks/use-current-scene.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useContext } from 'react'; 3 | import { SceneContext } from '../context/scene'; 4 | 5 | /** 6 | * Get scene in which interface was created. 7 | */ 8 | export function useCurrentScene(): T { 9 | const scene = useContext(SceneContext) as T; 10 | 11 | if (!scene) { 12 | throw Error('Undefined scene context'); 13 | } 14 | 15 | return scene; 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/use-event-value.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | /** 5 | * Get actual value from event. 6 | * 7 | * @param emitter - Events emitter 8 | * @param event - Event 9 | * @param defaultValue - Default value 10 | */ 11 | export function useEventValue( 12 | emitter: Phaser.Events.EventEmitter | null, 13 | event: string, 14 | defaultValue: T, 15 | ): T { 16 | const [value, setValue] = useState(defaultValue); 17 | 18 | useEffect(() => { 19 | if (!emitter) { 20 | return; 21 | } 22 | 23 | emitter.on(event, setValue); 24 | 25 | return () => { 26 | emitter.off(event, setValue); 27 | }; 28 | }, [emitter, event]); 29 | 30 | return value; 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/use-event.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useEffect } from 'react'; 3 | 4 | /** 5 | * Subscribe to event. 6 | * 7 | * @param emitter - Events emitter 8 | * @param event - Event 9 | * @param callback - Event callback 10 | * @param depends - Callback dependencies 11 | */ 12 | export function useEvent( 13 | emitter: Phaser.Events.EventEmitter | null, 14 | event: string, 15 | callback: (...args: any[]) => void, 16 | depends: any[], 17 | ) { 18 | useEffect(() => { 19 | if (!emitter) { 20 | return; 21 | } 22 | 23 | emitter.on(event, callback); 24 | 25 | return () => { 26 | emitter.off(event, callback); 27 | }; 28 | }, [emitter, event, ...depends]); 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/use-game.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useContext } from 'react'; 3 | import { SceneContext } from '../context/scene'; 4 | 5 | /** 6 | * Get game instance. 7 | */ 8 | export function useGame(): T { 9 | const scene = useContext(SceneContext); 10 | 11 | if (!scene) { 12 | throw Error('Undefined scene context'); 13 | } 14 | 15 | return scene.game as T; 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/use-interaction.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useMobilePlatform } from './use-mobile-platform'; 3 | import { useClick } from './use-click'; 4 | import { useClickOutside } from './use-click-outside'; 5 | 6 | /** 7 | * Use adaptive interaction flow. 8 | * 9 | * Desktop: 10 | * * [ mouse enter = hover ] --> [ click = use ] --> [ mouse leave = unhover ] 11 | * 12 | * Mobile: 13 | * * [ click = hover ] --> [ second click = use ] --> [ auto unhover ] 14 | * * [ click = hover ] --> [ outside click = unhover ] 15 | * 16 | * @param ref - Target ref 17 | * @param callback - Event callback 18 | * @param depends - Callback dependencies 19 | */ 20 | export function useInteraction( 21 | ref: React.RefObject, 22 | callback?: () => void, 23 | depends?: any[], 24 | ) { 25 | const isMobile = useMobilePlatform(); 26 | 27 | const [isSelected, setSelected] = useState(false); 28 | 29 | useClickOutside(ref, () => { 30 | setSelected(false); 31 | }, []); 32 | 33 | useClick(ref, 'down', () => { 34 | if (isSelected) { 35 | callback?.(); 36 | if (isMobile) { 37 | setSelected(false); 38 | } 39 | } else { 40 | setSelected(true); 41 | } 42 | }, [isSelected, isMobile, ...(depends ?? [])]); 43 | 44 | const onMouseEnter = () => { 45 | setSelected(true); 46 | }; 47 | 48 | const onMouseLeave = () => { 49 | setSelected(false); 50 | }; 51 | 52 | useEffect(() => { 53 | if (isMobile) { 54 | return; 55 | } 56 | 57 | const element = ref.current; 58 | 59 | if (!element) { 60 | return; 61 | } 62 | 63 | element.addEventListener('mouseenter', onMouseEnter); 64 | element.addEventListener('mouseleave', onMouseLeave); 65 | 66 | return () => { 67 | element.removeEventListener('mouseenter', onMouseEnter); 68 | element.removeEventListener('mouseleave', onMouseLeave); 69 | }; 70 | }, [isMobile]); 71 | 72 | return isSelected; 73 | } 74 | -------------------------------------------------------------------------------- /src/hooks/use-match-media.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Get actual media query result. 5 | * 6 | * @param query - Media query 7 | */ 8 | export function useMatchMedia( 9 | query: string, 10 | ) { 11 | const [matched, setMatched] = useState(null); 12 | 13 | const onChange = (event: MediaQueryListEvent) => { 14 | setMatched(event.matches); 15 | }; 16 | 17 | useEffect(() => { 18 | const match = window.matchMedia(query); 19 | 20 | setMatched(match.matches); 21 | 22 | match.addEventListener('change', onChange); 23 | 24 | return () => { 25 | match.removeEventListener('change', onChange); 26 | }; 27 | }, [query]); 28 | 29 | return matched; 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/use-mobile-platform.ts: -------------------------------------------------------------------------------- 1 | import { useGame } from './use-game'; 2 | 3 | /** 4 | * Check if platform is mobile. 5 | */ 6 | export function useMobilePlatform() { 7 | const game = useGame(); 8 | 9 | return !game.device.os.desktop; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/use-relative-position.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useCurrentScene } from './use-current-scene'; 3 | import { useSceneUpdate } from './use-scene-update'; 4 | import type { RelativePositionProps } from '../types/relative-position'; 5 | 6 | /** 7 | * Position relative to camera. 8 | * 9 | * @param props 10 | * @param props.x - World position X 11 | * @param props.y - World position Y 12 | * @param props.camera - Camera 13 | */ 14 | export function useRelativePosition({ 15 | x, 16 | y, 17 | camera, 18 | }: RelativePositionProps) { 19 | const currentScene = useCurrentScene(); 20 | const relativeCamera = camera ?? currentScene.cameras.main; 21 | 22 | const refElement = useRef(null); 23 | 24 | useSceneUpdate(currentScene, () => { 25 | if (!refElement.current) { 26 | return; 27 | } 28 | 29 | const rx = Math.round((x - relativeCamera.worldView.x) * relativeCamera.zoom); 30 | const ry = Math.round((y - relativeCamera.worldView.y) * relativeCamera.zoom); 31 | 32 | refElement.current.style.transform = `translate(${rx}px, ${ry}px)`; 33 | }, [x, y, relativeCamera]); 34 | 35 | return refElement; 36 | } 37 | -------------------------------------------------------------------------------- /src/hooks/use-relative-scale.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { useGame } from './use-game'; 3 | import type { RelativeScaleProps } from '../types/relative-scale'; 4 | 5 | /** 6 | * Scale relative to canvas size. 7 | * 8 | * @param props 9 | * @param props.target - Target value 10 | * @param props.min - Min scale 11 | * @param props.max - Max scale 12 | * @param props.round - Rounding 13 | */ 14 | export function useRelativeScale({ 15 | target, 16 | min, 17 | max, 18 | round, 19 | }: RelativeScaleProps) { 20 | const game = useGame(); 21 | 22 | const refElement = useRef(null); 23 | 24 | const onResize = useCallback(() => { 25 | if (!refElement.current) { 26 | return; 27 | } 28 | 29 | const container = game.canvas.parentElement; 30 | 31 | if (!container) { 32 | return; 33 | } 34 | 35 | let zoom = container.clientWidth / target; 36 | 37 | if (typeof max === 'number') { 38 | zoom = Math.min(zoom, max); 39 | } 40 | 41 | if (typeof min === 'number') { 42 | zoom = Math.max(zoom, min); 43 | } 44 | 45 | if (round) { 46 | zoom = Math.round(zoom * 10) / 10; 47 | } 48 | 49 | refElement.current.style.removeProperty('transform'); 50 | refElement.current.style.removeProperty('transformOrigin'); 51 | refElement.current.style.removeProperty('width'); 52 | refElement.current.style.removeProperty('height'); 53 | 54 | const originalWidth = refElement.current.clientWidth; 55 | const originalHeight = refElement.current.clientHeight; 56 | 57 | refElement.current.style.transform = `scale(${zoom})`; 58 | refElement.current.style.transformOrigin = '0 0'; 59 | refElement.current.style.width = `${originalWidth / zoom}px`; 60 | refElement.current.style.height = `${originalHeight / zoom}px`; 61 | }, [target, min, max, round]); 62 | 63 | useEffect(() => { 64 | onResize(); 65 | 66 | window.addEventListener('resize', onResize); 67 | 68 | return () => { 69 | window.removeEventListener('resize', onResize); 70 | }; 71 | }, [onResize]); 72 | 73 | return refElement; 74 | } 75 | -------------------------------------------------------------------------------- /src/hooks/use-scene-update.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useEffect } from 'react'; 3 | 4 | /** 5 | * Subscribe to scene update. 6 | * 7 | * @param scene - Scene 8 | * @param callback - Update callback 9 | * @param depends - Callback dependencies 10 | */ 11 | export function useSceneUpdate( 12 | scene: Phaser.Scene, 13 | callback: () => void, 14 | depends: any[], 15 | ) { 16 | useEffect(() => { 17 | callback(); 18 | 19 | scene.events.on('update', callback); 20 | 21 | return () => { 22 | scene.events.off('update', callback); 23 | }; 24 | }, depends); 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/use-scene.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | import { useMemo } from 'react'; 3 | import { useGame } from './use-game'; 4 | 5 | /** 6 | * Get scene by key. 7 | * 8 | * @param key - Scene key 9 | */ 10 | export function useScene(key: string): T { 11 | const game = useGame(); 12 | 13 | return useMemo(() => game.scene.getScene(key), [key]); 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/use-texture.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useGame } from './use-game'; 3 | import type { HTMLTextureElement } from '../types/texture'; 4 | 5 | /** 6 | * Get texture source image. 7 | * 8 | * @param key - Texture key 9 | */ 10 | export function useTexture(key: string): string | null { 11 | const game = useGame(); 12 | 13 | return useMemo(() => { 14 | const texture = game.textures.get(key); 15 | const image = texture.getSourceImage() as HTMLTextureElement; 16 | 17 | return image.originBlob 18 | ? URL.createObjectURL(image.originBlob) 19 | : null; 20 | }, [key]); 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './adapter'; 2 | 3 | export * from './hooks'; 4 | export * from './render'; 5 | export * from './utils'; 6 | export * from './components'; 7 | -------------------------------------------------------------------------------- /src/render.tsx: -------------------------------------------------------------------------------- 1 | import Phaser from 'phaser'; 2 | import React, { useEffect } from 'react'; 3 | import type { Root } from 'react-dom/client'; 4 | import { createRoot } from 'react-dom/client'; 5 | 6 | import { SceneProvider } from './context/scene'; 7 | 8 | export class Interface { 9 | readonly container: HTMLDivElement; 10 | 11 | readonly root: Root; 12 | 13 | readonly scene: Phaser.Scene; 14 | 15 | constructor(scene: Phaser.Scene) { 16 | const parent = scene.game.canvas.parentElement; 17 | 18 | if (!parent) { 19 | throw Error('Undefined canvas parent element'); 20 | } 21 | 22 | if (scene.interface) { 23 | console.warn('Scene already had an existing interface'); 24 | scene.interface.destroy(); 25 | } 26 | 27 | this.container = document.createElement('div'); 28 | this.root = createRoot(this.container); 29 | this.scene = scene; 30 | this.scene.interface = this; 31 | 32 | this.configureContainer(); 33 | this.setInteractive(false); 34 | 35 | parent.style.position = 'relative'; 36 | parent.append(this.container); 37 | 38 | this.scene.events.on('shutdown', () => { 39 | this.destroy(); 40 | }); 41 | } 42 | 43 | public setInteractive(state: boolean) { 44 | this.container.style.pointerEvents = state ? 'all' : 'none'; 45 | this.container.style.userSelect = state ? 'all' : 'none'; 46 | } 47 | 48 | // @ts-ignore 49 | public render

(Component: React.FC

, props: P = {}) { 50 | const { scene } = this; 51 | 52 | if (Component.displayName) { 53 | this.container.setAttribute('data-component', Component.displayName); 54 | } 55 | 56 | const Middleware: React.FC = () => { 57 | useEffect(() => { 58 | scene.events.emit(Phaser.Interface.Events.MOUNT); 59 | }, []); 60 | 61 | return ; 62 | }; 63 | 64 | this.root.render( 65 | 66 | 67 | , 68 | ); 69 | } 70 | 71 | public destroy() { 72 | this.scene.events.emit(Phaser.Interface.Events.UNMOUNT); 73 | 74 | delete this.scene.interface; 75 | 76 | this.root.unmount(); 77 | this.container.remove(); 78 | } 79 | 80 | private configureContainer() { 81 | this.container.className = 'phaser-scene-interface'; 82 | 83 | this.container.style.position = 'absolute'; 84 | 85 | this.container.style.left = '0'; 86 | this.container.style.right = '0'; 87 | this.container.style.top = '0'; 88 | this.container.style.bottom = '0'; 89 | } 90 | } 91 | 92 | declare global { 93 | namespace Phaser { 94 | interface Scene { 95 | interface?: Interface 96 | } 97 | namespace Interface { 98 | enum Events { 99 | MOUNT = 'interface_mount', 100 | UNMOUNT = 'interface_unmount', 101 | } 102 | } 103 | } 104 | } 105 | 106 | // Global share Phaser enum 107 | Phaser.Interface = { 108 | Events: { 109 | // @ts-ignore 110 | MOUNT: 'interface_mount', 111 | // @ts-ignore 112 | UNMOUNT: 'interface_unmount', 113 | }, 114 | }; 115 | -------------------------------------------------------------------------------- /src/types/relative-position.ts: -------------------------------------------------------------------------------- 1 | import type Phaser from 'phaser'; 2 | 3 | export type RelativePositionProps = { 4 | x: number 5 | y: number 6 | camera?: Phaser.Cameras.Scene2D.Camera 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/relative-scale.ts: -------------------------------------------------------------------------------- 1 | export type RelativeScaleProps = { 2 | target: number 3 | min?: number 4 | max?: number 5 | round?: boolean 6 | }; 7 | -------------------------------------------------------------------------------- /src/types/texture.ts: -------------------------------------------------------------------------------- 1 | export type HTMLTextureElement = HTMLImageElement & { 2 | originBlob: Blob 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/get-modified.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get modified array between current and target value. 3 | * 4 | * @param current - Current array 5 | * @param target - New array 6 | * @param keys - Keys to compare 7 | */ 8 | export function getModifiedArray( 9 | current: T[], 10 | target: T[], 11 | keys?: (keyof T)[], 12 | ) { 13 | if (!target) { 14 | return current; 15 | } 16 | 17 | if (!current) { 18 | return target; 19 | } 20 | 21 | if (current.length !== target.length) { 22 | return target; 23 | } 24 | 25 | const keysToCompare = keys ?? <(keyof T)[]>Object.keys(current[0]); 26 | 27 | for (let i = 0; i < current.length; i++) { 28 | for (const key of keysToCompare) { 29 | if (current[i][key] !== target[i][key]) { 30 | return target; 31 | } 32 | } 33 | } 34 | 35 | return current; 36 | } 37 | 38 | /** 39 | * Return callback for safe update state. 40 | * 41 | * @param target - New array 42 | * @param keys - Keys to compare 43 | */ 44 | export function ifModifiedArray(value: T[], keys?: (keyof T)[]) { 45 | return (currentValue: T[]) => getModifiedArray(currentValue, value, keys); 46 | } 47 | 48 | /** 49 | * Get modified object between current and target value. 50 | * 51 | * @param current - Current object 52 | * @param target - New object 53 | * @param keys - Keys to compare 54 | */ 55 | export function getModifiedObject( 56 | current: T, 57 | target: T, 58 | keys?: (keyof T)[], 59 | ) { 60 | if (!target) { 61 | return current; 62 | } 63 | 64 | if (!current) { 65 | return target; 66 | } 67 | 68 | const keysToCompare = keys ?? <(keyof T)[]>Object.keys(current); 69 | 70 | for (const key of keysToCompare) { 71 | if (current[key] !== target[key]) { 72 | return target; 73 | } 74 | } 75 | 76 | return current; 77 | } 78 | 79 | /** 80 | * Return callback for safe update state. 81 | * 82 | * @param target - New object 83 | * @param keys - Keys to compare 84 | */ 85 | export function ifModifiedObject(value: T, keys?: (keyof T)[]) { 86 | return (currentValue: T) => getModifiedObject(currentValue, value, keys); 87 | } 88 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-modified'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "es6", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "declaration": true 13 | }, 14 | "include": ["./src/**/*"] 15 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | resolve: { 5 | extensions: [".ts", ".tsx"], 6 | }, 7 | entry: [path.resolve(__dirname, "src/index.ts")], 8 | output: { 9 | path: path.resolve(__dirname, "dist"), 10 | filename: "index.js", 11 | libraryTarget: "commonjs2", 12 | clean: true, 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | exclude: /node_modules/, 19 | use: ["babel-loader", "ts-loader"], 20 | }, 21 | ], 22 | }, 23 | optimization: { 24 | minimize: true, 25 | }, 26 | externals: { 27 | phaser: "phaser", 28 | react: "react", 29 | "react-dom": "react-dom", 30 | "react-dom/client": "react-dom/client", 31 | }, 32 | }; 33 | --------------------------------------------------------------------------------