├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── docs └── api │ └── API.md ├── package.json ├── src ├── custom.d.ts ├── index.ts ├── logic │ ├── ApiWrapper.tsx │ ├── logicWorkerApp │ │ ├── MessageHandler.tsx │ │ ├── PhysicsHandler.tsx │ │ ├── hooks │ │ │ ├── messaging.ts │ │ │ └── sync.ts │ │ └── index.tsx │ └── workerHelper.ts ├── main │ ├── Body.tsx │ ├── Engine.tsx │ ├── InstancesProvider.tsx │ ├── LogicHandler.tsx │ ├── LogicWorker.tsx │ ├── MeshRefs.tsx │ ├── PhysicsWorker.tsx │ ├── R3FPhysicsObjectUpdater.tsx │ ├── hooks │ │ ├── useBody.ts │ │ ├── useCollisionEvents.ts │ │ └── useWorkerMessages.ts │ └── worker │ │ ├── app │ │ ├── Bodies.tsx │ │ ├── Collisions.tsx │ │ ├── World.tsx │ │ ├── WorldState.tsx │ │ ├── appContext.ts │ │ ├── buffers.ts │ │ ├── index.tsx │ │ ├── logicWorker.ts │ │ └── workerMessages.ts │ │ ├── data.ts │ │ ├── functions.ts │ │ ├── logicWorker.ts │ │ ├── methods.ts │ │ ├── physicsWorkerHelper.ts │ │ ├── planckjs │ │ ├── bodies.ts │ │ ├── cache.ts │ │ ├── collisions │ │ │ ├── collisions.ts │ │ │ ├── data.ts │ │ │ ├── filters.ts │ │ │ └── types.ts │ │ ├── config.ts │ │ ├── data.ts │ │ ├── shared.ts │ │ └── world.ts │ │ ├── shared.ts │ │ ├── shared │ │ └── types.ts │ │ ├── utils.ts │ │ └── worker.ts ├── shared │ ├── CollisionsProvider.tsx │ ├── MeshSubscriptions.tsx │ ├── Messages.tsx │ ├── PhysicsProvider.tsx │ ├── PhysicsSync.tsx │ ├── SendMessages.tsx │ ├── StoredPhysicsData.tsx │ ├── WorkerOnMessageProvider.tsx │ ├── types.ts │ └── utils.ts └── utils │ ├── hooks.ts │ ├── numbers.ts │ └── time.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | .idea 7 | temp 8 | temp2 9 | temp3 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Simon Hales 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⚠️ This will be replaced by an updated engine that I'm working on within a new repo. https://github.com/simonghales/rgg-engine The external API will be reasonably similar, but the internals are undergoing quite a few changes. I'm also working on an editor, you can check out a preview at https://rgg-demo.netlify.app/ 2 | 3 | 4 | ## ⚠️ Currently under development, use at your own risk 5 | 6 | # react-three-game-engine 7 | 8 | [![Version](https://img.shields.io/npm/v/react-three-game-engine?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/react-three-game-engine) 9 | 10 | A very early experimental work-in-progress package to help provide game engine functionality for [react-three-fiber](https://github.com/pmndrs/react-three-fiber). 11 | 12 | ### Key Features 13 | - [planck.js](https://github.com/shakiba/planck.js/) 2d physics integration 14 | - Physics update rate independent of frame rate 15 | - `OnFixedUpdate` functionality 16 | - Additional React app running in web worker, sync'd with physics, for handling game logic etc 17 | 18 | I will be working on an example starter-kit: [react-three-game-starter](https://github.com/simonghales/react-three-game-starter) which will be fleshed out over time. 19 | 20 | ### Note 21 | The planck.js integration currently isn't fully fleshed out. I've only been adding in support 22 | for functionality on an as-needed basis for my own games. 23 | 24 | Note: if you delve into the source code for this package, it's a bit messy! 25 | 26 | Note: right now I'm having issues getting this to run via codesandbox via create-react-app, hopefully I can resolve this eventually. 27 | 28 | ## Get Started 29 | 30 | ### General / Physics 31 | 32 | 1. Install required packages 33 | 34 | `npm install react-three-game-engine` 35 | 36 | plus 37 | 38 | `npm install three react-three-fiber planck-js` 39 | 40 | 2. Create Physics Web Worker 41 | 42 | You'll need to create your own web worker to handle the physics. You can 43 | check out my repo [react-three-game-starter](https://github.com/simonghales/react-three-game-starter) 44 | for an example of how you can do so with `create-react-app` without having to eject. 45 | 46 | Within this worker you'll need to import `physicsWorkerHandler` and pass it `self` as the param. 47 | 48 | Example: 49 | 50 | ```js 51 | // worker.js 52 | import {physicsWorkerHandler} from "react-three-game-engine"; 53 | 54 | // because of some weird react/dev/webpack/something quirk 55 | self.$RefreshReg$ = () => {}; 56 | self.$RefreshSig$ = () => () => {}; 57 | 58 | physicsWorkerHandler(self); 59 | ``` 60 | 61 | 3. Import and add [``](docs/api/API.md#Engine) component within your r3f `` component. 62 | 63 | ```jsx 64 | import { Engine } from 'react-three-game-engine' 65 | import { Canvas } from 'react-three-fiber' 66 | ``` 67 | 68 | And pass it the physics worker you created in the previous step. 69 | 70 | ```jsx 71 | 72 | const physicsWorker = new Worker('path/to/worker.js', { type: 'module' }); 73 | 74 | //... 75 | 76 | 77 | 78 | {/* Game components go here */} 79 | 80 | 81 | ``` 82 | 83 | 4. Create a planck.js driven physics [body](docs/api/API.md#Bodies) 84 | 85 | ```jsx 86 | import { useBody, BodyType, BodyShape } from 'react-three-game-engine' 87 | import { Vec2 } from 'planck-js' 88 | ``` 89 | 90 | ```jsx 91 | const [ref, api] = useBody(() => ({ 92 | type: BodyType.dynamic, 93 | position: Vec2(0, 0), 94 | linearDamping: 4, 95 | fixtures: [{ 96 | shape: BodyShape.circle, 97 | radius: 0.55, 98 | fixtureOptions: { 99 | density: 20, 100 | } 101 | }], 102 | })) 103 | ``` 104 | 105 | 5. Control the body via the returned [api](docs/api/API.md#BodyApi) 106 | 107 | ```jsx 108 | api.setLinearVelocity(Vec2(1, 1)) 109 | ``` 110 | 111 | 6. Utilise [`useFixedUpdate`](docs/api/API.md#usefixedupdate) for controlling the body 112 | 113 | ```jsx 114 | import { useFixedUpdate } from 'react-three-game-engine' 115 | ``` 116 | 117 | ```jsx 118 | 119 | const velocity = Vec2(0, 0) 120 | 121 | // ... 122 | 123 | const onFixedUpdate = useCallback(() => { 124 | 125 | // ... 126 | 127 | velocity.set(xVel * 5, yVel * 5) 128 | api.setLinearVelocity(velocity) 129 | 130 | }, [api]) 131 | 132 | useFixedUpdate(onFixedUpdate) 133 | 134 | ``` 135 | 136 | ### React Logic App Worker 137 | 138 | A key feature provided by react-three-game-engine is the separate React app running 139 | within a web worker. This helps keep the main thread free to handle rendering etc, 140 | helps keep performance smooth. 141 | 142 | To utilise this functionality you'll need to create your own web worker. You can 143 | check out my repo [react-three-game-starter](https://github.com/simonghales/react-three-game-starter) 144 | for an example of how you can do so with `create-react-app` without having to eject. 145 | 146 | 1. Create a React component to host your logic React app, export a new component wrapped with 147 | [`withLogicWrapper`](docs/api/API.md#withLogicWrapper) 148 | 149 | ```jsx 150 | import {withLogicWrapper} from "react-three-game-engine"; 151 | 152 | const App = () => { 153 | // ... your new logic app goes here 154 | } 155 | 156 | export const LgApp = withLogicWrapper(App) 157 | ``` 158 | 159 | 2. Set up your web worker like such 160 | (note requiring the file was due to jsx issues with my web worker compiler) 161 | 162 | ```jsx 163 | /* eslint-disable no-restricted-globals */ 164 | import {logicWorkerHandler} from "react-three-game-engine"; 165 | 166 | // because of some weird react/dev/webpack/something quirk 167 | (self).$RefreshReg$ = () => {}; 168 | (self).$RefreshSig$ = () => () => {}; 169 | 170 | logicWorkerHandler(self, require("../path/to/logic/app/component").LgApp) 171 | ``` 172 | 173 | 3. Import your web worker (this is just example code) 174 | 175 | ```jsx 176 | const [logicWorker] = useState(() => new Worker('../path/to/worker', { type: 'module' })) 177 | ``` 178 | 179 | 4. Pass worker to [``](docs/api/API.md#Engine) 180 | 181 | ```jsx 182 | 183 | {/* ... */} 184 | 185 | ``` 186 | 187 | You should now have your Logic App running within React within your web worker, 188 | synchronised with the physics worker as well. 189 | 190 | ### Controlling a body through both the main, and logic apps. 191 | 192 | To control a body via either the main or logic apps, you would create the body 193 | within one app via [`useBody`](docs/api/API.md#useBody) and then within the other app you can get api 194 | access via [`useBodyApi`](docs/api/API.md#useBodyApi). 195 | 196 | However you need to know the `uuid` of the body you wish to control. By default 197 | the uuid is one generated via threejs, but you can specify one yourself. 198 | 199 | 1. Create body 200 | 201 | ```jsx 202 | useBody(() => ({ 203 | type: BodyType.dynamic, 204 | position: Vec2(0, 0), 205 | linearDamping: 4, 206 | fixtures: [{ 207 | shape: BodyShape.circle, 208 | radius: 0.55, 209 | fixtureOptions: { 210 | density: 20, 211 | } 212 | }], 213 | }), { 214 | uuid: 'player' 215 | }) 216 | ``` 217 | 218 | 2. Get body api 219 | 220 | ```jsx 221 | const api = useBodyApi('player') 222 | 223 | // ... 224 | 225 | api.setLinearVelocity(Vec2(1, 1)) 226 | 227 | ``` 228 | 229 | So with this approach, you can for example initiate your player body via the logic app, 230 | and then get api access via the main app, and use that to move the body around. 231 | 232 | 3. Additionally, if you are creating your body in main / logic, you'll likely want to have 233 | access to the position / rotation of the body as well. 234 | 235 | You can use [`useSubscribeMesh`](docs/api/API.md#useSubscribeMesh) and pass in a ref you've created, which will synchronize 236 | with the physics body. 237 | 238 | ```jsx 239 | const ref = useRef(new Object3D()) 240 | useSubscribeMesh('player', ref.current, false) 241 | 242 | // ... 243 | 244 | return ( 245 | 246 | {/*...*/} 247 | 248 | ) 249 | 250 | ``` 251 | 252 | ### Synchronising Logic App with Main App 253 | 254 | I've added in [`useSyncWithMainComponent`](docs/api/API.md#useSyncWithMainComponent) to sync from the logic app to the main app 255 | 256 | 1. Within a component running on the logic app 257 | 258 | ```jsx 259 | const updateProps = useSyncWithMainComponent("player", "uniqueKey", { 260 | foo: "bar" 261 | }) 262 | 263 | // ... 264 | 265 | updateProps({ 266 | foo: "updated" 267 | }) 268 | 269 | 270 | ``` 271 | 272 | 2. Then within the main app 273 | 274 | ```jsx 275 | const Player = ({foo}) => { 276 | // foo -> "bar" 277 | // foo -> "updated" 278 | } 279 | 280 | const mappedComponents = { 281 | "player": Player 282 | } 283 | 284 | 285 | {/* ... */} 286 | 287 | 288 | ``` 289 | 290 | When [`useSyncWithMainComponent`](docs/api/API.md#useSyncWithMainComponent) is mounted / unmounted, the `` 291 | component will mount / unmount. 292 | 293 | Note: currently this only supports sync'ing from logic -> main, but I will add in the 294 | reverse soon. 295 | 296 | ### Communication 297 | 298 | To communicate between the main and logic workers you can use [`useSendMessage`](docs/api/API.md#useSendMessage) 299 | to send and [`useOnMessage`](docs/api/API.md#useOnMessage) to subscribe 300 | 301 | ```jsx 302 | import {useSendMessage} from "react-three-game-engine" 303 | 304 | // ... 305 | 306 | const sendMessage = useSendMessage() 307 | 308 | // ... 309 | 310 | sendMessage('messageKey', "any-data") 311 | 312 | ``` 313 | 314 | ```jsx 315 | import {useOnMessage} from "react-three-game-engine" 316 | 317 | // ... 318 | 319 | const onMessage = useOnMessage() 320 | 321 | // ... 322 | 323 | const unsubscribe = onMessage('messageKey', (data) => { 324 | // data -> "any-data" 325 | }) 326 | 327 | // ... 328 | 329 | unsubscribe() 330 | 331 | ``` 332 | 333 | ## API 334 | 335 | [Read the API documentation](docs/api/API.md) 336 | 337 | 338 | -------------------------------------------------------------------------------- /docs/api/API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This is a work in progress. 4 | 5 | # Engine 6 | 7 | Important: must be placed within the `Canvas` element provided by react-three-fiber. 8 | 9 | ```tsx 10 | 16 | ``` 17 | 18 | #### config 19 | _optional_ 20 | 21 | ```tsx 22 | type config = { 23 | maxNumberOfDynamicObjects: number // default 100 24 | updateRate: number // default 1000 / 30 (i.e. physics updates 30 times a second) 25 | } 26 | ``` 27 | 28 | #### worldParams 29 | _optional_ 30 | 31 | See the [planck.js documentation](https://github.com/shakiba/planck.js/blob/master/docs/interfaces/worlddef.md) for 32 | details regarding `worldDef` 33 | 34 | ```tsx 35 | type worldParams = { 36 | allowSleep: boolean // default to true 37 | gravity: Vec2 // default to Vec2(0, 0) (i.e. no gravity) 38 | ...worldDef 39 | } 40 | ``` 41 | 42 | #### logicWorker 43 | _optional_ 44 | 45 | A Web Worker that will be run as the logic worker, synchronized with the 46 | physics app. [Read more here.](../../README.md#react-logic-app-worker) 47 | 48 | 49 | #### logicMappedComponents 50 | _optional_ 51 | 52 | Facilitates synchronising components from the logic app with the main app. [Read more here.](../../README.md#synchronising-logic-app-with-main-app) 53 | 54 | ```tsx 55 | type logicMappedComponents = { 56 | [key: string]: ReactComponent 57 | } 58 | ``` 59 | 60 | See [useSyncWithMainComponent](#useSyncWithMainComponent) for further details. 61 | 62 | ## useFixedUpdate 63 | 64 | Inspired by Unity's `OnFixedUpDate`, `useFixedUpdate` is a hook that enables 65 | you to call a function after each physics update. For Unity this is the 66 | recommended way to apply physics effects. 67 | 68 | ```tsx 69 | import { useFixedUpdate } from "react-three-game-engine" 70 | 71 | useFixedUpdate((delta: number) => { 72 | // do something 73 | }) 74 | ``` 75 | 76 | # Physics 77 | 78 | ## Bodies 79 | 80 | #### BodyApi 81 | 82 | ```tsx 83 | type BodyApi = { 84 | // planck.js methods 85 | applyForceToCenter: (vec: Vec2, uuid?: string) => void; 86 | applyLinearImpulse: (vec: Vec2, pos: Vec2, uuid?: string) => void; 87 | setPosition: (vec: Vec2, uuid?: string) => void; 88 | setLinearVelocity: (vec: Vec2, uuid?: string) => void; 89 | setAngle: (angle: number, uuid?: string) => void; 90 | // custom 91 | updateBody: (data: UpdateBodyData, uuid?: string) => void; 92 | } 93 | 94 | type UpdateBodyData = { 95 | fixtureUpdate?: { 96 | groupIndex?: number, 97 | categoryBits?: number, 98 | maskBits?: number, 99 | } 100 | } 101 | 102 | ``` 103 | 104 | For detail on what the planck.js methods do, see the [documentation for planck.js](https://github.com/shakiba/planck.js/blob/master/docs/api/classes/body.md#methods). 105 | 106 | #### useBody 107 | 108 | ```tsx 109 | useBody(PropsFn, Config): [ref: MutableRefObject, api: BodyApi, uuid: string] 110 | ``` 111 | 112 | ```tsx 113 | type PropsFn = () => AddBodyDef 114 | 115 | type AddBodyDef = { 116 | bodyType: BodyType, 117 | fixtures: Fixture[], 118 | ...BodyDef, 119 | } 120 | 121 | type Fixture = BoxFixture | CircleFixture 122 | 123 | type BoxFixture = FixtureBase & { 124 | hx: number, 125 | hy: number, 126 | center?: [number, number], 127 | } 128 | 129 | type CircleFixture = FixtureBase & { 130 | radius: number, 131 | } 132 | 133 | type FixtureBase = { 134 | shape: BodyShape, 135 | fixtureOptions?: Partial, 136 | } 137 | 138 | enum BodyShape { 139 | box = 'box', 140 | circle = 'circle', 141 | } 142 | 143 | enum BodyType { 144 | static = 'static', 145 | kinematic = 'kinematic', 146 | dynamic = 'dynamic' 147 | } 148 | 149 | ``` 150 | 151 | ```tsx 152 | type Config = { 153 | listenForCollisions?: boolean; 154 | applyAngle?: boolean; 155 | cacheKey?: string; 156 | uuid?: string; 157 | fwdRef?: MutableRefObject; 158 | } 159 | ``` 160 | 161 | Note: for `BodyDef` [see the planck.js documentation](https://github.com/shakiba/planck.js/blob/master/docs/api/interfaces/bodydef.md). 162 | Note: for `FixtureOpt` [see the planck.js documentation](https://github.com/shakiba/planck.js/blob/master/docs/api/interfaces/fixtureopt.md). 163 | 164 | ```tsx 165 | import { useBody, BodyType, BodyShape } from "react-three-game-engine" 166 | import { Vec2 } from "planck-js" 167 | 168 | const [ref, api, uuid] = useBody(() => ({ 169 | type: BodyType.dynamic, 170 | position: Vec2(0, 0), 171 | linearDamping: 4, 172 | fixtures: [{ 173 | shape: BodyShape.circle, 174 | radius: 0.55, 175 | fixtureOptions: { 176 | density: 20, 177 | } 178 | }], 179 | })) 180 | 181 | ``` 182 | 183 | Note: if you set `bodyType` to `static` it will not be synchronised with either the main app or 184 | logic app. 185 | 186 | #### createBoxFixture 187 | 188 | ```tsx 189 | createBoxFixture = ({ 190 | width = 1, 191 | height = 1, 192 | center, 193 | fixtureOptions = {} 194 | }: { 195 | width?: number, 196 | height?: number, 197 | center?: [number, number], 198 | fixtureOptions?: Partial 199 | }) => BoxFixture 200 | ``` 201 | 202 | ```tsx 203 | 204 | useBody(() => ({ 205 | type: BodyType.dynamic, 206 | position: Vec2(0, 0), 207 | linearDamping: 4, 208 | fixtures: [ 209 | createBoxFixture({ 210 | width: 2, 211 | height: 2, 212 | }) 213 | ], 214 | })) 215 | ``` 216 | 217 | #### createCircleFixture 218 | 219 | ```tsx 220 | createCircleFixture = ({ 221 | radius = 1, 222 | fixtureOptions = {} 223 | }: { 224 | radius?: number, 225 | fixtureOptions?: Partial 226 | }) => CircleFixture 227 | ``` 228 | 229 | ```tsx 230 | 231 | useBody(() => ({ 232 | type: BodyType.dynamic, 233 | position: Vec2(0, 0), 234 | linearDamping: 4, 235 | fixtures: [ 236 | createCircleFixture({ 237 | radius: 2, 238 | }) 239 | ], 240 | })) 241 | ``` 242 | 243 | #### useBodyApi 244 | 245 | ```tsx 246 | useBodyApi: (uuid: string) => BodyApi 247 | ``` 248 | 249 | ```tsx 250 | const api = useBodyApi('player') 251 | 252 | // ... 253 | 254 | api.setLinearVelocity(Vec2(1, 1)) 255 | 256 | ``` 257 | 258 | #### useSubscribeMesh 259 | 260 | ```tsx 261 | useSubscribeMesh: ( 262 | uuid: string, 263 | object: Object3D, 264 | applyAngle: boolean = true, 265 | isDynamic: boolean = true 266 | ) 267 | ``` 268 | 269 | ```tsx 270 | const ref = useRef(new Object3D()) 271 | useSubscribeMesh('player', ref.current, false) 272 | ``` 273 | 274 | # Logic 275 | 276 | ## Logic Worker 277 | 278 | #### logicWorkerHandler 279 | 280 | ```tsx 281 | logicWorkerHandler: (worker: Worker, appComponent: ReactComponent) 282 | ``` 283 | 284 | ```jsx 285 | /* eslint-disable no-restricted-globals */ 286 | import { logicWorkerHandler } from "react-three-game-engine"; 287 | 288 | // because of some weird react/dev/webpack/something quirk 289 | (self).$RefreshReg$ = () => {}; 290 | (self).$RefreshSig$ = () => () => {}; 291 | 292 | logicWorkerHandler(self, require("../path/to/logic/app/component").LgApp) 293 | ``` 294 | 295 | ## Logic App 296 | 297 | #### withLogicWrapper 298 | 299 | ```tsx 300 | withLogicWrapper: (appComponent: ReactComponent) => ReactComponent 301 | ``` 302 | 303 | ```jsx 304 | import { withLogicWrapper } from "react-three-game-engine"; 305 | 306 | const App = () => { 307 | // ... your new logic app goes here 308 | } 309 | 310 | export const LgApp = withLogicWrapper(App) 311 | ``` 312 | 313 | #### useSyncWithMainComponent 314 | 315 | ``` 316 | useSyncWithMainComponent: (componentKey: string, uniqueKey: string, props: {}) => UpdateProps 317 | 318 | type UpdateProps = (props: {}) => void 319 | 320 | ``` 321 | 322 | ```tsx 323 | const updateProps = useSyncWithMainComponent("player", "uniqueKey", { 324 | foo: "bar", 325 | blah: "blah", 326 | }) 327 | 328 | // ... 329 | 330 | updateProps({ 331 | foo: "updated" 332 | }) 333 | ``` 334 | 335 | See [logicMappedComponents](#logicMappedComponents) for implementation details. 336 | 337 | ## Communication 338 | 339 | #### useSendMessage 340 | 341 | ```tsx 342 | useSendMessage: () => (messageKey: string, data: any) => void 343 | ``` 344 | 345 | ```jsx 346 | import { useSendMessage } from "react-three-game-engine" 347 | 348 | // ... 349 | 350 | const sendMessage = useSendMessage() 351 | 352 | // ... 353 | 354 | sendMessage('messageKey', "any-data") 355 | 356 | ``` 357 | 358 | #### useOnMessage 359 | 360 | ```tsx 361 | useOnMessage: () => (messageKey: string, callback: (data: any) => void) => UnsubscribeFn 362 | ``` 363 | 364 | ```jsx 365 | import { useOnMessage } from "react-three-game-engine" 366 | 367 | // ... 368 | 369 | const onMessage = useOnMessage() 370 | 371 | // ... 372 | 373 | const unsubscribe = onMessage('messageKey', (data) => { 374 | // data -> "any-data" 375 | }) 376 | 377 | // ... 378 | 379 | unsubscribe() 380 | 381 | ``` 382 | 383 | ## Misc 384 | 385 | ### Mesh Storage 386 | 387 | #### useStoreMesh 388 | 389 | ```tsx 390 | useStoreMesh: (uuid: string, mesh: Object3D) 391 | ``` 392 | 393 | #### useStoredMesh 394 | 395 | ```tsx 396 | useStoredMesh: (uuid: string) => Object3D | null 397 | ``` 398 | 399 | ### Mesh Instancing 400 | 401 | You'll need to install `@react-three/drei` 402 | 403 | #### InstancesProvider 404 | 405 | Place inside of `` 406 | 407 | ```tsx 408 | 409 | 410 | {/*...*/} 411 | 412 | 413 | ``` 414 | 415 | #### InstancedMesh 416 | 417 | ```tsx 418 | for r3f, e.g. castShadow 423 | /> 424 | 425 | type Props = { 426 | meshKey: string, 427 | maxInstances: number, 428 | gltfPath: string, 429 | meshProps?: JSX.IntrinsicElements['instancedMesh'] 430 | } 431 | 432 | ``` 433 | 434 | gltfPath is passed to `useGltf` from `@react-three/drei` 435 | 436 | Place inside of `` 437 | 438 | You need to wrap it with React's `` 439 | 440 | ```tsx 441 | 442 | 443 | 450 | 451 | 452 | ``` 453 | 454 | #### Instance 455 | 456 | ```tsx 457 | 463 | 464 | type Props = { 465 | meshKey: string, 466 | position?: [number, number, number], 467 | rotation?: [number, number, number], 468 | scale?: [number, number, number] 469 | } 470 | 471 | 472 | 473 | ``` 474 | 475 | #### useInstancedMesh 476 | 477 | todo... 478 | 479 | #### useAddInstance 480 | 481 | todo... 482 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.14.6", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "description": "A very early experimental work-in-progress package to help provide game engine functionality for react-three-fiber", 7 | "keywords": [ 8 | "react", 9 | "react-three-fiber", 10 | "threejs", 11 | "three", 12 | "game", 13 | "engine" 14 | ], 15 | "files": [ 16 | "dist", 17 | "src" 18 | ], 19 | "homepage": "https://github.com/simonghales/react-three-game-engine#readme", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/simonghales/react-three-game-engine.git" 23 | }, 24 | "engines": { 25 | "node": ">=10" 26 | }, 27 | "scripts": { 28 | "start": "tsdx watch", 29 | "build-worker": "tsdx build --entry src/main/worker/worker.ts --name worker && rm -rf temp && mkdir temp && mv dist/* temp/", 30 | "build-worker-final": "mkdir dist/worker && mv temp/* dist/worker && rm -rf temp", 31 | "build-worker-app": "tsdx build --entry src/main/worker/app/index.tsx --name physicsApp && rm -rf temp3 && mkdir temp3 && mv dist/* temp3/", 32 | "build-worker-app-final": "mkdir dist/worker/app && mv temp3/* dist/worker/app && rm -rf temp3", 33 | "build-logic-worker": "tsdx build --entry src/logic/logicWorkerApp/index.tsx --name workerApp && rm -rf temp2 && mkdir temp2 && mv dist/* temp2/", 34 | "build-logic-worker-final": "mkdir dist/logicWorkerApp && mv temp2/* dist/logicWorkerApp && rm -rf temp2", 35 | "build": "npm run build-logic-worker && npm run build-worker && npm run build-worker-app && tsdx build && npm run build-worker-final && npm run build-worker-app-final && npm run build-logic-worker-final", 36 | "local-build": "yalc publish --push", 37 | "test": "tsdx test --passWithNoTests", 38 | "lint": "tsdx lint", 39 | "prepare": "npm run build", 40 | "size": "size-limit", 41 | "analyze": "size-limit --why" 42 | }, 43 | "peerDependencies": { 44 | "@react-three/drei": ">=3.3", 45 | "planck-js": ">=0.3", 46 | "react": ">=16.13", 47 | "react-dom": ">=16.13", 48 | "react-three-fiber": ">=5.0", 49 | "typescript": ">=3.9" 50 | }, 51 | "husky": { 52 | "hooks": {} 53 | }, 54 | "prettier": { 55 | "printWidth": 80, 56 | "semi": true, 57 | "singleQuote": true, 58 | "trailingComma": "es5" 59 | }, 60 | "name": "react-three-game-engine", 61 | "module": "dist/react-three-game-engine.esm.js", 62 | "size-limit": [], 63 | "devDependencies": { 64 | "@react-three/drei": "^3.3.0", 65 | "@size-limit/preset-small-lib": "^4.9.1", 66 | "@types/react": "^17.0.0", 67 | "@types/react-dom": "^17.0.0", 68 | "husky": "^4.3.6", 69 | "planck-js": "^0.3.23", 70 | "react": "^17.0.1", 71 | "react-dom": "^17.0.1", 72 | "react-three-fiber": "^5.3.11", 73 | "size-limit": "^4.9.1", 74 | "three": "^0.124.0", 75 | "tsdx": "^0.14.1", 76 | "tslib": "^2.0.3", 77 | "typescript": "^4.1.3" 78 | }, 79 | "dependencies": { 80 | "react-nil": "^0.0.3", 81 | "valtio": "^0.5.2" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-nil'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { BodyApi, useBody, useBodyApi } from './main/hooks/useBody'; 2 | import { Engine } from './main/Engine'; 3 | import { logicWorkerHandler } from './logic/workerHelper'; 4 | import { useSendSyncComponentMessage } from './logic/logicWorkerApp/hooks/messaging'; 5 | import { withLogicWrapper } from './logic/ApiWrapper'; 6 | import { useSyncWithMainComponent } from './logic/logicWorkerApp/hooks/sync'; 7 | import { useFixedUpdate } from './shared/PhysicsSync'; 8 | import { 9 | useSubscribeMesh, 10 | } from './shared/MeshSubscriptions'; 11 | import { BodyShape, BodyType, createBoxFixture, createCircleFixture } from './main/worker/planckjs/bodies'; 12 | import {useStoredMesh, useStoreMesh } from './main/MeshRefs'; 13 | import { useOnMessage } from './shared/Messages'; 14 | import { useSendMessage } from './shared/SendMessages'; 15 | import { Body, BodySync } from "./main/Body"; 16 | import { 17 | useAddInstance, 18 | useInstancedMesh, 19 | Instance, 20 | InstancedMesh, 21 | InstancesProvider, 22 | } from "./main/InstancesProvider" 23 | import { physicsWorkerHandler } from './main/worker/physicsWorkerHelper'; 24 | import { useCollisionEvents } from './main/hooks/useCollisionEvents'; 25 | 26 | export { 27 | Engine, 28 | useCollisionEvents, 29 | useBodyApi, 30 | useBody, 31 | BodyApi, 32 | logicWorkerHandler, 33 | useSendSyncComponentMessage, 34 | useSyncWithMainComponent, 35 | useFixedUpdate, 36 | useSubscribeMesh, 37 | withLogicWrapper, 38 | BodyShape, 39 | BodyType, 40 | useStoreMesh, 41 | useStoredMesh, 42 | useOnMessage, 43 | useSendMessage, 44 | createBoxFixture, 45 | createCircleFixture, 46 | Body, 47 | BodySync, 48 | useAddInstance, 49 | useInstancedMesh, 50 | Instance, 51 | InstancedMesh, 52 | InstancesProvider, 53 | physicsWorkerHandler, 54 | }; 55 | -------------------------------------------------------------------------------- /src/logic/ApiWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext} from 'react'; 2 | import {MessageData} from "../shared/types"; 3 | import PhysicsHandler from "./logicWorkerApp/PhysicsHandler"; 4 | import CollisionsProvider from "../shared/CollisionsProvider"; 5 | import MeshRefs from "../main/MeshRefs"; 6 | import Messages from "../shared/Messages"; 7 | import SendMessages from "../shared/SendMessages"; 8 | import MessageHandler from "./logicWorkerApp/MessageHandler"; 9 | 10 | export type ContextState = { 11 | physicsWorker: Worker | MessagePort; 12 | sendMessageToMain: (message: MessageData) => void; 13 | }; 14 | 15 | export const Context = createContext((null as unknown) as ContextState); 16 | 17 | export const useWorkerAppContext = (): ContextState => { 18 | return useContext(Context); 19 | }; 20 | 21 | export const useSendMessageToMain = () => { 22 | return useWorkerAppContext().sendMessageToMain; 23 | }; 24 | 25 | const ApiWrapper: React.FC<{ 26 | worker: Worker, 27 | physicsWorker: Worker | MessagePort, 28 | sendMessageToMain: (message: MessageData) => void, 29 | }> = ({ 30 | children, 31 | worker, 32 | physicsWorker, 33 | sendMessageToMain 34 | }) => { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {children} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default ApiWrapper; 55 | 56 | export const withLogicWrapper = (WrappedComponent: any) => { 57 | return (props: any) => { 58 | return ( 59 | 60 | 61 | 62 | ); 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/logic/logicWorkerApp/MessageHandler.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react" 2 | import {useMessagesContext} from "../../shared/Messages"; 3 | import {WorkerOwnerMessageType} from "../../main/worker/shared/types"; 4 | import {MessageData} from "../../shared/types"; 5 | 6 | const MessageHandler: React.FC<{ 7 | worker: Worker, 8 | }> = ({children, worker}) => { 9 | 10 | const { handleMessage } = useMessagesContext(); 11 | 12 | useEffect(() => { 13 | 14 | worker.onmessage = (event: MessageEvent) => { 15 | const type = event.data.type; 16 | 17 | switch (type) { 18 | case WorkerOwnerMessageType.MESSAGE: 19 | handleMessage(event.data.message as MessageData); 20 | break; 21 | } 22 | }; 23 | }, [worker]); 24 | 25 | return ( 26 | <> 27 | {children} 28 | 29 | ) 30 | } 31 | 32 | export default MessageHandler -------------------------------------------------------------------------------- /src/logic/logicWorkerApp/PhysicsHandler.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StoredPhysicsData from '../../shared/StoredPhysicsData'; 3 | import MeshSubscriptions from '../../shared/MeshSubscriptions'; 4 | import WorkerOnMessageProvider from '../../shared/WorkerOnMessageProvider'; 5 | import PhysicsSync from '../../shared/PhysicsSync'; 6 | import PhysicsProvider from '../../shared/PhysicsProvider'; 7 | import {useWorkerMessages} from "../../main/hooks/useWorkerMessages"; 8 | 9 | const PhysicsHandler: React.FC<{ 10 | worker: null | Worker | MessagePort; 11 | }> = ({ children, worker }) => { 12 | if (!worker) return null; 13 | 14 | const subscribe = useWorkerMessages(worker) 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default PhysicsHandler; 32 | -------------------------------------------------------------------------------- /src/logic/logicWorkerApp/hooks/messaging.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { 3 | MessageKeys, 4 | SyncComponentMessageInfo, 5 | SyncComponentMessageType, 6 | } from '../../../shared/types'; 7 | import {useSendMessageToMain} from "../../ApiWrapper"; 8 | 9 | export const useSendSyncComponentMessage = () => { 10 | const sendMessageRaw = useSendMessageToMain(); 11 | 12 | const sendMessage = useCallback( 13 | ( 14 | messageType: SyncComponentMessageType, 15 | info: SyncComponentMessageInfo, 16 | data?: any 17 | ) => { 18 | sendMessageRaw({ 19 | key: MessageKeys.SYNC_COMPONENT, 20 | data: { 21 | messageType, 22 | info, 23 | data, 24 | }, 25 | }); 26 | }, 27 | [sendMessageRaw] 28 | ); 29 | 30 | return sendMessage; 31 | }; 32 | -------------------------------------------------------------------------------- /src/logic/logicWorkerApp/hooks/sync.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo } from 'react'; 2 | import { 3 | SyncComponentMessageType, 4 | SyncComponentType, 5 | } from '../../../shared/types'; 6 | import { useSendSyncComponentMessage } from './messaging'; 7 | 8 | export const useSyncWithMainComponent = ( 9 | componentType: SyncComponentType, 10 | componentKey: string, 11 | initialProps?: any 12 | ) => { 13 | const sendMessage = useSendSyncComponentMessage(); 14 | 15 | const info = useMemo( 16 | () => ({ 17 | componentType, 18 | componentKey, 19 | }), 20 | [componentType, componentKey] 21 | ); 22 | 23 | const updateProps = useCallback( 24 | (props: any) => { 25 | sendMessage(SyncComponentMessageType.UPDATE, info, props); 26 | }, 27 | [info] 28 | ); 29 | 30 | useEffect(() => { 31 | sendMessage(SyncComponentMessageType.MOUNT, info, initialProps); 32 | 33 | return () => { 34 | sendMessage(SyncComponentMessageType.UNMOUNT, info); 35 | }; 36 | }, [info]); 37 | 38 | return updateProps; 39 | }; 40 | -------------------------------------------------------------------------------- /src/logic/logicWorkerApp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | import { FC, useEffect } from 'react'; 3 | import { useProxy } from 'valtio'; 4 | import { MessageData } from '../../shared/types'; 5 | import { WorkerOwnerMessageType } from '../../main/worker/shared/types'; 6 | 7 | const WorkerApp: FC<{ 8 | worker: Worker; 9 | workerRef: { 10 | physicsWorker: null | Worker | MessagePort; 11 | }; 12 | state: { 13 | physicsWorkerLoaded: boolean; 14 | initiated: boolean; 15 | }; 16 | app: any; 17 | }> = ({ app, worker, state, workerRef }) => { 18 | const proxyState = useProxy(state); 19 | const initiated = proxyState.initiated; 20 | const physicsWorkerLoaded = proxyState.physicsWorkerLoaded; 21 | const [physicsWorker, setPhysicsWorker] = useState< 22 | null | Worker | MessagePort 23 | >(null); 24 | 25 | useEffect(() => { 26 | if (physicsWorkerLoaded) { 27 | if (!workerRef.physicsWorker) { 28 | throw new Error(`Worker missing.`); 29 | } 30 | setPhysicsWorker(workerRef.physicsWorker); 31 | } 32 | }, [physicsWorkerLoaded]); 33 | 34 | const sendMessageToMain = useCallback( 35 | (message: MessageData) => { 36 | const update = { 37 | type: WorkerOwnerMessageType.MESSAGE, 38 | message, 39 | }; 40 | 41 | worker.postMessage(update); 42 | }, 43 | [worker] 44 | ); 45 | 46 | if (!initiated || !physicsWorker) return null; 47 | 48 | const App = app 49 | 50 | return ( 51 | 52 | ) 53 | 54 | }; 55 | 56 | export { WorkerApp }; 57 | -------------------------------------------------------------------------------- /src/logic/workerHelper.ts: -------------------------------------------------------------------------------- 1 | import { render } from 'react-nil'; 2 | import { createElement, FC } from 'react'; 3 | import { proxy } from 'valtio'; 4 | import { WorkerMessageType } from '../main/worker/shared/types'; 5 | 6 | export const logicWorkerHandler = (selfWorker: Worker, app: FC) => { 7 | let physicsWorkerPort: MessagePort; 8 | 9 | const state = proxy<{ 10 | physicsWorkerLoaded: boolean; 11 | initiated: boolean; 12 | }>({ 13 | physicsWorkerLoaded: false, 14 | initiated: false, 15 | }); 16 | 17 | const workerRef: { 18 | physicsWorker: null | Worker | MessagePort; 19 | } = { 20 | physicsWorker: null, 21 | }; 22 | 23 | selfWorker.onmessage = (event: MessageEvent) => { 24 | switch (event.data.command) { 25 | case 'connect': 26 | physicsWorkerPort = event.ports[0]; 27 | workerRef.physicsWorker = physicsWorkerPort; 28 | state.physicsWorkerLoaded = true; 29 | return; 30 | 31 | case 'forward': 32 | physicsWorkerPort.postMessage(event.data.message); 33 | return; 34 | } 35 | 36 | const { type, props = {} } = event.data as { 37 | type: WorkerMessageType; 38 | props: any; 39 | }; 40 | 41 | switch (type) { 42 | case WorkerMessageType.INIT: 43 | state.initiated = true; 44 | break; 45 | } 46 | }; 47 | render( 48 | createElement( 49 | require('./logicWorkerApp/index').WorkerApp, 50 | { 51 | worker: selfWorker, 52 | state, 53 | workerRef, 54 | app, 55 | }, 56 | null 57 | ) 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/main/Body.tsx: -------------------------------------------------------------------------------- 1 | import React, {MutableRefObject, ReactElement, useRef} from "react" 2 | import {ValidUUID} from "./worker/shared/types"; 3 | import {Object3D} from "three"; 4 | import {BodyApi, BodyParams, useBody, useBodyApi} from "./hooks/useBody"; 5 | import {AddBodyDef} from "./worker/planckjs/bodies"; 6 | import { useSubscribeMesh } from "../shared/MeshSubscriptions"; 7 | 8 | export const BodySync: React.FC<{ 9 | children: ({uuid, ref, api}: {uuid: ValidUUID, ref: MutableRefObject, api?: BodyApi}) => ReactElement, 10 | uuid: ValidUUID, 11 | applyAngle?: boolean, 12 | isDynamic?: boolean, 13 | bodyRef?: MutableRefObject, 14 | wrapWithGroup?: boolean, 15 | }> = ({ 16 | children, 17 | uuid, 18 | bodyRef, 19 | applyAngle = true, 20 | isDynamic = true, 21 | wrapWithGroup = false, 22 | }) => { 23 | 24 | const localRef = useRef(new Object3D()) 25 | 26 | const ref = bodyRef ?? localRef 27 | 28 | useSubscribeMesh(uuid, ref.current, applyAngle, isDynamic) 29 | 30 | const api = useBodyApi(uuid) 31 | 32 | const inner = children({uuid, ref, api: api ?? undefined}) 33 | 34 | if (wrapWithGroup) { 35 | return ( 36 | 37 | {inner} 38 | 39 | ) 40 | } 41 | 42 | return inner 43 | 44 | } 45 | 46 | export const Body: React.FC<{ 47 | children: ({uuid, ref, api}: {uuid: ValidUUID, ref: MutableRefObject, api: BodyApi}) => ReactElement, 48 | bodyDef: AddBodyDef, 49 | params?: BodyParams, 50 | wrapWithGroup?: boolean, 51 | }> = ({children, params, bodyDef, wrapWithGroup}) => { 52 | 53 | const [ref, api, uuid] = useBody(() => bodyDef, params) 54 | 55 | const inner = children({ref, uuid, api}) 56 | 57 | if (wrapWithGroup) { 58 | return ( 59 | 60 | {inner} 61 | 62 | ) 63 | } 64 | 65 | return inner 66 | } -------------------------------------------------------------------------------- /src/main/Engine.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import PhysicsWorker from './PhysicsWorker'; 3 | import R3FPhysicsObjectUpdater from './R3FPhysicsObjectUpdater'; 4 | import CollisionsProvider from '../shared/CollisionsProvider'; 5 | import { MappedComponents } from '../shared/types'; 6 | import MeshRefs from "./MeshRefs"; 7 | import {PhysicsProps} from "./worker/shared/types"; 8 | import {LogicWorker} from "./LogicWorker"; 9 | 10 | export const Engine: FC = ({ 15 | children, 16 | physicsWorker, 17 | config, 18 | worldParams, 19 | logicWorker, 20 | logicMappedComponents = {}, 21 | }) => { 22 | if (logicWorker) { 23 | return ( 24 | 25 | 26 | 27 | 28 | 32 | {children} 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | {children} 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/main/InstancesProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from "react"; 2 | import {InstancedMesh as TInstancedMesh, Matrix4, Mesh, Object3D} from "three"; 3 | import {useGLTF} from "@react-three/drei/core/useGLTF"; 4 | import {useDidMount} from "../utils/hooks"; 5 | 6 | type InstanceData = { 7 | position?: [number, number, number], 8 | rotation?: [number, number, number], 9 | scale?: [number, number, number] 10 | } 11 | 12 | type UpdateInstanceFn = (data: InstanceData) => void 13 | 14 | type AddInstanceFn = (data: InstanceData) => [UpdateInstanceFn, () => void] 15 | 16 | type InstancedMeshes = { 17 | [key: string]: AddInstanceFn, 18 | } 19 | 20 | 21 | type ContextState = { 22 | instancedMeshes: InstancedMeshes, 23 | createInstancedMesh: (meshKey: string, addInstance: AddInstanceFn) => () => void, 24 | } 25 | 26 | const Context = createContext(null as unknown as ContextState) 27 | 28 | export const useCreateInstancedMesh = () => { 29 | return useContext(Context).createInstancedMesh 30 | } 31 | 32 | export const useAddInstance = (meshKey: string) => { 33 | const instancedMeshes = useContext(Context).instancedMeshes 34 | const addInstance = instancedMeshes[meshKey] ?? null 35 | return addInstance 36 | } 37 | 38 | export const useInstancedMesh = (meshKey: string, data: InstanceData) => { 39 | const updateRef = useRef() 40 | const addInstance = useAddInstance(meshKey) 41 | 42 | useEffect(() => { 43 | 44 | if (addInstance) { 45 | 46 | const [update, remove] = addInstance(data) 47 | updateRef.current = update 48 | return remove 49 | 50 | } 51 | 52 | return 53 | 54 | }, [addInstance, updateRef]) 55 | 56 | return useCallback((data: InstanceData) => { 57 | if (updateRef.current) { 58 | updateRef.current(data) 59 | } 60 | }, [updateRef]) 61 | 62 | } 63 | 64 | export const Instance: React.FC<{ 65 | meshKey: string, 66 | } & InstanceData> = ({meshKey, ...data}) => { 67 | 68 | const isMountRef = useRef(true) 69 | 70 | const update = useInstancedMesh(meshKey, data) 71 | 72 | const {position, rotation, scale} = data 73 | 74 | useEffect(() => { 75 | if (isMountRef.current) { 76 | isMountRef.current = false 77 | return 78 | } 79 | update({position, rotation, scale}) 80 | }, [position, rotation, scale, update]) 81 | 82 | return null 83 | 84 | } 85 | 86 | const defaultObject = new Object3D() 87 | 88 | export const InstancedMesh: React.FC<{ 89 | meshKey: string, 90 | maxInstances: number, 91 | gltfPath: string, 92 | meshProps?: JSX.IntrinsicElements['instancedMesh'] 93 | }> = ({meshKey, maxInstances, gltfPath, meshProps = {}}) => { 94 | 95 | const createInstancedMesh = useCreateInstancedMesh() 96 | 97 | const {nodes} = useGLTF(gltfPath) as unknown as {nodes: { 98 | [key: string]: Object3D, 99 | }} 100 | 101 | const meshes = Object.values(nodes).filter(node => node.type === "Mesh") as Mesh[] 102 | 103 | const instancedMeshes = useRef([]) 104 | 105 | const handleRef = useCallback((instancedMesh: TInstancedMesh) => { 106 | instancedMeshes.current.push(instancedMesh) 107 | }, [instancedMeshes]) 108 | 109 | useLayoutEffect(() => { 110 | instancedMeshes.current.forEach(instancedMesh => { 111 | instancedMesh.count = 0 112 | }) 113 | }, [instancedMeshes]) 114 | 115 | const idRef = useRef(0) 116 | const instancesRef = useRef([]) 117 | 118 | const removeInstance = useCallback((id: string) => { 119 | 120 | const instanceIndex = instancesRef.current.findIndex(instanceId => instanceId === id) 121 | 122 | instancedMeshes.current.forEach(instancedMesh => { 123 | const count = instancedMesh.count 124 | 125 | for (let i = instanceIndex + 1; i < count; i++) { 126 | const previousIndex = i - 1 127 | if (previousIndex < 0) break 128 | const matrix = new Matrix4() 129 | instancedMeshes.current.forEach(instancedMesh => { 130 | instancedMesh.getMatrixAt(i, matrix) 131 | instancedMesh.setMatrixAt(previousIndex, matrix) 132 | }) 133 | } 134 | 135 | }) 136 | 137 | instancesRef.current.splice(instanceIndex, 1) 138 | 139 | instancedMeshes.current.forEach(instancedMesh => { 140 | instancedMesh.count = instancesRef.current.length 141 | }) 142 | 143 | }, [instancedMeshes, instancesRef]) 144 | 145 | const updateInstance = useCallback((id: string, { 146 | position = [0, 0, 0], 147 | rotation = [0, 0, 0], 148 | scale = [1, 1, 1] 149 | }: InstanceData) => { 150 | 151 | const instanceIndex = instancesRef.current.findIndex(instanceId => instanceId === id) 152 | 153 | defaultObject.position.set(...position) 154 | defaultObject.rotation.set(...rotation) 155 | defaultObject.scale.set(...scale) 156 | defaultObject.updateMatrix() 157 | 158 | instancedMeshes.current.forEach(instancedMesh => { 159 | instancedMesh.setMatrixAt(instanceIndex, defaultObject.matrix) 160 | instancedMesh.instanceMatrix.needsUpdate = true 161 | }) 162 | 163 | }, [instancedMeshes, instancesRef]) 164 | 165 | const addInstance = useCallback((data: InstanceData): [UpdateInstanceFn, () => void] => { 166 | 167 | idRef.current += 1 168 | const id = `${idRef.current}` 169 | 170 | instancesRef.current.push(id) 171 | updateInstance(id, data) 172 | 173 | instancedMeshes.current.forEach(instancedMesh => { 174 | instancedMesh.count = instancesRef.current.length 175 | }) 176 | 177 | const update = (updateData: InstanceData) => { 178 | updateInstance(id, updateData) 179 | } 180 | 181 | const unsubscribe = () => { 182 | removeInstance(id) 183 | } 184 | 185 | return [update, unsubscribe] 186 | 187 | }, [instancedMeshes, idRef, updateInstance, removeInstance]) 188 | 189 | useEffect(() => { 190 | const unsubscribe = createInstancedMesh(meshKey, addInstance) 191 | 192 | return unsubscribe 193 | }, [createInstancedMesh, addInstance]) 194 | 195 | return ( 196 | <> 197 | {meshes.map((mesh) => ( 198 | 200 | ))} 201 | 202 | ) 203 | } 204 | 205 | export const InstancesProvider: React.FC = ({children}) => { 206 | 207 | const [instancedMeshes, setInstancedMeshes] = useState({}) 208 | 209 | const createInstancedMesh = useCallback((meshKey: string, addInstance: AddInstanceFn) => { 210 | 211 | setInstancedMeshes(state => { 212 | return { 213 | ...state, 214 | [meshKey]: addInstance, 215 | } 216 | }) 217 | 218 | const unsubscribe = () => { 219 | setInstancedMeshes(state => { 220 | const updatedState = { 221 | ...state, 222 | } 223 | delete updatedState[meshKey] 224 | return updatedState 225 | }) 226 | } 227 | 228 | return unsubscribe 229 | 230 | }, [setInstancedMeshes]) 231 | 232 | return ( 233 | 237 | {children} 238 | 239 | ); 240 | }; -------------------------------------------------------------------------------- /src/main/LogicHandler.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useOnMessage } from '../shared/Messages'; 3 | import { 4 | MappedComponents, 5 | MessageKeys, 6 | SyncComponentMessage, 7 | SyncComponentMessageType, 8 | SyncComponentType, 9 | ValidProps, 10 | } from '../shared/types'; 11 | 12 | const LogicHandler: React.FC<{ 13 | mappedComponentTypes: MappedComponents; 14 | }> = ({ children, mappedComponentTypes }) => { 15 | const subscribeToMessage = useOnMessage(); 16 | 17 | const [components, setComponents] = useState<{ 18 | [key: string]: { 19 | componentType: SyncComponentType; 20 | props: ValidProps; 21 | }; 22 | }>({}); 23 | 24 | useEffect(() => { 25 | const unsubscribe = subscribeToMessage( 26 | MessageKeys.SYNC_COMPONENT, 27 | ({ info, messageType, data }: SyncComponentMessage) => { 28 | const props = data || {}; 29 | 30 | switch (messageType) { 31 | case SyncComponentMessageType.MOUNT: 32 | setComponents(state => { 33 | return { 34 | ...state, 35 | [info.componentKey]: { 36 | componentType: info.componentType, 37 | props, 38 | }, 39 | }; 40 | }); 41 | break; 42 | case SyncComponentMessageType.UPDATE: 43 | setComponents(state => { 44 | const previousData = state[info.componentKey] 45 | const previousProps = previousData && previousData.props ? previousData.props : {} 46 | return { 47 | ...state, 48 | [info.componentKey]: { 49 | componentType: info.componentType, 50 | props: { 51 | ...previousProps, 52 | ...props, 53 | }, 54 | }, 55 | }; 56 | }); 57 | break; 58 | case SyncComponentMessageType.UNMOUNT: 59 | setComponents(state => { 60 | let update = { 61 | ...state, 62 | }; 63 | delete update[info.componentKey]; 64 | return update; 65 | }); 66 | break; 67 | } 68 | } 69 | ); 70 | 71 | return () => { 72 | unsubscribe(); 73 | }; 74 | }, []); 75 | 76 | return ( 77 | <> 78 | {children} 79 | {Object.entries(components).map(([key, { componentType, props }]) => { 80 | const Component = mappedComponentTypes[componentType]; 81 | return Component ? : null; 82 | })} 83 | 84 | ); 85 | }; 86 | 87 | export default LogicHandler; 88 | -------------------------------------------------------------------------------- /src/main/LogicWorker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { usePhysicsWorker } from './PhysicsWorker'; 3 | import { 4 | WorkerMessageType, 5 | WorkerOwnerMessageType, 6 | } from './worker/shared/types'; 7 | import { MappedComponents, MessageData } from '../shared/types'; 8 | import Messages, { useMessagesContext } from '../shared/Messages'; 9 | import LogicHandler from './LogicHandler'; 10 | import SendMessages from "../shared/SendMessages"; 11 | 12 | const LogicWorkerInner: React.FC<{ 13 | worker: Worker; 14 | }> = ({ children, worker }) => { 15 | const physicsWorker = usePhysicsWorker(); 16 | 17 | const { handleMessage } = useMessagesContext(); 18 | 19 | useEffect(() => { 20 | const channel = new MessageChannel(); 21 | physicsWorker.postMessage({ command: 'connect' }, [channel.port1]); 22 | worker.postMessage({ command: 'connect' }, [channel.port2]); 23 | 24 | worker.onmessage = (event: MessageEvent) => { 25 | const type = event.data.type; 26 | 27 | switch (type) { 28 | case WorkerOwnerMessageType.MESSAGE: 29 | handleMessage(event.data.message as MessageData); 30 | break; 31 | } 32 | }; 33 | 34 | worker.postMessage({ 35 | type: WorkerMessageType.INIT, 36 | }); 37 | }, [worker, physicsWorker]); 38 | 39 | return <>{children}; 40 | }; 41 | 42 | export const LogicWorker: React.FC<{ 43 | worker: Worker; 44 | logicMappedComponents: MappedComponents; 45 | }> = ({ worker, children, logicMappedComponents }) => { 46 | return ( 47 | 48 | 49 | 50 | 51 | {children} 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/main/MeshRefs.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from "react" 2 | import {Object3D} from "three"; 3 | 4 | type MeshRefsContextState = { 5 | meshes: { 6 | [key: string]: Object3D, 7 | }, 8 | addMesh: (uuid: string, mesh: Object3D) => () => void, 9 | } 10 | 11 | const MeshRefsContext = createContext(null as unknown as MeshRefsContextState) 12 | 13 | export const useStoreMesh = (uuid: string, mesh: Object3D) => { 14 | const addMesh = useContext(MeshRefsContext).addMesh 15 | 16 | useEffect(() => { 17 | 18 | const remove = addMesh(uuid, mesh) 19 | 20 | return () => { 21 | remove() 22 | } 23 | 24 | }, [addMesh, uuid, mesh]) 25 | 26 | } 27 | 28 | export const useStoredMesh = (uuid: string): Object3D | null => { 29 | const meshes = useContext(MeshRefsContext).meshes 30 | 31 | const mesh = useMemo(() => { 32 | return meshes[uuid] ?? null 33 | }, [uuid, meshes]) 34 | 35 | return mesh 36 | } 37 | 38 | const MeshRefs: React.FC = ({children}) => { 39 | 40 | const [meshes, setMeshes] = useState<{ 41 | [key: string]: Object3D, 42 | }>({}) 43 | 44 | const addMesh = useCallback((uuid: string, mesh: Object3D) => { 45 | 46 | setMeshes(state => { 47 | return { 48 | ...state, 49 | [uuid]: mesh, 50 | } 51 | }) 52 | 53 | const removeMesh = () => { 54 | setMeshes(state => { 55 | const updated = { 56 | ...state, 57 | } 58 | delete updated[uuid] 59 | return updated 60 | }) 61 | } 62 | 63 | return removeMesh 64 | 65 | }, [setMeshes]) 66 | 67 | return ( 68 | 72 | {children} 73 | 74 | ) 75 | } 76 | 77 | export default MeshRefs -------------------------------------------------------------------------------- /src/main/PhysicsWorker.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | FC, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from 'react'; 8 | import {PhysicsProps, WorkerMessageType, WorkerOwnerMessageType} from './worker/shared/types'; 9 | import WorkerOnMessageProvider from '../shared/WorkerOnMessageProvider'; 10 | import PhysicsSync from '../shared/PhysicsSync'; 11 | import StoredPhysicsData from '../shared/StoredPhysicsData'; 12 | import MeshSubscriptions from '../shared/MeshSubscriptions'; 13 | import PhysicsProvider from '../shared/PhysicsProvider'; 14 | import {useWorkerMessages} from "./hooks/useWorkerMessages"; 15 | 16 | type ContextState = { 17 | worker: Worker; 18 | }; 19 | 20 | const Context = createContext((null as unknown) as ContextState); 21 | 22 | export const usePhysicsWorker = () => { 23 | return useContext(Context).worker; 24 | }; 25 | 26 | const PhysicsWorker: FC = ({ children, physicsWorker, config, worldParams }) => { 29 | 30 | const worker = physicsWorker 31 | 32 | const [initiated, setInitiated] = useState(false) 33 | 34 | useEffect(() => { 35 | worker.postMessage({ 36 | type: WorkerMessageType.INIT, 37 | props: { 38 | config, 39 | worldParams, 40 | }, 41 | }); 42 | }, [worker]); 43 | 44 | const subscribe = useWorkerMessages(worker) 45 | 46 | useEffect(() => { 47 | 48 | const unsubscribe = subscribe((event) => { 49 | 50 | const type = event.data.type; 51 | 52 | if (type === WorkerOwnerMessageType.INITIATED) { 53 | setInitiated(true) 54 | } 55 | 56 | return () => { 57 | unsubscribe() 58 | } 59 | }) 60 | 61 | }, [subscribe, setInitiated]) 62 | 63 | if (!initiated) return null 64 | 65 | return ( 66 | 71 | 72 | 73 | 74 | 75 | {children} 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | }; 83 | 84 | export default PhysicsWorker; 85 | -------------------------------------------------------------------------------- /src/main/R3FPhysicsObjectUpdater.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useFrame } from 'react-three-fiber'; 3 | import { useGetPhysicsStepTimeRemainingRatio } from '../shared/PhysicsSync'; 4 | import { useLerpMeshes } from '../shared/MeshSubscriptions'; 5 | 6 | const R3FPhysicsObjectUpdater: React.FC = ({ children }) => { 7 | const getPhysicsStepTimeRemainingRatio = useGetPhysicsStepTimeRemainingRatio(); 8 | const lerpMeshes = useLerpMeshes(); 9 | 10 | const onFrame = useCallback((state: any, delta: number) => { 11 | lerpMeshes(getPhysicsStepTimeRemainingRatio); 12 | }, [getPhysicsStepTimeRemainingRatio, lerpMeshes]); 13 | 14 | useFrame(onFrame); 15 | 16 | return <>{children}; 17 | }; 18 | 19 | export default R3FPhysicsObjectUpdater; 20 | -------------------------------------------------------------------------------- /src/main/hooks/useBody.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddBodyDef, 3 | BodyType, 4 | UpdateBodyData, 5 | } from '../worker/planckjs/bodies'; 6 | import { 7 | MutableRefObject, 8 | useLayoutEffect, 9 | useMemo, 10 | useRef, 11 | useState, 12 | } from 'react'; 13 | import { Object3D } from 'three'; 14 | import { usePhysicsProvider } from '../../shared/PhysicsProvider'; 15 | import { ValidUUID } from '../worker/shared/types'; 16 | import { Vec2 } from 'planck-js'; 17 | import { 18 | useAddMeshSubscription, 19 | useSubscribeMesh, 20 | } from '../../shared/MeshSubscriptions'; 21 | 22 | export type BodyApi = { 23 | applyForceToCenter: (vec: Vec2, uuid?: ValidUUID) => void; 24 | applyLinearImpulse: (vec: Vec2, pos: Vec2, uuid?: ValidUUID) => void; 25 | setPosition: (vec: Vec2, uuid?: ValidUUID) => void; 26 | setLinearVelocity: (vec: Vec2, uuid?: ValidUUID) => void; 27 | setAngle: (angle: number, uuid?: ValidUUID) => void; 28 | updateBody: (data: UpdateBodyData, uuid?: ValidUUID) => void; 29 | }; 30 | 31 | export const useBodyApi = (passedUuid: ValidUUID): BodyApi => { 32 | const { workerSetBody, workerUpdateBody } = usePhysicsProvider(); 33 | 34 | const api = useMemo(() => { 35 | return { 36 | applyForceToCenter: (vec, uuid) => { 37 | workerSetBody({ 38 | uuid: uuid ?? passedUuid, 39 | method: 'applyForceToCenter', 40 | methodParams: [vec, true], 41 | }); 42 | }, 43 | applyLinearImpulse: (vec, pos, uuid) => { 44 | workerSetBody({ 45 | uuid: uuid ?? passedUuid, 46 | method: 'applyLinearImpulse', 47 | methodParams: [vec, pos, true], 48 | }); 49 | }, 50 | setPosition: (vec, uuid) => { 51 | workerSetBody({ 52 | uuid: uuid ?? passedUuid, 53 | method: 'setPosition', 54 | methodParams: [vec], 55 | }); 56 | }, 57 | setLinearVelocity: (vec, uuid) => { 58 | workerSetBody({ 59 | uuid: uuid ?? passedUuid, 60 | method: 'setLinearVelocity', 61 | methodParams: [vec], 62 | }); 63 | }, 64 | updateBody: (data: UpdateBodyData, uuid) => { 65 | workerUpdateBody({ uuid: uuid ?? passedUuid, data }); 66 | }, 67 | setAngle: (angle: number, uuid) => { 68 | workerSetBody({ 69 | uuid: uuid ?? passedUuid, 70 | method: 'setAngle', 71 | methodParams: [angle], 72 | }); 73 | }, 74 | }; 75 | }, [passedUuid]); 76 | 77 | return api; 78 | }; 79 | 80 | export type BodyParams = { 81 | syncBody?: boolean, 82 | listenForCollisions?: boolean; 83 | applyAngle?: boolean; 84 | cacheKey?: string; 85 | uuid?: ValidUUID; 86 | fwdRef?: MutableRefObject; 87 | }; 88 | 89 | export const useBody = ( 90 | propsFn: () => AddBodyDef, 91 | bodyParams: BodyParams = {} 92 | ): [MutableRefObject, BodyApi, ValidUUID] => { 93 | const { 94 | applyAngle = false, 95 | cacheKey, 96 | uuid: passedUUID, 97 | fwdRef, 98 | listenForCollisions = false, 99 | syncBody = true, 100 | } = bodyParams; 101 | const localRef = useRef((null as unknown) as Object3D); 102 | const ref = fwdRef ? fwdRef : localRef; 103 | const [uuid] = useState(() => { 104 | if (passedUUID) return passedUUID; 105 | if (!ref.current) { 106 | ref.current = new Object3D(); 107 | } 108 | return ref.current.uuid; 109 | }); 110 | const [isDynamic] = useState(() => { 111 | const props = propsFn(); 112 | return props.type !== BodyType.static; 113 | }); 114 | const { workerAddBody, workerRemoveBody } = usePhysicsProvider(); 115 | 116 | useLayoutEffect(() => { 117 | const props = propsFn(); 118 | 119 | const object = ref.current; 120 | 121 | if (object) { 122 | object.position.x = props.position?.x || 0; 123 | object.position.y = props.position?.y || 0; 124 | } 125 | 126 | workerAddBody({ 127 | uuid, 128 | listenForCollisions, 129 | cacheKey, 130 | ...props, 131 | }); 132 | 133 | return () => { 134 | workerRemoveBody({ uuid, cacheKey }); 135 | }; 136 | }, []); 137 | 138 | useSubscribeMesh(uuid, ref.current, applyAngle, isDynamic && syncBody); 139 | 140 | const api = useBodyApi(uuid); 141 | 142 | return [ref, api, uuid]; 143 | }; 144 | -------------------------------------------------------------------------------- /src/main/hooks/useCollisionEvents.ts: -------------------------------------------------------------------------------- 1 | import {ValidUUID} from "../worker/shared/types"; 2 | import {useCollisionsProviderContext} from "../../shared/CollisionsProvider"; 3 | import {useEffect} from "react"; 4 | 5 | export const useCollisionEvents = ( 6 | uuid: ValidUUID, 7 | onCollideStart?: (data: any, fixtureIndex: number, isSensor: boolean) => void, 8 | onCollideEnd?: (data: any, fixtureIndex: number, isSensor: boolean) => void, 9 | ) => { 10 | 11 | const { 12 | addCollisionHandler, 13 | removeCollisionHandler 14 | } = useCollisionsProviderContext() 15 | 16 | // @ts-ignore 17 | useEffect(() => { 18 | if (onCollideStart) { 19 | addCollisionHandler(true, uuid, onCollideStart) 20 | return () => { 21 | removeCollisionHandler(true, uuid) 22 | } 23 | } 24 | }, [uuid, onCollideStart]) 25 | 26 | // @ts-ignore 27 | useEffect(() => { 28 | if (onCollideEnd) { 29 | addCollisionHandler(false, uuid, onCollideEnd) 30 | return () => { 31 | removeCollisionHandler(false, uuid) 32 | } 33 | } 34 | }, [uuid, onCollideEnd]) 35 | 36 | } -------------------------------------------------------------------------------- /src/main/hooks/useWorkerMessages.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | export type Subscribe = (callback: (event: MessageEvent) => void) => () => void; 4 | 5 | export const useWorkerMessages = (worker: undefined | Worker | MessagePort) => { 6 | const idCount = useRef(0); 7 | const subscriptionsRef = useRef<{ 8 | [key: string]: (event: MessageEvent) => void; 9 | }>({}); 10 | 11 | const subscribe = useCallback( 12 | (callback: (event: MessageEvent) => void) => { 13 | const id = idCount.current; 14 | idCount.current += 1; 15 | 16 | subscriptionsRef.current[id] = callback; 17 | 18 | return () => { 19 | delete subscriptionsRef.current[id]; 20 | }; 21 | }, 22 | [subscriptionsRef] 23 | ); 24 | 25 | useEffect(() => { 26 | if (!worker) return; 27 | const previousOnMessage = worker.onmessage; 28 | worker.onmessage = (event: MessageEvent) => { 29 | Object.values(subscriptionsRef.current).forEach(callback => { 30 | callback(event); 31 | }); 32 | if (previousOnMessage) { 33 | (previousOnMessage as any)(event); 34 | } 35 | }; 36 | }, [worker, subscriptionsRef]); 37 | 38 | return subscribe; 39 | }; 40 | -------------------------------------------------------------------------------- /src/main/worker/app/Bodies.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { useAppContext, useWorld } from './appContext'; 3 | import { ValidUUID, WorkerMessageType } from '../shared/types'; 4 | import { 5 | Body, 6 | BodyDef, 7 | Box, 8 | Circle, 9 | DistanceJoint, 10 | FixtureOpt, 11 | Joint, 12 | RopeJoint, 13 | Vec2, 14 | } from 'planck-js'; 15 | import { 16 | AddBodyProps, 17 | BodyShape, 18 | BodyType, 19 | BoxFixture, 20 | CircleFixture, 21 | RemoveBodyProps, 22 | SetBodyProps, 23 | UpdateBodyProps, 24 | } from '../planckjs/bodies'; 25 | import { Shape } from 'planck-js/lib/shape'; 26 | import { useWorldState } from './WorldState'; 27 | 28 | const tempVec = Vec2(0, 0); 29 | 30 | export const useSubscribeToWorkerMessages = ( 31 | messageHandler: (event: MessageEvent) => void 32 | ) => { 33 | const { subscribe, logicSubscribe } = useAppContext(); 34 | 35 | useEffect(() => { 36 | const unsubscribe = subscribe(messageHandler); 37 | 38 | const unsubscribeLogic = logicSubscribe(messageHandler); 39 | 40 | return () => { 41 | unsubscribe(); 42 | unsubscribeLogic(); 43 | }; 44 | }, [subscribe, logicSubscribe, messageHandler]); 45 | }; 46 | 47 | type BodiesMap = Map; 48 | type CachedBodiesMap = Map; 49 | 50 | const applyBodyConfigToExistingBody = ( 51 | body: Body, 52 | data: AddBodyProps 53 | ): Body => { 54 | const { 55 | uuid, 56 | cacheKey, 57 | listenForCollisions, 58 | fixtures = [], 59 | attachToRope = false, 60 | ...props 61 | } = data; 62 | 63 | if (fixtures && fixtures.length > 0) { 64 | let bodyFixture = body.getFixtureList(); 65 | 66 | fixtures.forEach((fixture, fixtureIndex) => { 67 | let fixtureOptions = fixture.fixtureOptions; 68 | 69 | fixtureOptions = { 70 | userData: { 71 | uuid, 72 | fixtureIndex, 73 | ...fixtureOptions?.userData, 74 | }, 75 | ...fixtureOptions, 76 | }; 77 | 78 | if (bodyFixture) { 79 | if (fixtureOptions) { 80 | bodyFixture.setUserData(fixtureOptions.userData); 81 | } 82 | 83 | bodyFixture = bodyFixture.getNext(); 84 | } 85 | }); 86 | } 87 | 88 | const { position, angle } = props; 89 | 90 | if (position) { 91 | body.setPosition(position); 92 | } 93 | 94 | if (angle) { 95 | body.setAngle(angle); 96 | } 97 | 98 | body.setActive(true); 99 | 100 | return body; 101 | }; 102 | 103 | const useAddBody = (bodies: BodiesMap, cachedBodies: CachedBodiesMap) => { 104 | const { 105 | dynamicBodies, 106 | collisionListeners, 107 | bodiesNeedSyncRef, 108 | logicBodiesNeedSyncRef, 109 | } = useWorldState(); 110 | 111 | const addDynamicBody = useCallback( 112 | (uuid: ValidUUID) => { 113 | dynamicBodies.add(uuid); 114 | bodiesNeedSyncRef.current = true; 115 | logicBodiesNeedSyncRef.current = true; 116 | }, 117 | [dynamicBodies, bodiesNeedSyncRef, logicBodiesNeedSyncRef] 118 | ); 119 | 120 | const addCollisionListeners = useCallback( 121 | (uuid: ValidUUID) => { 122 | collisionListeners.add(uuid); 123 | }, 124 | [collisionListeners] 125 | ); 126 | 127 | const world = useWorld(); 128 | 129 | const getCachedBody = useCallback( 130 | (cacheKey: string) => { 131 | const cached = cachedBodies.get(cacheKey); 132 | 133 | if (cached && cached.length > 0) { 134 | const body = cached.pop(); 135 | if (body) { 136 | return body; 137 | } 138 | } 139 | 140 | return null; 141 | }, 142 | [cachedBodies] 143 | ); 144 | 145 | return useCallback( 146 | (data: AddBodyProps) => { 147 | const { 148 | uuid, 149 | cacheKey, 150 | listenForCollisions, 151 | fixtures = [], 152 | attachToRope = false, 153 | ...props 154 | } = data; 155 | 156 | const existingBody = bodies.get(uuid); 157 | 158 | if (existingBody) { 159 | return existingBody; 160 | } 161 | 162 | if (listenForCollisions) { 163 | addCollisionListeners(uuid); 164 | } 165 | 166 | const bodyDef: BodyDef = { 167 | type: BodyType.static, 168 | fixedRotation: true, 169 | ...props, 170 | }; 171 | 172 | const { type } = bodyDef; 173 | 174 | let body: Body | null = null; 175 | 176 | if (cacheKey) { 177 | let cachedBody = getCachedBody(cacheKey); 178 | 179 | if (cachedBody) { 180 | body = applyBodyConfigToExistingBody(cachedBody, data); 181 | } 182 | } 183 | 184 | if (!body) { 185 | body = world.createBody(bodyDef); 186 | 187 | if (fixtures && fixtures.length > 0) { 188 | fixtures.forEach((fixture, fixtureIndex) => { 189 | const { shape } = fixture; 190 | 191 | let fixtureOptions = fixture.fixtureOptions ?? {}; 192 | 193 | fixtureOptions = { 194 | ...fixtureOptions, 195 | userData: { 196 | uuid, 197 | fixtureIndex, 198 | ...fixtureOptions?.userData, 199 | }, 200 | }; 201 | 202 | let bodyShape: Shape; 203 | 204 | switch (shape) { 205 | case BodyShape.box: 206 | const { hx, hy, center } = fixture as BoxFixture; 207 | bodyShape = (Box( 208 | (hx as number) / 2, 209 | (hy as number) / 2, 210 | center ? Vec2(center[0], center[1]) : undefined 211 | ) as unknown) as Shape; 212 | break; 213 | case BodyShape.circle: 214 | const { radius } = fixture as CircleFixture; 215 | bodyShape = (Circle(radius as number) as unknown) as Shape; 216 | break; 217 | default: 218 | throw new Error(`Unhandled body shape ${shape}`); 219 | } 220 | 221 | if (fixtureOptions) { 222 | if (body) { 223 | body.createFixture(bodyShape, fixtureOptions as FixtureOpt); 224 | } 225 | } else { 226 | if (body) { 227 | body.createFixture(bodyShape); 228 | } 229 | } 230 | 231 | // todo - handle rope properly... 232 | if (attachToRope) { 233 | const { position, angle } = props; 234 | 235 | const ropeJointDef = { 236 | maxLength: 0.5, 237 | localAnchorA: position, 238 | localAnchorB: position, 239 | }; 240 | 241 | const startingBodyDef: BodyDef = { 242 | type: BodyType.static, 243 | fixedRotation: true, 244 | position, 245 | angle, 246 | }; 247 | 248 | const startingBody = world.createBody(startingBodyDef); 249 | 250 | if (body) { 251 | const distanceJoint = DistanceJoint( 252 | { 253 | collideConnected: false, 254 | frequencyHz: 5, 255 | dampingRatio: 0.5, 256 | length: 0.15, 257 | }, 258 | startingBody, 259 | body, 260 | position ?? Vec2(0, 0), 261 | position ?? Vec2(0, 0) 262 | ); 263 | 264 | const rope2 = world.createJoint( 265 | (RopeJoint( 266 | ropeJointDef, 267 | startingBody, 268 | body, 269 | position ?? Vec2(0, 0) 270 | ) as unknown) as Joint 271 | ); 272 | const rope = world.createJoint( 273 | (distanceJoint as unknown) as Joint 274 | ); 275 | } 276 | } 277 | }); 278 | } 279 | } 280 | 281 | if (type !== BodyType.static) { 282 | addDynamicBody(uuid); 283 | } 284 | 285 | if (!body) { 286 | throw new Error(`No body`); 287 | } 288 | 289 | bodies.set(uuid, body); 290 | 291 | return body; 292 | }, 293 | [world, bodies, getCachedBody, addDynamicBody, addCollisionListeners] 294 | ); 295 | }; 296 | 297 | const useRemoveBody = (bodies: BodiesMap, cachedBodies: CachedBodiesMap) => { 298 | const world = useWorld(); 299 | const { 300 | dynamicBodies, 301 | collisionListeners, 302 | bodiesNeedSyncRef, 303 | logicBodiesNeedSyncRef, 304 | } = useWorldState(); 305 | 306 | return useCallback( 307 | ({ uuid, cacheKey }: RemoveBodyProps) => { 308 | if (dynamicBodies.has(uuid)) { 309 | dynamicBodies.delete(uuid); 310 | bodiesNeedSyncRef.current = true; 311 | logicBodiesNeedSyncRef.current = true; 312 | } 313 | 314 | collisionListeners.delete(uuid); 315 | 316 | const body = bodies.get(uuid); 317 | 318 | if (!body) { 319 | console.warn(`Body not found for ${uuid}`); 320 | return; 321 | } 322 | 323 | bodies.delete(uuid); 324 | 325 | if (cacheKey) { 326 | tempVec.set(-1000, -1000); 327 | body.setPosition(tempVec); 328 | tempVec.set(0, 0); 329 | body.setLinearVelocity(tempVec); 330 | body.setActive(false); 331 | const cached = cachedBodies.get(cacheKey); 332 | if (cached) { 333 | cached.push(body); 334 | } else { 335 | cachedBodies.set(cacheKey, [body]); 336 | } 337 | } else { 338 | world.destroyBody(body); 339 | } 340 | }, 341 | [ 342 | world, 343 | bodies, 344 | dynamicBodies, 345 | collisionListeners, 346 | bodiesNeedSyncRef, 347 | logicBodiesNeedSyncRef, 348 | cachedBodies, 349 | ] 350 | ); 351 | }; 352 | 353 | const useSetBody = (bodies: BodiesMap) => { 354 | return useCallback( 355 | ({ uuid, method, methodParams }: SetBodyProps) => { 356 | const body = bodies.get(uuid); 357 | if (!body) { 358 | console.warn(`Body not found for ${uuid}`, bodies); 359 | return; 360 | } 361 | switch (method) { 362 | default: 363 | (body as any)[method](...methodParams); 364 | } 365 | }, 366 | [bodies] 367 | ); 368 | }; 369 | 370 | const useUpdateBody = (bodies: BodiesMap) => { 371 | return useCallback( 372 | ({ uuid, data }: UpdateBodyProps) => { 373 | const body = bodies.get(uuid); 374 | if (!body) { 375 | console.warn(`Body not found for ${uuid}`); 376 | return; 377 | } 378 | const { fixtureUpdate } = data; 379 | if (fixtureUpdate) { 380 | const fixture = body.getFixtureList(); 381 | if (fixture) { 382 | const { groupIndex, categoryBits, maskBits } = fixtureUpdate; 383 | if ( 384 | groupIndex !== undefined || 385 | categoryBits !== undefined || 386 | maskBits !== undefined 387 | ) { 388 | const originalGroupIndex = fixture.getFilterGroupIndex(); 389 | const originalCategoryBits = fixture.getFilterCategoryBits(); 390 | const originalMaskBits = fixture.getFilterMaskBits(); 391 | fixture.setFilterData({ 392 | groupIndex: 393 | groupIndex !== undefined ? groupIndex : originalGroupIndex, 394 | categoryBits: 395 | categoryBits !== undefined 396 | ? categoryBits 397 | : originalCategoryBits, 398 | maskBits: maskBits !== undefined ? maskBits : originalMaskBits, 399 | }); 400 | } 401 | } 402 | } 403 | }, 404 | [bodies] 405 | ); 406 | }; 407 | 408 | export const Bodies: React.FC = () => { 409 | const { bodies } = useWorldState(); 410 | const [cachedBodies] = useState(() => new Map()); 411 | 412 | const addBody = useAddBody(bodies, cachedBodies); 413 | const removeBody = useRemoveBody(bodies, cachedBodies); 414 | const setBody = useSetBody(bodies); 415 | const updateBody = useUpdateBody(bodies); 416 | 417 | const onMessage = useCallback( 418 | (event: MessageEvent) => { 419 | const { type, props = {} } = event.data as { 420 | type: WorkerMessageType; 421 | props: any; 422 | }; 423 | switch (type) { 424 | case WorkerMessageType.ADD_BODY: 425 | addBody(props); 426 | break; 427 | case WorkerMessageType.REMOVE_BODY: 428 | removeBody(props); 429 | break; 430 | case WorkerMessageType.SET_BODY: 431 | setBody(props); 432 | break; 433 | case WorkerMessageType.UPDATE_BODY: 434 | updateBody(props); 435 | break; 436 | } 437 | }, 438 | [addBody, removeBody, setBody, updateBody] 439 | ); 440 | 441 | useSubscribeToWorkerMessages(onMessage); 442 | 443 | return null; 444 | }; 445 | -------------------------------------------------------------------------------- /src/main/worker/app/Collisions.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from 'react'; 2 | import { useAppContext, useWorld } from './appContext'; 3 | import { Contact, Fixture } from 'planck-js'; 4 | import { 5 | getFixtureData, 6 | getFixtureIndex, 7 | getFixtureUuid, 8 | } from '../planckjs/collisions/collisions'; 9 | import { useWorldState } from './WorldState'; 10 | import { WorkerOwnerMessageType } from '../shared/types'; 11 | 12 | const useHandleBeginCollision = () => { 13 | const { worker, logicWorker } = useAppContext(); 14 | const { collisionListeners } = useWorldState(); 15 | const sendCollisionBeginEvent = useCallback( 16 | (uuid: string, data: any, fixtureIndex: number, isSensor: boolean) => { 17 | const update = { 18 | type: WorkerOwnerMessageType.BEGIN_COLLISION, 19 | props: { 20 | uuid, 21 | data, 22 | fixtureIndex, 23 | isSensor, 24 | }, 25 | }; 26 | worker.postMessage(update); 27 | if (logicWorker) { 28 | logicWorker.postMessage(update); 29 | } 30 | }, 31 | [worker, logicWorker] 32 | ); 33 | 34 | return useCallback( 35 | (fixtureA: Fixture, fixtureB: Fixture) => { 36 | const aData = getFixtureData(fixtureA); 37 | const bData = getFixtureData(fixtureB); 38 | const aUUID = getFixtureUuid(aData); 39 | const bUUID = getFixtureUuid(bData); 40 | 41 | if (aUUID && collisionListeners.has(aUUID)) { 42 | sendCollisionBeginEvent( 43 | aUUID, 44 | bData, 45 | getFixtureIndex(aData), 46 | fixtureB.isSensor() 47 | ); 48 | } 49 | 50 | if (bUUID && collisionListeners.has(bUUID)) { 51 | sendCollisionBeginEvent( 52 | bUUID, 53 | aData, 54 | getFixtureIndex(bData), 55 | fixtureA.isSensor() 56 | ); 57 | } 58 | }, 59 | [collisionListeners, sendCollisionBeginEvent] 60 | ); 61 | }; 62 | 63 | const useHandleEndCollision = () => { 64 | const { worker, logicWorker } = useAppContext(); 65 | const { collisionListeners } = useWorldState(); 66 | 67 | const sendCollisionEndEvent = useCallback( 68 | (uuid: string, data: any, fixtureIndex: number, isSensor: boolean) => { 69 | const update = { 70 | type: WorkerOwnerMessageType.END_COLLISION, 71 | props: { 72 | uuid, 73 | data, 74 | fixtureIndex, 75 | isSensor, 76 | }, 77 | }; 78 | worker.postMessage(update); 79 | if (logicWorker) { 80 | logicWorker.postMessage(update); 81 | } 82 | }, 83 | [worker, logicWorker] 84 | ); 85 | 86 | return useCallback( 87 | (fixtureA: Fixture, fixtureB: Fixture) => { 88 | const aData = getFixtureData(fixtureA); 89 | const bData = getFixtureData(fixtureB); 90 | const aUUID = getFixtureUuid(aData); 91 | const bUUID = getFixtureUuid(bData); 92 | 93 | if (aUUID && collisionListeners.has(aUUID)) { 94 | sendCollisionEndEvent( 95 | aUUID, 96 | bData, 97 | getFixtureIndex(aData), 98 | fixtureB.isSensor() 99 | ); 100 | } 101 | 102 | if (bUUID && collisionListeners.has(bUUID)) { 103 | sendCollisionEndEvent( 104 | bUUID, 105 | aData, 106 | getFixtureIndex(bData), 107 | fixtureA.isSensor() 108 | ); 109 | } 110 | }, 111 | [collisionListeners, sendCollisionEndEvent] 112 | ); 113 | }; 114 | 115 | export const Collisions: React.FC = () => { 116 | const world = useWorld(); 117 | 118 | const handleBeginCollision = useHandleBeginCollision(); 119 | const handleEndCollision = useHandleEndCollision(); 120 | 121 | useEffect(() => { 122 | world.on('begin-contact', (contact: Contact) => { 123 | const fixtureA = contact.getFixtureA(); 124 | const fixtureB = contact.getFixtureB(); 125 | handleBeginCollision(fixtureA, fixtureB); 126 | }); 127 | 128 | world.on('end-contact', (contact: Contact) => { 129 | const fixtureA = contact.getFixtureA(); 130 | const fixtureB = contact.getFixtureB(); 131 | handleEndCollision(fixtureA, fixtureB); 132 | }); 133 | }, [world]); 134 | 135 | return null; 136 | }; 137 | -------------------------------------------------------------------------------- /src/main/worker/app/World.tsx: -------------------------------------------------------------------------------- 1 | import React, {MutableRefObject, useCallback, useEffect, useRef, useState} from 'react'; 2 | import {useAppContext, useWorld} from './appContext'; 3 | import {Buffers, WorkerMessageType, WorkerOwnerMessageType,} from '../shared/types'; 4 | import {useWorldState} from './WorldState'; 5 | import {generateBuffers} from "./buffers"; 6 | 7 | const useSyncData = () => { 8 | const { dynamicBodies, bodies } = useWorldState(); 9 | return useCallback((positions: Float32Array, angles: Float32Array) => { 10 | const dynamicBodiesArray = Array.from(dynamicBodies); 11 | 12 | dynamicBodiesArray.forEach((uuid, index) => { 13 | const body = bodies.get(uuid); 14 | if (!body) return; 15 | const position = body.getPosition(); 16 | const angle = body.getAngle(); 17 | positions[2 * index + 0] = position.x; 18 | positions[2 * index + 1] = position.y; 19 | angles[index] = angle; 20 | }); 21 | }, []); 22 | }; 23 | 24 | const debug = { 25 | mainSent: false, 26 | mainLogged: false, 27 | mainLogged2: false, 28 | logicSent: false, 29 | logicLogged: false, 30 | logicLogged2: false, 31 | }; 32 | 33 | const useSendPhysicsUpdate = (tickRef: MutableRefObject) => { 34 | 35 | const localStateRef = useRef({ 36 | failedMainCount: 0, 37 | failedLogicCount: 0, 38 | lastPhysicsStep: 0, 39 | }) 40 | 41 | const { 42 | bodiesNeedSyncRef, 43 | logicBodiesNeedSyncRef, 44 | dynamicBodies, 45 | } = useWorldState(); 46 | 47 | const { 48 | buffers: mainBuffers, 49 | logicBuffers, 50 | worker, 51 | logicWorker, 52 | maxNumberOfDynamicObjects, 53 | } = useAppContext(); 54 | 55 | const syncData = useSyncData(); 56 | 57 | return useCallback( 58 | (target: Worker | MessagePort, buffer: Buffers, isMain: boolean) => { 59 | const { positions, angles } = buffer; 60 | if (!(positions.byteLength !== 0 && angles.byteLength !== 0)) { 61 | console.warn('cant send physics update to', isMain ? 'main' : 'logic') 62 | if (isMain) { 63 | if (localStateRef.current.failedMainCount >= 2) { 64 | const { positions: newPositions, angles: newAngles } = generateBuffers(maxNumberOfDynamicObjects); 65 | mainBuffers.positions = newPositions 66 | mainBuffers.angles = newAngles 67 | } 68 | } else { 69 | if (localStateRef.current.failedLogicCount >= 2) { 70 | const {positions: newPositions, angles: newAngles} = generateBuffers(maxNumberOfDynamicObjects); 71 | logicBuffers.positions = newPositions 72 | logicBuffers.angles = newAngles 73 | } 74 | } 75 | if (isMain) { 76 | localStateRef.current.failedMainCount += 1 77 | } else { 78 | localStateRef.current.failedLogicCount += 1 79 | } 80 | return; 81 | } 82 | if (isMain) { 83 | localStateRef.current.failedMainCount = 0 84 | } else { 85 | localStateRef.current.failedLogicCount = 0 86 | } 87 | syncData(positions, angles); 88 | const rawMessage: any = { 89 | type: WorkerOwnerMessageType.PHYSICS_STEP, 90 | physicsTick: tickRef.current, 91 | }; 92 | if (isMain) { 93 | rawMessage.bodies = Array.from(dynamicBodies); 94 | bodiesNeedSyncRef.current = false; 95 | } else { 96 | rawMessage.bodies = Array.from(dynamicBodies); 97 | logicBodiesNeedSyncRef.current = false; 98 | } 99 | const message = { 100 | ...rawMessage, 101 | positions, 102 | angles, 103 | }; 104 | target.postMessage(message, [positions.buffer, angles.buffer]); 105 | }, 106 | [bodiesNeedSyncRef, logicBodiesNeedSyncRef, tickRef, syncData] 107 | ); 108 | }; 109 | 110 | const useSendPhysicsUpdates = (tickRef: MutableRefObject) => { 111 | const { 112 | buffers: mainBuffers, 113 | logicBuffers, 114 | worker, 115 | logicWorker, 116 | } = useAppContext(); 117 | 118 | const sendPhysicsUpdate = useSendPhysicsUpdate(tickRef); 119 | 120 | const update = useCallback((isMain: boolean) => { 121 | if (isMain) { 122 | sendPhysicsUpdate(worker, mainBuffers, true); 123 | } else if (logicWorker) { 124 | sendPhysicsUpdate(logicWorker, logicBuffers, false); 125 | } 126 | }, [worker, logicWorker, sendPhysicsUpdate, mainBuffers, logicBuffers]); 127 | 128 | const updateRef = useRef(update); 129 | 130 | useEffect(() => { 131 | updateRef.current = update; 132 | }, [update, updateRef]); 133 | 134 | return useCallback((isMain: boolean) => { 135 | // using ref, as i don't want to interrupt the interval 136 | updateRef.current(isMain); 137 | }, [updateRef]); 138 | }; 139 | 140 | const useStepProcessed = (tickRef: MutableRefObject) => { 141 | const { 142 | buffers: mainBuffers, 143 | logicBuffers, 144 | worker, 145 | logicWorker, 146 | buffersRef, 147 | } = useAppContext(); 148 | 149 | return useCallback( 150 | ( 151 | isMain: boolean, 152 | lastProcessedPhysicsTick: number, 153 | positions: Float32Array, 154 | angles: Float32Array 155 | ) => { 156 | const buffers = isMain ? mainBuffers : logicBuffers; 157 | 158 | if (isMain) { 159 | buffers.positions = positions; 160 | buffers.angles = angles; 161 | } else { 162 | buffers.positions = positions; 163 | buffers.angles = angles; 164 | } 165 | }, 166 | [mainBuffers, logicBuffers, tickRef, worker, logicWorker] 167 | ); 168 | }; 169 | 170 | const useWorldLoop = () => { 171 | const world = useWorld(); 172 | const { updateRate, subscribe, logicSubscribe } = useAppContext(); 173 | const tickRef = useRef(0); 174 | const [tickCount, setTickCount] = useState(0) 175 | 176 | const lastSentMainUpdateRef = useRef(-1) 177 | const lastSentLogicUpdateRef = useRef(-1) 178 | const [mainBufferReady, setMainBufferReady] = useState(false) 179 | const [logicBufferReady, setLogicBufferReady] = useState(false) 180 | const sendPhysicsUpdate = useSendPhysicsUpdates(tickRef); 181 | 182 | useEffect(() => { 183 | 184 | if (mainBufferReady && lastSentMainUpdateRef.current < tickCount) { 185 | sendPhysicsUpdate(true) 186 | lastSentMainUpdateRef.current = tickCount 187 | setMainBufferReady(false) 188 | } 189 | 190 | }, [tickCount, mainBufferReady]) 191 | 192 | useEffect(() => { 193 | 194 | if (logicBufferReady && lastSentLogicUpdateRef.current < tickCount) { 195 | sendPhysicsUpdate(false) 196 | lastSentLogicUpdateRef.current = tickCount 197 | setLogicBufferReady(false) 198 | } 199 | 200 | }, [tickCount, logicBufferReady]) 201 | 202 | useEffect(() => { 203 | 204 | const step = () => { 205 | world.step(updateRate); 206 | }; 207 | 208 | const interval = setInterval(() => { 209 | tickRef.current += 1; 210 | setTickCount(state => state + 1) 211 | step(); 212 | }, updateRate); 213 | 214 | return () => { 215 | clearInterval(interval); 216 | }; 217 | }, []); 218 | 219 | const stepProcessed = useStepProcessed(tickRef); 220 | 221 | useEffect(() => { 222 | 223 | const onMessage = (event: MessageEvent, isMain: boolean = true) => { 224 | const { type, props = {} } = event.data as { 225 | type: WorkerMessageType; 226 | props: any; 227 | }; 228 | if (type === WorkerMessageType.READY_FOR_PHYSICS) { 229 | if (isMain) { 230 | setMainBufferReady(true) 231 | } else { 232 | setLogicBufferReady(true) 233 | } 234 | } else if (type === WorkerMessageType.PHYSICS_STEP_PROCESSED) { 235 | stepProcessed( 236 | isMain, 237 | event.data.physicsTick, 238 | event.data.positions, 239 | event.data.angles 240 | ); 241 | if (isMain) { 242 | setMainBufferReady(true) 243 | } else { 244 | setLogicBufferReady(true) 245 | } 246 | } 247 | }; 248 | 249 | const unsubscribe = subscribe(onMessage); 250 | 251 | const unsubscribeLogic = logicSubscribe(event => onMessage(event, false)); 252 | 253 | return () => { 254 | unsubscribe(); 255 | unsubscribeLogic(); 256 | }; 257 | }, [subscribe, logicSubscribe, stepProcessed]); 258 | }; 259 | 260 | export const World: React.FC = () => { 261 | useWorldLoop(); 262 | return null; 263 | }; 264 | -------------------------------------------------------------------------------- /src/main/worker/app/WorldState.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | MutableRefObject, 4 | useContext, 5 | useRef, 6 | useState, 7 | } from 'react'; 8 | import { ValidUUID } from '../shared/types'; 9 | import { Body } from 'planck-js'; 10 | 11 | type BodiesMap = Map; 12 | type DynamicBodies = Set; 13 | type CollisionListeners = Set; 14 | 15 | type ContextState = { 16 | bodies: BodiesMap; 17 | dynamicBodies: DynamicBodies; 18 | collisionListeners: CollisionListeners; 19 | bodiesNeedSync: boolean; 20 | setBodiesNeedSync: (bool: boolean) => void; 21 | bodiesNeedSyncRef: MutableRefObject; 22 | logicBodiesNeedSyncRef: MutableRefObject; 23 | }; 24 | 25 | const Context = createContext((null as unknown) as ContextState); 26 | 27 | export const useWorldState = (): ContextState => { 28 | return useContext(Context); 29 | }; 30 | 31 | export const WorldState: React.FC = ({ children }) => { 32 | const [bodies] = useState(() => new Map()); 33 | const [dynamicBodies] = useState(() => new Set()); 34 | const [collisionListeners] = useState(() => new Set()); 35 | const [bodiesNeedSync, setBodiesNeedSync] = useState(false); 36 | const bodiesNeedSyncRef = useRef(false); 37 | const logicBodiesNeedSyncRef = useRef(false); 38 | 39 | return ( 40 | 51 | {children} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/main/worker/app/appContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, MutableRefObject, useContext } from 'react'; 2 | import { World } from 'planck-js'; 3 | import { Subscribe } from '../../hooks/useWorkerMessages'; 4 | import { Buffers } from '../shared/types'; 5 | 6 | export type AppContextState = { 7 | updateRate: number; 8 | world: World; 9 | worker: Worker; 10 | logicWorker?: Worker | MessagePort; 11 | subscribe: Subscribe; 12 | logicSubscribe: Subscribe; 13 | buffers: Buffers; 14 | logicBuffers: Buffers; 15 | buffersRef: MutableRefObject<{ 16 | mainCount: number; 17 | logicCount: number; 18 | }>; 19 | maxNumberOfDynamicObjects: number; 20 | }; 21 | 22 | export const AppContext = createContext((null as unknown) as AppContextState); 23 | 24 | export const useWorld = (): World => { 25 | return useContext(AppContext).world; 26 | }; 27 | 28 | export const useAppContext = (): AppContextState => { 29 | return useContext(AppContext); 30 | }; 31 | -------------------------------------------------------------------------------- /src/main/worker/app/buffers.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef, useState} from 'react'; 2 | import { Buffers } from '../shared/types'; 3 | import { useDidMount } from '../../../utils/hooks'; 4 | 5 | export const generateBuffers = (maxNumberOfDynamicObjects: number): Buffers => { 6 | return { 7 | positions: new Float32Array(maxNumberOfDynamicObjects * 2), 8 | angles: new Float32Array(maxNumberOfDynamicObjects), 9 | }; 10 | }; 11 | 12 | export const useBuffers = (maxNumberOfDynamicObjects: number, debug: string): Buffers => { 13 | const isMountRef = useRef(true) 14 | const [buffers] = useState(() => generateBuffers(maxNumberOfDynamicObjects)); 15 | 16 | useEffect(() => { 17 | if (isMountRef.current) { 18 | isMountRef.current = false 19 | return 20 | } 21 | const { positions, angles } = generateBuffers(maxNumberOfDynamicObjects); 22 | buffers.positions = positions; 23 | buffers.angles = angles; 24 | }, [maxNumberOfDynamicObjects]); 25 | 26 | return buffers; 27 | }; 28 | -------------------------------------------------------------------------------- /src/main/worker/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Vec2, World, WorldDef } from 'planck-js'; 3 | import { AppContext } from './appContext'; 4 | import { useWorkerMessages } from '../../hooks/useWorkerMessages'; 5 | import { Bodies } from './Bodies'; 6 | import { World as WorldComponent } from './World'; 7 | import { useSubscribeLogicWorker, useLogicWorker } from './logicWorker'; 8 | import { WorldState } from './WorldState'; 9 | import { Collisions } from './Collisions'; 10 | import { WorkerOwnerMessageType } from '../shared/types'; 11 | import { useBuffers } from './buffers'; 12 | 13 | export const App: React.FC<{ 14 | config: { 15 | maxNumberOfDynamicObjects: number; 16 | updateRate: number; 17 | }; 18 | worldParams: WorldDef; 19 | worker: Worker; 20 | }> = ({ worldParams, worker, config }) => { 21 | const { updateRate, maxNumberOfDynamicObjects } = config; 22 | 23 | const defaultParams = { 24 | allowSleep: true, 25 | gravity: Vec2(0, 0), 26 | ...worldParams, 27 | }; 28 | 29 | const [world] = useState(() => World(defaultParams)); 30 | 31 | const subscribe = useWorkerMessages(worker); 32 | 33 | const logicWorker = useLogicWorker(worker, subscribe); 34 | 35 | const logicSubscribe = useSubscribeLogicWorker(logicWorker); 36 | 37 | const buffers = useBuffers(maxNumberOfDynamicObjects, 'main'); 38 | const logicBuffers = useBuffers(!logicWorker ? 0 : maxNumberOfDynamicObjects, 'logic'); 39 | 40 | const buffersRef = useRef({ 41 | mainCount: 0, 42 | logicCount: 0, 43 | }); 44 | 45 | useEffect(() => { 46 | worker.postMessage({ 47 | type: WorkerOwnerMessageType.INITIATED, 48 | }); 49 | }, [worker]); 50 | 51 | return ( 52 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/main/worker/app/logicWorker.ts: -------------------------------------------------------------------------------- 1 | import { Subscribe, useWorkerMessages } from '../../hooks/useWorkerMessages'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export const useSubscribeLogicWorker = ( 5 | worker: Worker | MessagePort | undefined 6 | ) => { 7 | const subscribe = useWorkerMessages(worker); 8 | return subscribe; 9 | }; 10 | 11 | export const useLogicWorker = ( 12 | worker: Worker, 13 | subscribe: Subscribe 14 | ): undefined | Worker | MessagePort => { 15 | const [logicWorker, setLogicWorker] = useState(); 16 | 17 | useEffect(() => { 18 | let logicWorkerPort: MessagePort; 19 | 20 | const handleMessage = (event: MessageEvent) => { 21 | if (event.data.command === 'connect') { 22 | logicWorkerPort = event.ports[0]; 23 | setLogicWorker(logicWorkerPort); 24 | return; 25 | } else if (event.data.command === 'forward') { 26 | logicWorkerPort.postMessage(event.data.message); 27 | return; 28 | } 29 | }; 30 | 31 | const unsubscribe = subscribe(event => { 32 | if (event.data.command) { 33 | handleMessage(event); 34 | } 35 | }); 36 | 37 | return () => { 38 | unsubscribe(); 39 | }; 40 | }, [worker, subscribe, setLogicWorker]); 41 | 42 | return logicWorker; 43 | }; 44 | -------------------------------------------------------------------------------- /src/main/worker/app/workerMessages.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | export const useHandleWorkerMessage = (isMainWorker: boolean = true) => { 4 | const handleMessage = useCallback( 5 | (event: MessageEvent) => { 6 | // todo - iterate through subscribers... 7 | }, 8 | [isMainWorker] 9 | ); 10 | 11 | return handleMessage; 12 | }; 13 | -------------------------------------------------------------------------------- /src/main/worker/data.ts: -------------------------------------------------------------------------------- 1 | import { Buffers } from './shared/types'; 2 | import { generateBuffers } from './utils'; 3 | 4 | export const storedData: { 5 | physicsTick: number; 6 | unsyncedBodies: boolean; 7 | unsyncedLogicBodies: boolean; 8 | maxNumberOfPhysicsObjects: number; 9 | buffers: Buffers; 10 | logicBuffers: Buffers; 11 | mainWorker: null | Worker; 12 | logicWorker: null | MessagePort; 13 | } = { 14 | physicsTick: 0, 15 | unsyncedBodies: false, 16 | unsyncedLogicBodies: false, 17 | maxNumberOfPhysicsObjects: 0, 18 | buffers: generateBuffers(0), 19 | logicBuffers: generateBuffers(0), 20 | mainWorker: null, 21 | logicWorker: null, 22 | }; 23 | -------------------------------------------------------------------------------- /src/main/worker/functions.ts: -------------------------------------------------------------------------------- 1 | import { storedData } from './data'; 2 | import { WorkerOwnerMessageType } from './shared/types'; 3 | 4 | export const sendCollisionBeginEvent = ( 5 | uuid: string, 6 | data: any, 7 | fixtureIndex: number, 8 | isSensor: boolean 9 | ) => { 10 | const update = { 11 | type: WorkerOwnerMessageType.BEGIN_COLLISION, 12 | props: { 13 | uuid, 14 | data, 15 | fixtureIndex, 16 | isSensor, 17 | }, 18 | }; 19 | if (storedData.mainWorker) { 20 | storedData.mainWorker.postMessage(update); 21 | } 22 | if (storedData.logicWorker) { 23 | storedData.logicWorker.postMessage(update); 24 | } 25 | }; 26 | 27 | export const sendCollisionEndEvent = ( 28 | uuid: string, 29 | data: any, 30 | fixtureIndex: number, 31 | isSensor: boolean 32 | ) => { 33 | const update = { 34 | type: WorkerOwnerMessageType.END_COLLISION, 35 | props: { 36 | uuid, 37 | data, 38 | fixtureIndex, 39 | isSensor, 40 | }, 41 | }; 42 | if (storedData.mainWorker) { 43 | storedData.mainWorker.postMessage(update); 44 | } 45 | if (storedData.logicWorker) { 46 | storedData.logicWorker.postMessage(update); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/main/worker/logicWorker.ts: -------------------------------------------------------------------------------- 1 | import { storedData } from './data'; 2 | import { WorkerMessageType } from './shared/types'; 3 | import { generateBuffers } from './utils'; 4 | import { stepProcessed } from './shared'; 5 | import { addBody, removeBody, setBody, updateBody } from './planckjs/bodies'; 6 | 7 | let logicWorkerPort: MessagePort; 8 | 9 | const onMessageFromLogicWorker = (event: MessageEvent) => { 10 | const { type, props = {} } = event.data as { 11 | type: WorkerMessageType; 12 | props: any; 13 | }; 14 | switch (type) { 15 | case WorkerMessageType.PHYSICS_STEP_PROCESSED: 16 | stepProcessed( 17 | false, 18 | event.data.physicsTick, 19 | event.data.positions, 20 | event.data.angles 21 | ); 22 | break; 23 | case WorkerMessageType.ADD_BODY: 24 | addBody(props); 25 | break; 26 | case WorkerMessageType.REMOVE_BODY: 27 | removeBody(props); 28 | break; 29 | case WorkerMessageType.SET_BODY: 30 | setBody(props); 31 | break; 32 | case WorkerMessageType.UPDATE_BODY: 33 | updateBody(props); 34 | break; 35 | } 36 | }; 37 | 38 | export const handleLogicWorkerMessage = (event: MessageEvent) => { 39 | if (event.data.command === 'connect') { 40 | logicWorkerPort = event.ports[0]; 41 | storedData.logicBuffers = generateBuffers( 42 | storedData.maxNumberOfPhysicsObjects 43 | ); 44 | storedData.logicWorker = logicWorkerPort; 45 | logicWorkerPort.onmessage = onMessageFromLogicWorker; 46 | return; 47 | } else if (event.data.command === 'forward') { 48 | logicWorkerPort.postMessage(event.data.message); 49 | return; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/main/worker/methods.ts: -------------------------------------------------------------------------------- 1 | import { storedData } from './data'; 2 | import {initPhysicsListeners, stepWorld, syncData} from './planckjs/world'; 3 | import { PHYSICS_UPDATE_RATE } from './planckjs/config'; 4 | import { generateBuffers } from './utils'; 5 | import { Buffers, WorkerOwnerMessageType } from './shared/types'; 6 | import { dynamicBodiesUuids } from './planckjs/shared'; 7 | import { setBodiesSynced, setLogicBodiesSynced } from './shared'; 8 | 9 | const sendPhysicsUpdate = ( 10 | target: Worker | MessagePort, 11 | buffer: Buffers, 12 | handleBodies: (message: any) => any 13 | ) => { 14 | const { positions, angles } = buffer; 15 | if (!(positions.byteLength !== 0 && angles.byteLength !== 0)) { 16 | return; 17 | } 18 | syncData(positions, angles); 19 | const rawMessage: any = { 20 | type: WorkerOwnerMessageType.PHYSICS_STEP, 21 | physicsTick: storedData.physicsTick, 22 | }; 23 | handleBodies(rawMessage); 24 | const message = { 25 | ...rawMessage, 26 | positions, 27 | angles, 28 | }; 29 | target.postMessage(message, [positions.buffer, angles.buffer]); 30 | }; 31 | 32 | const sendPhysicsUpdateToLogic = () => { 33 | if (!storedData.logicWorker) return; 34 | sendPhysicsUpdate( 35 | storedData.logicWorker, 36 | storedData.logicBuffers, 37 | (message: any) => { 38 | if (storedData.unsyncedLogicBodies) { 39 | message['bodies'] = dynamicBodiesUuids; 40 | setLogicBodiesSynced(); 41 | } 42 | } 43 | ); 44 | }; 45 | 46 | const sendPhysicsUpdateToMain = () => { 47 | if (!storedData.mainWorker) return; 48 | sendPhysicsUpdate( 49 | storedData.mainWorker, 50 | storedData.buffers, 51 | (message: any) => { 52 | if (storedData.unsyncedBodies) { 53 | message['bodies'] = dynamicBodiesUuids; 54 | setBodiesSynced(); 55 | } 56 | } 57 | ); 58 | }; 59 | 60 | const beginPhysicsLoop = () => { 61 | setInterval(() => { 62 | storedData.physicsTick += 1; 63 | stepWorld(); 64 | sendPhysicsUpdateToMain(); 65 | sendPhysicsUpdateToLogic(); 66 | }, PHYSICS_UPDATE_RATE); 67 | }; 68 | 69 | export const init = ({ 70 | maxNumberOfPhysicsObjects = 100, 71 | }: { 72 | maxNumberOfPhysicsObjects?: number; 73 | }) => { 74 | storedData.maxNumberOfPhysicsObjects = maxNumberOfPhysicsObjects; 75 | storedData.buffers = generateBuffers(maxNumberOfPhysicsObjects); 76 | if (storedData.logicWorker) { 77 | storedData.logicBuffers = generateBuffers(maxNumberOfPhysicsObjects); 78 | } 79 | initPhysicsListeners() 80 | beginPhysicsLoop(); 81 | }; 82 | -------------------------------------------------------------------------------- /src/main/worker/physicsWorkerHelper.ts: -------------------------------------------------------------------------------- 1 | import {createElement, FC} from "react"; 2 | import {PhysicsProps, WorkerMessageType} from "./shared/types"; 3 | import {render} from "react-nil"; 4 | import {PHYSICS_UPDATE_RATE} from "./planckjs/config"; 5 | 6 | export const physicsWorkerHandler = (selfWorker: Worker) => { 7 | 8 | selfWorker.onmessage = (event: MessageEvent) => { 9 | const { type, props = {} } = event.data as { 10 | type: WorkerMessageType; 11 | props: any; 12 | }; 13 | switch (type) { 14 | case WorkerMessageType.INIT: 15 | const { worldParams = {}, config = {} } = props as PhysicsProps; 16 | const { 17 | maxNumberOfDynamicObjects = 100, 18 | updateRate = PHYSICS_UPDATE_RATE, 19 | } = config; 20 | render( 21 | createElement( 22 | require('./worker/app/index').App, 23 | { 24 | worker: selfWorker, 25 | config: { 26 | maxNumberOfDynamicObjects, 27 | updateRate, 28 | }, 29 | worldParams, 30 | }, 31 | null 32 | ) 33 | ); 34 | break; 35 | } 36 | }; 37 | }; -------------------------------------------------------------------------------- /src/main/worker/planckjs/bodies.ts: -------------------------------------------------------------------------------- 1 | import {dynamicBodiesUuids, existingBodies, planckWorld} from "./shared"; 2 | import {Shape} from "planck-js/lib/shape"; 3 | import {activeCollisionListeners} from "./collisions/data"; 4 | import {addCachedBody, getCachedBody} from "./cache"; 5 | import type {BodyDef, FixtureOpt, Body, Joint} from "planck-js"; 6 | import {Box, Circle, DistanceJoint, RopeJoint, Vec2} from "planck-js"; 7 | import {ValidUUID} from "../shared/types"; 8 | import {syncBodies} from "../shared"; 9 | 10 | export enum BodyType { 11 | static = 'static', 12 | kinematic = 'kinematic', 13 | dynamic = 'dynamic' 14 | } 15 | 16 | export enum BodyShape { 17 | box = 'box', 18 | circle = 'circle', 19 | } 20 | 21 | export type FixtureBase = { 22 | shape: BodyShape, 23 | fixtureOptions?: Partial, 24 | } 25 | 26 | export type BoxFixture = FixtureBase & { 27 | hx: number, 28 | hy: number, 29 | center?: [number, number], 30 | } 31 | 32 | export const createBoxFixture = ({ 33 | width = 1, 34 | height = 1, 35 | center, 36 | fixtureOptions = {} 37 | }: { 38 | width?: number, 39 | height?: number, 40 | center?: [number, number], 41 | fixtureOptions?: Partial 42 | }): BoxFixture => { 43 | const fixture: BoxFixture = { 44 | shape: BodyShape.box, 45 | hx: width, 46 | hy: height, 47 | fixtureOptions, 48 | } 49 | if (center) { 50 | fixture.center = center 51 | } 52 | return fixture 53 | } 54 | 55 | export type CircleFixture = FixtureBase & { 56 | radius: number, 57 | } 58 | 59 | export const createCircleFixture = ({ radius = 1, fixtureOptions = {} }: { 60 | radius?: number, 61 | fixtureOptions?: Partial 62 | }): CircleFixture => { 63 | return { 64 | shape: BodyShape.circle, 65 | radius, 66 | fixtureOptions, 67 | } 68 | } 69 | 70 | type Fixture = BoxFixture | CircleFixture 71 | 72 | type BasicBodyProps = Partial & { 73 | fixtures?: Fixture[], 74 | } 75 | 76 | type AddBoxBodyProps = BasicBodyProps & {} 77 | 78 | type AddCircleBodyProps = BasicBodyProps & {} 79 | 80 | export type AddBodyDef = BasicBodyProps | AddBoxBodyProps | AddCircleBodyProps 81 | 82 | export type AddBodyProps = AddBodyDef & { 83 | uuid: ValidUUID, 84 | listenForCollisions: boolean, 85 | cacheKey?: string, 86 | attachToRope?: boolean, 87 | } 88 | 89 | export const addBody = ({uuid, cacheKey, listenForCollisions, fixtures = [], attachToRope = false, ...props}: AddBodyProps) => { 90 | 91 | const existingBody = existingBodies.get(uuid) 92 | 93 | if (existingBody) { 94 | return existingBody 95 | } 96 | 97 | if (listenForCollisions) { 98 | activeCollisionListeners[uuid] = true 99 | } 100 | 101 | const bodyDef: BodyDef = { 102 | type: BodyType.static, 103 | fixedRotation: true, 104 | ...props, 105 | } 106 | 107 | const {type} = bodyDef 108 | 109 | let body: Body | null = null; 110 | 111 | if (cacheKey) { 112 | const cachedBody = getCachedBody(cacheKey) 113 | if (cachedBody) { 114 | 115 | if (fixtures && fixtures.length > 0) { 116 | 117 | let bodyFixture = cachedBody.getFixtureList() 118 | 119 | fixtures.forEach((fixture, fixtureIndex) => { 120 | 121 | let fixtureOptions = fixture.fixtureOptions 122 | 123 | fixtureOptions = { 124 | userData: { 125 | uuid, 126 | fixtureIndex, 127 | ...fixtureOptions?.userData 128 | }, 129 | ...fixtureOptions, 130 | } 131 | 132 | if (bodyFixture) { 133 | 134 | if (fixtureOptions) { 135 | bodyFixture.setUserData(fixtureOptions.userData) 136 | } 137 | 138 | bodyFixture = bodyFixture.getNext() 139 | } 140 | 141 | }) 142 | 143 | } 144 | 145 | const {position, angle} = props 146 | 147 | if (position) { 148 | cachedBody.setPosition(position) 149 | } 150 | 151 | if (angle) { 152 | cachedBody.setAngle(angle) 153 | } 154 | 155 | cachedBody.setActive(true) 156 | 157 | body = cachedBody 158 | 159 | } 160 | } 161 | 162 | if (!body) { 163 | 164 | body = planckWorld.createBody(bodyDef) 165 | 166 | if (fixtures && fixtures.length > 0) { 167 | 168 | fixtures.forEach((fixture, fixtureIndex) => { 169 | 170 | const {shape} = fixture 171 | 172 | let fixtureOptions = fixture.fixtureOptions ?? {} 173 | 174 | fixtureOptions = { 175 | ...fixtureOptions, 176 | userData: { 177 | uuid, 178 | fixtureIndex, 179 | ...fixtureOptions?.userData 180 | }, 181 | } 182 | 183 | let bodyShape: Shape; 184 | 185 | switch (shape) { 186 | case BodyShape.box: 187 | const {hx, hy, center} = fixture as BoxFixture 188 | bodyShape = Box((hx as number) / 2, (hy as number) / 2, center ? Vec2(center[0], center[1]) : undefined) as unknown as Shape 189 | break; 190 | case BodyShape.circle: 191 | const {radius} = fixture as CircleFixture 192 | bodyShape = Circle((radius as number)) as unknown as Shape 193 | break; 194 | default: 195 | throw new Error(`Unhandled body shape ${shape}`) 196 | } 197 | 198 | if (fixtureOptions) { 199 | if (body) { 200 | body.createFixture(bodyShape, fixtureOptions as FixtureOpt) 201 | } 202 | } else { 203 | if (body) { 204 | body.createFixture(bodyShape) 205 | } 206 | } 207 | 208 | // todo - handle rope properly... 209 | if (attachToRope) { 210 | 211 | const {position, angle} = props 212 | 213 | const ropeJointDef = { 214 | maxLength: 0.5, 215 | localAnchorA: position, 216 | localAnchorB: position, 217 | }; 218 | 219 | const startingBodyDef: BodyDef = { 220 | type: BodyType.static, 221 | fixedRotation: true, 222 | position, 223 | angle, 224 | } 225 | 226 | const startingBody = planckWorld.createBody(startingBodyDef) 227 | 228 | if (body) { 229 | 230 | const distanceJoint = DistanceJoint({ 231 | collideConnected: false, 232 | frequencyHz: 5, 233 | dampingRatio: 0.5, 234 | length: 0.15, 235 | }, startingBody, body, position ?? Vec2(0, 0), position ?? Vec2(0, 0)) 236 | 237 | const rope2 = planckWorld.createJoint(RopeJoint(ropeJointDef, startingBody, body, position ?? Vec2(0, 0)) as unknown as Joint); 238 | const rope = planckWorld.createJoint(distanceJoint as unknown as Joint); 239 | } 240 | 241 | 242 | } 243 | 244 | }) 245 | 246 | 247 | } 248 | 249 | } 250 | 251 | if (type !== BodyType.static) { 252 | dynamicBodiesUuids.push(uuid) 253 | syncBodies() 254 | } 255 | 256 | if (!body) { 257 | throw new Error(`No body`) 258 | } 259 | 260 | existingBodies.set(uuid, body) 261 | 262 | return body 263 | 264 | } 265 | 266 | export type RemoveBodyProps = { 267 | uuid: ValidUUID, 268 | cacheKey?: string 269 | } 270 | 271 | const tempVec = Vec2(0, 0) 272 | 273 | export const removeBody = ({uuid, cacheKey}: RemoveBodyProps) => { 274 | const index = dynamicBodiesUuids.indexOf(uuid) 275 | if (index > -1) { 276 | dynamicBodiesUuids.splice(index, 1) 277 | syncBodies() 278 | } 279 | const body = existingBodies.get(uuid) 280 | if (!body) { 281 | console.warn(`Body not found for ${uuid}`) 282 | return 283 | } 284 | existingBodies.delete(uuid) 285 | if (cacheKey) { 286 | tempVec.set(-1000, -1000) 287 | body.setPosition(tempVec) 288 | tempVec.set(0, 0) 289 | body.setLinearVelocity(tempVec) 290 | body.setActive(false) 291 | addCachedBody(cacheKey, body) 292 | } else { 293 | planckWorld.destroyBody(body) 294 | } 295 | } 296 | 297 | export type SetBodyProps = { 298 | uuid: ValidUUID, 299 | method: string, 300 | methodParams: any[], 301 | } 302 | 303 | export const setBody = ({uuid, method, methodParams}: SetBodyProps) => { 304 | const body = existingBodies.get(uuid) 305 | if (!body) { 306 | console.warn(`Body not found for ${uuid}`) 307 | return 308 | } 309 | switch (method) { 310 | //case 'setAngle': 311 | // const [angle] = methodParams 312 | // body.setTransform(body.getPosition(), angle) 313 | // break; 314 | case 'setLinearVelocity': 315 | // console.log('methodParams', methodParams[0].x, methodParams[0].y); 316 | (body as any)[method](...methodParams) 317 | break; 318 | default: 319 | (body as any)[method](...methodParams) 320 | } 321 | } 322 | 323 | export type UpdateBodyData = { 324 | fixtureUpdate?: { 325 | groupIndex?: number, 326 | categoryBits?: number, 327 | maskBits?: number, 328 | } 329 | } 330 | 331 | export type UpdateBodyProps = { 332 | uuid: ValidUUID, 333 | data: UpdateBodyData, 334 | } 335 | 336 | export const updateBody = ({uuid, data}: UpdateBodyProps) => { 337 | const body = existingBodies.get(uuid) 338 | if (!body) { 339 | console.warn(`Body not found for ${uuid}`) 340 | return 341 | } 342 | const {fixtureUpdate} = data 343 | if (fixtureUpdate) { 344 | const fixture = body.getFixtureList() 345 | if (fixture) { 346 | const { 347 | groupIndex, 348 | categoryBits, 349 | maskBits 350 | } = fixtureUpdate 351 | if ( 352 | groupIndex !== undefined || categoryBits !== undefined || maskBits !== undefined 353 | ) { 354 | const originalGroupIndex = fixture.getFilterGroupIndex() 355 | const originalCategoryBits = fixture.getFilterCategoryBits() 356 | const originalMaskBits = fixture.getFilterMaskBits() 357 | fixture.setFilterData({ 358 | groupIndex: groupIndex !== undefined ? groupIndex : originalGroupIndex, 359 | categoryBits: categoryBits !== undefined ? categoryBits : originalCategoryBits, 360 | maskBits: maskBits !== undefined ? maskBits : originalMaskBits, 361 | }) 362 | } 363 | } 364 | } 365 | } -------------------------------------------------------------------------------- /src/main/worker/planckjs/cache.ts: -------------------------------------------------------------------------------- 1 | import { Body } from 'planck-js'; 2 | 3 | export const cachedBodies: { 4 | [key: string]: Body[]; 5 | } = {}; 6 | 7 | export const getCachedBody = (key: string): Body | null => { 8 | const bodies = cachedBodies[key]; 9 | if (bodies && bodies.length > 0) { 10 | const body = bodies.pop(); 11 | if (body) { 12 | return body; 13 | } 14 | } 15 | return null; 16 | }; 17 | 18 | export const addCachedBody = (key: string, body: Body) => { 19 | if (cachedBodies[key]) { 20 | cachedBodies[key].push(body); 21 | } else { 22 | cachedBodies[key] = [body]; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/collisions/collisions.ts: -------------------------------------------------------------------------------- 1 | import { Fixture } from 'planck-js'; 2 | import { FixtureUserData } from './types'; 3 | import { activeCollisionListeners } from './data'; 4 | import { 5 | sendCollisionBeginEvent, 6 | sendCollisionEndEvent, 7 | } from '../../functions'; 8 | 9 | export const getFixtureData = (fixture: Fixture): FixtureUserData | null => { 10 | const userData = fixture.getUserData() as null | FixtureUserData; 11 | return userData || null; 12 | }; 13 | 14 | export const getFixtureUuid = (data: FixtureUserData | null): string => { 15 | if (data && data['uuid']) { 16 | return data.uuid; 17 | } 18 | return ''; 19 | }; 20 | 21 | export const getFixtureIndex = (data: FixtureUserData | null): number => { 22 | if (data) { 23 | return data.fixtureIndex; 24 | } 25 | return -1; 26 | }; 27 | 28 | export const handleBeginCollision = (fixtureA: Fixture, fixtureB: Fixture) => { 29 | const aData = getFixtureData(fixtureA); 30 | const bData = getFixtureData(fixtureB); 31 | const aUUID = getFixtureUuid(aData); 32 | const bUUID = getFixtureUuid(bData); 33 | 34 | if (aUUID && activeCollisionListeners[aUUID]) { 35 | sendCollisionBeginEvent( 36 | aUUID, 37 | bData, 38 | getFixtureIndex(aData), 39 | fixtureB.isSensor() 40 | ); 41 | } 42 | 43 | if (bUUID && activeCollisionListeners[bUUID]) { 44 | sendCollisionBeginEvent( 45 | bUUID, 46 | aData, 47 | getFixtureIndex(bData), 48 | fixtureA.isSensor() 49 | ); 50 | } 51 | }; 52 | 53 | export const handleEndCollision = (fixtureA: Fixture, fixtureB: Fixture) => { 54 | const aData = getFixtureData(fixtureA); 55 | const bData = getFixtureData(fixtureB); 56 | const aUUID = getFixtureUuid(aData); 57 | const bUUID = getFixtureUuid(bData); 58 | 59 | if (aUUID && activeCollisionListeners[aUUID]) { 60 | sendCollisionEndEvent( 61 | aUUID, 62 | bData, 63 | getFixtureIndex(aData), 64 | fixtureB.isSensor() 65 | ); 66 | } 67 | 68 | if (bUUID && activeCollisionListeners[bUUID]) { 69 | sendCollisionEndEvent( 70 | bUUID, 71 | aData, 72 | getFixtureIndex(bData), 73 | fixtureA.isSensor() 74 | ); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/collisions/data.ts: -------------------------------------------------------------------------------- 1 | export const activeCollisionListeners: { 2 | [uuid: string]: boolean; 3 | } = {}; 4 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/collisions/filters.ts: -------------------------------------------------------------------------------- 1 | export const COLLISION_FILTER_GROUPS = { 2 | player: 1 << 0, 3 | duckling: 1 << 1, 4 | }; 5 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/collisions/types.ts: -------------------------------------------------------------------------------- 1 | export enum FixtureType { 2 | PLAYER_RANGE, 3 | MOB, 4 | } 5 | 6 | export type FixtureUserData = { 7 | uuid: string; 8 | fixtureIndex: number; 9 | type: FixtureType; 10 | [key: string]: any; 11 | }; 12 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/config.ts: -------------------------------------------------------------------------------- 1 | export const PHYSICS_UPDATE_RATE = 1000 / 60; 2 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/data.ts: -------------------------------------------------------------------------------- 1 | import { FixtureUserData } from './collisions/types'; 2 | 3 | export type CollisionEventProps = { 4 | uuid: string; 5 | fixtureIndex: number; 6 | isSensor: boolean; 7 | data: FixtureUserData | null; 8 | }; 9 | 10 | // todo - store in context... 11 | export const storedPhysicsData: { 12 | bodies: { 13 | [uuid: string]: number; 14 | }; 15 | } = { 16 | bodies: {}, 17 | }; 18 | -------------------------------------------------------------------------------- /src/main/worker/planckjs/shared.ts: -------------------------------------------------------------------------------- 1 | import {Vec2, World} from "planck-js/lib"; 2 | import type {Body} from "planck-js/lib" 3 | import {ValidUUID} from "../shared/types"; 4 | 5 | export const planckWorld = World({ 6 | allowSleep: true, 7 | gravity: Vec2(0, 0), 8 | }) 9 | 10 | export const dynamicBodiesUuids: ValidUUID[] = [] 11 | 12 | export const existingBodies = new Map() -------------------------------------------------------------------------------- /src/main/worker/planckjs/world.ts: -------------------------------------------------------------------------------- 1 | import { dynamicBodiesUuids, existingBodies, planckWorld } from './shared'; 2 | import { 3 | handleBeginCollision, 4 | handleEndCollision, 5 | } from './collisions/collisions'; 6 | import { Contact } from 'planck-js'; 7 | import {PHYSICS_UPDATE_RATE} from "./config"; 8 | 9 | let lastUpdate = 0; 10 | 11 | export const syncData = (positions: Float32Array, angles: Float32Array) => { 12 | dynamicBodiesUuids.forEach((uuid, index) => { 13 | const body = existingBodies.get(uuid); 14 | if (!body) return; 15 | const position = body.getPosition(); 16 | const angle = body.getAngle(); 17 | positions[2 * index + 0] = position.x; 18 | positions[2 * index + 1] = position.y; 19 | angles[index] = angle; 20 | }); 21 | }; 22 | 23 | export const stepWorld = () => { 24 | var now = Date.now(); 25 | var delta = !lastUpdate ? 0 : (now - lastUpdate) / 1000; 26 | planckWorld.step(PHYSICS_UPDATE_RATE); 27 | lastUpdate = now; 28 | }; 29 | 30 | export const initPhysicsListeners = () => { 31 | planckWorld.on('begin-contact', (contact: Contact) => { 32 | const fixtureA = contact.getFixtureA(); 33 | const fixtureB = contact.getFixtureB(); 34 | handleBeginCollision(fixtureA, fixtureB); 35 | }); 36 | 37 | planckWorld.on('end-contact', (contact: Contact) => { 38 | const fixtureA = contact.getFixtureA(); 39 | const fixtureB = contact.getFixtureB(); 40 | handleEndCollision(fixtureA, fixtureB); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/main/worker/shared.ts: -------------------------------------------------------------------------------- 1 | import { syncData } from './planckjs/world'; 2 | import { dynamicBodiesUuids } from './planckjs/shared'; 3 | import { storedData } from './data'; 4 | import { Buffers, WorkerOwnerMessageType } from './shared/types'; 5 | 6 | export const setBodiesSynced = () => { 7 | storedData.unsyncedBodies = false; 8 | }; 9 | 10 | export const setLogicBodiesSynced = () => { 11 | storedData.unsyncedLogicBodies = false; 12 | }; 13 | 14 | export const syncBodies = () => { 15 | storedData.unsyncedBodies = true; 16 | storedData.unsyncedLogicBodies = true; 17 | }; 18 | 19 | const sendPhysicsUpdate = ( 20 | target: Worker | MessagePort, 21 | buffer: Buffers, 22 | handleBodies: (message: any) => any 23 | ) => { 24 | const { positions, angles } = buffer; 25 | if (!(positions.byteLength !== 0 && angles.byteLength !== 0)) { 26 | return; 27 | } 28 | syncData(positions, angles); 29 | const rawMessage: any = { 30 | type: WorkerOwnerMessageType.PHYSICS_STEP, 31 | physicsTick: storedData.physicsTick, 32 | }; 33 | handleBodies(rawMessage); 34 | const message = { 35 | ...rawMessage, 36 | positions, 37 | angles, 38 | }; 39 | target.postMessage(message, [positions.buffer, angles.buffer]); 40 | }; 41 | 42 | const sendPhysicsUpdateToLogic = () => { 43 | if (!storedData.logicWorker) return; 44 | sendPhysicsUpdate( 45 | storedData.logicWorker, 46 | storedData.logicBuffers, 47 | (message: any) => { 48 | if (storedData.unsyncedLogicBodies) { 49 | message['bodies'] = dynamicBodiesUuids; 50 | setLogicBodiesSynced(); 51 | } 52 | } 53 | ); 54 | }; 55 | 56 | const sendPhysicsUpdateToMain = () => { 57 | if (!storedData.mainWorker) return; 58 | sendPhysicsUpdate( 59 | storedData.mainWorker, 60 | storedData.buffers, 61 | (message: any) => { 62 | if (storedData.unsyncedBodies) { 63 | message['bodies'] = dynamicBodiesUuids; 64 | setBodiesSynced(); 65 | } 66 | } 67 | ); 68 | }; 69 | 70 | export const stepProcessed = ( 71 | isMain: boolean, 72 | lastProcessedPhysicsTick: number, 73 | positions: Float32Array, 74 | angles: Float32Array 75 | ) => { 76 | const buffer = isMain ? storedData.buffers : storedData.logicBuffers; 77 | buffer.positions = positions; 78 | buffer.angles = angles; 79 | if (lastProcessedPhysicsTick < storedData.physicsTick) { 80 | if (isMain) { 81 | sendPhysicsUpdateToMain(); 82 | } else { 83 | sendPhysicsUpdateToLogic(); 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/main/worker/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { WorldDef } from 'planck-js'; 2 | 3 | export enum WorkerMessageType { 4 | INIT, 5 | STEP, 6 | LOGIC_FRAME, 7 | ADD_BODY, 8 | REMOVE_BODY, 9 | SET_BODY, 10 | UPDATE_BODY, 11 | PHYSICS_STEP_PROCESSED, 12 | READY_FOR_PHYSICS, 13 | } 14 | 15 | export enum WorkerOwnerMessageType { 16 | FRAME, 17 | PHYSICS_STEP, 18 | SYNC_BODIES, 19 | BEGIN_COLLISION, 20 | END_COLLISION, 21 | MESSAGE, 22 | INITIATED, 23 | } 24 | 25 | export type Buffers = { 26 | positions: Float32Array; 27 | angles: Float32Array; 28 | }; 29 | 30 | export type ValidUUID = string | number; 31 | 32 | export type PhysicsProps = { 33 | config?: { 34 | maxNumberOfDynamicObjects?: number; 35 | updateRate?: number; 36 | }; 37 | worldParams?: WorldDef; 38 | }; 39 | -------------------------------------------------------------------------------- /src/main/worker/utils.ts: -------------------------------------------------------------------------------- 1 | import { Buffers } from './shared/types'; 2 | 3 | export const generateBuffers = (maxNumberOfPhysicsObjects: number): Buffers => { 4 | return { 5 | positions: new Float32Array(maxNumberOfPhysicsObjects * 2), 6 | angles: new Float32Array(maxNumberOfPhysicsObjects), 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/main/worker/worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | import { render } from 'react-nil'; 3 | import { PhysicsProps, WorkerMessageType } from './shared/types'; 4 | import { createElement } from 'react'; 5 | import {PHYSICS_UPDATE_RATE} from "./planckjs/config"; 6 | 7 | // because of some weird react/dev/webpack/something quirk 8 | (self as any).$RefreshReg$ = () => {}; 9 | (self as any).$RefreshSig$ = () => () => {}; 10 | 11 | const selfWorker = (self as unknown) as Worker; 12 | 13 | selfWorker.onmessage = (event: MessageEvent) => { 14 | const { type, props = {} } = event.data as { 15 | type: WorkerMessageType; 16 | props: any; 17 | }; 18 | switch (type) { 19 | case WorkerMessageType.INIT: 20 | const { worldParams = {}, config = {} } = props as PhysicsProps; 21 | const { 22 | maxNumberOfDynamicObjects = 100, 23 | updateRate = PHYSICS_UPDATE_RATE, 24 | } = config; 25 | render( 26 | createElement( 27 | require('./app/index').App, 28 | { 29 | worker: selfWorker, 30 | config: { 31 | maxNumberOfDynamicObjects, 32 | updateRate, 33 | }, 34 | worldParams, 35 | }, 36 | null 37 | ) 38 | ); 39 | break; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/shared/CollisionsProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useCallback, useContext, useEffect, useState} from 'react'; 2 | import {ValidUUID, WorkerOwnerMessageType} from '../main/worker/shared/types'; 3 | import { CollisionEventProps } from '../main/worker/planckjs/data'; 4 | import {useWorkerOnMessage} from "./WorkerOnMessageProvider"; 5 | 6 | type CollisionsProviderContextState = { 7 | addCollisionHandler: ( 8 | started: boolean, 9 | uuid: ValidUUID, 10 | callback: (data: any, fixtureIndex: number, isSensor: boolean) => void 11 | ) => void; 12 | removeCollisionHandler: (started: boolean, uuid: ValidUUID) => void; 13 | handleBeginCollision: (data: CollisionEventProps) => void; 14 | handleEndCollision: (data: CollisionEventProps) => void; 15 | }; 16 | 17 | const CollisionsProviderContext = createContext( 18 | (null as unknown) as CollisionsProviderContextState 19 | ); 20 | 21 | export const useCollisionsProviderContext = (): CollisionsProviderContextState => { 22 | return useContext(CollisionsProviderContext); 23 | }; 24 | 25 | const CollisionsProvider: React.FC = ({ children }) => { 26 | const [collisionStartedEvents] = useState<{ 27 | [key: string]: (data: any, fixtureIndex: number, isSensor: boolean) => void; 28 | }>({}); 29 | 30 | const [collisionEndedEvents] = useState<{ 31 | [key: string]: (data: any, fixtureIndex: number, isSensor: boolean) => void; 32 | }>({}); 33 | 34 | const addCollisionHandler = useCallback( 35 | ( 36 | started: boolean, 37 | uuid: ValidUUID, 38 | callback: (data: any, fixtureIndex: number, isSensor: boolean) => void 39 | ) => { 40 | if (started) { 41 | collisionStartedEvents[uuid] = callback; 42 | } else { 43 | collisionEndedEvents[uuid] = callback; 44 | } 45 | }, 46 | [] 47 | ); 48 | 49 | const removeCollisionHandler = useCallback( 50 | (started: boolean, uuid: ValidUUID) => { 51 | if (started) { 52 | delete collisionStartedEvents[uuid]; 53 | } else { 54 | delete collisionEndedEvents[uuid]; 55 | } 56 | }, 57 | [] 58 | ); 59 | 60 | const handleBeginCollision = useCallback( 61 | (data: CollisionEventProps) => { 62 | if (collisionStartedEvents[data.uuid]) { 63 | collisionStartedEvents[data.uuid]( 64 | data.data, 65 | data.fixtureIndex, 66 | data.isSensor 67 | ); 68 | } 69 | }, 70 | [collisionStartedEvents] 71 | ); 72 | 73 | const handleEndCollision = useCallback( 74 | (data: CollisionEventProps) => { 75 | if (collisionEndedEvents[data.uuid]) { 76 | collisionEndedEvents[data.uuid]( 77 | data.data, 78 | data.fixtureIndex, 79 | data.isSensor 80 | ); 81 | } 82 | }, 83 | [collisionEndedEvents] 84 | ); 85 | 86 | const onMessage = useWorkerOnMessage(); 87 | 88 | useEffect(() => { 89 | 90 | const unsubscribe = onMessage((event: MessageEvent) => { 91 | const type = event.data.type; 92 | 93 | switch (type) { 94 | case WorkerOwnerMessageType.BEGIN_COLLISION: 95 | handleBeginCollision(event.data.props as any) 96 | break; 97 | case WorkerOwnerMessageType.END_COLLISION: 98 | handleEndCollision(event.data.props as any) 99 | break; 100 | default: 101 | } 102 | 103 | }) 104 | 105 | return unsubscribe 106 | 107 | }, []) 108 | 109 | return ( 110 | 118 | {children} 119 | 120 | ); 121 | }; 122 | 123 | export default CollisionsProvider; 124 | -------------------------------------------------------------------------------- /src/shared/MeshSubscriptions.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import { Object3D } from 'three'; 10 | import { ValidUUID } from '../main/worker/shared/types'; 11 | import { getPositionAndAngle } from './utils'; 12 | import { useStoredData } from './StoredPhysicsData'; 13 | import { lerp } from '../utils/numbers'; 14 | import {getNow} from "../utils/time"; 15 | import {PHYSICS_UPDATE_RATE} from "../main/worker/planckjs/config"; 16 | 17 | export type ContextState = { 18 | lerpMeshes: ( 19 | getPhysicsStepTimeRemainingRatio: (time: number) => number 20 | ) => void; 21 | updateMeshes: ( 22 | positions: Float32Array, 23 | angles: Float32Array, 24 | immediate: boolean 25 | ) => void; 26 | addSubscription: ( 27 | uuid: ValidUUID, 28 | object: Object3D, 29 | applyAngle: boolean 30 | ) => () => void; 31 | }; 32 | 33 | export const Context = createContext((null as unknown) as ContextState); 34 | 35 | export const useLerpMeshes = () => { 36 | return useContext(Context).lerpMeshes; 37 | }; 38 | 39 | export const useAddMeshSubscription = () => { 40 | return useContext(Context).addSubscription; 41 | }; 42 | 43 | export const useSubscribeMesh = ( 44 | uuid: ValidUUID, 45 | object: Object3D, 46 | applyAngle: boolean = true, 47 | isDynamic: boolean = true 48 | ) => { 49 | const addSubscription = useContext(Context).addSubscription; 50 | 51 | useEffect(() => { 52 | if (!isDynamic) return; 53 | 54 | const unsubscribe = addSubscription(uuid, object, applyAngle); 55 | 56 | return () => { 57 | unsubscribe(); 58 | }; 59 | }, [uuid, object, applyAngle, isDynamic, addSubscription]); 60 | }; 61 | 62 | export const useUpdateMeshes = () => { 63 | return useContext(Context).updateMeshes; 64 | }; 65 | 66 | type Subscriptions = { 67 | [uuid: string]: { 68 | uuid: ValidUUID, 69 | object: Object3D, 70 | applyAngle: boolean, 71 | lastUpdate?: number, 72 | lastRender?: number, 73 | previous?: { 74 | position: [number, number], 75 | angle: number, 76 | }, 77 | target?: { 78 | position: [number, number], 79 | angle: number, 80 | }, 81 | } 82 | } 83 | 84 | const MeshSubscriptions: React.FC = ({ children }) => { 85 | const subscriptionsRef = useRef({}); 86 | 87 | const lerpMeshes = useCallback( 88 | (getPhysicsStepTimeRemainingRatio: (time: number) => number) => { 89 | Object.values(subscriptionsRef.current).forEach( 90 | ({ uuid, 91 | object, 92 | target, 93 | previous, 94 | applyAngle, 95 | lastUpdate , lastRender}) => { 96 | if (!target || !previous) { 97 | return; 98 | } 99 | const { position, angle } = target; 100 | const {position: previousPosition, angle: previousAngle} = previous 101 | lastUpdate = lastUpdate || getNow() 102 | 103 | // lastUpdate = 10 104 | // nextUpdate = 20 105 | // currentUpdate = 15 106 | // min = 10 107 | // max = 20 108 | 109 | const nextExpectedUpdate = lastUpdate + PHYSICS_UPDATE_RATE + 2 110 | 111 | const min = lastUpdate 112 | const max = nextExpectedUpdate 113 | const now = getNow() 114 | 115 | const timeSinceLastRender = now - (lastRender || now) 116 | 117 | const timeUntilNextUpdate = nextExpectedUpdate - now 118 | 119 | // console.log('timeUntilNextUpdate', timeUntilNextUpdate) 120 | 121 | let normalised = ((now - min) / (max - min)) 122 | 123 | normalised = normalised < 0 ? 0 : normalised > 1 ? 1 : normalised 124 | 125 | const physicsRemainingRatio = normalised 126 | 127 | // console.log('physicsRemainingRatio', physicsRemainingRatio, timeUntilNextUpdate) 128 | 129 | object.position.x = lerp( 130 | previousPosition[0], 131 | position[0], 132 | physicsRemainingRatio 133 | ); 134 | object.position.y = lerp( 135 | previousPosition[1], 136 | position[1], 137 | physicsRemainingRatio 138 | ); 139 | if (applyAngle) { 140 | object.rotation.z = angle; // todo - lerp 141 | } 142 | 143 | subscriptionsRef.current[uuid as string].lastRender = getNow() 144 | 145 | } 146 | ); 147 | }, 148 | [subscriptionsRef] 149 | ); 150 | 151 | const storedData = useStoredData(); 152 | 153 | const updateMeshes = useCallback( 154 | (positions: Float32Array, angles: Float32Array, immediate: boolean) => { 155 | Object.entries(subscriptionsRef.current).forEach( 156 | ([uuid, { object, target, previous, applyAngle }]) => { 157 | const index = storedData.bodies[uuid]; 158 | const update = getPositionAndAngle({ positions, angles }, index); 159 | if (update) { 160 | if (immediate) { 161 | object.position.x = update.position[0]; 162 | object.position.y = update.position[1]; 163 | if (applyAngle) { 164 | object.rotation.x = update.angle; 165 | } 166 | } 167 | const previousTarget = subscriptionsRef.current[uuid].target 168 | if (!previousTarget) { 169 | subscriptionsRef.current[uuid].previous = { 170 | position: [object.position.x, object.position.y], 171 | angle: object.rotation.x, 172 | }; 173 | } else { 174 | subscriptionsRef.current[uuid].previous = { 175 | position: previousTarget.position, 176 | angle: previousTarget.angle, 177 | }; 178 | } 179 | subscriptionsRef.current[uuid].target = { 180 | position: update.position, 181 | angle: update.angle, 182 | }; 183 | subscriptionsRef.current[uuid as string].lastUpdate = getNow() 184 | } 185 | } 186 | ); 187 | }, 188 | [subscriptionsRef, storedData] 189 | ); 190 | 191 | const addSubscription = useCallback( 192 | (uuid: ValidUUID, object: Object3D, applyAngle: boolean) => { 193 | subscriptionsRef.current[uuid as string] = { 194 | uuid, 195 | object, 196 | applyAngle, 197 | }; 198 | 199 | const unsubscribe = () => { 200 | delete subscriptionsRef.current[uuid as string]; 201 | }; 202 | 203 | return unsubscribe; 204 | }, 205 | [subscriptionsRef] 206 | ); 207 | 208 | return ( 209 | 216 | {children} 217 | 218 | ); 219 | }; 220 | 221 | export default MeshSubscriptions; 222 | -------------------------------------------------------------------------------- /src/shared/Messages.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useRef, 6 | useState, 7 | } from 'react'; 8 | import { MessageData } from './types'; 9 | 10 | type MessagesContextState = { 11 | handleMessage: (message: MessageData) => void; 12 | subscribeToMessage: ( 13 | messageKey: string, 14 | callback: (data: any) => void 15 | ) => () => void; 16 | }; 17 | 18 | const MessagesContext = createContext( 19 | (null as unknown) as MessagesContextState 20 | ); 21 | 22 | export const useMessagesContext = () => { 23 | return useContext(MessagesContext); 24 | }; 25 | 26 | export const useOnMessage = () => { 27 | return useMessagesContext().subscribeToMessage; 28 | }; 29 | 30 | const Messages: React.FC = ({ children }) => { 31 | const messageCountRef = useRef(0); 32 | const [messageSubscriptions] = useState<{ 33 | [key: string]: { 34 | [id: number]: (data: any) => void; 35 | }; 36 | }>({}); 37 | 38 | const subscribeToMessage = useCallback( 39 | (messageKey: string, callback: (data: any) => void) => { 40 | const id = messageCountRef.current; 41 | messageCountRef.current += 1; 42 | 43 | if (!messageSubscriptions[messageKey]) { 44 | messageSubscriptions[messageKey] = { 45 | [id]: callback, 46 | }; 47 | } else { 48 | messageSubscriptions[messageKey][id] = callback; 49 | } 50 | 51 | const unsubscribe = () => { 52 | delete messageSubscriptions[messageKey][id]; 53 | }; 54 | 55 | return unsubscribe; 56 | }, 57 | [messageSubscriptions] 58 | ); 59 | 60 | const handleMessage = useCallback( 61 | ({ key, data }: MessageData) => { 62 | 63 | const subscriptions = messageSubscriptions[key]; 64 | 65 | if (subscriptions) { 66 | Object.values(subscriptions).forEach(subscription => { 67 | subscription(data); 68 | }); 69 | } 70 | }, 71 | [messageSubscriptions] 72 | ); 73 | 74 | return ( 75 | 81 | {children} 82 | 83 | ); 84 | }; 85 | 86 | export default Messages; 87 | -------------------------------------------------------------------------------- /src/shared/PhysicsProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from 'react'; 8 | import { 9 | AddBodyProps, 10 | RemoveBodyProps, 11 | SetBodyProps, 12 | UpdateBodyProps, 13 | } from '../main/worker/planckjs/bodies'; 14 | import { WorkerMessageType } from '../main/worker/shared/types'; 15 | 16 | export type ContextState = { 17 | workerAddBody: (props: AddBodyProps) => void; 18 | workerRemoveBody: (props: RemoveBodyProps) => void; 19 | workerSetBody: (props: SetBodyProps) => void; 20 | workerUpdateBody: (props: UpdateBodyProps) => void; 21 | }; 22 | 23 | export const Context = createContext((null as unknown) as ContextState); 24 | 25 | export const usePhysicsProvider = (): ContextState => { 26 | return useContext(Context); 27 | }; 28 | 29 | const PhysicsProvider: React.FC<{ 30 | worker: Worker | MessagePort; 31 | }> = ({ children, worker }) => { 32 | const workerAddBody = useCallback((props: AddBodyProps) => { 33 | worker.postMessage({ 34 | type: WorkerMessageType.ADD_BODY, 35 | props: props, 36 | }); 37 | }, []); 38 | 39 | const workerRemoveBody = useCallback((props: RemoveBodyProps) => { 40 | worker.postMessage({ 41 | type: WorkerMessageType.REMOVE_BODY, 42 | props, 43 | }); 44 | }, []); 45 | 46 | const workerSetBody = useCallback((props: SetBodyProps) => { 47 | worker.postMessage({ 48 | type: WorkerMessageType.SET_BODY, 49 | props, 50 | }); 51 | }, []); 52 | 53 | const workerUpdateBody = useCallback((props: UpdateBodyProps) => { 54 | worker.postMessage({ 55 | type: WorkerMessageType.UPDATE_BODY, 56 | props, 57 | }); 58 | }, []); 59 | 60 | return ( 61 | 69 | {children} 70 | 71 | ); 72 | }; 73 | 74 | export default PhysicsProvider; 75 | -------------------------------------------------------------------------------- /src/shared/PhysicsSync.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | FC, 4 | useCallback, 5 | useContext, 6 | useEffect, 7 | useRef, 8 | } from 'react'; 9 | import { PHYSICS_UPDATE_RATE } from '../main/worker/planckjs/config'; 10 | import { useWorkerOnMessage } from './WorkerOnMessageProvider'; 11 | import { 12 | WorkerMessageType, 13 | WorkerOwnerMessageType, 14 | } from '../main/worker/shared/types'; 15 | import { useStoredData } from './StoredPhysicsData'; 16 | import { useUpdateMeshes } from './MeshSubscriptions'; 17 | import {getNow} from "../utils/time"; 18 | 19 | type State = { 20 | onFixedUpdate: (callback: (delta: number) => void) => () => void; 21 | getPhysicsStepTimeRemainingRatio: (time: number) => number; 22 | }; 23 | 24 | const Context = createContext((null as unknown) as State); 25 | 26 | export const useGetPhysicsStepTimeRemainingRatio = () => { 27 | return useContext(Context).getPhysicsStepTimeRemainingRatio; 28 | }; 29 | 30 | export const useFixedUpdate = (callback: (delta: number) => void) => { 31 | const onFixedUpdate = useContext(Context).onFixedUpdate; 32 | 33 | useEffect(() => { 34 | const unsubscribe = onFixedUpdate(callback); 35 | 36 | return () => { 37 | unsubscribe(); 38 | }; 39 | }, [onFixedUpdate, callback]); 40 | }; 41 | 42 | const PhysicsSync: FC<{ 43 | worker: Worker | MessagePort; 44 | noLerping?: boolean; 45 | }> = ({ children, worker, noLerping = false }) => { 46 | const lastUpdateRef = useRef(getNow()); 47 | const countRef = useRef(0); 48 | const callbacksRef = useRef<{ 49 | [key: string]: (delta: number) => void; 50 | }>({}); 51 | const updateMeshes = useUpdateMeshes(); 52 | 53 | const getPhysicsStepTimeRemainingRatio = useCallback( 54 | (previousTime: number) => { 55 | const nextExpectedUpdate = 56 | lastUpdateRef.current + PHYSICS_UPDATE_RATE + 1; 57 | const time = getNow(); 58 | let ratio = (time - previousTime) / (nextExpectedUpdate - previousTime); 59 | ratio = ratio > 1 ? 1 : ratio; 60 | ratio = ratio < 0 ? 0 : ratio; 61 | return ratio; 62 | }, 63 | [lastUpdateRef] 64 | ); 65 | 66 | const onFixedUpdate = useCallback( 67 | (callback: (delta: number) => void) => { 68 | const key = countRef.current; 69 | countRef.current += 1; 70 | 71 | callbacksRef.current[key] = callback; 72 | 73 | const unsubscribe = () => { 74 | delete callbacksRef.current[key]; 75 | }; 76 | 77 | return unsubscribe; 78 | }, 79 | [callbacksRef] 80 | ); 81 | 82 | const onMessage = useWorkerOnMessage(); 83 | const storedData = useStoredData(); 84 | 85 | const debugRefs = useRef<{ 86 | timer: any; 87 | hasReceived: boolean; 88 | }>({ 89 | timer: null, 90 | hasReceived: false, 91 | }); 92 | 93 | useEffect(() => { 94 | debugRefs.current.timer = setTimeout(() => { 95 | console.warn('no initial physics data received...'); 96 | }, 1000); 97 | 98 | const onPhysicsStep = () => { 99 | const lastUpdate = lastUpdateRef.current; 100 | const now = getNow(); 101 | const delta = !lastUpdate ? 1 / 60 : (now - lastUpdate) / 1000; 102 | lastUpdateRef.current = now; 103 | 104 | const callbacks = callbacksRef.current; 105 | 106 | Object.values(callbacks).forEach(callback => { 107 | callback(delta); 108 | }); 109 | }; 110 | 111 | const unsubscribe = onMessage((event: MessageEvent) => { 112 | const type = event.data.type; 113 | 114 | if (type === WorkerOwnerMessageType.PHYSICS_STEP) { 115 | debugRefs.current.hasReceived = true; 116 | if (debugRefs.current.timer) { 117 | clearInterval(debugRefs.current.timer); 118 | } 119 | debugRefs.current.timer = setTimeout(() => { 120 | console.warn('over 1 second since last physics step...'); 121 | }, 1000); 122 | const positions = event.data.positions as Float32Array; 123 | const angles = event.data.angles as Float32Array; 124 | // console.log('update') 125 | updateMeshes(positions, angles, noLerping); 126 | worker.postMessage( 127 | { 128 | type: WorkerMessageType.PHYSICS_STEP_PROCESSED, 129 | positions, 130 | angles, 131 | physicsTick: event.data.physicsTick as number, 132 | }, 133 | [positions.buffer, angles.buffer] 134 | ); 135 | 136 | if (event.data.bodies) { 137 | storedData.bodies = event.data.bodies.reduce( 138 | (acc: { [key: string]: number }, id: string) => ({ 139 | ...acc, 140 | [id]: (event.data as any).bodies.indexOf(id), 141 | }), 142 | {} 143 | ); 144 | } 145 | onPhysicsStep(); 146 | } 147 | }); 148 | 149 | worker.postMessage( 150 | { 151 | type: WorkerMessageType.READY_FOR_PHYSICS, 152 | } 153 | ) 154 | 155 | return () => { 156 | unsubscribe(); 157 | }; 158 | }, [ 159 | onMessage, 160 | callbacksRef, 161 | lastUpdateRef, 162 | worker, 163 | updateMeshes, 164 | noLerping, 165 | storedData, 166 | ]); 167 | 168 | return ( 169 | 175 | {children} 176 | 177 | ); 178 | }; 179 | 180 | export default PhysicsSync; 181 | -------------------------------------------------------------------------------- /src/shared/SendMessages.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useCallback, useContext} from "react" 2 | import {WorkerOwnerMessageType} from "../main/worker/shared/types"; 3 | import {MessageData, MessageKeys} from "./types"; 4 | import {useMessagesContext} from "./Messages"; 5 | 6 | type ContextState = { 7 | sendMessage: (key: string, data: any) => void, 8 | } 9 | 10 | const Context = createContext(null as unknown as ContextState) 11 | 12 | export const useSendMessage = () => { 13 | return useContext(Context).sendMessage 14 | } 15 | 16 | const SendMessages: React.FC<{ 17 | worker: Worker, 18 | }> = ({children, worker}) => { 19 | 20 | const { handleMessage } = useMessagesContext(); 21 | 22 | const sendMessage = useCallback((key: string, data: any) => { 23 | 24 | if (key === MessageKeys.SYNC_COMPONENT) { 25 | throw new Error(`${key} is a reserved message key.`) 26 | } 27 | 28 | const message: MessageData = { 29 | key, 30 | data 31 | } 32 | 33 | worker.postMessage({ 34 | type: WorkerOwnerMessageType.MESSAGE, 35 | message, 36 | }) 37 | 38 | handleMessage(message) 39 | 40 | }, [worker, handleMessage]) 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ) 47 | } 48 | 49 | export default SendMessages -------------------------------------------------------------------------------- /src/shared/StoredPhysicsData.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, FC, useContext, useState } from 'react'; 2 | 3 | type Bodies = { 4 | [uuid: string]: number; 5 | }; 6 | 7 | type ContextState = { 8 | data: { 9 | bodies: Bodies; 10 | }; 11 | }; 12 | 13 | const Context = createContext((null as unknown) as ContextState); 14 | 15 | export const useStoredData = () => { 16 | return useContext(Context).data; 17 | }; 18 | 19 | const StoredPhysicsData: FC = ({ children }) => { 20 | const [data] = useState<{ 21 | bodies: Bodies; 22 | }>({ 23 | bodies: {}, 24 | }); 25 | 26 | return ( 27 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export default StoredPhysicsData; 38 | -------------------------------------------------------------------------------- /src/shared/WorkerOnMessageProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useCallback, 4 | useContext, 5 | useEffect, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | 10 | export type ContextState = { 11 | subscribe: (callback: (event: MessageEvent) => void) => () => void; 12 | }; 13 | 14 | export const Context = createContext((null as unknown) as ContextState); 15 | 16 | export const useWorkerOnMessage = () => { 17 | return useContext(Context).subscribe; 18 | }; 19 | 20 | const WorkerOnMessageProvider: React.FC<{ 21 | subscribe: (callback: (event: MessageEvent) => void) => () => void; 22 | }> = ({ children, subscribe }) => { 23 | return ( 24 | 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | export default WorkerOnMessageProvider; 35 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export enum MessageKeys { 2 | SYNC_COMPONENT = 'SYNC_COMPONENT', 3 | } 4 | 5 | export type MessageData = { 6 | key: string; 7 | data: any; 8 | }; 9 | 10 | export enum SyncComponentMessageType { 11 | MOUNT, 12 | UNMOUNT, 13 | UPDATE, 14 | } 15 | 16 | export enum SyncComponentType { 17 | PLAYER, 18 | } 19 | 20 | export type SyncComponentMessageInfo = { 21 | componentType: SyncComponentType; 22 | componentKey: string; 23 | }; 24 | 25 | export type ValidProps = 26 | | undefined 27 | | { 28 | [key: string]: any; 29 | }; 30 | 31 | export type SyncComponentMessage = { 32 | data: ValidProps; 33 | info: SyncComponentMessageInfo; 34 | messageType: SyncComponentMessageType; 35 | }; 36 | 37 | export type MappedComponents = { 38 | [key: string]: any; 39 | }; 40 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { Buffers } from '../main/worker/shared/types'; 2 | import { Object3D } from 'three'; 3 | 4 | export const getPositionAndAngle = ( 5 | buffers: Buffers, 6 | index: number 7 | ): { 8 | position: [number, number]; 9 | angle: number; 10 | } | null => { 11 | if (index !== undefined && buffers.positions.length) { 12 | const start = index * 2; 13 | const position = (buffers.positions.slice(start, start + 2) as unknown) as [ 14 | number, 15 | number 16 | ]; 17 | return { 18 | position, 19 | angle: buffers.angles[index], 20 | }; 21 | } else { 22 | return null; 23 | } 24 | }; 25 | export const applyPositionAngle = ( 26 | buffers: Buffers, 27 | object: Object3D | null, 28 | index: number, 29 | applyAngle: boolean = false 30 | ) => { 31 | if (index !== undefined && buffers.positions.length && !!object) { 32 | const start = index * 2; 33 | const position = buffers.positions.slice(start, start + 2); 34 | object.position.x = position[0]; 35 | object.position.y = position[1]; 36 | if (applyAngle) { 37 | object.rotation.z = buffers.angles[index]; 38 | } 39 | } else { 40 | // console.warn('no match?') 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from "react"; 2 | 3 | export const useDidMount = () => { 4 | const mountRef = useRef(false); 5 | 6 | useEffect(() => { mountRef.current = true }, []); 7 | 8 | return () => mountRef.current; 9 | } -------------------------------------------------------------------------------- /src/utils/numbers.ts: -------------------------------------------------------------------------------- 1 | import { MathUtils } from 'three'; 2 | 3 | export const lerp = MathUtils.lerp; 4 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export const getNow = () => { 2 | return performance.timing.navigationStart + performance.now() 3 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": false, 21 | "noUnusedParameters": false, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | "allowSyntheticDefaultImports": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | } 36 | } 37 | --------------------------------------------------------------------------------