├── .eslintrc.cjs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .node-version ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── components │ ├── AudioEngine.tsx │ ├── BitmapImage.test.tsx │ ├── BitmapImage.tsx │ ├── BitmapSprite.test.tsx │ ├── BitmapSprite.tsx │ ├── BitmapText.test.tsx │ ├── BitmapText.tsx │ ├── Box.test.tsx │ ├── Box.tsx │ ├── Camera.test.tsx │ ├── Camera.tsx │ ├── Circle.test.tsx │ ├── Circle.tsx │ ├── CollisionBox.tsx │ ├── Device.test.tsx │ ├── Device.tsx │ ├── Engine.tsx │ ├── FrameRate.tsx │ ├── Gamepad.tsx │ ├── Keyboard.tsx │ ├── Motion.tsx │ ├── Node.test.tsx │ ├── Node.tsx │ ├── Orientation.tsx │ ├── ParticleEngine.test.tsx │ ├── ParticleEngine.tsx │ ├── Particles.tsx │ ├── Physics.tsx │ ├── PhysicsBox.tsx │ ├── PhysicsCircle.tsx │ ├── Pointer.tsx │ ├── SpriteSet.tsx │ ├── Tilemap.tsx │ ├── VectorSprite.tsx │ ├── Viewport.tsx │ ├── VirtualInput.tsx │ ├── World.tsx │ └── index.ts ├── constants.ts ├── context │ ├── DeviceContext.ts │ ├── EngineContext.ts │ ├── GamepadContext.ts │ ├── KeyboardContext.ts │ ├── MotionContext.ts │ ├── NodeContext.ts │ ├── OrientationContext.ts │ ├── PhysicsContext.ts │ ├── PointerContext.ts │ ├── VirtualInputContext.ts │ ├── WorldContext.ts │ └── index.ts ├── hooks │ ├── index.ts │ ├── use8DirectionMovement.ts │ ├── useAudio.ts │ ├── useAudioEngine.ts │ ├── useBaseStyleProperties.ts │ ├── useBoxCollider.ts │ ├── useCollider.ts │ ├── useCollision.test.tsx │ ├── useCollision.ts │ ├── useDebug.ts │ ├── useDevice.test.tsx │ ├── useDevice.ts │ ├── useDynamicProperty.test.ts │ ├── useDynamicProperty.ts │ ├── useElement.test.tsx │ ├── useElement.ts │ ├── useEventHandler.test.tsx │ ├── useEventHandler.ts │ ├── useEventListeners.test.tsx │ ├── useEventListeners.ts │ ├── useFlash.test.ts │ ├── useFlash.ts │ ├── useGamepad.test.ts │ ├── useGamepad.ts │ ├── useGamepadAxisMap.ts │ ├── useGamepadButtonMap.test.ts │ ├── useGamepadButtonMap.ts │ ├── useIntegerPosition.test.ts │ ├── useIntegerPosition.ts │ ├── useKeySequence.ts │ ├── useKeyboard.test.ts │ ├── useKeyboard.ts │ ├── useKeyboardMap.test.ts │ ├── useKeyboardMap.ts │ ├── useLogMount.test.ts │ ├── useLogMount.ts │ ├── useMergeProperty.test.ts │ ├── useMergeProperty.ts │ ├── useMotion.test.ts │ ├── useMotion.ts │ ├── useNode.ts │ ├── useOffsetPosition.test.ts │ ├── useOffsetPosition.ts │ ├── useOrientation.ts │ ├── useOverlap.test.tsx │ ├── useOverlap.ts │ ├── useParticles.ts │ ├── usePhysics.ts │ ├── usePhysicsEngine.ts │ ├── usePlatformMovement.ts │ ├── usePointer.ts │ ├── usePolygonCollider.ts │ ├── usePosition.test.tsx │ ├── usePosition.ts │ ├── usePostCollisions.ts │ ├── useProperty.test.ts │ ├── useProperty.ts │ ├── usePropertyListen.test.ts │ ├── usePropertyListen.ts │ ├── useRender.ts │ ├── useSequence.test.ts │ ├── useSequence.ts │ ├── useShaker.test.tsx │ ├── useShaker.ts │ ├── useSpeech.ts │ ├── useSpriteSet.ts │ ├── useStateMachine.test.tsx │ ├── useStateMachine.ts │ ├── useSwipe.ts │ ├── useSync.test.ts │ ├── useSync.ts │ ├── useTicker.test.ts │ ├── useTicker.ts │ ├── useUpdate.test.ts │ ├── useUpdate.ts │ ├── useViewport.ts │ ├── useVirtualAction.test.ts │ ├── useVirtualAction.ts │ ├── useVirtualInput.test.ts │ ├── useVirtualInput.ts │ ├── useVisible.ts │ └── useWorld.ts ├── index.ts ├── test │ ├── index.ts │ ├── mockDeviceMotionEvent.ts │ ├── mockDeviceOrientationEvent.ts │ ├── mockGamepads.ts │ ├── mockResizeObserver.ts │ ├── render.tsx │ └── setup.ts ├── types.ts └── utils │ ├── BaseParticle.ts │ ├── Body.ts │ ├── DynamicProperty.test.ts │ ├── DynamicProperty.ts │ ├── EventTarget.test.ts │ ├── EventTarget.ts │ ├── MapSet.test.ts │ ├── MapSet.ts │ ├── MergeProperty.test.ts │ ├── MergeProperty.ts │ ├── ObjectState.test.ts │ ├── ObjectState.ts │ ├── Particle.ts │ ├── SlidingWindow.test.ts │ ├── SlidingWindow.ts │ ├── StateMachine.test.ts │ ├── StateMachine.ts │ ├── Validator.ts │ ├── VariableProperty.test.ts │ ├── VariableProperty.ts │ ├── index.ts │ ├── utils.test.ts │ └── utils.ts ├── tsconfig.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Run tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: 'npm' 23 | - name: Install dependencies 24 | run: npm ci 25 | - name: Run tests 26 | run: npm run test-ci 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | coverage 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v18.19.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Craig Stephen Smith. 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 | # @overreact/engine 2 | 3 | Overreact is a browser-based game engine, built on top of React, that renders to the DOM, and is styled with plain old CSS! 4 | 5 | ⚛️ Built on top of React, allowing you to utilize the same patterns and architecture you're familiar with. 6 | 7 | ⚡️ Share components with the rest of your app. Use your design system components in your game! 8 | 9 | 💅 Style your game elements using regular CSS. Or bring in your favorite CSS frameworks, such as tailwind. 10 | 11 | Full docs and getting started guide at: https://overreactjs.github.io/ 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@overreact/engine", 3 | "version": "2.0.0", 4 | "type": "module", 5 | "main": "./dist/engine.umd.cjs", 6 | "module": "./dist/engine.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/engine.js", 12 | "require": "./dist/engine.umd.cjs" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "description": "Browser-based game engine, built on top of React.", 19 | "author": "Craig Smith", 20 | "license": "MIT", 21 | "homepage": "https://overreactjs.github.io", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/overreactjs/engine.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/overreactjs/engine/issues" 28 | }, 29 | "scripts": { 30 | "dev": "vite", 31 | "build": "tsc && vite build", 32 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 33 | "preview": "vite preview", 34 | "test": "vitest", 35 | "test-ci": "vitest --coverage run" 36 | }, 37 | "dependencies": { 38 | "detect-collisions": "^9.2.2", 39 | "matter-js": "^0.19.0", 40 | "react-merge-refs": "^3.0.2" 41 | }, 42 | "peerDependencies": { 43 | "react": "^18.0.0 || ^19.0.0" 44 | }, 45 | "devDependencies": { 46 | "@testing-library/jest-dom": "^6.4.2", 47 | "@testing-library/react": "^14.2.1", 48 | "@types/matter-js": "^0.19.5", 49 | "@types/node": "^20.10.5", 50 | "@types/react": "^18.2.45", 51 | "@typescript-eslint/eslint-plugin": "^6.14.0", 52 | "@typescript-eslint/parser": "^6.14.0", 53 | "@vitejs/plugin-react": "^4.2.1", 54 | "@vitest/coverage-v8": "^1.4.0", 55 | "eslint": "^8.55.0", 56 | "eslint-plugin-react-hooks": "^4.6.0", 57 | "eslint-plugin-react-refresh": "^0.4.5", 58 | "jsdom": "^24.0.0", 59 | "typescript": "^5.2.2", 60 | "vite": "^5.0.8", 61 | "vite-plugin-dts": "^3.6.4", 62 | "vitest": "^1.4.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/BitmapImage.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { render } from '../test'; 3 | import { BitmapImage } from './BitmapImage'; 4 | import { BitmapAsset, Position, Size, VariableProperty } from '..'; 5 | 6 | const IMAGE: BitmapAsset = { 7 | url: 'test.url', 8 | size: [32, 64], 9 | }; 10 | 11 | describe('BitmapImage', () => { 12 | const renderSubject = (props: Partial>) => { 13 | return render(); 14 | }; 15 | 16 | const getSubject = () => { 17 | return document.body.getElementsByTagName('div')[1]; 18 | }; 19 | 20 | describe('position and size', () => { 21 | it('sets the position and size of the image', () => { 22 | renderSubject({ pos: [100, 50] }); 23 | expect(getSubject()).toHaveStyle({ 24 | transform: 'translate(100px, 50px) rotate(0deg) scale(1, 1)', 25 | width: '48px', 26 | height: '96px', 27 | }); 28 | }); 29 | }); 30 | 31 | describe('rotation', () => { 32 | it('rotates the image', () => { 33 | renderSubject({ angle: 42 }); 34 | expect(getSubject()).toHaveStyle({ 35 | transform: 'translate(0px, 0px) rotate(42deg) scale(1, 1)', 36 | }); 37 | }); 38 | }); 39 | 40 | describe('flip', () => { 41 | it('flips the image', () => { 42 | renderSubject({ flip: true }); 43 | expect(getSubject()).toHaveStyle({ 44 | transform: 'translate(0px, 0px) rotate(0deg) scale(-1, 1)', 45 | }); 46 | }); 47 | }); 48 | 49 | describe('scale', () => { 50 | it('scales the image', () => { 51 | renderSubject({ scale: 0.5 }); 52 | expect(getSubject()).toHaveStyle({ 53 | transform: 'translate(0px, 0px) rotate(0deg) scale(0.5, 0.5)', 54 | }); 55 | }); 56 | }); 57 | 58 | describe('background image', () => { 59 | it('sets the background image', () => { 60 | renderSubject({}); 61 | expect(getSubject()).toHaveStyle({ 62 | 'background-image': "url(test.url)", 63 | }); 64 | }); 65 | 66 | it('validates the image prop', () => { 67 | const image = new VariableProperty(IMAGE); 68 | renderSubject({ image }); 69 | 70 | expect(image.invalidated).toBe(false); 71 | }); 72 | }); 73 | 74 | describe('background position', () => { 75 | it('sets the background position', () => { 76 | renderSubject({ offset: [10, 5] }); 77 | expect(getSubject()).toHaveStyle({ 78 | 'background-position': '-10px -5px', 79 | }); 80 | }); 81 | 82 | describe('when the image scale factor is given', () => { 83 | it('stretches the image', () => { 84 | renderSubject({ offset: [10, 5], factor: [2, 3] }); 85 | expect(getSubject()).toHaveStyle({ 86 | 'background-position': '-20px -15px', 87 | }); 88 | }); 89 | }); 90 | 91 | it('validates the offset and factor props', () => { 92 | const offset = new VariableProperty([100, 50]); 93 | const factor = new VariableProperty([2, 3]); 94 | renderSubject({ offset, factor }); 95 | 96 | expect(offset.invalidated).toBe(false); 97 | expect(factor.invalidated).toBe(false); 98 | }); 99 | }); 100 | 101 | describe('background size', () => { 102 | it('sets the background size', () => { 103 | renderSubject({}); 104 | expect(getSubject()).toHaveStyle({ 105 | 'background-size': '32px 64px', 106 | }); 107 | }); 108 | 109 | describe('when the image scale factor is given', () => { 110 | it('stretches the image', () => { 111 | renderSubject({ factor: [2, 3] }); 112 | expect(getSubject()).toHaveStyle({ 113 | 'background-size': '64px 192px', 114 | }); 115 | }); 116 | }); 117 | 118 | it('validates the image and factor props', () => { 119 | const image = new VariableProperty(IMAGE); 120 | const factor = new VariableProperty([2, 3]); 121 | renderSubject({ image, factor }); 122 | 123 | expect(image.invalidated).toBe(false); 124 | expect(factor.invalidated).toBe(false); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/components/BitmapImage.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import { Node } from '../components'; 3 | import { useBaseStyleProperties, useProperty, useRender } from "../hooks"; 4 | import { UseElementResult, useElement } from "../hooks/useElement"; 5 | import { BitmapAsset, BaseStyleProps, Position, Prop, Size } from "../types"; 6 | 7 | export type BitmapImageProps = BaseStyleProps & { 8 | className?: string; 9 | style?: CSSProperties; 10 | element?: UseElementResult; 11 | image: Prop; 12 | offset: Prop; 13 | factor?: Prop; 14 | } 15 | 16 | /** 17 | * BitmapImage 18 | * ----------- 19 | * 20 | * Render part of a bitmap image. The image is shown an a background image, sized and cropped as 21 | * required. 22 | */ 23 | export const BitmapImage: React.FC = ({ className, style, ...props }) => { 24 | const element = useElement(props.element); 25 | 26 | const base = useBaseStyleProperties(props); 27 | const image = useProperty(props.image); 28 | const offset = useProperty(props.offset); 29 | const factor = useProperty(props.factor || [1, 1]); 30 | 31 | useRender(() => { 32 | element.setBaseStyles(base); 33 | 34 | if (image.invalidated) { 35 | element.setLegacyStyle('backgroundImage', `url(${image.current.url})`); 36 | image.invalidated = false; 37 | } 38 | 39 | if (offset.invalidated || factor.invalidated) { 40 | const x = -offset.current[0] * factor.current[0]; 41 | const y = -offset.current[1] * factor.current[1]; 42 | element.setLegacyStyle('backgroundPosition', `${x}px ${y}px`); 43 | offset.invalidated = false; 44 | factor.invalidated = false; 45 | } 46 | 47 | if (image.invalidated || factor.invalidated) { 48 | const width = image.current.size[0] * factor.current[0]; 49 | const height = image.current.size[1] * factor.current[1]; 50 | element.setLegacyStyle('backgroundSize', `${width}px ${height}px`); 51 | image.invalidated = false; 52 | factor.invalidated = false; 53 | } 54 | }); 55 | 56 | return ( 57 | 58 |
63 | 64 | ) 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/BitmapSprite.tsx: -------------------------------------------------------------------------------- 1 | import { useElement, usePosition, useProperty, useSpriteSet, useUpdate, useVisible } from "../hooks"; 2 | import { BitmapSpriteAsset, BaseStyleProps, Position, Size } from "../types"; 3 | import { BitmapImage } from "./BitmapImage"; 4 | 5 | export type BitmapSpriteProps = BaseStyleProps & { 6 | name?: string; 7 | sprite: BitmapSpriteAsset; 8 | repeat?: boolean; 9 | }; 10 | 11 | /** 12 | * BitmapSprite 13 | * ------------ 14 | * 15 | * Animate a bitmap sprite by changing the offset of the background position, based on the number 16 | * of frames and the frame rate. 17 | */ 18 | export const BitmapSprite: React.FC = ({ sprite, ...props }) => { 19 | const element = useElement(); 20 | 21 | const pos = usePosition(props.pos); 22 | const size = useProperty(props.size); 23 | const flip = useProperty(props.flip || false); 24 | const angle = useProperty(props.angle || 0); 25 | const scale = useProperty(props.scale || 1); 26 | const visible = useVisible(props.visible); 27 | 28 | const repeat = useProperty(props.repeat === undefined ? true : props.repeat); 29 | const frameWidth = useProperty(sprite.size[0] / sprite.count); 30 | const frameIndex = useProperty(0); 31 | const frameTime = useProperty(0); 32 | const offset = useProperty([0, 0]); 33 | const factor = useProperty([size.current[0] / frameWidth.current, size.current[1] / sprite.size[1]]); 34 | 35 | useSpriteSet(props.name, element.ref, () => { 36 | frameIndex.current = 0; 37 | }); 38 | 39 | useUpdate((delta) => { 40 | frameTime.current += delta; 41 | 42 | const frameMillis = 1000 / sprite.rate; 43 | const frameIncrement = Math.floor(frameTime.current / frameMillis); 44 | 45 | frameIndex.current = frameIndex.current + frameIncrement; 46 | 47 | if (repeat.current) { 48 | frameIndex.current = frameIndex.current % sprite.count; 49 | } 50 | 51 | frameIndex.current = Math.min(sprite.count - 1, frameIndex.current); 52 | frameTime.current -= frameIncrement * frameMillis; 53 | 54 | if (element.ref.current?.style.display !== 'none') { 55 | offset.current[0] = frameIndex.current * frameWidth.current; 56 | } 57 | }); 58 | 59 | return ( 60 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/BitmapText.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { render } from '../test'; 3 | import { BitmapText } from './BitmapText'; 4 | import { BitmapFontFace } from '..'; 5 | 6 | const FONT: BitmapFontFace = { 7 | image: { 8 | url: 'test.url', 9 | size: [32, 32], 10 | }, 11 | glyphs: 'ABCDEFGH01234567', 12 | glyphSize: [8, 8], 13 | }; 14 | 15 | describe('BitmapText', () => { 16 | const renderSubject = (props: Partial> = {}) => { 17 | return render(); 18 | }; 19 | 20 | const getContainer = () => document.body.getElementsByTagName('div')[1]; 21 | const getCharacter = (i: number) => document.body.getElementsByTagName('div')[i + 2]; 22 | 23 | it('automatically sizes the container based on the text', () => { 24 | renderSubject(); 25 | expect(getContainer()).toHaveStyle({ 26 | width: '32px', 27 | height: '8px', 28 | }); 29 | }); 30 | 31 | it('generates one image per character', () => { 32 | renderSubject(); 33 | 34 | const size = { width: '8px', height: '8px' }; 35 | expect(getCharacter(0)).toHaveStyle({ ...size, 'background-position': '0px 0px' }); 36 | expect(getCharacter(1)).toHaveStyle({ ...size, 'background-position': '-8px 0px' }); 37 | expect(getCharacter(2)).toHaveStyle({ ...size, 'background-position': '-16px 0px' }); 38 | expect(getCharacter(3)).toHaveStyle({ ...size, 'background-position': '-24px 0px' }); 39 | expect(getCharacter(4)).toBeUndefined(); 40 | }); 41 | 42 | describe('when the glyph is on the first row of the image', () => { 43 | it('sets the background position', () => { 44 | renderSubject({ text: 'C' }); 45 | expect(getCharacter(0)).toHaveStyle({ 'background-position': '-16px 0px' }); 46 | }); 47 | }); 48 | 49 | describe('when the glyph is on the second row of the image', () => { 50 | it('sets the background position', () => { 51 | renderSubject({ text: 'G' }); 52 | expect(getCharacter(0)).toHaveStyle({ 'background-position': '-16px -8px' }); 53 | }); 54 | }); 55 | 56 | describe('when the glyph is on the third row of the image', () => { 57 | it('sets the background position', () => { 58 | renderSubject({ text: '2' }); 59 | expect(getCharacter(0)).toHaveStyle({ 'background-position': '-16px -16px' }); 60 | }); 61 | }); 62 | 63 | describe('when the glyph is not in the font description', () => { 64 | it('sets the background position that results in an empty image', () => { 65 | renderSubject({ text: 'Z' }); 66 | expect(getCharacter(0)).toHaveStyle({ 'background-position': '8px 8px' }); 67 | }); 68 | }) 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /src/components/Box.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { render } from '../test'; 4 | import { Box } from './Box'; 5 | 6 | describe('Box', () => { 7 | it('uses the given dimensions', () => { 8 | render(test); 9 | 10 | const box = screen.getByText('test'); 11 | expect(box).toHaveStyle({ 12 | transform: 'translate(0px, 0px) rotate(0deg) scale(1, 1)', 13 | width: '20px', 14 | height: '10px', 15 | }); 16 | }); 17 | 18 | it('uses the given angle', () => { 19 | render(test); 20 | 21 | const box = screen.getByText('test'); 22 | expect(box).toHaveStyle({ 23 | transform: 'translate(0px, 0px) rotate(42deg) scale(1, 1)', 24 | width: '20px', 25 | height: '10px', 26 | }); 27 | }); 28 | 29 | it('uses the given background color', () => { 30 | render(test); 31 | 32 | const box = screen.getByText('test'); 33 | expect(box).toHaveStyle({ 34 | 'background-color': 'rgb(255, 69, 0)', 35 | transform: 'translate(0px, 0px) rotate(0deg) scale(1, 1)', 36 | width: '20px', 37 | height: '10px', 38 | }); 39 | }); 40 | 41 | describe('when no background color is given', () => { 42 | it('defaults to transparent', () => { 43 | render(test); 44 | 45 | const box = screen.getByText('test'); 46 | expect(box).toHaveStyle({ 47 | 'background-color': 'rgba(0, 0, 0, 0)', 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/Box.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { useBaseStyleProperties, useElement, useProperty, useRender } from "../hooks"; 3 | import { UseElementResult } from "../hooks/useElement"; 4 | import { BaseStyleProps, Prop } from "../types"; 5 | import { Node } from "./Node"; 6 | 7 | export type BoxProps = BaseStyleProps & { 8 | className?: string; 9 | style?: CSSProperties; 10 | element?: UseElementResult; 11 | color?: Prop; 12 | children?: React.ReactNode; 13 | }; 14 | 15 | /** 16 | * Box 17 | * --- 18 | * 19 | * A rectangle (just a div) with a position, size, angle, and a background color. It can be used to 20 | * group elements that should be moved as though one. 21 | */ 22 | export const Box: React.FC = ({ className, style, ...props }) => { 23 | const element = useElement(props.element); 24 | 25 | const base = useBaseStyleProperties(props); 26 | const color = useProperty(props.color || 'transparent'); 27 | 28 | useRender(() => { 29 | element.setBaseStyles(base); 30 | 31 | if (color.invalidated) { 32 | element.setStyle('background-color', color.current); 33 | color.invalidated = false; 34 | } 35 | }); 36 | 37 | return ( 38 | 39 |
44 | {props.children} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Camera.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { screen } from "@testing-library/react"; 3 | import { nextFrame, render } from "../test"; 4 | import { Position, Prop } from "../types"; 5 | import { Camera } from "./Camera"; 6 | import { Node } from "./Node"; 7 | import { Viewport } from "./Viewport"; 8 | 9 | describe('Camera', () => { 10 | const renderSubject = (props?: { pos?: Prop } & Partial>) => { 11 | const { pos, ...rest } = props || {}; 12 | 13 | render( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | const getViewport = () => screen.getByTestId('viewport'); 23 | 24 | it('defaults to a position of [0, 0], with a scale factor of 1', () => { 25 | renderSubject(); 26 | nextFrame(); 27 | expect(getViewport()).toHaveStyle({ 28 | transform: 'translate(0px, 0px) scale(1)', 29 | }); 30 | }); 31 | 32 | it('scrolls the viewport on both axes, by default', () => { 33 | renderSubject({ pos: [100, 50], axis: 'xy' }); 34 | nextFrame(); 35 | expect(getViewport()).toHaveStyle({ 36 | transform: 'translate(-100px, -50px) scale(1)', 37 | }); 38 | }); 39 | 40 | describe('when scroll axis is "xy"', () => { 41 | it('scrolls the viewport on both axes', () => { 42 | renderSubject({ pos: [100, 50], axis: 'xy' }); 43 | nextFrame(); 44 | expect(getViewport()).toHaveStyle({ 45 | transform: 'translate(-100px, -50px) scale(1)', 46 | }); 47 | }); 48 | }); 49 | 50 | describe('when scroll axis is "x"', () => { 51 | it('only scrolls the viewport on the x axis', () => { 52 | renderSubject({ pos: [100, 50], axis: 'x' }); 53 | nextFrame(); 54 | expect(getViewport()).toHaveStyle({ 55 | transform: 'translate(-100px, 0px) scale(1)', 56 | }); 57 | }); 58 | }); 59 | 60 | describe('when scroll axis is "y"', () => { 61 | it('only scrolls the viewport on the y axis', () => { 62 | renderSubject({ pos: [100, 50], axis: 'y' }); 63 | nextFrame(); 64 | expect(getViewport()).toHaveStyle({ 65 | transform: 'translate(0px, -50px) scale(1)', 66 | }); 67 | }); 68 | }); 69 | 70 | describe('smooth scrolling', () => { 71 | it('pans the camera position smoothly', () => { 72 | renderSubject({ pos: [100, 50], axis: 'xy', smooth: true }); 73 | nextFrame(); 74 | expect(getViewport()).toHaveStyle({ transform: 'translate(-7px, -4px) scale(1)' }); 75 | nextFrame(); 76 | expect(getViewport()).toHaveStyle({ transform: 'translate(-14px, -7px) scale(1)' }); 77 | nextFrame(); 78 | expect(getViewport()).toHaveStyle({ transform: 'translate(-20px, -10px) scale(1)' }); 79 | nextFrame(); 80 | expect(getViewport()).toHaveStyle({ transform: 'translate(-26px, -13px) scale(1)' }); 81 | nextFrame(); 82 | expect(getViewport()).toHaveStyle({ transform: 'translate(-31px, -16px) scale(1)' }); 83 | nextFrame(); 84 | expect(getViewport()).toHaveStyle({ transform: 'translate(-36px, -18px) scale(1)' }); 85 | nextFrame(); 86 | expect(getViewport()).toHaveStyle({ transform: 'translate(-41px, -20px) scale(1)' }); 87 | nextFrame(); 88 | expect(getViewport()).toHaveStyle({ transform: 'translate(-45px, -22px) scale(1)' }); 89 | nextFrame(); 90 | expect(getViewport()).toHaveStyle({ transform: 'translate(-49px, -24px) scale(1)' }); 91 | nextFrame(); 92 | expect(getViewport()).toHaveStyle({ transform: 'translate(-53px, -26px) scale(1)' }); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/components/Camera.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { useDebug, useElement, usePosition, useRender, useUpdate, useViewport } from "../hooks"; 3 | import { CameraAxis, Position } from "../types"; 4 | import { lerp } from "../utils"; 5 | 6 | const SMOOTH_FACTOR = 0.0048; 7 | 8 | const DEBUG: CSSProperties = { 9 | display: 'none', 10 | position: 'absolute', 11 | boxSizing: 'border-box', 12 | background: '#f0f3', 13 | border: '1px solid #f0f', 14 | width: '10px', 15 | height: '10px', 16 | marginLeft: '-5px', 17 | marginTop: '-5px', 18 | }; 19 | 20 | type CameraProps = { 21 | axis?: CameraAxis; 22 | offset?: Position; 23 | smooth?: boolean; 24 | } 25 | 26 | /** 27 | * Camera 28 | * ------ 29 | * 30 | * Reports the position of the nested object up to the closest viewport. 31 | */ 32 | export const Camera: React.FC = ({ axis = 'xy', offset = [0, 0], smooth = false }) => { 33 | const element = useElement(); 34 | const debug = useDebug(); 35 | 36 | const { origin } = useViewport(); 37 | const pos = usePosition(); 38 | 39 | useUpdate((delta) => { 40 | if (origin) { 41 | const newOrigin: Position = [origin.current[0], origin.current[1]]; 42 | 43 | if (axis === 'x' || axis === 'xy') { 44 | newOrigin[0] = lerp(origin.current[0], pos.current[0] + offset[0], smooth ? SMOOTH_FACTOR * delta : 1); 45 | } 46 | if (axis === 'y' || axis === 'xy') { 47 | newOrigin[1] = lerp(origin.current[1], pos.current[1] + offset[1], smooth ? SMOOTH_FACTOR * delta : 1); 48 | } 49 | 50 | origin.current = newOrigin; 51 | } 52 | }); 53 | 54 | useRender(() => { 55 | if (debug.current) { 56 | element.setBaseStyles({ pos: origin }); 57 | } 58 | 59 | if (debug.invalidated) { 60 | element.setStyle('display', debug.current ? 'block' : 'none'); 61 | debug.invalidated = false; 62 | } 63 | }); 64 | 65 | return
; 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/Circle.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { render } from '../test'; 4 | import { Circle } from './Circle'; 5 | 6 | describe('Circle', () => { 7 | it('uses the given dimensions', () => { 8 | render(test); 9 | 10 | const box = screen.getByText('test'); 11 | expect(box).toHaveStyle({ 12 | transform: 'translate(0px, 0px)', 13 | width: '20px', 14 | height: '20px', 15 | }); 16 | }); 17 | 18 | it('uses the given background color', () => { 19 | render(test); 20 | 21 | const box = screen.getByText('test'); 22 | expect(box).toHaveStyle({ 23 | 'background-color': 'rgb(255, 69, 0)', 24 | transform: 'translate(0px, 0px)', 25 | }); 26 | }); 27 | 28 | describe('when no background color is given', () => { 29 | it('defaults to transparent', () => { 30 | render(test); 31 | 32 | const box = screen.getByText('test'); 33 | expect(box).toHaveStyle({ 34 | 'background-color': 'rgba(0, 0, 0, 0)', 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/Circle.tsx: -------------------------------------------------------------------------------- 1 | import { useElement, usePosition, useProperty, useRender } from "../hooks"; 2 | import { Position, Prop, Size } from "../types"; 3 | import { Node } from "./Node"; 4 | 5 | type CircleProps = { 6 | pos?: Prop; 7 | size: Prop; 8 | color?: Prop; 9 | children?: React.ReactNode; 10 | className?: string; 11 | }; 12 | 13 | /** 14 | * Circle 15 | * ------ 16 | * 17 | * A fully rounded oval (just a div) with a position, size, angle, and a background color. It can 18 | * be used to group elements that should be moved as though one. 19 | */ 20 | export const Circle: React.FC = ({ className, ...props }) => { 21 | const element = useElement(); 22 | 23 | const pos = usePosition(props.pos); 24 | const size = useProperty(props.size); 25 | const color = useProperty(props.color || 'transparent'); 26 | 27 | useRender(() => { 28 | element.setBaseStyles({ pos, size }); 29 | 30 | if (color.invalidated) { 31 | element.setStyle('background-color', color.current); 32 | color.invalidated = false; 33 | } 34 | }); 35 | 36 | return ( 37 | 38 |
39 | {props.children} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/CollisionBox.tsx: -------------------------------------------------------------------------------- 1 | import { usePosition, useProperty, useBoxCollider } from "../hooks"; 2 | import { Prop, Position, Size, CollisionTag } from "../types"; 3 | 4 | // const DEBUG: CSSProperties = { 5 | // display: 'none', 6 | // position: 'absolute', 7 | // boxSizing: 'border-box', 8 | // background: '#0ff3', 9 | // border: '1px solid #0ff', 10 | // }; 11 | 12 | type CollisionBoxProps = { 13 | id?: string; 14 | pos?: Prop; 15 | size: Prop; 16 | tags?: Prop; 17 | active?: Prop; 18 | entity?: unknown; 19 | } 20 | 21 | /** 22 | * CollisionBox 23 | * ------------ 24 | * 25 | * Register a box-shaped collider that will report collisions and overlaps with other colliders 26 | */ 27 | export const CollisionBox: React.FC = ({ id, entity, ...props }) => { 28 | // const element = useElement(); 29 | // const debug = useDebug(); 30 | 31 | const pos = usePosition(props.pos); 32 | const size = useProperty(props.size); 33 | const tags = useProperty(props.tags || []); 34 | const active = useProperty(props.active !== undefined ? props.active : true); 35 | 36 | useBoxCollider(id, active, tags, pos, size, entity); 37 | 38 | // useRender(() => { 39 | // element.setBaseStyles({ pos, size }); 40 | 41 | // if (debug.invalidated) { 42 | // element.setStyle('display', debug.current ? 'block' : 'none'); 43 | // debug.invalidated = false; 44 | // } 45 | // }); 46 | 47 | return null; //
; 48 | } -------------------------------------------------------------------------------- /src/components/Device.test.tsx: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, Mock } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { useSync } from '../hooks'; 4 | import { mockDeviceMotionEvent, mockDeviceOrientationEvent, mockResizeObserver, render } from '../test'; 5 | import { DeviceContext } from '../context'; 6 | import { useContext } from 'react'; 7 | import { Device } from './Device'; 8 | 9 | const TextFixture = () => { 10 | const device = useContext(DeviceContext); 11 | const size = useSync(() => [...device.size.current]); 12 | return
{`[${size[0]}, ${size[1]}]`}
; 13 | }; 14 | 15 | describe('Device', () => { 16 | describe('device size', () => { 17 | let observe: Mock; 18 | let requestMotionPermission: Mock; 19 | let requestOrientationPermission: Mock; 20 | 21 | beforeEach(() => { 22 | observe = mockResizeObserver(256, 192); 23 | requestMotionPermission = mockDeviceMotionEvent(); 24 | requestOrientationPermission = mockDeviceOrientationEvent(); 25 | }); 26 | 27 | it('sets up a resize observer ', () => { 28 | render( 29 | 30 | 31 | 32 | ); 33 | 34 | expect(observe).toHaveBeenCalledOnce(); 35 | expect(screen.getByText('[256, 192]')).toBeDefined(); 36 | }); 37 | 38 | it('requests permissions for device orientation events', () => { 39 | render(foo); 40 | expect(requestOrientationPermission).toHaveBeenCalledOnce(); 41 | }); 42 | 43 | it('requests permissions for device motion events', () => { 44 | render(foo); 45 | expect(requestMotionPermission).toHaveBeenCalledOnce(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/components/Device.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react"; 2 | import { FrameRate } from "./FrameRate"; 3 | import { useElement, useProperty } from "../hooks"; 4 | import { Size } from "../types"; 5 | import { DeviceContext } from "../context"; 6 | 7 | type DeviceProps = { 8 | className?: string; 9 | children: React.ReactNode; 10 | showFPS?: boolean; 11 | bg?: string; 12 | }; 13 | 14 | /** 15 | * Device 16 | * ------ 17 | * 18 | * Try to mimic a real device, to improve the developer experience by allowing us to trigger shake 19 | * actions, adjust the device orientation, and track the dimensions. 20 | */ 21 | export const Device: React.FC = ({ 22 | className = '', 23 | children, 24 | bg = 'white', 25 | showFPS = false, 26 | }) => { 27 | const screen = useElement(); 28 | const size = useProperty([0, 0]); 29 | 30 | useEffect(() => { 31 | if (screen.ref.current) { 32 | const observer = new ResizeObserver((entries) => { 33 | size.current[0] = entries[0].contentRect.width; 34 | size.current[1] = entries[0].contentRect.height; 35 | }); 36 | 37 | observer.observe(screen.ref.current); 38 | } 39 | }, [screen.ref, size]); 40 | 41 | useEffect(() => { 42 | console.log('requesting permissions...'); 43 | const motion = DeviceMotionEvent as unknown as { requestPermission?: () => void }; 44 | const orientation = DeviceOrientationEvent as unknown as { requestPermission?: () => void }; 45 | motion.requestPermission?.(); 46 | orientation.requestPermission?.(); 47 | }, []); 48 | 49 | const context = useMemo(() => ({ 50 | size, 51 | }), [size]); 52 | 53 | return ( 54 | 55 |
56 | {children} 57 | {showFPS && } 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/Engine.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useCallback, useEffect, useLayoutEffect, MutableRefObject } from "react"; 2 | import { EngineContext, NodeContext } from "../context"; 3 | import { useNode, useProperty } from "../hooks"; 4 | import { SlidingWindow, Validator } from "../utils"; 5 | import { AudioEngine } from "./AudioEngine"; 6 | import { Gamepad } from "./Gamepad"; 7 | import { Keyboard } from "./Keyboard"; 8 | import { Motion } from "./Motion"; 9 | import { Orientation } from "./Orientation"; 10 | import { Pointer } from "./Pointer"; 11 | import { VirtualInput } from "./VirtualInput"; 12 | 13 | type EngineProps = { 14 | children: React.ReactNode; 15 | minFrameRate?: number; 16 | } 17 | 18 | /** 19 | * Engine 20 | * ------ 21 | * 22 | * Provides a game loop, ensuring updates are made at a constant frame rate. 23 | */ 24 | export const Engine: React.FC = ({ children, minFrameRate }) => { 25 | const maxDelta = 1000 / (minFrameRate || 15); 26 | 27 | const fps = useProperty(new SlidingWindow(30)); 28 | const ups = useProperty(new SlidingWindow(30)); 29 | 30 | const started = useRef(false); 31 | const paused = useRef(true); 32 | const time = useRef(0); 33 | const debug = useProperty(false); 34 | const node = useNode(); 35 | 36 | const onPause = useCallback(() => paused.current = !paused.current, []); 37 | const onDebug = useCallback(() => { 38 | debug.current = !debug.current; 39 | document.body.classList[debug.current ? 'add' : 'remove']('debug'); 40 | }, [debug]); 41 | 42 | // Automatically pause and unpause the engine as the page lose and gains visibility. 43 | usePauseOnPageHidden(paused); 44 | 45 | // Handle one tick of the game loop. 46 | const tick = useCallback((t: number) => { 47 | requestAnimationFrame(tick); 48 | const start = performance.now(); 49 | 50 | // Limit the time delta, to avoid strange happenings! 51 | const rawDelta = t - time.current; 52 | const delta = Math.min(rawDelta, maxDelta); 53 | time.current = t; 54 | 55 | // The ticker phase runs even when the engine is paused. 56 | node.ticker(delta, t); 57 | 58 | // The update phase only runs when the engine is running. 59 | if (!paused.current) node.update(delta, t); 60 | 61 | // The render phase always runs. 62 | node.render(); 63 | 64 | // Revalidate all properties that were previously invalidated. 65 | Validator.run(); 66 | 67 | // Update the FPS/UPS counts. 68 | fps?.current.push(Math.min(9999, 1000 / rawDelta)); 69 | ups?.current.push(Math.min(9999, 1000 / (performance.now() - start))); 70 | }, [fps, maxDelta, node, ups]); 71 | 72 | // Start the game loop. 73 | useEffect(() => { 74 | if (!started.current) { 75 | started.current = true; 76 | setTimeout(onPause, 100); 77 | tick(0); 78 | } 79 | }, [onPause, tick]); 80 | 81 | const engineContext = useMemo(() => ({ debug, onDebug, onPause, fps, ups }), [debug, fps, onDebug, onPause, ups]); 82 | 83 | return ( 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {children} 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | /** 107 | * Listen for page visibility change events, pausing and unpausing the engine accordingly. 108 | */ 109 | const usePauseOnPageHidden = (paused: MutableRefObject) => { 110 | const onVisibilityChange = useCallback(() => { 111 | if (document.hidden) { 112 | paused.current = true; 113 | } else { 114 | setTimeout(() => paused.current = false, 500); 115 | } 116 | }, [paused]); 117 | 118 | useLayoutEffect(() => { 119 | document.addEventListener('visibilitychange', onVisibilityChange); 120 | return () => document.removeEventListener('visibilitychange', onVisibilityChange); 121 | }, [onVisibilityChange]); 122 | }; -------------------------------------------------------------------------------- /src/components/FrameRate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useElement, useProperty, useRender } from "../hooks"; 3 | import { EngineContext } from "../context"; 4 | 5 | /** 6 | * FrameRate 7 | * --------- 8 | * 9 | * Show the current FPS in the top right of the device. 10 | */ 11 | export const FrameRate: React.FC = () => { 12 | const fpsElement = useElement(); 13 | const { fps, ups } = useContext(EngineContext); 14 | 15 | const next = useProperty(0); 16 | 17 | useRender(() => { 18 | if (next.current === 0) { 19 | const fpsStr = fps.current.mean().toFixed(0) + ' fps'; 20 | const upsStr = ups.current.mean().toFixed(0) + ' ups'; 21 | fpsElement.setText([fpsStr, upsStr].join('\n')); 22 | next.current = 10; 23 | } 24 | 25 | next.current--; 26 | }); 27 | 28 | return ( 29 |
30 |
31 |
32 | ); 33 | }; -------------------------------------------------------------------------------- /src/components/Gamepad.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo } from "react"; 2 | import { GamepadContext } from "../context"; 3 | import { GamepadAxisName, GamepadButtonName } from "../types"; 4 | import { STANDARD_AXIS_MAPPING, STANDARD_BUTTON_MAPPING, STANDARD_BUTTON_UNMAPPING } from "../constants"; 5 | 6 | type GamepadProps = { 7 | children: React.ReactNode; 8 | } 9 | 10 | /** 11 | * Gamepad 12 | * -------- 13 | */ 14 | export const Gamepad: React.FC = ({ children }) => { 15 | /** 16 | * Dynamic 'property' consisting of a set of all gamepad buttons currently being held down. 17 | */ 18 | const down = useMemo(() => ({ 19 | get current(): Set { 20 | const buttons = new Set(); 21 | 22 | navigator.getGamepads().forEach((gamepad) => { 23 | gamepad?.buttons.forEach((button, index) => { 24 | if (button.pressed) { 25 | buttons.add(STANDARD_BUTTON_UNMAPPING[index]); 26 | } 27 | }); 28 | }); 29 | 30 | return buttons; 31 | } 32 | }), []); 33 | 34 | /** 35 | * Return true if the given button is down for the given gamepad. 36 | */ 37 | const isButtonDown = useCallback((index: number | null, button: GamepadButtonName): boolean => { 38 | if (index !== null) { 39 | const gamepad = navigator.getGamepads()[index]; 40 | const mapping = STANDARD_BUTTON_MAPPING[button]; 41 | return gamepad?.buttons[mapping]?.pressed || false; 42 | } 43 | 44 | return false; 45 | }, []); 46 | 47 | /** 48 | * Returns a value between -1 and +1, based on whether the negative and position buttons are 49 | * currently being pressed. 50 | */ 51 | const getButtonAxis = useCallback((index: number | null, negative: GamepadButtonName, positive: GamepadButtonName): number => { 52 | return +isButtonDown(index, positive) - +isButtonDown(index, negative); 53 | }, [isButtonDown]); 54 | 55 | /** 56 | * Returns a value between -1 and +1, representing the position of an analog stick in either 57 | * the horizontal or vertical dimension. 58 | */ 59 | const getAnalogAxis = useCallback((index: number | null, axis: GamepadAxisName) => { 60 | if (index !== null) { 61 | const gamepad = navigator.getGamepads()[index]; 62 | const mapping = STANDARD_AXIS_MAPPING[axis]; 63 | return gamepad?.axes[mapping] || 0; 64 | } 65 | 66 | return 0; 67 | }, []); 68 | 69 | /** 70 | * Trigger the gamepad rumble for the given duration and magnitude. 71 | */ 72 | const vibrate = useCallback((index: number | null, duration: number, magnitude = 0.5) => { 73 | if (index !== null) { 74 | const gamepad = navigator.getGamepads()[index]; 75 | 76 | if (gamepad?.vibrationActuator) { 77 | gamepad.vibrationActuator.playEffect('dual-rumble', { 78 | startDelay: 0, 79 | duration, 80 | weakMagnitude: magnitude, 81 | strongMagnitude: magnitude, 82 | }); 83 | } 84 | } 85 | }, []); 86 | 87 | const context = useMemo( 88 | () => ({ down, isButtonDown, getButtonAxis, getAnalogAxis, vibrate }), 89 | [down, isButtonDown, getButtonAxis, getAnalogAxis, vibrate] 90 | ); 91 | 92 | return ( 93 | 94 | {children} 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/Keyboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useCallback, useEffect, useMemo } from "react"; 2 | import { KeyboardContext } from "../context"; 3 | import { useTicker } from "../hooks"; 4 | import { KeyboardKeyName } from "../types"; 5 | 6 | type KeyboardProps = { 7 | children: React.ReactNode; 8 | } 9 | 10 | /** 11 | * Keyboard 12 | * -------- 13 | * 14 | * 15 | */ 16 | export const Keyboard: React.FC = ({ children }) => { 17 | const down = useRef>(new Set()); 18 | const pressed = useRef>(new Set()); 19 | const _pressed = useRef>(new Set()); 20 | 21 | /** 22 | * Return true if the given key is being held down. 23 | */ 24 | const isKeyDown = useCallback((code: KeyboardKeyName) => { 25 | return down.current.has(code); 26 | }, []); 27 | 28 | /** 29 | * Return true if any key is being held down. 30 | */ 31 | const isAnyKeyDown = useCallback(() => { 32 | return down.current.size > 0; 33 | }, []); 34 | 35 | /** 36 | * Return true if the given key was pressed (down and up). 37 | */ 38 | const isKeyPressed = useCallback((code: KeyboardKeyName) => { 39 | return pressed.current.has(code); 40 | }, []); 41 | 42 | /** 43 | * Return true if any key was pressed. 44 | */ 45 | const isAnyKeyPressed = useCallback(() => { 46 | return pressed.current.size > 0; 47 | }, []); 48 | 49 | /** 50 | * Returns a value between -1 and +1, based on whether the negative and position keys are 51 | * currently being pressed. 52 | */ 53 | const hasKeyAxis = useCallback((negative: KeyboardKeyName, positive: KeyboardKeyName) => { 54 | return +isKeyDown(positive) - +isKeyDown(negative); 55 | }, [isKeyDown]); 56 | 57 | /** 58 | * Simulate the press of a key. 59 | */ 60 | const simulateKeyDown = useCallback((code: KeyboardKeyName) => { 61 | dispatchEvent(new KeyboardEvent('keydown', { code })); 62 | }, []); 63 | 64 | /** 65 | * Simulate the release of a key. 66 | */ 67 | const simulateKeyUp = useCallback((code: KeyboardKeyName) => { 68 | dispatchEvent(new KeyboardEvent('keyup', { code })); 69 | }, []); 70 | 71 | /** 72 | * When a key is pressed down, add it to the 'down' list. 73 | */ 74 | const handleKeyDown = useCallback((event: KeyboardEvent) => { 75 | down.current.add(event.code); 76 | }, []); 77 | 78 | /** 79 | * When a key is released, remove it from the 'down' list, and add it to the 'pressed' list, but 80 | * only for one animation frame, allowing components to check whether a key was just pressed. 81 | */ 82 | const handleKeyUp = useCallback((event: KeyboardEvent) => { 83 | down.current.delete(event.code); 84 | _pressed.current.add(event.code); 85 | }, []); 86 | 87 | /** 88 | * Ensure that the 'pressed' state is in place for exactly one frame, and no more. 89 | */ 90 | useTicker(() => { 91 | for (const code of pressed.current) { 92 | pressed.current.delete(code); 93 | } 94 | 95 | for (const code of _pressed.current) { 96 | pressed.current.add(code); 97 | _pressed.current.delete(code); 98 | } 99 | }); 100 | 101 | /** 102 | * Attach key event handlers to the window, to capture all events. 103 | */ 104 | useEffect(() => { 105 | addEventListener('keydown', handleKeyDown); 106 | addEventListener('keyup', handleKeyUp); 107 | 108 | return () => { 109 | removeEventListener('keydown', handleKeyDown); 110 | removeEventListener('keyup', handleKeyUp); 111 | }; 112 | }, [handleKeyDown, handleKeyUp]); 113 | 114 | const context = useMemo( 115 | () => ({ 116 | down, 117 | pressed, 118 | isKeyDown, 119 | isAnyKeyDown, 120 | isKeyPressed, 121 | isAnyKeyPressed, 122 | hasKeyAxis, 123 | simulateKeyDown, 124 | simulateKeyUp, 125 | }), 126 | [down, pressed, isKeyDown, isAnyKeyDown, isKeyPressed, isAnyKeyPressed, hasKeyAxis, simulateKeyDown, simulateKeyUp] 127 | ); 128 | 129 | return ( 130 | 131 | {children} 132 | 133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /src/components/Motion.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo } from "react"; 2 | import { MotionContext } from "../context"; 3 | import { useKeyPressed, useProperty } from "../hooks"; 4 | 5 | type MotionProps = { 6 | children: React.ReactNode; 7 | } 8 | 9 | /** 10 | * Motion 11 | * ------ 12 | * 13 | * Provides access to the current acceleration of the device along three axes. Implemented using 14 | * 'devicemotion' events, which have to be activated on some devices. 15 | */ 16 | export const Motion: React.FC = ({ children }) => { 17 | const acceleration = useProperty<[number, number, number]>([0, 0, 0]); 18 | 19 | /** 20 | * Returns true whilst the user is shaking the device, above a set threshold. 21 | */ 22 | const isShaking = useCallback(() => { 23 | const [x, y, z] = acceleration.current; 24 | return Math.abs(x) + Math.abs(y) + Math.abs(z) >= 25; 25 | }, [acceleration]); 26 | 27 | /** 28 | * Handle raw device motion events, storing the current acceleration. 29 | */ 30 | const handleDeviceMotion = useCallback((event: DeviceMotionEvent) => { 31 | const { x, y, z } = event.acceleration || {}; 32 | acceleration.current = [x || 0, y || 0, z || 0]; 33 | }, [acceleration]); 34 | 35 | /** 36 | * Attach key event handlers to the window, to capture all events. 37 | */ 38 | useEffect(() => { 39 | addEventListener('devicemotion', handleDeviceMotion); 40 | 41 | return () => { 42 | removeEventListener('devicemotion', handleDeviceMotion); 43 | }; 44 | }, [handleDeviceMotion]); 45 | 46 | /** 47 | * In development, when the app is running in a browser, simulate shaking the device using the 48 | * S key. 49 | */ 50 | useKeyPressed('Digit3', () => { 51 | acceleration.current = [50, 0, 0]; 52 | setTimeout(() => acceleration.current = [0, 0, 0], 300); 53 | }); 54 | 55 | const context = useMemo( 56 | () => ({ acceleration, isShaking }), 57 | [acceleration, isShaking], 58 | ); 59 | 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Node.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { useUpdate } from '../hooks'; 4 | import { nextFrame, render, renderHook } from '../test'; 5 | import { Prop } from '../types'; 6 | import { VariableProperty } from '../utils'; 7 | import { Box } from './Box'; 8 | import { Engine } from './Engine'; 9 | import { Node } from './Node'; 10 | 11 | describe('Node', () => { 12 | it('passes the position on to nested elements', () => { 13 | render( 14 | 15 | test 16 | 17 | ); 18 | 19 | const box = screen.getByText('test'); 20 | expect(box).toHaveStyle({ transform: 'translate(100px, 50px) rotate(0deg) scale(1, 1)' }); 21 | }); 22 | 23 | describe('when a position offset is given', () => { 24 | it('adds the offset onto the position', () => { 25 | render( 26 | 27 | test 28 | 29 | ); 30 | 31 | const box = screen.getByText('test'); 32 | expect(box).toHaveStyle({ transform: 'translate(110px, 70px) rotate(0deg) scale(1, 1)' }); 33 | }); 34 | }); 35 | 36 | describe('when the rounded flag is set', () => { 37 | it('rounds the position to the nearest integer', () => { 38 | render( 39 | 40 | test 41 | 42 | ); 43 | 44 | const box = screen.getByText('test'); 45 | expect(box).toHaveStyle({ transform: 'translate(101px, 52px) rotate(0deg) scale(1, 1)' }); 46 | }); 47 | }); 48 | 49 | describe('when a time scale is given', () => { 50 | const createWrapper = ({ timeScale }: { timeScale?: Prop }) => { 51 | return ({ children }: { children: React.ReactNode }) => { 52 | return ( 53 | 54 | {children} 55 | 56 | ); 57 | }; 58 | }; 59 | 60 | it('affects the delta passed to nested useUpdate calls', () => { 61 | const callback = vi.fn(); 62 | const timeScale = new VariableProperty(1); 63 | renderHook(() => useUpdate(callback), { wrapper: createWrapper({ timeScale })}); 64 | 65 | nextFrame(); 66 | expect(callback).toBeCalledWith(15, expect.any(Number)); 67 | 68 | timeScale.current = 0.5; 69 | nextFrame(); 70 | expect(callback).toBeCalledWith(7.5, expect.any(Number)); 71 | 72 | timeScale.current = 2.0; 73 | nextFrame(); 74 | expect(callback).toBeCalledWith(30, expect.any(Number)); 75 | 76 | }); 77 | }); 78 | }); -------------------------------------------------------------------------------- /src/components/Node.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from "react"; 2 | import { useProperty, useNode, useOffsetPosition, useIntegerPosition } from "../hooks"; 3 | import { Prop, Position } from "../types"; 4 | import { NodeContext } from "../context"; 5 | 6 | /** 7 | * Node 8 | * ---- 9 | * 10 | * ... 11 | */ 12 | 13 | type NodeProps = { 14 | children: React.ReactNode; 15 | visible?: Prop; 16 | pos?: Prop; 17 | offset?: Prop; 18 | rounded?: boolean; 19 | timeScale?: Prop; 20 | name?: string; 21 | } 22 | 23 | export const Node: React.FC = ({ name, children, timeScale, rounded, ...props }) => { 24 | const parent = useContext(NodeContext); 25 | const isVisible = props.visible === undefined ? (parent.visible === undefined ? true : parent.visible) : props.visible; 26 | const visible = useProperty(isVisible); 27 | const pos = useProperty(props.pos || parent.pos || [0, 0]); 28 | const offsetPos = useOffsetPosition(pos, props.offset || [0, 0]); 29 | const roundedPos = useIntegerPosition(offsetPos); 30 | const node = useNode({ name, timeScale }); 31 | 32 | const context = useMemo(() => ({ 33 | ...node, 34 | debug: parent.debug, 35 | pos: rounded ? roundedPos : offsetPos, 36 | visible, 37 | name, 38 | }), [name, node, offsetPos, parent.debug, rounded, roundedPos, visible]); 39 | 40 | return {children}; 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/Orientation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo } from "react"; 2 | import { OrientationContext } from "../context"; 3 | import { useKeyAxis, useProperty } from "../hooks"; 4 | 5 | type OrientationProps = { 6 | children: React.ReactNode; 7 | } 8 | 9 | /** 10 | * Motion 11 | * ------ 12 | * 13 | * Provides access to the current orientation of the device along three axes. Implemented using 14 | * 'deviceorientation' events, which have to be activated on some devices. 15 | */ 16 | export const Orientation: React.FC = ({ children }) => { 17 | const alpha = useProperty(0); 18 | const beta = useProperty(45); 19 | const gamma = useProperty(0); 20 | const angle = useProperty(0); 21 | 22 | const handleDeviceOrientation = useCallback((event: DeviceOrientationEvent) => { 23 | alpha.current = event.alpha || 0; 24 | beta.current = event.beta || 0; 25 | gamma.current = event.gamma || 0; 26 | }, [alpha, beta, gamma]); 27 | 28 | /** 29 | * Attach key event handlers to the window, to capture all events. 30 | */ 31 | useEffect(() => { 32 | addEventListener('deviceorientation', handleDeviceOrientation); 33 | 34 | return () => { 35 | removeEventListener('deviceorientation', handleDeviceOrientation); 36 | }; 37 | }, [handleDeviceOrientation]); 38 | 39 | /** 40 | * In development, when the app is running in a browser, simulate tilting the device using the 41 | * G and H keys. 42 | */ 43 | useKeyAxis('Digit4', 'Digit5', (value) => { 44 | if (value !== 0) { 45 | angle.current += value; 46 | gamma.current = Math.sin(angle.current * Math.PI / 90) * 45; 47 | beta.current = Math.cos(angle.current * Math.PI / 90) * 45; 48 | } 49 | }); 50 | 51 | const context = useMemo( 52 | () => ({ alpha, beta, gamma }), 53 | [alpha, beta, gamma], 54 | ); 55 | 56 | return ( 57 | 58 | {children} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/ParticleEngine.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useCallback, useLayoutEffect, useMemo, useRef } from "react"; 2 | import { BaseParticle } from "../utils"; 3 | import { useEventListeners, useUpdate } from "../hooks"; 4 | 5 | export type ParticleContextProps = { 6 | attach: (particle: BaseParticle) => void; 7 | addEventListener: (type: "create", fn: (element: HTMLElement) => void) => void 8 | removeEventListener: (type: "create", fn: (element: HTMLElement) => void) => void 9 | }; 10 | 11 | export const ParticleContext = createContext({ 12 | attach: () => {}, 13 | addEventListener: () => {}, 14 | removeEventListener: () => {}, 15 | }); 16 | 17 | export type ParticleEngineProps = { 18 | children: React.ReactNode; 19 | pool?: number 20 | }; 21 | 22 | /** 23 | * The particle engine. 24 | */ 25 | export const ParticleEngine: React.FC = ({ children, pool }) => { 26 | const active = useRef>(new Set()); 27 | const inactive = useRef>(new Set()); 28 | 29 | const { addEventListener, removeEventListener, fireEvent } = useEventListeners<'create', HTMLElement>(); 30 | 31 | /** 32 | * Attach a HTML element to a particle object, either reusing an existing, inactive element, or 33 | * by creating a new element. Note: This does not attach it to the DOM. That is handled by the 34 | * `Particles` component. 35 | */ 36 | const attach = useCallback((particle: BaseParticle) => { 37 | if (inactive.current.size > 0) { 38 | const node = [...inactive.current][0]; 39 | inactive.current.delete(node); 40 | active.current.add(particle); 41 | particle.attach(node); 42 | particle.init(); 43 | } else { 44 | const node = document.createElement('div'); 45 | active.current.add(particle); 46 | particle.attach(node); 47 | particle.init(); 48 | fireEvent('create', node); 49 | } 50 | }, [fireEvent]); 51 | 52 | /** 53 | * Update all active particles, destroying them once they have reached the end of their life. 54 | */ 55 | useUpdate((delta) => { 56 | for (const particle of active.current) { 57 | if (particle.node) { 58 | if (particle.spawned) { 59 | particle.spawned = false; 60 | } else { 61 | particle.ttl -= delta; 62 | particle.update(delta); 63 | 64 | if (particle.ttl <= 0) { 65 | active.current.delete(particle); 66 | inactive.current.add(particle.node); 67 | particle.destroy(); 68 | } 69 | } 70 | } 71 | } 72 | }); 73 | 74 | /** 75 | * Initialise a fixed-size pool of elements, to avoid the overhead of creating elements and 76 | * attaching them to the DOM when new particles are spawned. 77 | */ 78 | useLayoutEffect(() => { 79 | if (pool) { 80 | while (inactive.current.size < pool) { 81 | const node = document.createElement('div'); 82 | inactive.current.add(node); 83 | fireEvent('create', node); 84 | } 85 | } 86 | }, [fireEvent, pool]); 87 | 88 | const context = useMemo(() => ({ attach, addEventListener, removeEventListener }), [attach, addEventListener, removeEventListener]); 89 | 90 | return ( 91 | 92 | {children} 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/Particles.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback, useEffect, useRef } from "react"; 2 | import { mergeRefs } from "react-merge-refs"; 3 | import { useParticles } from "../hooks"; 4 | 5 | export const Particles = forwardRef((_, ref) => { 6 | const particles = useParticles(); 7 | const localRef = useRef(null); 8 | 9 | const attach = useCallback((element: HTMLElement) => { 10 | if (localRef.current && element) { 11 | localRef.current.insertBefore(element, localRef.current.firstChild); 12 | } 13 | }, []); 14 | 15 | useEffect(() => { 16 | particles.addEventListener('create', attach); 17 | return () => particles.removeEventListener('create', attach); 18 | }, [attach, particles]); 19 | 20 | return
; 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Physics.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useRef } from "react"; 2 | import { Engine, Composite, Events, Body } from "matter-js"; 3 | import { PhysicsContext } from "../context"; 4 | import { useEventListeners, useProperty, useUpdate } from "../hooks"; 5 | import { PhysicsEvent, PhysicsEventType, PhysicsUpdateFunction, Position, Velocity } from "../types"; 6 | 7 | type PhysicsProps = { 8 | children: React.ReactNode; 9 | } 10 | 11 | /** 12 | * Physics 13 | * ------- 14 | * 15 | * Setup the physics engine (using matter-js), and provide functions for registering new physical 16 | * bodies via context. 17 | */ 18 | export const Physics: React.FC = ({ children }) => { 19 | const engine = useProperty(Engine.create()); 20 | const updaters = useRef>(new Map()); 21 | 22 | /** 23 | * Register function is used to add (and remove) physics bodies to (and from) the system. Each 24 | * body is paired with an update function, which is called each time the body moves, allowing 25 | * its properties (such as position and rotation) to be synced with other elements. 26 | */ 27 | const register = useCallback((body: Matter.Body, fn: PhysicsUpdateFunction) => { 28 | Composite.add(engine.current.world, body); 29 | updaters.current.set(body, fn); 30 | 31 | return () => { 32 | Composite.remove(engine.current.world, body) 33 | updaters.current.delete(body); 34 | }; 35 | }, [engine]); 36 | 37 | /** 38 | * Set the angle of gravity. 39 | */ 40 | const setGravity = useCallback(([x, y]: Velocity) => { 41 | engine.current.gravity.x = x; 42 | engine.current.gravity.y = y; 43 | }, [engine]); 44 | 45 | /** 46 | * Set the velocity of a physics body. 47 | */ 48 | const setVelocity = useCallback((body: Matter.Body, [x, y]: Velocity) => { 49 | Body.setVelocity(body, { x, y }); 50 | }, []); 51 | 52 | /** 53 | * Apply a force to a physics body. 54 | */ 55 | const applyForce = useCallback((body: Matter.Body, [px, py]: Position, [vx, vy]: Velocity) => { 56 | Body.applyForce(body, { x: px, y: py }, { x: vx, y: vy }); 57 | }, []); 58 | 59 | /** 60 | * 61 | */ 62 | const { addEventListener, removeEventListener, fireEvent } = useEventListeners(); 63 | const handleCollision = useCallback((event: Matter.IEventCollision) => { 64 | fireEvent('collision', event); 65 | }, [fireEvent]); 66 | 67 | /** 68 | * 69 | */ 70 | useEffect(() => { 71 | const e = engine.current; 72 | Events.on(e, 'collisionStart', handleCollision); 73 | return () => Events.off(e, 'collisionStart', handleCollision); 74 | }, [engine, handleCollision]); 75 | 76 | /** 77 | * Each frame, play the physics system forwards, then call all of the update functions. 78 | */ 79 | useUpdate((delta) => { 80 | Engine.update(engine.current, delta); 81 | 82 | for (const [body, update] of updaters.current) { 83 | update(body); 84 | } 85 | }); 86 | 87 | const context = useMemo(() => ({ 88 | engine, register, setGravity, setVelocity, applyForce, addEventListener, removeEventListener 89 | }), [engine, register, setGravity, setVelocity, applyForce, addEventListener, removeEventListener]); 90 | 91 | return ( 92 | 93 | {children} 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/components/PhysicsBox.tsx: -------------------------------------------------------------------------------- 1 | import { useElement, useDebug, usePosition, useProperty, useRender } from "../hooks"; 2 | import { useBoxPhysics } from "../hooks/usePhysics"; 3 | import { Prop, Position, Size } from "../types"; 4 | import { VariableProperty } from "../utils"; 5 | 6 | const CLASS_NAME = "absolute outline outline-2 outline-[#f0f] bg-[#f0f3] -outline-offset-1"; 7 | 8 | type PhysicsBoxProps = { 9 | id?: string; 10 | pos?: Prop; 11 | size: Prop; 12 | static?: boolean; 13 | } 14 | 15 | /** 16 | * PhysicsBox 17 | * ---------- 18 | * 19 | * Register a box-shaped physics body. 20 | */ 21 | export const PhysicsBox: React.FC = (props) => { 22 | const element = useElement(); 23 | const debug = useDebug(); 24 | 25 | const pos = usePosition(props.pos); 26 | const size = useProperty(props.size); 27 | 28 | useBoxPhysics(pos, size, { isStatic: props.static }); 29 | 30 | useRender(() => { 31 | if (debug.current) { 32 | element.setBaseStyles({ 33 | pos: new VariableProperty([pos.current[0] - size.current[0] / 2, pos.current[1] - size.current[1] / 2]), 34 | size, 35 | }); 36 | } 37 | 38 | if (debug.invalidated) { 39 | element.setStyle('display', debug.current ? 'block' : 'none'); 40 | debug.invalidated = false; 41 | } 42 | }); 43 | 44 | return
; 45 | } -------------------------------------------------------------------------------- /src/components/PhysicsCircle.tsx: -------------------------------------------------------------------------------- 1 | import { useElement, useDebug, usePosition, useProperty, useRender, useCirclePhysics, useOffsetPosition } from "../hooks"; 2 | import { useDynamicProperty } from "../hooks/useDynamicProperty"; 3 | import { Prop, Position, Size } from "../types"; 4 | 5 | const CLASS_NAME = "absolute outline outline-2 outline-[#f0f] bg-[#f0f3] -outline-offset-1 rounded-full"; 6 | 7 | type PhysicsCircleProps = { 8 | id?: string; 9 | pos?: Prop; 10 | radius: Prop; 11 | static?: boolean; 12 | } 13 | 14 | /** 15 | * PhysicsCircle 16 | * ------------- 17 | * 18 | * Register a circular physics body. 19 | */ 20 | export const PhysicsCircle: React.FC = (props) => { 21 | const element = useElement(); 22 | const debug = useDebug(); 23 | 24 | const pos = usePosition(props.pos); 25 | const radius = useProperty(props.radius); 26 | const debugPos = useOffsetPosition(pos, [-radius.current, -radius.current]); 27 | const debugSize = useDynamicProperty(radius, (value): Size => [value * 2, value * 2]); 28 | 29 | useCirclePhysics(pos, radius, { friction: 0.5, restitution: 0.5, slop: 0.01, isStatic: props.static }); 30 | 31 | useRender(() => { 32 | if (debug.current) { 33 | element.setBaseStyles({ pos: debugPos, size: debugSize }); 34 | } 35 | 36 | if (debug.invalidated) { 37 | element.setStyle('display', debug.current ? 'block' : 'none'); 38 | debug.invalidated = false; 39 | } 40 | }); 41 | 42 | return
; 43 | } -------------------------------------------------------------------------------- /src/components/SpriteSet.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useCallback, useMemo, MutableRefObject } from "react"; 2 | import { useProperty, useRender } from "../hooks"; 3 | import { Prop } from "../types"; 4 | 5 | type AnimationConfig = { 6 | element: MutableRefObject; 7 | reset: () => void; 8 | } 9 | 10 | type SpriteSetProps = { 11 | animation: Prop; 12 | children: React.ReactNode; 13 | }; 14 | 15 | /** 16 | * SpriteSet 17 | * --------- 18 | * 19 | * Wrap a set of bitmap or vector sprites, ensuring that only one is ever shown at once, based on 20 | * the current animation. 21 | */ 22 | export const SpriteSet: React.FC = (props) => { 23 | const animation = useProperty(props.animation); 24 | const animations = useRef>(new Map()); 25 | 26 | const register = useCallback((name: string, element: MutableRefObject, reset: () => void) => { 27 | animations.current.set(name, { element, reset }); 28 | return () => animations.current.delete(name); 29 | }, []); 30 | 31 | const context = useMemo(() => ({ register }), [register]); 32 | 33 | useRender(() => { 34 | if (animation.invalidated) { 35 | for (const [id, { element, reset }] of animations.current) { 36 | const elem = element.current as HTMLElement; 37 | 38 | if (elem.style.display === 'none' && id === animation.current) { 39 | reset(); 40 | } 41 | 42 | elem.style.display = id === animation.current ? 'block' : 'none'; 43 | } 44 | 45 | animation.invalidated = false; 46 | } 47 | }); 48 | 49 | return ( 50 | 51 | {props.children} 52 | 53 | ); 54 | }; 55 | 56 | type SpriteSetContextProps = { 57 | register: (animation: string, element: MutableRefObject, reset: () => void) => () => void; 58 | } 59 | 60 | export const SpriteSetContext = React.createContext({ 61 | register: () => () => {}, 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/Tilemap.tsx: -------------------------------------------------------------------------------- 1 | import { CollisionBox } from "."; 2 | import { usePosition, useProperty } from "../hooks"; 3 | import { Position, Prop, Size, Tileset } from "../types"; 4 | import { BitmapImage } from "./BitmapImage"; 5 | import { Box } from "./Box"; 6 | 7 | type TilemapProps = { 8 | pos?: Prop; 9 | tileset: Tileset; 10 | tiles: number[]; 11 | collisions?: (string[] | false)[]; 12 | scale?: Prop; 13 | active?: Prop; 14 | } 15 | 16 | /** 17 | * Tilemap 18 | * ------- 19 | * 20 | * ... 21 | */ 22 | export const Tilemap: React.FC = ({ tileset, tiles, collisions, ...props }) => { 23 | const { image, cellSize, tileSize, gridSize } = tileset; 24 | 25 | const pos = usePosition(props.pos); 26 | const size = useProperty([gridSize[0] * cellSize[0], gridSize[1] * cellSize[0]]); 27 | const active = useProperty(props.active !== undefined ? props.active : true); 28 | const factor = useProperty([cellSize[0] / tileSize[0], cellSize[1] / tileSize[1]]); 29 | 30 | const tilesetCols = Math.floor(image.size[0] / tileSize[0]); 31 | 32 | return ( 33 | 34 | {tiles.map((tile, index) => { 35 | if (tile >= 0) { 36 | const key = `${index}_${tile}`; 37 | const x = (index % gridSize[0]) * cellSize[0]; 38 | const y = Math.floor(index / gridSize[0]) * cellSize[1]; 39 | const ox = (tile % tilesetCols) * tileSize[0]; 40 | const oy = Math.floor(tile / tilesetCols) * tileSize[1]; 41 | return ; 42 | } else { 43 | return null; 44 | } 45 | })} 46 | {collisions?.map((tags, index) => { 47 | if (tags) { 48 | const key = `${index}_${tags.join('_')}`; 49 | const x = pos.current[0] + (index % gridSize[0]) * cellSize[0]; 50 | const y = pos.current[1] + Math.floor(index / gridSize[0]) * cellSize[0]; 51 | return ; 52 | } else { 53 | return null; 54 | } 55 | })} 56 | 57 | ) 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/VectorSprite.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from "react"; 2 | import { useElement, usePosition, useProperty, useRender, useSpriteSet, useUpdate } from "../hooks"; 3 | import { Position, Prop, Size } from "../types"; 4 | import { Node } from "./Node"; 5 | 6 | /** 7 | * VectorSprite 8 | * ----- 9 | * 10 | * ... 11 | */ 12 | 13 | type VectorSpriteProps = { 14 | name: string; 15 | sprite: React.FC>; 16 | pos?: Prop; 17 | size: Prop; 18 | }; 19 | 20 | export const VectorSprite: React.FC = ({ sprite: Sprite, ...props }) => { 21 | const element = useElement(); 22 | 23 | const pos = usePosition(props.pos); 24 | const size = useProperty(props.size); 25 | const frameCount = useProperty(1); 26 | const frameRate = useProperty(10); 27 | const frameWidth = useProperty(100); 28 | const frameHeight = useProperty(100); 29 | const frameIndex = useProperty(0); 30 | const frameTime = useProperty(0); 31 | 32 | // Pull the sprite parameters (frame rate, number of frame, and frame size) from the SVG element. 33 | useLayoutEffect(() => { 34 | const { frames, rate, width, height } = element.ref.current?.dataset || {}; 35 | frameWidth.current = parseInt(width || '100', 10); 36 | frameHeight.current = parseInt(height || '100', 10); 37 | frameRate.current = parseInt(rate || '10', 10); 38 | frameCount.current = parseInt(frames || '1', 10); 39 | }, [element.ref, frameCount, frameHeight, frameRate, frameWidth]); 40 | 41 | useSpriteSet(props.name, element.ref, () => { 42 | frameIndex.current = 0; 43 | }); 44 | 45 | useUpdate((delta) => { 46 | frameTime.current += delta; 47 | 48 | const frameMillis = 1000 / frameRate.current; 49 | const frameIncrement = Math.floor(frameTime.current / frameMillis); 50 | 51 | frameIndex.current = (frameIndex.current + frameIncrement) % frameCount.current; 52 | frameTime.current -= frameIncrement * frameMillis; 53 | }); 54 | 55 | useRender(() => { 56 | element.setBaseStyles({ pos, size }); 57 | 58 | if (frameIndex.invalidated || frameWidth.invalidated || frameHeight.invalidated) { 59 | const viewBox = `${frameIndex.current * frameWidth.current} 0 ${frameWidth.current} ${frameHeight.current}`; 60 | element.ref.current?.setAttribute('viewBox', viewBox); 61 | frameIndex.invalidated = false; 62 | frameWidth.invalidated = false; 63 | frameHeight.invalidated = false; 64 | } 65 | }); 66 | 67 | return ( 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/Viewport.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Property, Position, Prop } from "../types"; 3 | import { useElement, useProperty, useRender } from "../hooks"; 4 | 5 | type ViewportContextProps = { 6 | origin?: Property; 7 | } 8 | 9 | export const ViewportContext = React.createContext({ 10 | origin: undefined, 11 | }); 12 | 13 | /** 14 | * Viewport 15 | * -------- 16 | * 17 | * Hide everything outside of the viewport, centering the content. The focus position can be 18 | * controlled by a nested camera. 19 | */ 20 | 21 | type ViewportProps = { 22 | children: React.ReactNode; 23 | scale?: Prop; 24 | } 25 | 26 | export const Viewport: React.FC = ({ children, ...props }) => { 27 | const element = useElement(); 28 | 29 | const scale = useProperty(props.scale || 1); 30 | const origin = useProperty([0, 0]); 31 | const context = useMemo(() => ({ origin }), [origin]); 32 | 33 | useRender(() => { 34 | if (origin.invalidated || scale.invalidated) { 35 | const x = -Math.round(origin.current[0] * scale.current); 36 | const y = -Math.round(origin.current[1] * scale.current); 37 | element.setStyle('transform', `translate(${x}px, ${y}px) scale(${scale.current})`); 38 | origin.invalidated = false; 39 | scale.invalidated = false; 40 | } 41 | }); 42 | 43 | return ( 44 |
45 |
46 | 47 | {children} 48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/VirtualInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef } from "react"; 2 | import { VirtualInputContext } from "../context"; 3 | import { useTicker } from "../hooks"; 4 | 5 | type VirtualInputProps = { 6 | children: React.ReactNode; 7 | } 8 | 9 | /** 10 | * VirtualInput 11 | * ----- 12 | * 13 | * ... 14 | */ 15 | export const VirtualInput: React.FC = ({ children }) => { 16 | const down = useRef>(new Map()); 17 | 18 | const simulate = useCallback((action: string): void => { 19 | down.current.set(action, 50); 20 | }, []); 21 | 22 | const isActive = useCallback((action: string): boolean => { 23 | return down.current.has(action); 24 | }, []); 25 | 26 | const hasAxis = useCallback((negative: string, positive: string): number => { 27 | return +isActive(positive) - +isActive(negative); 28 | }, [isActive]); 29 | 30 | // Clear any inputs that have not been activated in the last 50ms. 31 | useTicker((delta) => { 32 | for (const [action, remaining] of down.current) { 33 | if (remaining > delta) { 34 | down.current.set(action, remaining - delta); 35 | } else { 36 | down.current.delete(action); 37 | } 38 | } 39 | }); 40 | 41 | const context = useMemo( 42 | () => ({ simulate, isActive, hasAxis }), 43 | [simulate, isActive, hasAxis], 44 | ); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { AudioEngine, AudioEngineContext } from './AudioEngine'; 2 | export { BitmapImage } from './BitmapImage'; 3 | export { BitmapSprite } from './BitmapSprite'; 4 | export { BitmapText } from './BitmapText'; 5 | export { Box } from './Box'; 6 | export { Camera } from './Camera'; 7 | export { Circle } from './Circle'; 8 | export { CollisionBox } from './CollisionBox'; 9 | export { Device } from './Device'; 10 | export { Engine } from './Engine'; 11 | export { Gamepad } from './Gamepad'; 12 | export { Keyboard } from './Keyboard'; 13 | export { Motion } from './Motion'; 14 | export { Node } from './Node'; 15 | export { Orientation } from './Orientation'; 16 | export { ParticleEngine } from './ParticleEngine'; 17 | export { Particles } from './Particles'; 18 | export { Physics } from './Physics'; 19 | export { PhysicsBox } from './PhysicsBox'; 20 | export { PhysicsCircle } from './PhysicsCircle'; 21 | export { SpriteSet, SpriteSetContext } from './SpriteSet'; 22 | export { Tilemap } from './Tilemap'; 23 | export { VectorSprite } from './VectorSprite'; 24 | export { Viewport, ViewportContext } from './Viewport'; 25 | export { VirtualInput } from './VirtualInput'; 26 | export { World } from './World'; 27 | 28 | export type { BitmapImageProps } from './BitmapImage'; 29 | export type { BoxProps } from './Box'; 30 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { GamepadAxisName, GamepadButtonName } from "./types"; 2 | 3 | export const STANDARD_BUTTON_MAPPING: Record = { 4 | Up: 12, 5 | Down: 13, 6 | Left: 14, 7 | Right: 15, 8 | A: 0, 9 | B: 1, 10 | X: 2, 11 | Y: 3, 12 | Shoulder_L1: 4, 13 | Shoulder_L2: 6, 14 | Shoulder_R1: 5, 15 | Shoulder_R2: 7, 16 | Start: 8, 17 | Select: 9, 18 | }; 19 | 20 | export const STANDARD_AXIS_MAPPING: Record = { 21 | Left_Horizontal: 0, 22 | Left_Vertical: 1, 23 | Right_Horizontal: 2, 24 | Right_Vertical: 3, 25 | }; 26 | 27 | export const STANDARD_BUTTON_UNMAPPING: Record = { 28 | 12: 'Up', 29 | 13: 'Down', 30 | 14: 'Left', 31 | 15: 'Right', 32 | 0: 'A', 33 | 1: 'B', 34 | 2: 'X', 35 | 3: 'Y', 36 | 4: 'Shoulder_L1', 37 | 6: 'Shoulder_L2', 38 | 5: 'Shoulder_R1', 39 | 7: 'Shoulder_R2', 40 | 8: 'Start', 41 | 9: 'Select', 42 | }; -------------------------------------------------------------------------------- /src/context/DeviceContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Property, Size } from "../types"; 3 | import { VariableProperty } from "../utils"; 4 | 5 | type DeviceContextProps = { 6 | size: Property; 7 | } 8 | 9 | export const DeviceContext = React.createContext({ 10 | size: new VariableProperty([0, 0]), 11 | }); 12 | -------------------------------------------------------------------------------- /src/context/EngineContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Property, SlidingWindow } from ".."; 3 | 4 | export type EngineContextProps = { 5 | debug: Property; 6 | onDebug: () => void; 7 | onPause: () => void; 8 | fps: Property; 9 | ups: Property; 10 | } 11 | 12 | export const EngineContext = React.createContext({ 13 | debug: { current: false, invalidated: true, listen: () => () => {} }, 14 | onDebug: () => {}, 15 | onPause: () => {}, 16 | fps: { current: new SlidingWindow(30), invalidated: true, listen: () => () => {} }, 17 | ups: { current: new SlidingWindow(30), invalidated: true, listen: () => () => {} }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/context/GamepadContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { GamepadAxisName, GamepadButtonName } from "../types"; 3 | 4 | type GamepadContextProps = { 5 | down: React.MutableRefObject>; 6 | isButtonDown: (index: number | null, button: GamepadButtonName) => boolean; 7 | getButtonAxis: (index: number | null, negative: GamepadButtonName, positive: GamepadButtonName) => number; 8 | getAnalogAxis: (index: number | null, axis: GamepadAxisName) => number; 9 | vibrate: (index: number | null, duration: number, magnitude: number) => void; 10 | } 11 | 12 | export const GamepadContext = React.createContext({ 13 | down: { current: new Set() }, 14 | isButtonDown: () => false, 15 | getButtonAxis: () => 0, 16 | getAnalogAxis: () => 0, 17 | vibrate: () => {}, 18 | }); 19 | -------------------------------------------------------------------------------- /src/context/KeyboardContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { KeyboardKeyName } from "../types"; 3 | 4 | type KeyboardContextProps = { 5 | down: React.MutableRefObject>; 6 | pressed: React.MutableRefObject>; 7 | isKeyDown: (code: KeyboardKeyName) => boolean; 8 | isAnyKeyDown: () => boolean; 9 | isKeyPressed: (code: KeyboardKeyName) => boolean; 10 | isAnyKeyPressed: () => boolean; 11 | hasKeyAxis: (negative: KeyboardKeyName, positive: KeyboardKeyName) => number; 12 | simulateKeyDown: (code: KeyboardKeyName) => void; 13 | simulateKeyUp: (code: KeyboardKeyName) => void; 14 | } 15 | 16 | export const KeyboardContext = React.createContext({ 17 | down: { current: new Set() }, 18 | pressed: { current: new Set() }, 19 | isKeyDown: () => false, 20 | isAnyKeyDown: () => false, 21 | isKeyPressed: () => false, 22 | isAnyKeyPressed: () => false, 23 | hasKeyAxis: () => 0, 24 | simulateKeyDown: () => {}, 25 | simulateKeyUp: () => {}, 26 | }); -------------------------------------------------------------------------------- /src/context/MotionContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Property } from "../types"; 3 | import { VariableProperty } from "../utils"; 4 | 5 | export type MotionContextProps = { 6 | acceleration: Property<[number, number, number]>; 7 | isShaking: () => boolean; 8 | }; 9 | 10 | export const MotionContext = React.createContext({ 11 | acceleration: new VariableProperty([0, 0, 0]), 12 | isShaking: () => false, 13 | }); 14 | -------------------------------------------------------------------------------- /src/context/NodeContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Prop, Position, UpdateFunction, RenderFunction, TickerFunction, UpdateOptions } from "../types"; 3 | 4 | type NodeContextProps = { 5 | debug?: boolean; 6 | pos?: Prop; 7 | visible?: Prop; 8 | registerTicker: (id: string, fn: TickerFunction) => void; 9 | registerUpdate: (id: string, fn: UpdateFunction, options?: UpdateOptions) => void; 10 | registerRender: (id: string, fn: RenderFunction) => void; 11 | } 12 | 13 | export const NodeContext = React.createContext({ 14 | debug: false, 15 | pos: [0, 0], 16 | visible: true, 17 | registerTicker: () => {}, 18 | registerUpdate: () => {}, 19 | registerRender: () => {}, 20 | }); 21 | -------------------------------------------------------------------------------- /src/context/OrientationContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Property } from "../types"; 3 | import { VariableProperty } from "../utils"; 4 | 5 | export type OrientationContextProps = { 6 | alpha: Property; 7 | beta: Property; 8 | gamma: Property; 9 | }; 10 | 11 | export const OrientationContext = React.createContext({ 12 | alpha: new VariableProperty(0), 13 | beta: new VariableProperty(0), 14 | gamma: new VariableProperty(0), 15 | }); 16 | -------------------------------------------------------------------------------- /src/context/PhysicsContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Engine } from "matter-js"; 3 | import { PhysicsEvent, PhysicsEventType, PhysicsUpdateFunction, Position, Property, Velocity } from "../types"; 4 | import { VariableProperty } from "../utils"; 5 | 6 | type PhysicsContextProps = { 7 | engine: Property; 8 | register: (body: Matter.Body, fn: PhysicsUpdateFunction) => () => void; 9 | setGravity: (velocity: Velocity) => void; 10 | setVelocity: (body: Matter.Body, velocity: Velocity) => void; 11 | applyForce: (body: Matter.Body, position: Position, velocity: Velocity) => void; 12 | addEventListener: (type: PhysicsEventType, fn: (event: PhysicsEvent) => void) => void; 13 | removeEventListener: (type: PhysicsEventType, fn: (event: PhysicsEvent) => void) => void; 14 | } 15 | 16 | export const PhysicsContext = React.createContext({ 17 | engine: new VariableProperty(null), 18 | register: () => () => {}, 19 | setGravity: () => {}, 20 | setVelocity: () => {}, 21 | applyForce: () => {}, 22 | addEventListener: () => {}, 23 | removeEventListener: () => {}, 24 | }); 25 | -------------------------------------------------------------------------------- /src/context/PointerContext.ts: -------------------------------------------------------------------------------- 1 | import React, { MutableRefObject } from "react"; 2 | import { Position, Property } from "../types"; 3 | import { VariableProperty } from "../utils"; 4 | 5 | type PointerContextProps = { 6 | pos: Property; 7 | isDown: () => boolean; 8 | isPressed: () => boolean; 9 | isTarget: (element: MutableRefObject | Element | null) => boolean; 10 | }; 11 | 12 | export const PointerContext = React.createContext({ 13 | pos: new VariableProperty([0, 0]), 14 | isDown: () => false, 15 | isPressed: () => false, 16 | isTarget: () => false, 17 | }); 18 | -------------------------------------------------------------------------------- /src/context/VirtualInputContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type VirtualInputContextProps = { 4 | simulate: (action: string) => void; 5 | isActive: (action: string) => boolean; 6 | hasAxis: (negative: string, positive: string) => number; 7 | }; 8 | 9 | export const VirtualInputContext = React.createContext({ 10 | simulate: () => {}, 11 | isActive: () => false, 12 | hasAxis: () => 0, 13 | }); 14 | -------------------------------------------------------------------------------- /src/context/WorldContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CollisionUpdateFunction, CollisionEventFunction, Property, Position } from "../types"; 3 | import { Body, VariableProperty } from "../utils"; 4 | 5 | type WorldContextProps = { 6 | registerCollider: (id: string, active: Property, tags: Property, body: Body, fn: CollisionUpdateFunction) => () => void; 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | registerHandler: (id: string, fn: CollisionEventFunction) => () => void; 9 | registerPostHandler: (fn: (delta: number) => void) => () => void; 10 | isInside: (id: string, pos?: Property) => boolean; 11 | pointer: Property; 12 | } 13 | 14 | export const WorldContext = React.createContext({ 15 | registerCollider: () => () => {}, 16 | registerHandler: () => () => {}, 17 | registerPostHandler: () => () => {}, 18 | isInside: () => false, 19 | pointer: new VariableProperty([0, 0]), 20 | }); 21 | -------------------------------------------------------------------------------- /src/context/index.ts: -------------------------------------------------------------------------------- 1 | export { DeviceContext } from './DeviceContext'; 2 | export { EngineContext } from './EngineContext'; 3 | export { GamepadContext } from './GamepadContext'; 4 | export { KeyboardContext } from './KeyboardContext'; 5 | export { MotionContext } from './MotionContext'; 6 | export { NodeContext } from './NodeContext'; 7 | export { OrientationContext } from './OrientationContext'; 8 | export { PhysicsContext } from './PhysicsContext'; 9 | export { PointerContext } from './PointerContext'; 10 | export { VirtualInputContext } from './VirtualInputContext'; 11 | export { WorldContext } from './WorldContext'; 12 | 13 | export type { EngineContextProps } from './EngineContext'; 14 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { use8DirectionMovement } from './use8DirectionMovement'; 2 | export { useAudio } from './useAudio'; 3 | export { useAudioEngine } from './useAudioEngine'; 4 | export { useBaseStyleProperties } from './useBaseStyleProperties'; 5 | export { useBoxCollider } from './useBoxCollider'; 6 | export { useCollision, useTaggedCollision } from './useCollision'; 7 | export { useCollider } from './useCollider'; 8 | export { useDebug } from './useDebug'; 9 | export { useDevice } from './useDevice'; 10 | export { useDynamicProperty, useCachedDynamicProperty } from './useDynamicProperty'; 11 | export { useElement } from './useElement'; 12 | export { useEventHandler } from './useEventHandler'; 13 | export { useEventListeners } from './useEventListeners'; 14 | export { useFlash } from './useFlash'; 15 | export { useGamepad } from './useGamepad'; 16 | export { useGamepadAxisMap } from './useGamepadAxisMap'; 17 | export { useGamepadButtonMap } from './useGamepadButtonMap'; 18 | export { useIntegerPosition } from './useIntegerPosition'; 19 | export { useKeyboard, useKeyAxis, useKeyPressed } from './useKeyboard'; 20 | export { useKeyboardMap } from './useKeyboardMap'; 21 | export { useKeySequence } from './useKeySequence'; 22 | export { useLogMount } from './useLogMount'; 23 | export { useMergeProperty } from './useMergeProperty'; 24 | export { useMotion, useDeviceShaken } from './useMotion'; 25 | export { useNode } from './useNode'; 26 | export { useOffsetPosition } from './useOffsetPosition'; 27 | export { useOrientation } from './useOrientation'; 28 | export { useOverlap } from './useOverlap'; 29 | export { useParticles } from './useParticles'; 30 | export { usePhysicsBody, useBoxPhysics, useCirclePhysics, useSyncPositions } from './usePhysics'; 31 | export { usePhysicsEngine, usePhysicsCollision } from './usePhysicsEngine'; 32 | export { usePlatformMovement } from './usePlatformMovement'; 33 | export { usePointer } from './usePointer'; 34 | export { usePolygonCollider } from './usePolygonCollider'; 35 | export { usePosition } from './usePosition'; 36 | export { usePostCollisions } from './usePostCollisions'; 37 | export { useProperty } from './useProperty'; 38 | export { usePropertyListen } from './usePropertyListen'; 39 | export { useRender } from './useRender'; 40 | export { useShaker } from './useShaker'; 41 | export { useSpeech } from './useSpeech'; 42 | export { useSpriteSet } from './useSpriteSet'; 43 | export { useStateMachine } from './useStateMachine'; 44 | export { useSwipe } from './useSwipe'; 45 | export { useSync } from './useSync'; 46 | export { useTicker } from './useTicker'; 47 | export { useUpdate, useUpdateAfter, useFixedUpdate } from './useUpdate'; 48 | export { useViewport } from './useViewport'; 49 | export { useVirtualAction } from './useVirtualAction'; 50 | export { useVirtualInput } from './useVirtualInput'; 51 | export { useVisible } from './useVisible'; 52 | export { useWorld } from './useWorld'; 53 | 54 | export type { PlatformMovementEventType, UsePlatformMovementOptions, UsePlatformMovementResult } from './usePlatformMovement'; 55 | -------------------------------------------------------------------------------- /src/hooks/use8DirectionMovement.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Property, Position, Velocity } from "../types"; 3 | import { lerp } from "../utils"; 4 | import { useKeyboard } from "./useKeyboard"; 5 | import { useOverlap } from "./useOverlap"; 6 | import { useUpdate } from "./useUpdate"; 7 | import { useProperty } from "./useProperty"; 8 | 9 | const DEFAULT_OPTIONS = { 10 | speed: 0.5, 11 | acceleration: 0.2, 12 | } 13 | 14 | type Direction = 'n' | 'e' | 's' | 'w' | 'ne' | 'se' | 'sw' | 'nw'; 15 | 16 | type Use8DirectionMovementOptions = { 17 | speed?: number; 18 | acceleration?: number; 19 | }; 20 | 21 | type Use8DirectionMovementResult = { 22 | direction: Property; 23 | } 24 | 25 | export const use8DirectionMovement = (collider: string, pos: Property, velocity: Property, options?: Use8DirectionMovementOptions): Use8DirectionMovementResult => { 26 | const { speed, acceleration } = { ...DEFAULT_OPTIONS, ...options }; 27 | const { hasKeyAxis } = useKeyboard(); 28 | 29 | const direction = useProperty('n'); 30 | 31 | useUpdate((delta) => { 32 | // Read keyboard input. 33 | const keyboardHorizontal = hasKeyAxis('KeyA', 'KeyD'); 34 | const keyboardVertical = hasKeyAxis('KeyW', 'KeyS'); 35 | 36 | // Apply keyboard input to the player's velocity. 37 | velocity.current[0] = lerp(velocity.current[0], keyboardHorizontal * speed, acceleration); 38 | velocity.current[1] = lerp(velocity.current[1], keyboardVertical * speed, acceleration); 39 | 40 | // Apply the velocity to the player. 41 | pos.current[0] += velocity.current[0] * delta; 42 | pos.current[1] += velocity.current[1] * delta; 43 | 44 | // Update the player's direction. 45 | if (keyboardHorizontal < 0) { 46 | if (keyboardVertical < 0) { 47 | direction.current = 'nw'; 48 | } else if (keyboardVertical > 0) { 49 | direction.current = 'sw'; 50 | } else { 51 | direction.current = 'w'; 52 | } 53 | } else if (keyboardHorizontal > 0) { 54 | if (keyboardVertical < 0) { 55 | direction.current = 'ne'; 56 | } else if (keyboardVertical > 0) { 57 | direction.current = 'se'; 58 | } else { 59 | direction.current = 'e'; 60 | } 61 | } else { 62 | if (keyboardVertical < 0) { 63 | direction.current = 'n'; 64 | } else if (keyboardVertical > 0) { 65 | direction.current = 's'; 66 | } 67 | } 68 | }); 69 | 70 | useOverlap(collider, (collisions) => { 71 | for (const { overlap } of collisions) { 72 | pos.current[0] -= overlap.x; 73 | pos.current[1] -= overlap.y; 74 | } 75 | }); 76 | 77 | return useMemo(() => ({ direction }), [direction]); 78 | }; -------------------------------------------------------------------------------- /src/hooks/useAudio.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback, useMemo } from "react"; 2 | import { useAudioEngine } from "./useAudioEngine"; 3 | 4 | // CONSTANTS 5 | 6 | const DEFAULT_PLAY_OPTIONS: Required> = { 7 | volume: 1, 8 | loop: false, 9 | }; 10 | 11 | // TYPES 12 | 13 | type PlayAudioOptions = { 14 | key?: string; 15 | volume?: number; 16 | loop?: boolean; 17 | }; 18 | 19 | type UseAudioOptions = { 20 | key?: string; 21 | channel?: string; 22 | }; 23 | 24 | type UseAudioResult = { 25 | play: (url: string, options?: PlayAudioOptions) => Promise; 26 | stop: (key?: string) => void; 27 | getAudioTrack: (key?: string) => AudioTrackRef | null; 28 | }; 29 | 30 | type AudioTrackRef = { 31 | source: AudioBufferSourceNode; 32 | url: string; 33 | }; 34 | 35 | /** 36 | * useAudio 37 | * -------- 38 | * 39 | * 40 | */ 41 | export const useAudio = (rootOptions?: UseAudioOptions): UseAudioResult => { 42 | const { key: rootKey, channel } = rootOptions || {}; 43 | 44 | const engine = useAudioEngine(); 45 | const tracks = useRef>(new Map()); 46 | 47 | /** 48 | * Play an audio track, and return a promise that resolves when the track completes. 49 | */ 50 | const play = useCallback(async (url: string, options?: PlayAudioOptions): Promise => { 51 | const key = options?.key || rootKey; 52 | const { volume, loop } = { ...DEFAULT_PLAY_OPTIONS, ...options }; 53 | 54 | // Connect the gain node to the destination. 55 | const gain = new GainNode(engine.context); 56 | gain.gain.value = volume; 57 | gain.connect(engine.getChannel(channel).node); 58 | 59 | // Connect the source node to the gain node. 60 | const source = new AudioBufferSourceNode(engine.context); 61 | source.loop = loop; 62 | source.connect(gain); 63 | source.start(0); 64 | 65 | // Keep track of keyed tracks. 66 | if (key) { 67 | tracks.current.set(key, { source, url }); 68 | } 69 | 70 | // Get an audio buffer for the given url. 71 | const buffer = await engine.getBuffer(url); 72 | source.buffer = buffer; 73 | 74 | // Return a promise which resolves when the track has ended. 75 | // Note: Only clear the track if another one hasn't already replaced it. 76 | return new Promise((resolve): void => { 77 | source.addEventListener('ended', () => resolve()); 78 | }); 79 | }, [channel, engine, rootKey]); 80 | 81 | /** 82 | * Stop the track with the given key, if it is playing. 83 | */ 84 | const stop = useCallback((key?: string) => { 85 | const k = key || rootKey; 86 | 87 | if (k) { 88 | const track = tracks.current.get(k); 89 | 90 | if (track) { 91 | track.source.stop(); 92 | tracks.current.delete(k); 93 | } 94 | } 95 | }, [rootKey]); 96 | 97 | /** 98 | * Lookup an audio track by its unique key. If an audio track wasn't assigned a key, it cannot 99 | * be looked up. 100 | */ 101 | const getAudioTrack = useCallback((key?: string): AudioTrackRef | null => { 102 | const k = key || rootKey; 103 | 104 | if (k) { 105 | return tracks.current.get(k) || null; 106 | } else { 107 | return null; 108 | } 109 | }, [rootKey]); 110 | 111 | return useMemo(() => ({ play, stop, getAudioTrack }), [play, stop, getAudioTrack]); 112 | }; -------------------------------------------------------------------------------- /src/hooks/useAudioEngine.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { AudioEngineContext } from "../components"; 3 | 4 | export const useAudioEngine = () => { 5 | return useContext(AudioEngineContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useBaseStyleProperties.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { BaseStyleProps } from "../types"; 3 | import { usePosition } from "./usePosition"; 4 | import { useProperty } from "./useProperty"; 5 | import { useVisible } from "./useVisible"; 6 | 7 | export const useBaseStyleProperties = (props: BaseStyleProps) => { 8 | const pos = usePosition(props.pos); 9 | const size = useProperty(props.size); 10 | const flip = useProperty(props.flip || false); 11 | const scale = useProperty(props.scale || 1); 12 | const angle = useProperty(props.angle || 0); 13 | const visible = useVisible(props.visible); 14 | 15 | return useMemo(() => ({ pos, size, flip, scale, angle, visible}), [angle, flip, pos, scale, size, visible]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/hooks/useBoxCollider.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { Position, Property, Size } from "../types"; 3 | import { Body, BoxBody } from "../utils"; 4 | import { useCollider } from "./useCollider"; 5 | 6 | /** 7 | * Register a new box shaped collider, and keep the position of it in sync. 8 | */ 9 | export const useBoxCollider = ( 10 | id: string | undefined, 11 | active: Property, 12 | tags: Property, 13 | pos: Property, 14 | size: Property, 15 | entity?: unknown, 16 | ) => { 17 | const [x, y] = pos.current; 18 | const [w, h] = size.current; 19 | 20 | const box = useRef(new BoxBody({ x, y }, w, h, entity)); 21 | 22 | const update = useCallback((body: Body) => { 23 | body.setPosition(pos.current[0], pos.current[1]); 24 | }, [pos]); 25 | 26 | useCollider(id, active, tags, box, update); 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/useCollider.ts: -------------------------------------------------------------------------------- 1 | import { useId, useEffect, MutableRefObject } from "react"; 2 | import { CollisionUpdateFunction, Property } from "../types"; 3 | import { useWorld } from "./useWorld"; 4 | import { Body } from "../utils"; 5 | 6 | /** 7 | * Register a new collider instance. 8 | */ 9 | export const useCollider = ( 10 | id: string | undefined, 11 | active: Property, 12 | tags: Property, 13 | body: MutableRefObject, 14 | update: CollisionUpdateFunction, 15 | ) => { 16 | const { registerCollider } = useWorld(); 17 | const generatedId = useId(); 18 | const resolvedId = id || generatedId; 19 | 20 | useEffect( 21 | () => registerCollider(resolvedId, active, tags, body.current, update), 22 | [resolvedId, active, tags, update, registerCollider, body], 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/hooks/useCollision.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { CollisionEventFunction } from "../types"; 3 | import { useOverlap } from "./useOverlap"; 4 | 5 | export function useCollision(id: string, handler: CollisionEventFunction) { 6 | const wrappedHandler: CollisionEventFunction = useCallback((collisions, delta) => { 7 | const filtered = collisions.filter(({ firstTime }) => firstTime); 8 | 9 | if (filtered.length > 0) { 10 | handler(filtered, delta); 11 | } 12 | }, [handler]); 13 | 14 | return useOverlap(id, wrappedHandler); 15 | } 16 | 17 | export function useTaggedCollision(collider: string, tag: string | string[], handler: CollisionEventFunction) { 18 | const tags = typeof tag === 'string' ? [tag] : tag; 19 | 20 | useCollision(collider, (collisions, delta) => { 21 | const filtered = collisions.filter((props) => { 22 | for (tag of props.tags) { 23 | if (tags.includes(tag)) { 24 | return true; 25 | } 26 | } 27 | 28 | return false; 29 | }); 30 | 31 | if (filtered.length > 0) { 32 | handler(filtered, delta); 33 | } 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useDebug.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { EngineContext } from "../context"; 3 | import { Property } from "../types"; 4 | 5 | export const useDebug = (): Property => { 6 | const { debug } = useContext(EngineContext); 7 | return debug; 8 | } 9 | -------------------------------------------------------------------------------- /src/hooks/useDevice.test.tsx: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, Mock } from 'vitest'; 2 | import { useDevice } from '../hooks'; 3 | import { mockDeviceMotionEvent, mockDeviceOrientationEvent, mockResizeObserver, renderHook } from '../test'; 4 | import { Device, Engine } from '../components'; 5 | 6 | describe('useDevice', () => { 7 | const Wrapper = ({ children }: { children: React.ReactNode }) => ( 8 | 9 | 10 | {children} 11 | 12 | 13 | ); 14 | 15 | let observe: Mock; 16 | 17 | beforeEach(() => { 18 | observe = mockResizeObserver(256, 192); 19 | mockDeviceMotionEvent(); 20 | mockDeviceOrientationEvent(); 21 | }); 22 | 23 | it('returns the device size', () => { 24 | const { size } = renderHook(() => useDevice(), { wrapper: Wrapper }).result.current; 25 | expect(observe).toHaveBeenCalledOnce(); 26 | expect(size.current).toEqual([256, 192]); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/hooks/useDevice.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { DeviceContext } from "../context"; 3 | 4 | export const useDevice = () => { 5 | return useContext(DeviceContext); 6 | }; -------------------------------------------------------------------------------- /src/hooks/useDynamicProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { VariableProperty } from '../utils'; 4 | import { useCachedDynamicProperty, useDynamicProperty } from "./useDynamicProperty"; 5 | 6 | describe('useDynamicProperty', () => { 7 | it('calculates the dynamic property value', () => { 8 | const property = new VariableProperty(42); 9 | const callback = vi.fn().mockImplementation((value: number) => value + 10); 10 | const { result } = renderHook(() => useDynamicProperty(property, callback)); 11 | 12 | expect(result.current.current).toBe(52); 13 | expect(callback).toHaveBeenCalledTimes(1); 14 | 15 | property.current = 52; 16 | expect(result.current.current).toBe(62); 17 | expect(callback).toHaveBeenCalledTimes(2); 18 | 19 | property.current = 42; 20 | expect(result.current.current).toBe(52); 21 | expect(callback).toHaveBeenCalledTimes(3); 22 | }); 23 | 24 | it('inherits the invalidated flag', () => { 25 | const property = new VariableProperty(42); 26 | const callback = vi.fn().mockImplementation((value: number) => value + 10); 27 | const { result } = renderHook(() => useDynamicProperty(property, callback)); 28 | 29 | result.current.invalidated = true; 30 | expect(result.current.invalidated).toBe(true); 31 | 32 | property.invalidated = false; 33 | nextFrame(); 34 | expect(result.current.invalidated).toBe(false); 35 | }); 36 | 37 | it('sets the invalidated flag upstream', () => { 38 | const property = new VariableProperty(42); 39 | const callback = vi.fn().mockImplementation((value: number) => value + 10); 40 | const { result } = renderHook(() => useDynamicProperty(property, callback)); 41 | 42 | expect(result.current.invalidated).toBe(true); 43 | 44 | result.current.invalidated = false; 45 | nextFrame(); 46 | expect(property.invalidated).toBe(false); 47 | }); 48 | }); 49 | 50 | describe('useCachedDynamicProperty', () => { 51 | it('calculates the dynamic property value', () => { 52 | const property = new VariableProperty(42); 53 | const callback = vi.fn().mockImplementation((value: number) => value + 10); 54 | const { result } = renderHook(() => useCachedDynamicProperty(property, callback)); 55 | 56 | expect(result.current.current).toBe(52); 57 | expect(callback).toHaveBeenCalledTimes(1); 58 | 59 | property.current = 52; 60 | expect(result.current.current).toBe(62); 61 | expect(callback).toHaveBeenCalledTimes(2); 62 | }); 63 | 64 | it('does not recalculate a cached value', () => { 65 | const property = new VariableProperty(42); 66 | const callback = vi.fn().mockImplementation((value: number) => value + 10); 67 | const { result } = renderHook(() => useCachedDynamicProperty(property, callback)); 68 | 69 | expect(result.current.current).toBe(52); 70 | expect(callback).toHaveBeenCalledTimes(1); 71 | 72 | property.current = 52; 73 | expect(result.current.current).toBe(62); 74 | expect(callback).toHaveBeenCalledTimes(2); 75 | 76 | property.current = 42; 77 | expect(result.current.current).toBe(52); 78 | expect(callback).toHaveBeenCalledTimes(2); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/hooks/useDynamicProperty.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "react"; 2 | import { Property } from "../types"; 3 | import { DynamicProperty } from "../utils"; 4 | 5 | /** 6 | * Create a new dynamic property, with a value derived from another property. 7 | */ 8 | export function useDynamicProperty( 9 | value: Property, 10 | fn: (value: IN) => OUT, 11 | ) { 12 | const ref = useRef(fn); 13 | 14 | return useMemo(() => new DynamicProperty(value, ref.current), [ref, value]); 15 | } 16 | 17 | /** 18 | * Create a new dynamic property, with a value derived from another property, with the output 19 | * values cached against the input values, so the function will only be called when an unseen input 20 | * values is encountered. 21 | */ 22 | export function useCachedDynamicProperty( 23 | value: Property, 24 | fn: (value: IN) => OUT, 25 | ) { 26 | const cache = useRef>(new Map()); 27 | const ref = useRef((value: IN): OUT => { 28 | const cached = cache.current.get(value); 29 | 30 | if (cached !== undefined) { 31 | return cached; 32 | } 33 | 34 | const output = fn(value); 35 | cache.current.set(value, output); 36 | return output 37 | }); 38 | 39 | return useMemo(() => new DynamicProperty(value, ref.current), [ref, value]); 40 | } 41 | -------------------------------------------------------------------------------- /src/hooks/useEventHandler.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { useEventListeners } from "./useEventListeners"; 4 | import { useEventHandler } from './useEventHandler'; 5 | 6 | describe('useEventHandler', () => { 7 | describe('when an event is fired', () => { 8 | describe('and there is a listener', () => { 9 | it('calls the listener', () => { 10 | const listener = vi.fn(); 11 | const target = renderHook(() => useEventListeners<'jump'>()).result.current; 12 | renderHook(() => useEventHandler(target, 'jump', listener)); 13 | 14 | target.fireEvent('jump', undefined); 15 | expect(listener).toBeCalledTimes(1); 16 | expect(listener).toBeCalledWith(undefined); 17 | }); 18 | }); 19 | 20 | describe('but there is no listener', () => { 21 | describe('when a listener is registered', () => { 22 | it('calls the listener', () => { 23 | const listener = vi.fn(); 24 | const target = renderHook(() => useEventListeners<'jump'>()).result.current; 25 | 26 | target.fireEvent('jump', undefined); 27 | expect(listener).not.toBeCalled(); 28 | 29 | renderHook(() => useEventHandler(target, 'jump', listener)); 30 | expect(listener).toBeCalledTimes(1); 31 | expect(listener).toBeCalledWith(undefined); 32 | }); 33 | }); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/hooks/useEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { UseEventTarget } from "../types"; 3 | 4 | export function useEventHandler(target: UseEventTarget, name: E, fn: (payload: T) => void): void { 5 | useEffect(() => { 6 | target.addEventListener(name, fn); 7 | return () => target.removeEventListener(name, fn); 8 | }, [fn, name, target]); 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/useEventListeners.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { useEventListeners } from "./useEventListeners"; 4 | 5 | describe('useEventListeners', () => { 6 | describe('addEventListener', () => { 7 | it('adds an new event listener', () => { 8 | const { result } = renderHook(() => useEventListeners()); 9 | const { addEventListener, fireEvent } = result.current; 10 | const jump = vi.fn(); 11 | 12 | addEventListener('jump', jump); 13 | expect(jump).not.toHaveBeenCalled(); 14 | 15 | fireEvent('jump', undefined); 16 | expect(jump).toHaveBeenCalledOnce(); 17 | }); 18 | }); 19 | 20 | describe('removeEventListener', () => { 21 | it('removes an new event listener', () => { 22 | const { result } = renderHook(() => useEventListeners()); 23 | const { addEventListener, removeEventListener, fireEvent } = result.current; 24 | const jump = vi.fn(); 25 | 26 | addEventListener('jump', jump); 27 | removeEventListener('jump', jump); 28 | fireEvent('jump', undefined); 29 | expect(jump).not.toHaveBeenCalled(); 30 | }); 31 | }); 32 | 33 | describe('fireEvent', () => { 34 | it('only calls listeners of the correct type', () => { 35 | const { result } = renderHook(() => useEventListeners()); 36 | const { addEventListener, fireEvent } = result.current; 37 | const jump = vi.fn(); 38 | const fall = vi.fn(); 39 | 40 | addEventListener('jump', jump); 41 | addEventListener('fall', fall); 42 | 43 | fireEvent('jump', undefined); 44 | expect(jump).toHaveBeenCalledOnce(); 45 | expect(fall).not.toHaveBeenCalled(); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/hooks/useEventListeners.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback, useMemo } from "react"; 2 | import { EventHandler, UseEventListenersResult } from "../types"; 3 | import { EventTarget } from "../utils/EventTarget"; 4 | 5 | export function useEventListeners(): UseEventListenersResult { 6 | const events = useRef(new EventTarget()); 7 | 8 | const addEventListener = useCallback((type: E, fn: EventHandler) => { 9 | events.current.addEventListener(type, fn); 10 | }, []); 11 | 12 | const removeEventListener = useCallback((type: E, fn: EventHandler) => { 13 | events.current.removeEventListener(type, fn); 14 | }, []); 15 | 16 | const fireEvent = useCallback((type: E, payload: T) => { 17 | events.current.fireEvent(type, payload); 18 | }, []); 19 | 20 | return useMemo(() => ({ addEventListener, removeEventListener, fireEvent }), [addEventListener, removeEventListener, fireEvent]); 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useFlash.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { useFlash } from './useFlash'; 4 | 5 | describe('useFlash', () => { 6 | it('alternates between true and false', () => { 7 | const value = renderHook(() => useFlash(30)).result.current; 8 | expect(value.current).toBe(true); 9 | 10 | nextFrame(); 11 | expect(value.current).toBe(true); 12 | 13 | nextFrame(); 14 | expect(value.current).toBe(false); 15 | 16 | nextFrame(); 17 | expect(value.current).toBe(false); 18 | 19 | nextFrame(); 20 | expect(value.current).toBe(true); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/hooks/useFlash.ts: -------------------------------------------------------------------------------- 1 | import { useProperty } from "./useProperty"; 2 | import { useUpdate } from "./useUpdate"; 3 | 4 | export const useFlash = (duration: number) => { 5 | const visible = useProperty(true); 6 | const cooldown = useProperty(duration); 7 | 8 | useUpdate((delta) => { 9 | cooldown.current -= delta; 10 | 11 | if (cooldown.current <= 0) { 12 | cooldown.current += duration; 13 | visible.current = !visible.current; 14 | } 15 | }); 16 | 17 | return visible; 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/useGamepad.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { mockGamepads, resetGamepads, renderHook, MockGamepad } from '../test'; 3 | import { useGamepad } from "./useGamepad"; 4 | 5 | describe('useGamepad', () => { 6 | let gamepad: MockGamepad; 7 | 8 | beforeEach(() => { 9 | gamepad = mockGamepads(); 10 | }); 11 | 12 | afterEach(() => { 13 | resetGamepads(); 14 | }); 15 | 16 | describe('isButtonDown', () => { 17 | it('returns false by default', () => { 18 | const { result } = renderHook(() => useGamepad()); 19 | const { isButtonDown } = result.current; 20 | expect(isButtonDown(0, 'Shoulder_R1')).toBe(false); 21 | }); 22 | 23 | describe('when the button is pressed', () => { 24 | it('returns true', () => { 25 | const { result } = renderHook(() => useGamepad()); 26 | const { isButtonDown } = result.current; 27 | gamepad.simulateButtonDown(5); 28 | expect(isButtonDown(0, 'Shoulder_R1')).toBe(true); 29 | }); 30 | }); 31 | 32 | describe('when the button is released', () => { 33 | it('returns true', () => { 34 | const { result } = renderHook(() => useGamepad()); 35 | const { isButtonDown } = result.current; 36 | gamepad.simulateButtonDown(5); 37 | gamepad.simulateButtonUp(5); 38 | expect(isButtonDown(0, 'Shoulder_R1')).toBe(false); 39 | }); 40 | }); 41 | }); 42 | 43 | describe('getButtonAxis', () => { 44 | describe('when neither button is pressed', () => { 45 | it('returns 0', () => { 46 | const { result } = renderHook(() => useGamepad()); 47 | const { getButtonAxis } = result.current; 48 | expect(getButtonAxis(0, 'Left', 'Right')).toBe(0); 49 | }); 50 | }); 51 | 52 | describe('when negative button is pressed', () => { 53 | it('returns -1', () => { 54 | const { result } = renderHook(() => useGamepad()); 55 | const { getButtonAxis } = result.current; 56 | gamepad.simulateButtonDown(14); 57 | expect(getButtonAxis(0, 'Left', 'Right')).toBe(-1); 58 | }); 59 | }); 60 | 61 | describe('when the positive button is pressed', () => { 62 | it('returns 1', () => { 63 | const { result } = renderHook(() => useGamepad()); 64 | const { getButtonAxis } = result.current; 65 | gamepad.simulateButtonDown(15); 66 | expect(getButtonAxis(0, 'Left', 'Right')).toBe(1); 67 | }); 68 | }); 69 | 70 | describe('when both buttons are pressed', () => { 71 | it('returns 0', () => { 72 | const { result } = renderHook(() => useGamepad()); 73 | const { getButtonAxis } = result.current; 74 | gamepad.simulateButtonDown(14); 75 | gamepad.simulateButtonDown(15); 76 | expect(getButtonAxis(0, 'Left', 'Right')).toBe(0); 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/hooks/useGamepad.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { GamepadContext } from "../context"; 3 | 4 | export const useGamepad = () => { 5 | return useContext(GamepadContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useGamepadAxisMap.ts: -------------------------------------------------------------------------------- 1 | import { GamepadAxisMap, GamepadAxisName, Prop } from "../types"; 2 | import { useGamepad } from "./useGamepad"; 3 | import { useProperty } from "./useProperty"; 4 | import { useUpdate } from "./useUpdate"; 5 | import { useVirtualInput } from "./useVirtualInput"; 6 | 7 | const THRESHOLD = 0.5; 8 | 9 | export const useGamepadAxisMap = (index: Prop, map: GamepadAxisMap, active?: Prop) => { 10 | const { simulate } = useVirtualInput(); 11 | const { getAnalogAxis } = useGamepad(); 12 | 13 | const gamepadIndex = useProperty(index); 14 | const isActive = useProperty(active === undefined ? true : active); 15 | 16 | useUpdate(() => { 17 | if (isActive.current && gamepadIndex.current !== null) { 18 | for (const axis in map) { 19 | const [negative, positive] = map[axis as GamepadAxisName] || []; 20 | 21 | if (negative && getAnalogAxis(gamepadIndex.current, axis as GamepadAxisName) <= -THRESHOLD) { 22 | simulate(negative); 23 | } 24 | 25 | if (positive && getAnalogAxis(gamepadIndex.current, axis as GamepadAxisName) >= THRESHOLD) { 26 | simulate(positive); 27 | } 28 | } 29 | } 30 | }); 31 | }; -------------------------------------------------------------------------------- /src/hooks/useGamepadButtonMap.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { MockGamepad, mockGamepads, nextFrame, renderHook, resetGamepads } from '../test'; 3 | import { useGamepadButtonMap } from './useGamepadButtonMap'; 4 | import { useVirtualInput } from './useVirtualInput'; 5 | 6 | describe('useGamepadButtonMap', () => { 7 | const renderSubject = (active?: boolean) => { 8 | return renderHook(() => { 9 | useGamepadButtonMap(0, { Shoulder_R1: 'jump' }, active); 10 | const input = useVirtualInput(); 11 | return { input }; 12 | }); 13 | } 14 | 15 | let gamepad: MockGamepad; 16 | 17 | beforeEach(() => { 18 | gamepad = mockGamepads(); 19 | }); 20 | 21 | afterEach(() => { 22 | resetGamepads(); 23 | }); 24 | 25 | describe('when a mapped button is pressed', () => { 26 | it('activates the associated virtual input', () => { 27 | const { result } = renderSubject(); 28 | const { input } = result.current; 29 | 30 | expect(input.isActive('jump')).toBe(false); 31 | 32 | gamepad.simulateButtonDown(5); 33 | nextFrame(); 34 | expect(input.isActive('jump')).toBe(true); 35 | }); 36 | 37 | describe('but the map is not active', () => { 38 | it('does not activate the associated virtual input', () => { 39 | const { result } = renderSubject(false); 40 | const { input } = result.current; 41 | 42 | expect(input.isActive('jump')).toBe(false); 43 | 44 | gamepad.simulateButtonDown(5); 45 | nextFrame(); 46 | expect(input.isActive('jump')).toBe(false); 47 | }); 48 | }) 49 | }); 50 | }); -------------------------------------------------------------------------------- /src/hooks/useGamepadButtonMap.ts: -------------------------------------------------------------------------------- 1 | import { GamepadButtonMap, GamepadButtonName, Prop } from "../types"; 2 | import { useGamepad } from "./useGamepad"; 3 | import { useProperty } from "./useProperty"; 4 | import { useUpdate } from "./useUpdate"; 5 | import { useVirtualInput } from "./useVirtualInput"; 6 | 7 | export const useGamepadButtonMap = (index: Prop, map: Prop, active?: Prop) => { 8 | const { simulate } = useVirtualInput(); 9 | const { isButtonDown } = useGamepad(); 10 | 11 | const gamepadIndex = useProperty(index); 12 | const isActive = useProperty(active === undefined ? true : active); 13 | const bindings = useProperty(map); 14 | 15 | useUpdate(() => { 16 | if (isActive.current && gamepadIndex.current !== null) { 17 | for (const button in bindings.current) { 18 | const action = bindings.current[button as GamepadButtonName]; 19 | 20 | if (action && isButtonDown(gamepadIndex.current, button as GamepadButtonName)) { 21 | simulate(action); 22 | } 23 | } 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/hooks/useIntegerPosition.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { Position } from '../types'; 4 | import { VariableProperty } from '../utils'; 5 | import { useIntegerPosition } from './useIntegerPosition'; 6 | 7 | describe('useIntegerPosition', () => { 8 | it('rounds positive values', () => { 9 | const pos = new VariableProperty([9.1, 9.5]); 10 | const { result } = renderHook(() => useIntegerPosition(pos)); 11 | expect(result.current.current).toEqual([9, 10]); 12 | }); 13 | 14 | it('rounds near-zero positive values', () => { 15 | const pos = new VariableProperty([0.1, 0.5]); 16 | const { result } = renderHook(() => useIntegerPosition(pos)); 17 | expect(result.current.current).toEqual([0, 1]); 18 | }); 19 | 20 | it('rounds near-zero negative values', () => { 21 | const pos = new VariableProperty([-0.1, -0.5]); 22 | const { result } = renderHook(() => useIntegerPosition(pos)); 23 | expect(result.current.current).toEqual([-0, -0]); 24 | }); 25 | 26 | it('rounds negative values', () => { 27 | const pos = new VariableProperty([-0.9, -9.5]); 28 | const { result } = renderHook(() => useIntegerPosition(pos)); 29 | expect(result.current.current).toEqual([-1, -9]); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/hooks/useIntegerPosition.ts: -------------------------------------------------------------------------------- 1 | import { Position, Property } from "../types"; 2 | import { useDynamicProperty } from "./useDynamicProperty"; 3 | 4 | export const useIntegerPosition = (pos: Property) => { 5 | return useDynamicProperty(pos, (pos): Position => [ 6 | Math.round(pos[0]), 7 | Math.round(pos[1]), 8 | ]); 9 | }; 10 | -------------------------------------------------------------------------------- /src/hooks/useKeySequence.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardKeyName } from "../types"; 2 | import { useKeyboard } from "./useKeyboard"; 3 | import { useProperty } from "./useProperty"; 4 | import { useUpdate } from "./useUpdate"; 5 | 6 | const KEYS: Record = { 7 | A: 'KeyA', 8 | B: 'KeyB', 9 | C: 'KeyC', 10 | D: 'KeyD', 11 | E: 'KeyE', 12 | F: 'KeyF', 13 | G: 'KeyG', 14 | H: 'KeyH', 15 | I: 'KeyI', 16 | J: 'KeyJ', 17 | K: 'KeyK', 18 | L: 'KeyL', 19 | M: 'KeyM', 20 | N: 'KeyN', 21 | O: 'KeyO', 22 | P: 'KeyP', 23 | Q: 'KeyQ', 24 | S: 'KeyS', 25 | T: 'KeyT', 26 | U: 'KeyU', 27 | V: 'KeyV', 28 | W: 'KeyW', 29 | X: 'KeyX', 30 | Y: 'KeyY', 31 | Z: 'KeyZ', 32 | }; 33 | 34 | export const useKeySequence = (sequence: string, fn: () => void) => { 35 | const keyboard = useKeyboard(); 36 | const index = useProperty(0); 37 | 38 | useUpdate(() => { 39 | const nextKey = KEYS[sequence[index.current]]; 40 | 41 | if (keyboard.isKeyPressed(nextKey)) { 42 | index.current += 1; 43 | } else if (keyboard.isAnyKeyPressed()) { 44 | index.current = 0; 45 | } 46 | 47 | if (index.current === sequence.length) { 48 | index.current = 0; 49 | fn(); 50 | } 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /src/hooks/useKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { KeyboardContext } from "../context"; 3 | import { useUpdate } from "./useUpdate"; 4 | import { KeyboardKeyName } from "../types"; 5 | 6 | export const useKeyboard = () => { 7 | return useContext(KeyboardContext); 8 | }; 9 | 10 | export const useKeyPressed = (key: KeyboardKeyName, fn: () => void) => { 11 | const { isKeyPressed } = useKeyboard(); 12 | 13 | useUpdate(() => { 14 | if (isKeyPressed(key)) { 15 | fn(); 16 | } 17 | }); 18 | }; 19 | 20 | type UseKeyAxisHandler = (value: number, delta: number) => void 21 | 22 | export const useKeyAxis = (negative: KeyboardKeyName, positive: KeyboardKeyName, fn: UseKeyAxisHandler) => { 23 | const { hasKeyAxis } = useKeyboard(); 24 | 25 | useUpdate((delta) => { 26 | const value = hasKeyAxis(negative, positive); 27 | fn(value, delta); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /src/hooks/useKeyboardMap.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { useKeyboard } from "./useKeyboard"; 4 | import { useKeyboardMap, useVirtualInput } from '.'; 5 | 6 | describe('useKeyboardMap', () => { 7 | const renderSubject = (active?: boolean) => { 8 | return renderHook(() => { 9 | useKeyboardMap({ KeyW: 'jump' }, active); 10 | const keyboard = useKeyboard(); 11 | const input = useVirtualInput(); 12 | return { keyboard, input }; 13 | }); 14 | } 15 | 16 | describe('when a mapped key is pressed', () => { 17 | it('activates the associated virtual input', () => { 18 | const { result } = renderSubject(); 19 | const { keyboard, input } = result.current; 20 | 21 | expect(input.isActive('jump')).toBe(false); 22 | 23 | keyboard.simulateKeyDown('KeyW'); 24 | nextFrame(); 25 | expect(input.isActive('jump')).toBe(true); 26 | }); 27 | 28 | describe('but the map is not active', () => { 29 | it('does not activate the associated virtual input', () => { 30 | const { result } = renderSubject(false); 31 | const { keyboard, input } = result.current; 32 | 33 | expect(input.isActive('jump')).toBe(false); 34 | 35 | keyboard.simulateKeyDown('KeyW'); 36 | nextFrame(); 37 | expect(input.isActive('jump')).toBe(false); 38 | }); 39 | }) 40 | }); 41 | }); -------------------------------------------------------------------------------- /src/hooks/useKeyboardMap.ts: -------------------------------------------------------------------------------- 1 | import { useProperty } from "."; 2 | import { KeyboardKeyName, KeyboardMap, Prop } from "../types"; 3 | import { useKeyboard } from "./useKeyboard"; 4 | import { useUpdate } from "./useUpdate"; 5 | import { useVirtualInput } from "./useVirtualInput"; 6 | 7 | export const useKeyboardMap = (map: Prop, active?: Prop) => { 8 | const { simulate } = useVirtualInput(); 9 | const { isKeyDown } = useKeyboard(); 10 | 11 | const isActive = useProperty(active === undefined ? true : active); 12 | const bindings = useProperty(map); 13 | 14 | useUpdate(() => { 15 | if (isActive.current) { 16 | for (const key in bindings.current) { 17 | const action = bindings.current[key as KeyboardKeyName]; 18 | 19 | if (action && key !== null && isKeyDown(key as KeyboardKeyName)) { 20 | simulate(action); 21 | } 22 | } 23 | } 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/hooks/useLogMount.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { useLogMount } from "./useLogMount"; 4 | 5 | describe('useLogMount', () => { 6 | it('outputs to the console on mount', () => { 7 | const spy = vi.spyOn(global.console, 'log').mockImplementation(() => {}); 8 | expect(spy).not.toHaveBeenCalled(); 9 | 10 | const { unmount } = renderHook(() => useLogMount('testing')); 11 | 12 | expect(spy).toHaveBeenCalledOnce(); 13 | expect(spy).toHaveBeenLastCalledWith('testing.mount'); 14 | 15 | unmount(); 16 | spy.mockRestore(); 17 | }); 18 | 19 | it('outputs to the console on unmount', () => { 20 | const spy = vi.spyOn(global.console, 'log').mockImplementation(() => {}); 21 | expect(spy).not.toHaveBeenCalled(); 22 | 23 | const { unmount } = renderHook(() => useLogMount('testing')); 24 | 25 | unmount(); 26 | expect(spy).toHaveBeenCalledTimes(2); 27 | expect(spy).toHaveBeenLastCalledWith('testing.unmount'); 28 | 29 | spy.mockRestore(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/hooks/useLogMount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDebug } from "./useDebug"; 3 | 4 | export const useLogMount = (name: string) => { 5 | const debug = useDebug(); 6 | 7 | useEffect(() => { 8 | if (debug) { 9 | console.log(`${name}.mount`); 10 | } 11 | 12 | return () => { 13 | if (debug) { 14 | console.log(`${name}.unmount`); 15 | } 16 | }; 17 | }, [debug, name]); 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/useMergeProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { VariableProperty } from '../utils'; 4 | import { useMergeProperty } from "./useMergeProperty"; 5 | 6 | describe('useMergeProperty', () => { 7 | it('calculates a value from the two upstream properties', () => { 8 | const a = new VariableProperty(42); 9 | const b = new VariableProperty(20); 10 | const callback = vi.fn().mockImplementation((a: number, b: number) => a + b); 11 | const { result } = renderHook(() => useMergeProperty(a, b, callback)); 12 | 13 | expect(result.current.current).toBe(62); 14 | expect(callback).toHaveBeenCalledTimes(1); 15 | 16 | a.current = 52; 17 | expect(result.current.current).toBe(72); 18 | expect(callback).toHaveBeenCalledTimes(2); 19 | 20 | b.current = 10; 21 | expect(result.current.current).toBe(62); 22 | expect(callback).toHaveBeenCalledTimes(3); 23 | }); 24 | 25 | it('inherits the invalidated flag', () => { 26 | const a = new VariableProperty(42); 27 | const b = new VariableProperty(10); 28 | const callback = vi.fn().mockImplementation((a: number, b: number) => a + b); 29 | const { result } = renderHook(() => useMergeProperty(a, b, callback)); 30 | 31 | expect(result.current.invalidated).toBe(true); 32 | 33 | a.invalidated = false; 34 | b.invalidated = false; 35 | nextFrame(); 36 | expect(result.current.invalidated).toBe(false); 37 | }); 38 | 39 | it('sets the invalidated flag upstream', () => { 40 | const a = new VariableProperty(42); 41 | const b = new VariableProperty(10); 42 | const callback = vi.fn().mockImplementation((a: number, b: number) => a + b); 43 | const { result } = renderHook(() => useMergeProperty(a, b, callback)); 44 | 45 | expect(result.current.invalidated).toBe(true); 46 | 47 | result.current.invalidated = false; 48 | nextFrame(); 49 | expect(a.invalidated).toBe(false); 50 | expect(b.invalidated).toBe(false); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/hooks/useMergeProperty.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "react"; 2 | import { Property } from "../types"; 3 | import { MergeProperty } from "../utils"; 4 | 5 | export function useMergeProperty( 6 | a: Property, 7 | b: Property, 8 | fn: (a: A, b: B) => OUT, 9 | ) { 10 | const ref = useRef(fn); 11 | return useMemo(() => new MergeProperty(a, b, ref.current), [ref, a, b]); 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useMotion.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useMemo, useRef } from "react"; 2 | import { MotionContext } from "../context"; 3 | import { MotionContextProps } from "../context/MotionContext"; 4 | import { useUpdate } from "./useUpdate"; 5 | 6 | type UseMotionResult = Omit & { 7 | isShaking: (cooldown?: number) => boolean; 8 | } 9 | 10 | export const useMotion = (): UseMotionResult => { 11 | const context = useContext(MotionContext); 12 | const remaining = useRef(0); 13 | 14 | const isShaking = useCallback((cooldown: number = 0) => { 15 | if (remaining.current === 0 && context.isShaking()) { 16 | remaining.current = cooldown; 17 | return true; 18 | } 19 | 20 | return false; 21 | }, [context]); 22 | 23 | useUpdate((delta) => { 24 | remaining.current = Math.max(0, remaining.current - delta); 25 | }); 26 | 27 | return useMemo(() => ({ ...context, isShaking }), [context, isShaking]); 28 | }; 29 | 30 | export const useDeviceShaken = (cooldown: number, fn: () => void) => { 31 | const { isShaking } = useMotion(); 32 | 33 | useUpdate(() => { 34 | if (isShaking(cooldown)) { 35 | fn(); 36 | } 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/hooks/useNode.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback, useMemo, useId } from "react"; 2 | import { UpdateFunction, RenderFunction, TickerFunction, UpdateConfig, UpdateOptions, Prop } from "../types"; 3 | import { useRender } from "./useRender"; 4 | import { useUpdate } from "./useUpdate"; 5 | import { useTicker } from "./useTicker"; 6 | import { useProperty } from "."; 7 | 8 | type UseNodeOptions = { 9 | timeScale?: Prop; 10 | name?: string; 11 | } 12 | 13 | export const useNode = (options?: UseNodeOptions) => { 14 | const tickers = useRef>(new Map()); 15 | const updates = useRef>(new Map()); 16 | const renders = useRef>(new Map()); 17 | 18 | const timeScale = useProperty(options?.timeScale || 1); 19 | 20 | const ticker = useCallback((delta: number, time: number) => { 21 | for (const entry of tickers.current) { 22 | entry[1](delta, time); 23 | } 24 | }, []); 25 | 26 | const update = useCallback((delta: number, time: number) => { 27 | const waiting: Map = new Map(); 28 | const completed: Set = new Set(); 29 | 30 | 31 | for (const [id, config] of updates.current) { 32 | const { fn, after } = config; 33 | if (after && !completed.has(after)) { 34 | waiting.set(id, config); 35 | } else { 36 | fn(delta * timeScale.current, time); 37 | completed.add(id); 38 | } 39 | } 40 | 41 | let previousCycleSize = waiting.size; 42 | while (waiting.size > 0) { 43 | for (const [id, config] of waiting) { 44 | const { fn, after } = config; 45 | if (after && completed.has(after)) { 46 | fn(delta * timeScale.current, time); 47 | completed.add(id); 48 | waiting.delete(id); 49 | } 50 | } 51 | 52 | if (waiting.size === previousCycleSize) { 53 | break; 54 | } 55 | 56 | previousCycleSize = waiting.size; 57 | } 58 | }, [timeScale]); 59 | 60 | const render = useCallback(() => { 61 | for (const entry of renders.current) { 62 | entry[1](); 63 | } 64 | }, []); 65 | 66 | const registerTicker = useCallback((id: string, fn: TickerFunction) => { 67 | tickers.current.set(id, fn); 68 | return () => tickers.current.delete(id); 69 | }, []); 70 | 71 | const registerUpdate = useCallback((id: string, fn: UpdateFunction, options?: UpdateOptions) => { 72 | updates.current.set(id, { ...options, fn }); 73 | return () => updates.current.delete(id); 74 | }, []); 75 | 76 | const registerRender = useCallback((id: string, fn: RenderFunction) => { 77 | renders.current.set(id, fn); 78 | return () => renders.current.delete(id); 79 | }, []); 80 | 81 | const generatedId = useId(); 82 | const id = options?.name ? 'useNode.' + options?.name : generatedId; 83 | 84 | useTicker(ticker); 85 | useUpdate(update, { id }); 86 | useRender(render); 87 | 88 | return useMemo( 89 | () => ({ ticker, update, render, registerTicker, registerUpdate, registerRender }), 90 | [ticker, update, render, registerTicker, registerUpdate, registerRender] 91 | ); 92 | } -------------------------------------------------------------------------------- /src/hooks/useOffsetPosition.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { Position } from '../types'; 4 | import { VariableProperty } from '../utils'; 5 | import { useOffsetPosition } from './useOffsetPosition'; 6 | 7 | describe('useOffsetPosition', () => { 8 | describe('when offset is not a property', () => { 9 | it('combines the position and offset', () => { 10 | const pos = new VariableProperty([100, 50]); 11 | const offset: Position = [10, 20]; 12 | const { result } = renderHook(() => useOffsetPosition(pos, offset)); 13 | expect(result.current.current).toEqual([110, 70]); 14 | 15 | pos.current = [300, 200]; 16 | expect(result.current.current).toEqual([310, 220]); 17 | }); 18 | }); 19 | 20 | describe('when offset is a property', () => { 21 | it('combines the position and offset', () => { 22 | const pos = new VariableProperty([100, 50]); 23 | const offset = new VariableProperty([10, 20]); 24 | const { result } = renderHook(() => useOffsetPosition(pos, offset)); 25 | expect(result.current.current).toEqual([110, 70]); 26 | 27 | pos.current = [300, 200]; 28 | expect(result.current.current).toEqual([310, 220]); 29 | 30 | offset.current = [40, 50]; 31 | expect(result.current.current).toEqual([340, 250]); 32 | }); 33 | 34 | it('combines the child invalidated flags', () => { 35 | const pos = new VariableProperty([100, 50]); 36 | const offset = new VariableProperty([10, 20]); 37 | const { result } = renderHook(() => useOffsetPosition(pos, offset)); 38 | expect(result.current.invalidated).toBe(true); 39 | 40 | pos.invalidated = false; 41 | nextFrame(); 42 | expect(result.current.invalidated).toBe(true); 43 | 44 | offset.invalidated = false; 45 | nextFrame(); 46 | expect(result.current.invalidated).toBe(false); 47 | 48 | pos.invalidated = true; 49 | offset.invalidated = true; 50 | nextFrame(); 51 | expect(result.current.invalidated).toBe(true); 52 | 53 | offset.invalidated = false; 54 | nextFrame(); 55 | expect(result.current.invalidated).toBe(true); 56 | 57 | pos.invalidated = false; 58 | nextFrame(); 59 | expect(result.current.invalidated).toBe(false); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/hooks/useOffsetPosition.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { Position, Prop, Property } from "../types"; 3 | import { useProperty } from "./useProperty"; 4 | 5 | export const useOffsetPosition = (value: Property, offset: Prop) => { 6 | const _offset = useProperty(offset); 7 | return useMemo(() => new DynamicPosition(value, _offset), [_offset, value]); 8 | } 9 | 10 | export class DynamicPosition { 11 | 12 | value: Property; 13 | offset: Property; 14 | 15 | constructor(value: Property, offset: Property) { 16 | this.value = value; 17 | this.offset = offset; 18 | } 19 | 20 | listen(fn: (value: Position) => void) { 21 | return this.value.listen(() => fn(this.current)); 22 | } 23 | 24 | get current(): [number, number] { 25 | const x = this.value.current[0] + this.offset.current[0]; 26 | const y = this.value.current[1] + this.offset.current[1]; 27 | return [x, y]; 28 | } 29 | 30 | get invalidated(): boolean { 31 | return this.value.invalidated || this.offset.invalidated; 32 | } 33 | 34 | set invalidated(_: boolean) { 35 | // Do nothing! 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/useOrientation.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { OrientationContext } from "../context"; 3 | 4 | export const useOrientation = () => { 5 | return useContext(OrientationContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useOverlap.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useWorld } from "./useWorld"; 3 | import { CollisionEventFunction } from "../types"; 4 | 5 | export function useOverlap(id: string, handler: CollisionEventFunction) { 6 | const { registerHandler } = useWorld(); 7 | 8 | useEffect(() => { 9 | return registerHandler?.(id, handler); 10 | }, [id, handler, registerHandler]); 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useParticles.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ParticleContext } from "../components/ParticleEngine"; 3 | 4 | export const useParticles = () => { 5 | return useContext(ParticleContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/usePhysics.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useCallback, useContext, useEffect, useRef } from "react"; 2 | import { Bodies, Body } from "matter-js"; 3 | import { PhysicsContext } from "../context"; 4 | import { Property, PhysicsUpdateFunction, Position, Size } from "../types"; 5 | import { usePropertyListen } from "./usePropertyListen"; 6 | 7 | /** 8 | * Register a new physics body. 9 | */ 10 | export const usePhysicsBody = ( 11 | body: MutableRefObject, 12 | update: PhysicsUpdateFunction, 13 | ) => { 14 | const { register } = useContext(PhysicsContext); 15 | 16 | useEffect( 17 | () => register(body.current, update), 18 | [body, register, update], 19 | ); 20 | }; 21 | 22 | /** 23 | * Register a new box shaped physics body. 24 | */ 25 | export const useBoxPhysics = ( 26 | pos: Property, 27 | size: Property, 28 | options?: Matter.IBodyDefinition, 29 | ): MutableRefObject => { 30 | const [x, y] = pos.current; 31 | const [w, h] = size.current; 32 | 33 | const body = useRef(Bodies.rectangle(x, y, w, h, options)); 34 | const update = useSyncPositions(body, pos); 35 | 36 | usePhysicsBody(body, update); 37 | 38 | return body; 39 | }; 40 | 41 | /** 42 | * Register a new circular physics body. 43 | */ 44 | export const useCirclePhysics = ( 45 | pos: Property, 46 | radius: Property, 47 | options?: Matter.IBodyDefinition, 48 | ): MutableRefObject => { 49 | const [x, y] = pos.current; 50 | const r = radius.current; 51 | 52 | const body = useRef(Bodies.circle(x, y, r, options)); 53 | const update = useSyncPositions(body, pos); 54 | 55 | usePhysicsBody(body, update); 56 | 57 | return body; 58 | }; 59 | 60 | /** 61 | * Keep the given position property in sync with the position of the physics body. 62 | * To do this we have to keep track of the last position, so that if it is manually changed, the 63 | * physics body changes to match, otherwise, the physics body position take priority. 64 | */ 65 | export const useSyncPositions = (body: MutableRefObject, pos: Property) => { 66 | // Update the position of the physics body in the physics engine. 67 | const updateBody = useCallback(([x, y]: Position) => { 68 | if (x !== body.current.position.x || y !== body.current.position.y) { 69 | Body.setPosition(body.current, { x, y }); 70 | } 71 | }, [body]); 72 | 73 | // Listen for changes made to the position, then reflect those changes in the physics engine. 74 | usePropertyListen(pos, updateBody); 75 | 76 | // Return a callback that is called by the physics engine to synchronise the position property. 77 | return useCallback(({ position: { x, y } }: Matter.Body) => { 78 | if (x !== pos.current[0] || y !== pos.current[1]) { 79 | pos.current = [x, y]; 80 | } 81 | }, [pos]); 82 | }; 83 | -------------------------------------------------------------------------------- /src/hooks/usePhysicsEngine.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | import { PhysicsContext } from "../context"; 3 | import { PhysicsEvent } from "../types"; 4 | 5 | export const usePhysicsEngine = () => { 6 | return useContext(PhysicsContext); 7 | }; 8 | 9 | export const usePhysicsCollision = (fn: (event: PhysicsEvent) => void) => { 10 | const { addEventListener, removeEventListener } = usePhysicsEngine(); 11 | 12 | useEffect(() => { 13 | addEventListener('collision', fn); 14 | return () => removeEventListener('collision', fn); 15 | }, [addEventListener, fn, removeEventListener]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/hooks/usePointer.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { PointerContext } from "../context"; 3 | 4 | export const usePointer = () => { 5 | return useContext(PointerContext); 6 | }; -------------------------------------------------------------------------------- /src/hooks/usePolygonCollider.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { PotentialVector } from "detect-collisions"; 3 | import { Position, Property } from "../types"; 4 | import { Body, PolygonBody } from "../utils"; 5 | import { useCollider } from "./useCollider"; 6 | 7 | /** 8 | * Register a new collider that can be any arbitrary polygon in shape. 9 | */ 10 | export const usePolygonCollider = ( 11 | id: string | undefined, 12 | active: Property, 13 | tags: Property, 14 | pos: Property, 15 | points: Position[], 16 | ) => { 17 | const [x, y] = pos.current; 18 | 19 | const box = useRef(new PolygonBody({ x, y }, points.map(([x, y]): PotentialVector => ({ x, y })))); 20 | 21 | const update = useCallback((body: Body) => { 22 | body.setPosition(pos.current[0], pos.current[1]); 23 | }, [pos]); 24 | 25 | useCollider(id, active, tags, box, update); 26 | }; 27 | -------------------------------------------------------------------------------- /src/hooks/usePosition.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { Node } from '../components'; 4 | import { Position, Prop } from '../types'; 5 | import { VariableProperty } from '../utils'; 6 | import { usePosition } from './usePosition'; 7 | 8 | const createWrapper = ({ pos }: { pos?: Prop }) => { 9 | return ({ children }: { children: React.ReactNode }) => { 10 | return {children}; 11 | }; 12 | }; 13 | 14 | describe('usePosition', () => { 15 | describe('when it is not wrapped in a node', () => { 16 | it('uses the given position', () => { 17 | const { result } = renderHook(() => usePosition([300, 200])); 18 | expect(result.current.current).toEqual([300, 200]); 19 | }); 20 | 21 | it('defaults to [0, 0]', () => { 22 | const { result } = renderHook(() => usePosition()); 23 | expect(result.current.current).toEqual([0, 0]); 24 | }); 25 | }); 26 | 27 | describe('when it is wrapped in a node', () => { 28 | describe('and no position is given', () => { 29 | it('inherits position from the node', () => { 30 | const pos = new VariableProperty([100, 50]); 31 | const { result } = renderHook(() => usePosition(), { wrapper: createWrapper({ pos })}); 32 | expect(result.current.current).toEqual([100, 50]); 33 | 34 | pos.current = [200, 300]; 35 | expect(result.current.current).toEqual([200, 300]); 36 | }); 37 | }); 38 | 39 | describe('and a position is given', () => { 40 | it('uses the given position', () => { 41 | const { result } = renderHook(() => usePosition([300, 200]), { wrapper: createWrapper({ pos: [100, 50] })}); 42 | expect(result.current.current).toEqual([300, 200]); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/hooks/usePosition.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useProperty } from "./useProperty"; 3 | import { Position, Prop } from "../types"; 4 | import { NodeContext } from "../context"; 5 | 6 | export const usePosition = (value?: Prop) => { 7 | const parent = useContext(NodeContext); 8 | const pos = useProperty(value || parent.pos || [0, 0]); 9 | return pos; 10 | } -------------------------------------------------------------------------------- /src/hooks/usePostCollisions.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useWorld } from "./useWorld"; 3 | 4 | export const usePostCollisions = (handler: (delta: number) => void) => { 5 | const { registerPostHandler } = useWorld(); 6 | 7 | useEffect(() => { 8 | return registerPostHandler?.(handler); 9 | }, [handler, registerPostHandler]); 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { VariableProperty } from '../utils'; 4 | import { useProperty } from './useProperty'; 5 | 6 | describe('useProperty', () => { 7 | describe('when passed a variable property', () => { 8 | it('returns the same object', () => { 9 | const property = new VariableProperty(42); 10 | const { result } = renderHook(() => useProperty(property)); 11 | expect(result.current).toBe(property); 12 | }); 13 | }); 14 | 15 | describe('when passed something that looks like a property', () => { 16 | it('returns the same object', () => { 17 | const property = { current: 42, invalidated: false, listen: () => () => {} }; 18 | const { result } = renderHook(() => useProperty(property)); 19 | expect(result.current).toBe(property); 20 | }); 21 | }); 22 | 23 | describe('when passed a literal', () => { 24 | it('coalesces it into a property', () => { 25 | const { result } = renderHook(() => useProperty(42)); 26 | expect(result.current.current).toBe(42); 27 | expect(result.current.invalidated).toBe(true); 28 | }); 29 | }); 30 | 31 | describe('when value is changed', () => { 32 | it('sets the invalidated flag', () => { 33 | const { result } = renderHook(() => useProperty(42)); 34 | result.current.invalidated = false; 35 | 36 | nextFrame(); 37 | expect(result.current.invalidated).toBe(false); 38 | 39 | result.current.current = 43; 40 | expect(result.current.invalidated).toBe(true); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/hooks/useProperty.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from "react"; 2 | import { Prop, Property } from "../types"; 3 | import { VariableProperty } from "../utils"; 4 | 5 | export function useProperty(value: Prop): Property { 6 | const ref = useRef(value); 7 | 8 | const property = useMemo(() => { 9 | if (ref.current instanceof VariableProperty) { 10 | return ref.current; 11 | } else if (typeof ref.current === 'object' && ref.current !== null && 'current' in ref.current && 'invalidated' in ref.current) { 12 | return ref.current; 13 | } else { 14 | return new VariableProperty(ref.current); 15 | } 16 | }, [ref]); 17 | 18 | /** 19 | * Update the property value when the input value changes, but only for scalars, since objects 20 | * that are passed directly as props will otherwise trigger a change every cycle. 21 | */ 22 | useEffect(() => { 23 | if (!(value instanceof Object)) { 24 | property.current = value; 25 | } 26 | }, [property, value]); 27 | 28 | return property; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/usePropertyListen.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { renderHook } from '../test'; 3 | import { VariableProperty } from '../utils'; 4 | import { usePropertyListen } from './usePropertyListen'; 5 | 6 | describe('usePropertyListen', () => { 7 | describe('when a scalar property changes', () => { 8 | it('calls the listener function', () => { 9 | const property = new VariableProperty(42); 10 | const fn = vi.fn(); 11 | 12 | renderHook(() => usePropertyListen(property, fn)); 13 | expect(fn).not.toBeCalled(); 14 | 15 | property.current = 43; 16 | expect(fn).toBeCalledTimes(1); 17 | expect(fn).toBeCalledWith(43); 18 | }); 19 | }); 20 | 21 | describe('when an array property changes', () => { 22 | it('calls the listener function', () => { 23 | const property = new VariableProperty(['a', 'b']); 24 | const fn = vi.fn(); 25 | 26 | renderHook(() => usePropertyListen(property, fn)); 27 | expect(fn).not.toBeCalled(); 28 | 29 | property.current = ['c', 'd']; 30 | expect(fn).toBeCalledTimes(1); 31 | expect(fn).toBeCalledWith(['c', 'd']); 32 | }); 33 | }); 34 | 35 | describe('when an entry in an array property changes', () => { 36 | it('calls the listener function', () => { 37 | const property = new VariableProperty(['a', 'b']); 38 | const fn = vi.fn(); 39 | 40 | renderHook(() => usePropertyListen(property, fn)); 41 | expect(fn).not.toBeCalled(); 42 | 43 | property.current[0] = 'c'; 44 | expect(fn).toBeCalledTimes(1); 45 | expect(fn).toBeCalledWith(['c', 'b']); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/hooks/usePropertyListen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Property } from "../types"; 3 | 4 | export const usePropertyListen = (prop: Property, fn: (value: T) => void) => { 5 | useEffect(() => { 6 | return prop.listen(fn); 7 | }, [fn, prop]); 8 | }; 9 | -------------------------------------------------------------------------------- /src/hooks/useRender.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useId } from "react"; 2 | import { RenderFunction } from "../types"; 3 | import { NodeContext } from "../context"; 4 | 5 | export const useRender = (fn: RenderFunction): string => { 6 | const { registerRender } = useContext(NodeContext); 7 | const id = useId(); 8 | 9 | useEffect(() => registerRender?.(id, fn), [fn, id, registerRender]); 10 | 11 | return id; 12 | } -------------------------------------------------------------------------------- /src/hooks/useSequence.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { useSequence } from "./useSequence"; 4 | 5 | describe('useSequence', () => { 6 | describe('when all of the conditions have been met, in order', () => { 7 | it ('invokes the callback', () => { 8 | const callback = vi.fn(); 9 | const data = { a: false, b: false }; 10 | renderHook(() => useSequence([() => data.a, () => data.b], callback)); 11 | 12 | expect(callback).not.toHaveBeenCalled(); 13 | 14 | data.a = true; 15 | nextFrame(); 16 | expect(callback).not.toHaveBeenCalled(); 17 | 18 | data.b = true; 19 | nextFrame(); 20 | expect(callback).toHaveBeenCalled(); 21 | }); 22 | 23 | it ('resets the sequence afterwards', () => { 24 | const callback = vi.fn(); 25 | const data = { a: false, b: false }; 26 | renderHook(() => useSequence([() => data.a, () => data.b], callback)); 27 | 28 | expect(callback).not.toHaveBeenCalled(); 29 | 30 | data.a = true; 31 | nextFrame(); 32 | expect(callback).not.toHaveBeenCalled(); 33 | 34 | data.b = true; 35 | nextFrame(); 36 | expect(callback).toHaveBeenCalled(); 37 | 38 | nextFrame(); 39 | expect(callback).toHaveBeenCalledTimes(1); 40 | }) 41 | }); 42 | 43 | describe('when all of the conditions have been met, out of order', () => { 44 | it ('does not invoke the callback', () => { 45 | const callback = vi.fn(); 46 | const data = { a: false, b: false }; 47 | renderHook(() => useSequence([() => data.a, () => data.b], callback)); 48 | 49 | expect(callback).not.toHaveBeenCalled(); 50 | 51 | data.b = true; 52 | nextFrame(); 53 | expect(callback).not.toHaveBeenCalled(); 54 | 55 | data.a = true; 56 | nextFrame(); 57 | expect(callback).not.toHaveBeenCalled(); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/hooks/useSequence.ts: -------------------------------------------------------------------------------- 1 | import { useProperty } from "./useProperty"; 2 | import { useUpdate } from "./useUpdate"; 3 | 4 | export const useSequence = (conditions: (() => boolean)[], fn: () => void) => { 5 | const phase = useProperty(0); 6 | 7 | useUpdate(() => { 8 | if (conditions[phase.current]()) { 9 | phase.current++; 10 | } 11 | 12 | if (phase.current === conditions.length) { 13 | fn(); 14 | phase.current = 0; 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useShaker.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, render, renderHook } from '../test'; 3 | import { useShaker } from './useShaker'; 4 | 5 | describe('useShaker', () => { 6 | it('defaults to no movement', () => { 7 | const shaker = renderHook(() => useShaker()).result.current; 8 | render(
); 9 | 10 | expect(shaker.ref.current?.style.transform).toBe('translate(0px, 0px)'); 11 | }); 12 | 13 | describe('when the shake function is called', () => { 14 | it('shakes the element side to side', () => { 15 | const shaker = renderHook(() => useShaker({ phase: 100 })).result.current; 16 | render(
); 17 | 18 | shaker.shake(); 19 | 20 | expectElementToShake(shaker.ref.current, [45.7, 31.9, 16.4, 0.8, -13.4, -25.1, -33.4]); 21 | }); 22 | }); 23 | 24 | describe('when the strength is increased', () => { 25 | it('increases the shake amount', () => { 26 | const shaker = renderHook(() => useShaker({ phase: 100, strength: 80 })).result.current; 27 | render(
); 28 | 29 | shaker.shake(); 30 | 31 | expectElementToShake(shaker.ref.current, [91.4, 63.9, 32.9, 1.7, -26.8, -50.2]); 32 | }); 33 | }); 34 | 35 | describe('when the phase is reduced', () => { 36 | it('shakes the element faster', () => { 37 | const shaker = renderHook(() => useShaker({ phase: 15 })).result.current; 38 | render(
); 39 | 40 | shaker.shake(); 41 | 42 | expectElementToShake(shaker.ref.current, [-44.3, 49.8, 0.9, -46.2, 35.8, 13.5]); 43 | }); 44 | }); 45 | }); 46 | 47 | /** 48 | * Run through a number of frames, building up an array of actual translate values, then compare 49 | * it to the expected translate amounts. 50 | */ 51 | const expectElementToShake = (element: HTMLElement | null, expected: number[]) => { 52 | const actual = []; 53 | 54 | while (actual.length < expected.length) { 55 | nextFrame(); 56 | actual.push(parseFloat((element?.style.transform || '').match(/translate\((.+?)px/)?.[1] || 'NaN').toFixed(1)); 57 | } 58 | 59 | expect(actual).toEqual(expected.map((value) => value.toFixed(1))); 60 | } -------------------------------------------------------------------------------- /src/hooks/useShaker.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useMemo } from "react"; 2 | import { UseElementResult, useElement } from "./useElement"; 3 | import { useProperty } from "./useProperty"; 4 | import { useDynamicProperty } from "./useDynamicProperty"; 5 | import { ElementType, Position } from "../types"; 6 | import { useUpdate } from "./useUpdate"; 7 | import { useRender } from "./useRender"; 8 | 9 | type UseShakerProps = { 10 | element?: UseElementResult; 11 | strength?: number; 12 | phase?: number; 13 | } 14 | 15 | type UseShakerResult = { 16 | ref: RefObject; 17 | shake: () => void; 18 | } 19 | 20 | export function useShaker(props?: UseShakerProps): UseShakerResult { 21 | const element = useElement(props?.element); 22 | const amount = useProperty(0); 23 | const strength = useProperty(props?.strength || 40); 24 | const phase = useProperty(props?.phase || 30); 25 | 26 | const pos = useDynamicProperty(amount, (amount): Position => ([ 27 | Math.sin(amount / phase.current) * strength.current * (amount / 500), 28 | 0, 29 | ])); 30 | 31 | useUpdate((delta) => { 32 | if (amount.current > 0) { 33 | amount.current = Math.max(0, amount.current - delta); 34 | } 35 | }); 36 | 37 | useRender(() => { 38 | element.setBaseStyles({ pos }); 39 | }); 40 | 41 | const shake = useCallback(() => { 42 | amount.current = 750; 43 | }, [amount]); 44 | 45 | return useMemo(() => ({ ref: element.ref, shake }), [element.ref, shake]); 46 | } 47 | -------------------------------------------------------------------------------- /src/hooks/useSpeech.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | 3 | export const useSpeech = () => { 4 | const speak = useCallback((text: string) => { 5 | const utterance = new SpeechSynthesisUtterance(text); 6 | speechSynthesis.speak(utterance); 7 | }, []); 8 | 9 | return useMemo(() => ({ speak }), [speak]); 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useSpriteSet.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useContext, useLayoutEffect } from "react"; 2 | import { SpriteSetContext } from "../components"; 3 | 4 | export const useSpriteSet = (name: string | undefined, element: MutableRefObject, reset: () => void) => { 5 | const { register } = useContext(SpriteSetContext); 6 | 7 | useLayoutEffect(() => { 8 | if (name) { 9 | return register(name, element, reset); 10 | } 11 | }, [element, name, register, reset]); 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/useStateMachine.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { Engine, World } from '../components'; 4 | import { useStateMachine } from './useStateMachine'; 5 | 6 | const createWrapper = () => { 7 | return ({ children }: { children: React.ReactNode }) => { 8 | return {children}; 9 | }; 10 | }; 11 | 12 | describe('useStateMachine', () => { 13 | it('it calls the state function once each frame', () => { 14 | const idle = vi.fn(); 15 | renderHook(() => useStateMachine({}, 'idle', { idle }), { wrapper: createWrapper() }); 16 | 17 | expect(idle).not.toHaveBeenCalled(); 18 | 19 | nextFrame(); 20 | expect(idle).toHaveBeenCalledOnce(); 21 | 22 | nextFrame(); 23 | expect(idle).toHaveBeenCalledTimes(2); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/hooks/useStateMachine.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { StateDefinitions } from "../types"; 3 | import { StateMachine } from "../utils"; 4 | import { usePostCollisions } from "./usePostCollisions"; 5 | import { useProperty } from "./useProperty"; 6 | 7 | export function useStateMachine(entity: T, state: string, states: StateDefinitions) { 8 | const fsm = useProperty(new StateMachine(entity, state, states)); 9 | 10 | // TODO: Fix this. We shouldn't force state machines to require collisions. 11 | usePostCollisions((delta) => { 12 | fsm.current.update(delta); 13 | }); 14 | 15 | return useMemo(() => fsm, [fsm]); 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/useSwipe.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef } from "react"; 2 | import { usePointer } from "./usePointer"; 3 | import { Position } from "../types"; 4 | import { useTicker } from "./useTicker"; 5 | 6 | export const useSwipe = () => { 7 | const pointer = usePointer(); 8 | const started = useRef(null); 9 | const swiped = useRef(false); 10 | const distance = useRef<[number, number]>([0, 0]); 11 | 12 | const hasSwiped = useCallback((): boolean => { 13 | return swiped.current; 14 | }, []); 15 | 16 | useTicker(() => { 17 | if (swiped.current) { 18 | swiped.current = false; 19 | } 20 | 21 | if (pointer.isDown()) { 22 | const [x, y] = pointer.pos.current; 23 | 24 | if (!Number.isNaN(x) && !Number.isNaN(y)) { 25 | if (!started.current) { 26 | started.current = [x, y]; 27 | } else { 28 | distance.current = [x - started.current[0], y - started.current[1]]; 29 | } 30 | } 31 | } else if (started.current !== null) { 32 | swiped.current = true; 33 | started.current = null; 34 | } 35 | }); 36 | 37 | return useMemo(() => ({ hasSwiped, distance }), [hasSwiped, distance]); 38 | }; 39 | -------------------------------------------------------------------------------- /src/hooks/useSync.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { VariableProperty } from '../utils'; 4 | import { useSync } from "./useSync"; 5 | 6 | describe('useSync', () => { 7 | describe('when the value changes', () => { 8 | it('updates the state', () => { 9 | const property = new VariableProperty(42); 10 | const { result, rerender } = renderHook(() => useSync(() => property.current)); 11 | 12 | expect(result.current).toBe(42); 13 | expect(property.current).toBe(42); 14 | 15 | property.current = 43; 16 | 17 | rerender(); 18 | expect(result.current).toBe(42); 19 | 20 | nextFrame(); 21 | rerender(); 22 | expect(result.current).toBe(43); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/hooks/useSync.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useTicker } from "./useTicker"; 3 | 4 | export function useSync(fn: () => T): T { 5 | const [value, setValue] = useState(fn()); 6 | 7 | useTicker(() => { 8 | const newValue = fn(); 9 | if (newValue !== value) { 10 | setValue(newValue); 11 | } 12 | }); 13 | 14 | return value; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useTicker.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHookWithEngine } from '../test'; 3 | import { useTicker } from "./useTicker"; 4 | 5 | describe('useTicker', () => { 6 | describe('when the engine is not paused', () => { 7 | it('invokes the callback once per animation frame', () => { 8 | const callback = vi.fn(); 9 | renderHookWithEngine(() => useTicker(callback)); 10 | 11 | nextFrame(); 12 | expect(callback).toHaveBeenCalled(); 13 | nextFrame(); 14 | expect(callback).toHaveBeenCalled(); 15 | nextFrame(); 16 | expect(callback).toHaveBeenCalled(); 17 | }); 18 | }); 19 | 20 | it('receives the frame duration and total time', () => { 21 | const callback = vi.fn(); 22 | renderHookWithEngine(() => useTicker(callback)); 23 | 24 | nextFrame(); 25 | expect(callback).toHaveBeenCalledWith(15, 105); 26 | nextFrame(); 27 | expect(callback).toHaveBeenCalledWith(15, 120); 28 | nextFrame(); 29 | expect(callback).toHaveBeenCalledWith(15, 135); 30 | }); 31 | 32 | describe('when the engine is paused', () => { 33 | it('still invokes the callback once per animation frame', () => { 34 | const callback = vi.fn(); 35 | const { result } = renderHookWithEngine(() => useTicker(callback)); 36 | 37 | result.current.engine.onPause(); 38 | 39 | nextFrame(); 40 | expect(callback).toHaveBeenCalled(); 41 | nextFrame(); 42 | expect(callback).toHaveBeenCalled(); 43 | nextFrame(); 44 | expect(callback).toHaveBeenCalled(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/hooks/useTicker.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useId, useEffect } from "react"; 2 | import { TickerFunction } from "../types"; 3 | import { NodeContext } from "../context"; 4 | 5 | export const useTicker = (fn: TickerFunction, name?: string) => { 6 | const { registerTicker } = useContext(NodeContext); 7 | const id = useId(); 8 | 9 | useEffect(() => registerTicker?.(id, fn), [fn, id, name, registerTicker]); 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useId, useEffect } from "react"; 2 | import { Prop, UpdateFunction } from "../types"; 3 | import { NodeContext } from "../context"; 4 | import { useProperty } from "./useProperty"; 5 | 6 | type UseUpdateOptions = { 7 | id?: string; 8 | } 9 | 10 | /** 11 | * Register an update function that runs every frame. 12 | */ 13 | export const useUpdate = (fn: UpdateFunction, options?: UseUpdateOptions): string => { 14 | const { registerUpdate } = useContext(NodeContext); 15 | const generatedId = useId(); 16 | const id = options?.id || generatedId; 17 | 18 | useEffect(() => registerUpdate?.(id, fn), [fn, id, registerUpdate]); 19 | 20 | return id; 21 | }; 22 | 23 | /** 24 | * Register an update function that runs every frame, and is guaranteed to run after another 25 | * update function has completed. 26 | */ 27 | export const useUpdateAfter = (target: string | undefined, fn: UpdateFunction, options?: UseUpdateOptions): string => { 28 | const { registerUpdate } = useContext(NodeContext); 29 | const generatedId = useId(); 30 | const id = options?.id || generatedId; 31 | 32 | useEffect(() => registerUpdate?.(id, fn, { after: target }), [fn, id, registerUpdate, target]); 33 | 34 | return id; 35 | }; 36 | 37 | /** 38 | * Register an update function that runs at a fixed rate, regardless of the native device frame 39 | * rate. If the desired rate is greater than the maximum frame rate, then the update function will 40 | * run multiple times each frame. 41 | */ 42 | export const useFixedUpdate = (rate: Prop, fn: UpdateFunction): string => { 43 | const elapsed = useProperty(0); 44 | const ups = useProperty(rate); 45 | const period = useProperty(1000 / ups.current); 46 | 47 | return useUpdate((delta, time) => { 48 | elapsed.current += delta; 49 | 50 | if (elapsed.current >= period.current) { 51 | const qty = Math.floor(elapsed.current / period.current); 52 | elapsed.current = elapsed.current % period.current; 53 | 54 | for (let i = 0; i < qty; i++) { 55 | fn(period.current, time); 56 | } 57 | } 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/hooks/useViewport.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ViewportContext } from "../components"; 3 | 4 | export const useViewport = () => { 5 | return useContext(ViewportContext); 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useVirtualAction.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { useVirtualInput } from './useVirtualInput'; 4 | import { useVirtualAction } from './useVirtualAction'; 5 | 6 | describe('useVirtualAction', () => { 7 | describe('when the action is not active', () => { 8 | it('does not invoke the callback', () => { 9 | const callback = vi.fn(); 10 | renderHook(() => useVirtualAction('jump', callback)); 11 | expect(callback).not.toHaveBeenCalled(); 12 | }); 13 | }); 14 | 15 | describe('when the action is active', () => { 16 | it('invokes the callback each frame for 50ms', () => { 17 | const callback = vi.fn(); 18 | const { result } = renderHook(() => { 19 | useVirtualAction('jump', callback); 20 | return useVirtualInput(); 21 | }); 22 | 23 | const { simulate } = result.current; 24 | 25 | simulate('jump'); 26 | nextFrame(); 27 | expect(callback).toHaveBeenCalledTimes(1); 28 | 29 | nextFrame(); 30 | expect(callback).toHaveBeenCalledTimes(2); 31 | 32 | nextFrame(); 33 | expect(callback).toHaveBeenCalledTimes(3); 34 | 35 | nextFrame(); 36 | expect(callback).toHaveBeenCalledTimes(3); 37 | }); 38 | }); 39 | }); -------------------------------------------------------------------------------- /src/hooks/useVirtualAction.ts: -------------------------------------------------------------------------------- 1 | import { useUpdate } from "./useUpdate"; 2 | import { useVirtualInput } from "./useVirtualInput"; 3 | 4 | export const useVirtualAction = (action: string, fn: () => void) => { 5 | const { isActive } = useVirtualInput(); 6 | 7 | useUpdate(() => { 8 | if (isActive(action)) { 9 | fn(); 10 | } 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/hooks/useVirtualInput.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { nextFrame, renderHook } from '../test'; 3 | import { useVirtualInput } from './useVirtualInput'; 4 | 5 | describe('useVirtualInput', () => { 6 | describe('isActive', () => { 7 | describe('when the input is not active', () => { 8 | it ('returns false', () => { 9 | const { result } = renderHook(() => useVirtualInput()); 10 | const { isActive } = result.current; 11 | 12 | expect(isActive('jump')).toBe(false); 13 | }); 14 | }); 15 | 16 | describe('when the input is activated', () => { 17 | it ('returns true', () => { 18 | const { result } = renderHook(() => useVirtualInput()); 19 | const { isActive, simulate } = result.current; 20 | 21 | simulate('jump'); 22 | expect(isActive('jump')).toBe(true); 23 | }); 24 | 25 | it('remains true for 50ms', () => { 26 | const { result } = renderHook(() => useVirtualInput()); 27 | const { isActive, simulate } = result.current; 28 | 29 | simulate('jump'); 30 | expect(isActive('jump')).toBe(true); 31 | 32 | nextFrame(); // 15ms 33 | expect(isActive('jump')).toBe(true); 34 | 35 | nextFrame(); // 30ms 36 | expect(isActive('jump')).toBe(true); 37 | 38 | nextFrame(); // 45ms 39 | expect(isActive('jump')).toBe(true); 40 | 41 | nextFrame(); // 60ms 42 | expect(isActive('jump')).toBe(false); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('hasAxis', () => { 48 | describe('when neither axis is set', () => { 49 | it('returns 0', () => { 50 | const { result } = renderHook(() => useVirtualInput()); 51 | const { hasAxis } = result.current; 52 | 53 | expect(hasAxis('left', 'right')).toBe(0); 54 | }); 55 | }); 56 | 57 | describe('when only the negative axis is set', () => { 58 | it('returns -1', () => { 59 | const { result } = renderHook(() => useVirtualInput()); 60 | const { hasAxis, simulate } = result.current; 61 | 62 | simulate('left'); 63 | expect(hasAxis('left', 'right')).toBe(-1); 64 | }); 65 | }); 66 | 67 | describe('when only the postive axis is set', () => { 68 | it('returns 1', () => { 69 | const { result } = renderHook(() => useVirtualInput()); 70 | const { hasAxis, simulate } = result.current; 71 | 72 | simulate('right'); 73 | expect(hasAxis('left', 'right')).toBe(1); 74 | }); 75 | }); 76 | 77 | describe('when both axis is set', () => { 78 | it('returns 0', () => { 79 | const { result } = renderHook(() => useVirtualInput()); 80 | const { hasAxis, simulate } = result.current; 81 | 82 | simulate('left'); 83 | simulate('right'); 84 | expect(hasAxis('left', 'right')).toBe(0); 85 | }); 86 | }); 87 | }); 88 | }); -------------------------------------------------------------------------------- /src/hooks/useVirtualInput.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { VirtualInputContext } from "../context"; 3 | 4 | export const useVirtualInput = () => { 5 | return useContext(VirtualInputContext); 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/useVisible.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { NodeContext } from "../context"; 3 | import { Prop } from "../types"; 4 | import { useProperty } from "./useProperty"; 5 | 6 | export const useVisible = (value?: Prop) => { 7 | const parent = useContext(NodeContext); 8 | const isVisible = value === undefined ? (parent.visible === undefined ? true : parent.visible) : value; 9 | return useProperty(isVisible); 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useWorld.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { WorldContext } from "../context"; 3 | 4 | export const useWorld = () => { 5 | return useContext(WorldContext); 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './context'; 3 | export * from './hooks'; 4 | export * from './types'; 5 | export * from './utils'; -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | export { advanceFrames, nextFrame, render, renderHook, renderHookWithEngine } from './render'; 2 | export { mockGamepads, resetGamepads, MockGamepad } from "./mockGamepads"; 3 | export { mockResizeObserver } from "./mockResizeObserver"; 4 | export { mockDeviceOrientationEvent } from "./mockDeviceOrientationEvent"; 5 | export { mockDeviceMotionEvent } from "./mockDeviceMotionEvent"; 6 | -------------------------------------------------------------------------------- /src/test/mockDeviceMotionEvent.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const mockDeviceMotionEvent = () => { 4 | const fn = vi.fn(); 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-expect-error 8 | global.DeviceMotionEvent = class MockedDeviceMotionEvent { 9 | static requestPermission = fn; 10 | }; 11 | 12 | return fn; 13 | }; 14 | -------------------------------------------------------------------------------- /src/test/mockDeviceOrientationEvent.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const mockDeviceOrientationEvent = () => { 4 | const fn = vi.fn(); 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-expect-error 8 | global.DeviceOrientationEvent = class MockedDeviceOrientationEvent { 9 | static requestPermission = fn; 10 | }; 11 | 12 | return fn; 13 | }; 14 | -------------------------------------------------------------------------------- /src/test/mockGamepads.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | let original: () => (Gamepad | null)[]; 4 | 5 | export const mockGamepads = () => { 6 | original = navigator.getGamepads; 7 | const gamepad = new MockGamepad(); 8 | navigator.getGamepads = vi.fn().mockImplementation(() => ([gamepad])); 9 | return gamepad; 10 | }; 11 | 12 | export const resetGamepads = () => { 13 | navigator.getGamepads = original; 14 | }; 15 | 16 | export class MockGamepad implements Gamepad { 17 | axes: readonly number[]; 18 | buttons: readonly MockGamepadButton[]; 19 | connected: boolean; 20 | hapticActuators: readonly GamepadHapticActuator[]; 21 | id: string; 22 | index: number; 23 | mapping: GamepadMappingType; 24 | timestamp: number; 25 | vibrationActuator: GamepadHapticActuator; 26 | 27 | constructor() { 28 | this.axes = []; 29 | this.connected = true; 30 | this.hapticActuators = []; 31 | this.id = 'gamepad_0'; 32 | this.index = 0; 33 | this.mapping = 'standard'; 34 | this.timestamp = 0; 35 | this.vibrationActuator = {} as GamepadHapticActuator; 36 | this.buttons = (new Array(20)).fill(null).map(() => new MockGamepadButton()); 37 | } 38 | 39 | simulateButtonDown(button: number) { 40 | this.buttons[button].pressed = true; 41 | } 42 | 43 | simulateButtonUp(button: number) { 44 | this.buttons[button].pressed = false; 45 | } 46 | } 47 | 48 | class MockGamepadButton implements GamepadButton { 49 | pressed: boolean; 50 | touched: boolean; 51 | value: number; 52 | 53 | constructor() { 54 | this.pressed = false; 55 | this.touched = false; 56 | this.value = 0; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/mockResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const mockResizeObserver = (width: number, height: number) => { 4 | const observe = vi.fn(); 5 | 6 | global.ResizeObserver = class MockedResizeObserver { 7 | constructor(fn: ResizeObserverCallback) { 8 | fn([{ contentRect: { width, height } }] as ResizeObserverEntry[], this); 9 | } 10 | observe = observe; 11 | unobserve = vi.fn(); 12 | disconnect = vi.fn(); 13 | }; 14 | 15 | return observe; 16 | }; 17 | -------------------------------------------------------------------------------- /src/test/render.tsx: -------------------------------------------------------------------------------- 1 | import { Queries, RenderHookOptions, RenderHookResult, queries, render as _render, renderHook as _renderHook, RenderOptions, RenderResult } from "@testing-library/react"; 2 | import { useContext } from "react"; 3 | import { vi } from 'vitest'; 4 | import { Engine } from "../components"; 5 | import { EngineContext, EngineContextProps } from "../context"; 6 | 7 | export function render< 8 | Q extends Queries = typeof queries, 9 | Container extends Element | DocumentFragment = HTMLElement, 10 | BaseElement extends Element | DocumentFragment = Container, 11 | >( 12 | ui: React.ReactNode, 13 | options?: RenderOptions, 14 | ): RenderResult { 15 | const result = _render(ui, { wrapper: Engine, ...options }); 16 | 17 | // Run the engine until it unpauses. 18 | nextFrame(); 19 | return result; 20 | } 21 | 22 | export function renderHook< 23 | Result, 24 | Props, 25 | Q extends Queries = typeof queries, 26 | Container extends Element | DocumentFragment = HTMLElement, 27 | BaseElement extends Element | DocumentFragment = Container, 28 | >( 29 | render: (initialProps: Props) => Result, 30 | options?: RenderHookOptions, 31 | ): RenderHookResult { 32 | const result = _renderHook(render, { wrapper: Engine, ...options }); 33 | 34 | // Run the engine until it unpauses. 35 | nextFrame(); 36 | return result; 37 | } 38 | 39 | export function renderHookWithEngine< 40 | Result, 41 | Props, 42 | Q extends Queries = typeof queries, 43 | Container extends Element | DocumentFragment = HTMLElement, 44 | BaseElement extends Element | DocumentFragment = Container, 45 | >( 46 | render: (initialProps: Props) => Result, 47 | options?: RenderHookOptions, 48 | ): RenderHookResult { 49 | // Augment the renderHook result with the engine context. 50 | const result = _renderHook((initialProps: Props) => { 51 | const engine = useContext(EngineContext); 52 | const hook = render(initialProps); 53 | return { ...hook, engine }; 54 | }, { wrapper: Engine, ...options }); 55 | 56 | // Run the engine until it unpauses. 57 | nextFrame(); 58 | return result; 59 | } 60 | 61 | export function nextFrame() { 62 | vi.runOnlyPendingTimers(); 63 | } 64 | 65 | export function advanceFrames(count: number) { 66 | for (let i = 0; i < count; i++) { 67 | nextFrame(); 68 | } 69 | } -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import '@testing-library/jest-dom/vitest'; 4 | import { cleanup } from '@testing-library/react'; 5 | import { afterEach, beforeAll, vi } from 'vitest'; 6 | 7 | let currentTime = 0; 8 | 9 | /** 10 | * Use fake timers so that we can step through one frame at a time. 11 | */ 12 | beforeAll(() => { 13 | vi.useFakeTimers(); 14 | }); 15 | 16 | /** 17 | * Full cleanup of the whole dom. 18 | */ 19 | afterEach(() => { 20 | cleanup(); 21 | }); 22 | 23 | /** 24 | * Implement a fake raf function, which runs in a way that we can step through with fake timers. 25 | */ 26 | beforeAll(() => { 27 | global.requestAnimationFrame = vi.fn((callback) => { 28 | setTimeout(() => { 29 | currentTime += 15; 30 | callback(currentTime); 31 | }, 15); 32 | return 1; 33 | }); 34 | }); 35 | 36 | /** 37 | * Reset our fake raf implementation. 38 | */ 39 | afterEach(() => { 40 | vi.clearAllTimers(); 41 | currentTime = 0; 42 | }); 43 | 44 | /** 45 | * 46 | */ 47 | type DeviceMotionEventAcceleration = { 48 | x: number; 49 | y: number; 50 | z: number; 51 | }; 52 | 53 | type DeviceMotionEventOptions = { 54 | acceleration: DeviceMotionEventAcceleration; 55 | }; 56 | 57 | class DeviceMotionEvent extends Event { 58 | readonly acceleration: any; 59 | 60 | constructor(type: string, options: DeviceMotionEventOptions) { 61 | super(type); 62 | this.acceleration = options.acceleration; 63 | } 64 | } 65 | 66 | window.DeviceMotionEvent = DeviceMotionEvent as any; 67 | 68 | class AudioContext { 69 | 70 | } 71 | 72 | window.AudioContext = AudioContext as any; -------------------------------------------------------------------------------- /src/utils/BaseParticle.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseParticle { 2 | node: HTMLDivElement = {} as unknown as HTMLDivElement; 3 | spawned = true; 4 | ttl = 0; 5 | 6 | attach(node: HTMLDivElement) { 7 | this.node = node; 8 | } 9 | 10 | abstract init(): void; 11 | abstract update(delta: number): void; 12 | abstract destroy(): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/Body.ts: -------------------------------------------------------------------------------- 1 | import { Box, Polygon, PotentialVector } from "detect-collisions"; 2 | 3 | export type Body = BoxBody | PolygonBody; 4 | 5 | export class BoxBody extends Box { 6 | readonly entity?: T; 7 | 8 | constructor(position: PotentialVector, width: number, height: number, entity?: T) { 9 | super(position, width, height, undefined); 10 | this.entity = entity; 11 | } 12 | } 13 | 14 | export class PolygonBody extends Polygon { 15 | readonly entity?: T; 16 | 17 | constructor(position: PotentialVector, points: PotentialVector[], entity?: T) { 18 | super(position, points); 19 | this.entity = entity; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/DynamicProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { Validator, DynamicProperty, VariableProperty } from '.'; 3 | 4 | describe('DynamicProperty', () => { 5 | it('invalidated defaults to true', () => { 6 | const source = new VariableProperty(42); 7 | const prop = new DynamicProperty(source, (source) => source + 1); 8 | expect(prop.invalidated).toBe(true); 9 | }); 10 | 11 | describe('when the source value changes', () => { 12 | it('set the invalidated flag', () => { 13 | const source = new VariableProperty(42); 14 | const prop = new DynamicProperty(source, (source) => source + 1); 15 | prop.invalidated = false; 16 | Validator.run(); 17 | expect(prop.invalidated).toBe(false); 18 | 19 | source.current = 45; 20 | expect(prop.invalidated).toBe(true); 21 | expect(prop.current).toBe(46); 22 | }); 23 | }); 24 | 25 | describe('listeners', () => { 26 | describe('when a scalar property changes', () => { 27 | it('calls the listener function with the output value, not the source value', () => { 28 | const source = new VariableProperty(42); 29 | const prop = new DynamicProperty(source, (source) => source + 1); 30 | const fn = vi.fn(); 31 | 32 | prop.listen(fn); 33 | expect(fn).not.toBeCalled(); 34 | 35 | source.current = 45; 36 | expect(fn).toBeCalledTimes(1); 37 | expect(fn).toBeCalledWith(46); 38 | }); 39 | }); 40 | 41 | describe('when an entry of an array property changes', () => { 42 | it('calls the listener function', () => { 43 | const source = new VariableProperty(['a', 'b']); 44 | const prop = new DynamicProperty(source, (source) => source.join('|')); 45 | const fn = vi.fn(); 46 | 47 | prop.listen(fn); 48 | expect(fn).not.toBeCalled(); 49 | 50 | source.current[0] = 'c'; 51 | expect(fn).toBeCalledTimes(1); 52 | expect(fn).toBeCalledWith('c|b'); 53 | }); 54 | }); 55 | }); 56 | }); -------------------------------------------------------------------------------- /src/utils/DynamicProperty.ts: -------------------------------------------------------------------------------- 1 | import { Property } from "../types"; 2 | 3 | export class DynamicProperty { 4 | 5 | private _invalidated: boolean = true; 6 | private source: Property; 7 | private fn: (source: IN) => OUT; 8 | 9 | constructor(source: Property, fn: (source: IN) => OUT) { 10 | this.source = source; 11 | this.fn = fn; 12 | } 13 | 14 | listen(fn: (value: OUT) => void) { 15 | return this.source.listen(() => fn(this.current)); 16 | } 17 | 18 | get current(): OUT { 19 | return this.fn(this.source.current); 20 | } 21 | 22 | get invalidated(): boolean { 23 | return this._invalidated || this.source.invalidated; 24 | } 25 | 26 | set invalidated(value: boolean) { 27 | this._invalidated = false; 28 | this.source.invalidated = value; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/EventTarget.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { EventTarget } from './EventTarget'; 3 | 4 | describe('EventTarget', () => { 5 | describe('addEventListener', () => { 6 | describe('when the event target takes no payload', () => { 7 | describe('when an event is fired', () => { 8 | it('calls the listener', () => { 9 | const target = new EventTarget<'jump'>(); 10 | const listener = vi.fn(); 11 | 12 | target.addEventListener('jump', listener); 13 | expect(listener).not.toBeCalled(); 14 | 15 | target.fireEvent('jump', undefined); 16 | expect(listener).toBeCalledTimes(1); 17 | expect(listener).toBeCalledWith(undefined); 18 | }); 19 | }); 20 | }); 21 | 22 | describe('when the event target takes a payload', () => { 23 | describe('when an event is fired', () => { 24 | it('calls the listener', () => { 25 | const target = new EventTarget<'jump', { foo: string }>(); 26 | const listener = vi.fn(); 27 | 28 | target.addEventListener('jump', listener); 29 | expect(listener).not.toBeCalled(); 30 | 31 | target.fireEvent('jump', { foo: 'bar' }); 32 | expect(listener).toBeCalledTimes(1); 33 | expect(listener).toBeCalledWith({ foo: 'bar' }); 34 | }); 35 | }); 36 | }); 37 | }); 38 | 39 | describe('removeEventListener', () => { 40 | describe('when an event is fired', () => { 41 | it('does not call the listener', () => { 42 | const target = new EventTarget<'jump'>(); 43 | const listener = vi.fn(); 44 | 45 | target.addEventListener('jump', listener); 46 | expect(listener).not.toBeCalled(); 47 | 48 | target.removeEventListener('jump', listener); 49 | target.fireEvent('jump', undefined); 50 | expect(listener).not.toBeCalled(); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('when an event is fired but there are no listeners', () => { 56 | describe('when a listener is registered', () => { 57 | it('handles all previously uncaught events', () => { 58 | const target = new EventTarget<'jump'>(); 59 | const listener = vi.fn(); 60 | 61 | target.fireEvent('jump', undefined); 62 | expect(listener).not.toBeCalled(); 63 | 64 | target.addEventListener('jump', listener); 65 | expect(listener).toBeCalledTimes(1); 66 | }); 67 | }); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/utils/EventTarget.ts: -------------------------------------------------------------------------------- 1 | import { EventHandler } from "../types"; 2 | 3 | export class EventTarget { 4 | listeners: Map>> = new Map(); 5 | uncaught: Map> = new Map(); 6 | 7 | addEventListener(type: E, callback: EventHandler): void { 8 | if (!this.listeners.has(type)) { 9 | this.listeners.set(type, new Set()); 10 | } 11 | 12 | this.listeners.get(type)?.add(callback); 13 | 14 | const uncaught = this.uncaught.get(type) || []; 15 | if (uncaught.length > 0) { 16 | for (const payload of uncaught) { 17 | this.fireEvent(type, payload); 18 | } 19 | 20 | this.uncaught.delete(type); 21 | } 22 | } 23 | 24 | removeEventListener(type: E, callback: EventHandler): void { 25 | if (this.listeners.get(type)?.has(callback)) { 26 | this.listeners.get(type)?.delete(callback); 27 | } 28 | } 29 | 30 | fireEvent(type: E, payload: T): void { 31 | const listeners = this.listeners.get(type); 32 | 33 | if (listeners) { 34 | for (const listener of listeners) { 35 | listener(payload); 36 | } 37 | } else { 38 | if (!this.uncaught.has(type)) { 39 | this.uncaught.set(type, []); 40 | } 41 | 42 | this.uncaught.get(type)?.push(payload); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/MapSet.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { MapSet } from './MapSet'; 3 | 4 | describe('MapSet', () => { 5 | it('is initially empty', () => { 6 | const map = new MapSet(); 7 | expect(map.size).toBe(0); 8 | }); 9 | 10 | describe('when the map set does not contain a value', () => { 11 | it('has() returns false', () => { 12 | const map = new MapSet(); 13 | expect(map.has('a', 'b')).toBe(false); 14 | }); 15 | }); 16 | 17 | describe('when the map set contains a value', () => { 18 | it('has() returns false', () => { 19 | const map = new MapSet(); 20 | map.add('a', 'b'); 21 | expect(map.has('a', 'b')).toBe(true); 22 | }); 23 | }); 24 | 25 | describe('when a value is removed from the map set', () => { 26 | it('has() returns false', () => { 27 | const map = new MapSet(); 28 | map.add('a', 'b'); 29 | map.delete('a', 'b'); 30 | expect(map.has('a', 'b')).toBe(false); 31 | }); 32 | 33 | it('reduces the size by 1', () => { 34 | const map = new MapSet(); 35 | map.add('a', 'b'); 36 | map.delete('a', 'b'); 37 | expect(map.size).toBe(0); 38 | }) 39 | }); 40 | 41 | describe('when there is one set', () => { 42 | describe('and the set contains one value', () => { 43 | it('has a size of 1', () => { 44 | const map = new MapSet(); 45 | map.add('a', 'b'); 46 | expect(map.size).toBe(1); 47 | }); 48 | }); 49 | 50 | describe('and the set contains two values', () => { 51 | it('has a size of 2', () => { 52 | const map = new MapSet(); 53 | map.add('a', 'b'); 54 | map.add('a', 'c'); 55 | expect(map.size).toBe(2); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('when there are two sets', () => { 61 | describe('and each set contains one value', () => { 62 | it('has a size of 2', () => { 63 | const map = new MapSet(); 64 | map.add('a', 'b'); 65 | map.add('c', 'd'); 66 | expect(map.size).toBe(2); 67 | }); 68 | }); 69 | 70 | describe('and each set contains two values', () => { 71 | it('has a size of 4', () => { 72 | const map = new MapSet(); 73 | map.add('a', 'b'); 74 | map.add('a', 'c'); 75 | map.add('d', 'e'); 76 | map.add('d', 'f'); 77 | expect(map.size).toBe(4); 78 | }); 79 | }); 80 | 81 | it('can hold the same value in multiple sets', () => { 82 | const map = new MapSet(); 83 | map.add('a', 'c'); 84 | map.add('b', 'c'); 85 | expect(map.size).toBe(2); 86 | }); 87 | }); 88 | 89 | describe('adding a value that already exists', () => { 90 | it('does not increase the size', () => { 91 | const map = new MapSet(); 92 | map.add('a', 'b'); 93 | map.add('a', 'c'); 94 | expect(map.size).toBe(2); 95 | 96 | map.add('a', 'c'); 97 | expect(map.size).toBe(2); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/utils/MapSet.ts: -------------------------------------------------------------------------------- 1 | export class MapSet { 2 | entries: Map> = new Map(); 3 | 4 | add(a: S, b: T) { 5 | if (!this.entries.has(a)) { 6 | this.entries.set(a, new Set()); 7 | } 8 | this.entries.get(a)?.add(b); 9 | } 10 | 11 | delete(a: S, b: T) { 12 | this.entries.get(a)?.delete(b); 13 | } 14 | 15 | has(a: S, b: T) { 16 | return !!this.entries.get(a)?.has(b); 17 | } 18 | 19 | get size() { 20 | let result = 0; 21 | for (const entry of this.entries) { 22 | result += entry[1].size; 23 | } 24 | return result; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/MergeProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { Validator, MergeProperty, VariableProperty } from '.'; 3 | 4 | describe('MergeProperty', () => { 5 | it('invalidated defaults to true', () => { 6 | const a = new VariableProperty(1); 7 | const b = new VariableProperty(2); 8 | const prop = new MergeProperty(a, b, (a, b) => [a, b]); 9 | expect(prop.invalidated).toBe(true); 10 | }); 11 | 12 | it('combines the source properties via the merge funtion', () => { 13 | const a = new VariableProperty(1); 14 | const b = new VariableProperty(2); 15 | const prop = new MergeProperty(a, b, (a, b) => [a, b]); 16 | expect(prop.current).toEqual([1, 2]) 17 | }); 18 | 19 | describe('when either of the source values change', () => { 20 | it('set the invalidated flag', () => { 21 | const a = new VariableProperty(1); 22 | const b = new VariableProperty(2); 23 | const prop = new MergeProperty(a, b, (a, b) => [a, b]); 24 | prop.invalidated = false; 25 | Validator.run(); 26 | expect(prop.invalidated).toBe(false); 27 | 28 | a.current = 3; 29 | expect(prop.invalidated).toBe(true); 30 | expect(prop.current).toEqual([3, 2]); 31 | 32 | prop.invalidated = false; 33 | Validator.run(); 34 | expect(prop.invalidated).toBe(false); 35 | 36 | b.current = 4; 37 | expect(prop.invalidated).toBe(true); 38 | expect(prop.current).toEqual([3, 4]); 39 | 40 | prop.invalidated = false; 41 | Validator.run(); 42 | expect(prop.invalidated).toBe(false); 43 | }); 44 | }); 45 | 46 | describe('listeners', () => { 47 | describe('when either of the source values change', () => { 48 | it('calls the listener function', () => { 49 | const a = new VariableProperty(1); 50 | const b = new VariableProperty(2); 51 | const prop = new MergeProperty(a, b, (a, b) => [a, b]); 52 | const fn = vi.fn(); 53 | 54 | prop.listen(fn); 55 | expect(fn).not.toBeCalled(); 56 | 57 | a.current = 3; 58 | expect(fn).toBeCalledTimes(1); 59 | expect(fn).toBeCalledWith([3, 2]); 60 | 61 | b.current = 4; 62 | expect(fn).toBeCalledTimes(2); 63 | expect(fn).toBeCalledWith([3, 4]); 64 | }); 65 | }); 66 | }); 67 | }); -------------------------------------------------------------------------------- /src/utils/MergeProperty.ts: -------------------------------------------------------------------------------- 1 | import { Property } from "../types"; 2 | 3 | export class MergeProperty { 4 | 5 | private a: Property; 6 | private b: Property; 7 | private fn: (a: A, b: B) => OUT; 8 | 9 | constructor(a: Property, b: Property, fn: (a: A, b: B) => OUT) { 10 | this.a = a; 11 | this.b = b; 12 | this.fn = fn; 13 | } 14 | 15 | listen(listener: (value: OUT) => void) { 16 | const cleanup = [ 17 | this.a.listen(() => listener(this.current)), 18 | this.b.listen(() => listener(this.current)) 19 | ]; 20 | 21 | return () => cleanup.forEach((fn) => fn()); 22 | } 23 | 24 | get current(): OUT { 25 | return this.fn(this.a.current, this.b.current); 26 | } 27 | 28 | get invalidated(): boolean { 29 | return this.a.invalidated || this.b.invalidated; 30 | } 31 | 32 | set invalidated(value: boolean) { 33 | this.a.invalidated = value; 34 | this.b.invalidated = value; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/ObjectState.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { ObjectState } from './ObjectState'; 3 | 4 | describe('ObjectState', () => { 5 | it('begins the auto index at zero', () => { 6 | const state = new ObjectState(); 7 | expect(state.id).toBe(0); 8 | }); 9 | 10 | it('increments by one for each new object', () => { 11 | const state = new ObjectState(); 12 | expect(state.id).toBe(1); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/utils/ObjectState.ts: -------------------------------------------------------------------------------- 1 | export class ObjectState { 2 | static autoId: number = 0; 3 | 4 | id: number; 5 | 6 | constructor() { 7 | this.id = ObjectState.autoId++; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/Particle.ts: -------------------------------------------------------------------------------- 1 | import { Property, Position, Velocity } from "../types"; 2 | import { VariableProperty } from "./VariableProperty"; 3 | 4 | export class Particle { 5 | node: HTMLDivElement; 6 | init: Property = new VariableProperty(true); 7 | age: Property = new VariableProperty(0); 8 | pos: Property = new VariableProperty([0, 0]); 9 | velocity: Property = new VariableProperty([0, 0]); 10 | opacity: Property = new VariableProperty(100); 11 | scale: Property = new VariableProperty(1); 12 | 13 | constructor(node: HTMLDivElement) { 14 | this.node = node; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/SlidingWindow.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { SlidingWindow } from './SlidingWindow'; 3 | 4 | describe('SlidingWindow', () => { 5 | describe('when it it empty', () => { 6 | it('returns an empty array', () => { 7 | const window = new SlidingWindow(5); 8 | expect(window.values).toEqual([]); 9 | }); 10 | 11 | it('returns a mean of zero', () => { 12 | const window = new SlidingWindow(5); 13 | expect(window.mean()).toEqual(0); 14 | }); 15 | }); 16 | 17 | describe('when there is unused space', () => { 18 | it('pushes new values into the unused space', () => { 19 | const window = new SlidingWindow(5); 20 | window.push(1); 21 | window.push(2); 22 | window.push(3); 23 | expect(window.values).toEqual([1, 2, 3]); 24 | expect(window.mean()).toEqual(1.2); 25 | }); 26 | }); 27 | 28 | describe('when there is no unused space', () => { 29 | it('overrides existing values in a first-in first-out pattern', () => { 30 | const window = new SlidingWindow(5); 31 | window.push(1); 32 | window.push(2); 33 | window.push(3); 34 | window.push(4); 35 | window.push(5); 36 | expect(window.values).toEqual([1, 2, 3, 4, 5]); 37 | 38 | window.push(6); 39 | window.push(7); 40 | window.push(8); 41 | expect(window.values).toEqual([6, 7, 8, 4, 5]); 42 | expect(window.mean()).toEqual(6); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/utils/SlidingWindow.ts: -------------------------------------------------------------------------------- 1 | export class SlidingWindow { 2 | _values: number[] = []; 3 | size: number; 4 | index: number = 0; 5 | 6 | constructor(size: number) { 7 | this.size = size; 8 | } 9 | 10 | get values() { 11 | return [...this._values]; 12 | } 13 | 14 | push(value: number) { 15 | this._values[this.index] = value; 16 | this.index = (this.index + 1) % this.size; 17 | } 18 | 19 | mean(): number { 20 | return this._values.reduce((result, current) => result + current, 0) / this.size; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/StateMachine.ts: -------------------------------------------------------------------------------- 1 | import { Property, StateDefinitions } from "../types"; 2 | import { VariableProperty } from "./VariableProperty"; 3 | 4 | export class StateMachine { 5 | 6 | readonly entity: T; 7 | 8 | readonly states: StateDefinitions; 9 | 10 | readonly stack: Property; 11 | 12 | readonly state: Property; 13 | 14 | readonly age: Property; 15 | 16 | readonly init: Property; 17 | 18 | constructor(entity: T, state: string, states: StateDefinitions) { 19 | this.entity = entity; 20 | this.age = new VariableProperty(0); 21 | this.stack = new VariableProperty([state]); 22 | this.state = new VariableProperty(state); 23 | this.init = new VariableProperty(true); 24 | this.states = states; 25 | } 26 | 27 | update(delta: number) { 28 | if (!this.init.current) { 29 | this.age.current += delta; 30 | } 31 | 32 | this.init.current = false; 33 | 34 | const stack = this.stack.current; 35 | (this.states[stack[stack.length - 1]])?.(this, delta); 36 | } 37 | 38 | push(state: string) { 39 | this.stack.current.push(state); 40 | this.state.current = state; 41 | this.age.current = 0; 42 | this.init.current = true; 43 | } 44 | 45 | pop() { 46 | this.stack.current.pop(); 47 | this.state.current = this.stack.current[this.stack.current.length - 1]; 48 | this.age.current = 0; 49 | this.init.current = true; 50 | } 51 | 52 | replace(state: string) { 53 | const index = this.stack.current.length - 1; 54 | 55 | if (this.stack.current[index] !== state) { 56 | this.stack.current[index] = state; 57 | this.state.current = state; 58 | this.age.current = 0; 59 | this.init.current = true; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/Validator.ts: -------------------------------------------------------------------------------- 1 | export class Validator { 2 | 3 | static queue: (() => void)[] = []; 4 | 5 | static add(fn: () => void) { 6 | this.queue.push(fn); 7 | } 8 | 9 | static run() { 10 | for (const fn of this.queue) { 11 | fn(); 12 | } 13 | this.queue = []; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/VariableProperty.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { Validator, VariableProperty } from '.'; 3 | 4 | describe('VariableProperty', () => { 5 | it('invalidated defaults to true', () => { 6 | const prop = new VariableProperty(42); 7 | expect(prop.invalidated).toBe(true); 8 | }); 9 | 10 | describe('scalar values', () => { 11 | describe('when the value changes', () => { 12 | it('set the invalidated flag', () => { 13 | const prop = new VariableProperty(42); 14 | prop.invalidated = false; 15 | Validator.run(); 16 | expect(prop.invalidated).toBe(false); 17 | 18 | prop.current = 42; 19 | expect(prop.invalidated).toBe(true); 20 | }); 21 | }); 22 | }); 23 | 24 | describe('object values', () => { 25 | describe('when the values changes', () => { 26 | it('set the invalidated flag', () => { 27 | const prop = new VariableProperty({ foo: 'bar' }); 28 | prop.invalidated = false; 29 | Validator.run(); 30 | expect(prop.invalidated).toBe(false); 31 | 32 | prop.current = { foo: 'bar' }; 33 | expect(prop.invalidated).toBe(true); 34 | }); 35 | }); 36 | 37 | describe('when a property of the value changes', () => { 38 | it('set the invalidated flag', () => { 39 | const prop = new VariableProperty({ foo: 'bar' }); 40 | prop.invalidated = false; 41 | Validator.run(); 42 | expect(prop.invalidated).toBe(false); 43 | 44 | prop.current.foo = 'qux'; 45 | expect(prop.invalidated).toBe(true); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('listeners', () => { 51 | describe('when a scalar property changes', () => { 52 | it('calls the listener function', () => { 53 | const property = new VariableProperty(42); 54 | const fn = vi.fn(); 55 | 56 | property.listen(fn); 57 | expect(fn).not.toBeCalled(); 58 | 59 | property.current = 43; 60 | expect(fn).toBeCalledTimes(1); 61 | expect(fn).toBeCalledWith(43); 62 | }); 63 | }); 64 | 65 | describe('when an array property changes', () => { 66 | it('calls the listener function', () => { 67 | const property = new VariableProperty(['a', 'b']); 68 | const fn = vi.fn(); 69 | 70 | property.listen(fn); 71 | expect(fn).not.toBeCalled(); 72 | 73 | property.current = ['c', 'd']; 74 | expect(fn).toBeCalledTimes(1); 75 | expect(fn).toBeCalledWith(['c', 'd']); 76 | }); 77 | }); 78 | 79 | describe('when an entry of an array property changes', () => { 80 | it('calls the listener function', () => { 81 | const property = new VariableProperty(['a', 'b']); 82 | const fn = vi.fn(); 83 | 84 | property.listen(fn); 85 | expect(fn).not.toBeCalled(); 86 | 87 | property.current[0] = 'c'; 88 | expect(fn).toBeCalledTimes(1); 89 | expect(fn).toBeCalledWith(['c', 'b']); 90 | }); 91 | }); 92 | }); 93 | }); -------------------------------------------------------------------------------- /src/utils/VariableProperty.ts: -------------------------------------------------------------------------------- 1 | import { ObjectState } from "./ObjectState"; 2 | import { Validator } from "./Validator"; 3 | 4 | type Listener = (value: T) => void; 5 | 6 | export class VariableProperty extends ObjectState { 7 | 8 | private _current: T; 9 | private _invalidated: boolean = false; 10 | 11 | private listeners: Set> = new Set(); 12 | 13 | constructor(initial: T) { 14 | super(); 15 | this._current = this.proxy(initial); 16 | this._invalidated = true; 17 | } 18 | 19 | listen(listener: (value: T) => void) { 20 | this.listeners.add(listener); 21 | return () => this.listeners.delete(listener); 22 | } 23 | 24 | broadcast() { 25 | this.listeners.forEach((listener) => { 26 | listener(this.current); 27 | }); 28 | } 29 | 30 | /** 31 | * If the value is an object, proxy it so that any set operations automatically invalidate the 32 | * property, so that any dependant DOM elements will be updated in the next render pass. 33 | */ 34 | proxy(value: T) { 35 | const invalidate = () => this.invalidated = true; 36 | const broadcast = () => this.broadcast(); 37 | 38 | return value instanceof Object 39 | ? new Proxy(value, { 40 | set(target, prop, value) { 41 | invalidate(); 42 | const result = Reflect.set(target, prop, value); 43 | broadcast(); 44 | return result; 45 | } 46 | }) 47 | : value; 48 | } 49 | 50 | get current() { 51 | return this._current; 52 | } 53 | 54 | set current(value: T) { 55 | this._current = this.proxy(value); 56 | this._invalidated = true; 57 | this.broadcast(); 58 | } 59 | 60 | get invalidated() { 61 | return this._invalidated; 62 | } 63 | 64 | set invalidated(value: boolean) { 65 | if (value) { 66 | this._invalidated = true; 67 | } else { 68 | Validator.add(() => this._invalidated = false); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils"; 2 | export { BaseParticle } from "./BaseParticle"; 3 | export type { Body } from "./Body"; 4 | export { BoxBody, PolygonBody } from "./Body"; 5 | export { DynamicProperty } from "./DynamicProperty"; 6 | export { EventTarget } from "./EventTarget"; 7 | export { MapSet } from "./MapSet"; 8 | export { MergeProperty } from "./MergeProperty"; 9 | export { ObjectState } from "./ObjectState"; 10 | export { Particle } from "./Particle"; 11 | export { SlidingWindow } from "./SlidingWindow"; 12 | export { StateMachine } from "./StateMachine"; 13 | export { Validator } from "./Validator"; 14 | export { VariableProperty } from "./VariableProperty"; 15 | -------------------------------------------------------------------------------- /src/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 2 | import { chance, clamp, dist, intersects, lerp, permutator } from './utils'; 3 | 4 | describe('utils', () => { 5 | describe('chance', () => { 6 | let original: () => number; 7 | 8 | beforeEach(() => { 9 | original = Math.random; 10 | Math.random = () => 0.01; 11 | }); 12 | 13 | afterEach(() => { 14 | Math.random = original; 15 | }); 16 | 17 | describe('when the random value is less than the threshold', () => { 18 | it('returns true', () =>{ 19 | expect(chance(20)).toBe(true); 20 | }); 21 | }); 22 | 23 | describe('when the random value is greater than the threshold', () => { 24 | it('returns false', () =>{ 25 | expect(chance(19)).toBe(false); 26 | }); 27 | }); 28 | }); 29 | 30 | describe('lerp', () => { 31 | it('linearly interpolates between two values', () => { 32 | expect(lerp(0, 10, 0.5)).toBe(5); 33 | }); 34 | }); 35 | 36 | describe('dist', () => { 37 | it('calculates the euclidean distance between two points', () => { 38 | expect(dist([0, 0], [40, 30])).toBe(50) 39 | }); 40 | }); 41 | 42 | describe('clamp', () => { 43 | describe('when the value is within the range', () => { 44 | it('returns the value', () => { 45 | expect(clamp(15, 10, 20)).toBe(15); 46 | }); 47 | }); 48 | 49 | describe('when the value less than the range', () => { 50 | it('returns the min value', () => { 51 | expect(clamp(5, 10, 20)).toBe(10); 52 | }); 53 | }); 54 | 55 | describe('when the value is greater than the range', () => { 56 | it('returns the max value', () => { 57 | expect(clamp(25, 10, 20)).toBe(20); 58 | }); 59 | }); 60 | }); 61 | 62 | describe('intersects', () => { 63 | describe('when the two bounding boxes intersect', () => { 64 | it('returns true', () => { 65 | const a = { minX: 0, minY: 0, maxX: 100, maxY: 100 }; 66 | const b = { minX: 50, minY: 50, maxX: 150, maxY: 150 }; 67 | expect(intersects(a, b)).toBe(true); 68 | }); 69 | }); 70 | 71 | describe('when the two bounding boxes do not intersect', () => { 72 | it('returns false', () => { 73 | const a = { minX: 0, minY: 0, maxX: 100, maxY: 100 }; 74 | const b = { minX: 101, minY: 101, maxX: 150, maxY: 150 }; 75 | expect(intersects(a, b)).toBe(false); 76 | }); 77 | }); 78 | 79 | describe('when the two bounding boxes are touching but not overlapping', () => { 80 | it('returns false', () => { 81 | const a = { minX: 0, minY: 0, maxX: 100, maxY: 100 }; 82 | const b = { minX: 100, minY: 100, maxX: 150, maxY: 150 }; 83 | expect(intersects(a, b)).toBe(false); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('permutator', () => { 89 | it('returns all possible permutations of the input array', () => { 90 | expect(permutator([1, 2, 3])).toEqual([ 91 | [1, 2, 3], 92 | [1, 3, 2], 93 | [2, 1, 3], 94 | [2, 3, 1], 95 | [3, 1, 2], 96 | [3, 2, 1], 97 | ]); 98 | }); 99 | }) 100 | }); -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { BBox } from "detect-collisions"; 2 | import { Position } from "../types"; 3 | 4 | /** 5 | * Calculate the chance of something occurring. 6 | */ 7 | export const chance = (threshold: number): boolean => { 8 | return Math.random() <= (threshold / 2000); 9 | } 10 | 11 | /** 12 | * Linear interpolation between two numbers, "a" and "b". 13 | */ 14 | export const lerp = (a: number, b: number, t: number): number => { 15 | return a + t * (b - a); 16 | }; 17 | 18 | /** 19 | * Euclidean distance between two points. 20 | */ 21 | export const dist = (a: Position, b: Position): number => { 22 | const x = a[0] - b[0]; 23 | const y = a[1] - b[1]; 24 | return Math.sqrt((x * x) + (y * y)); 25 | }; 26 | 27 | /** 28 | * Clamp a number to a given range. 29 | */ 30 | export const clamp = (value: number, min: number, max: number): number => { 31 | return Math.min(max, Math.max(min, value)); 32 | }; 33 | 34 | /** 35 | * Determine if two rectangular bounding boxes intersect. 36 | */ 37 | export const intersects = (a: BBox, b: BBox): boolean => { 38 | return a.minX < b.maxX && b.minX < a.maxX && a.minY < b.maxY && b.minY < a.maxY; 39 | }; 40 | 41 | /** 42 | * Find all permutations of the given input array. 43 | */ 44 | export function permutator(input: T[]): T[][] { 45 | const result: T[][] = []; 46 | 47 | const permute = (arr: T[], m: T[] = []) => { 48 | if (arr.length === 0) { 49 | result.push(m); 50 | } else { 51 | for (let i = 0; i < arr.length; i++) { 52 | const curr = arr.slice(); 53 | const next = curr.splice(i, 1); 54 | permute(curr.slice(), m.concat(next)); 55 | } 56 | } 57 | } 58 | 59 | permute(input); 60 | return result; 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vitest/config'; 3 | import dts from 'vite-plugin-dts'; 4 | import react from '@vitejs/plugin-react' 5 | 6 | export default defineConfig({ 7 | build: { 8 | minify: false, 9 | lib: { 10 | entry: resolve(__dirname, 'src/index.ts'), 11 | name: 'engine', 12 | fileName: 'engine', 13 | }, 14 | rollupOptions: { 15 | external: ['react', 'react-dom', 'react/jsx-runtime'], 16 | output: { 17 | globals: { 18 | 'react': 'React', 19 | 'react-dom': 'ReactDom', 20 | 'react/jsx-runtime': 'ReactJsxRuntime', 21 | }, 22 | }, 23 | }, 24 | }, 25 | plugins: [ 26 | dts(), 27 | react(), 28 | ], 29 | test: { 30 | environment: 'jsdom', 31 | setupFiles: ['./src/test/setup.ts'], 32 | } 33 | }); --------------------------------------------------------------------------------