├── .yarnrc.yml ├── src ├── vite-env.d.ts ├── main.tsx ├── server.ts ├── css │ ├── physics-ui.css │ └── index.css ├── physics │ ├── ui │ │ ├── overrides.ts │ │ └── PhysicsUi.tsx │ ├── utils.ts │ └── PhysicsCollection.tsx ├── App.tsx ├── default_store.ts └── useYjsStore.ts ├── tldraw-collections ├── .yarn │ └── install-state.gz ├── src │ ├── index.ts │ ├── useCollection.ts │ ├── CollectionProvider.tsx │ └── BaseCollection.ts ├── tsconfig.json ├── package.json └── yarn.lock ├── partykit.json ├── tsconfig.node.json ├── vite.config.ts ├── index.html ├── biome.json ├── .gitignore ├── .github └── workflows │ └── deploy.yml ├── tsconfig.json ├── package.json └── README.md /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tldraw-collections/.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OrionReed/tldraw-physics/HEAD/tldraw-collections/.yarn/install-state.gz -------------------------------------------------------------------------------- /tldraw-collections/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BaseCollection'; 2 | export * from './CollectionProvider'; 3 | export * from './useCollection'; -------------------------------------------------------------------------------- /partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas", 3 | "main": "src/server.ts", 4 | "serve": { 5 | "path": "dist" 6 | }, 7 | "compatibilityDate": "2024-01-01" 8 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./css/index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import wasm from "vite-plugin-wasm"; 4 | import topLevelAwait from "vite-plugin-top-level-await"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | wasm(), 10 | topLevelAwait() 11 | ], 12 | }) 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | physics in tldraw 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as Party from "partykit/server"; 2 | import { onConnect } from "y-partykit"; 3 | 4 | export default { 5 | async onConnect(conn: Party.Connection, room: Party.Party) { 6 | return await onConnect(conn, room, { 7 | // experimental: persist the document to partykit's room storage 8 | persist: true, 9 | }); 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tldraw-collections/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "strict": true, 8 | "jsx": "react-jsx", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src" 14 | ], 15 | "exclude": [ 16 | "node_modules", 17 | "dist" 18 | ] 19 | } -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "ignore": [ 8 | "src/hull" 9 | ] 10 | }, 11 | "linter": { 12 | "enabled": true, 13 | "rules": { 14 | "recommended": true, 15 | "complexity": { 16 | "noForEach": "off" 17 | }, 18 | "correctness": { 19 | "useExhaustiveDependencies": "off" 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .vscode 15 | .pnp.* 16 | .yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | 23 | # Editor directories and files 24 | .idea 25 | .DS_Store 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | .vercel 32 | .env 33 | -------------------------------------------------------------------------------- /tldraw-collections/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@orion/tldraw-collections", 3 | "version": "0.1.1", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "build": "tsc", 11 | "watch": "tsc --watch", 12 | "prepublish": "yarn build" 13 | }, 14 | "peerDependencies": { 15 | "@tldraw/tldraw": "2.0.0-beta.2", 16 | "react": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^18.2.15", 20 | "typescript": "^5.0.2" 21 | } 22 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - pages-demo 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-deploy: 13 | concurrency: ci-${{ github.ref }} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 🛎️ 17 | uses: actions/checkout@v3 18 | 19 | - name: Enable Corepack 📦 20 | run: | 21 | corepack enable 22 | corepack prepare yarn@4.0.2 --activate 23 | 24 | 25 | - name: Build 🔧 26 | run: | 27 | yarn install 28 | npm run build 29 | 30 | - name: Deploy 🚀 31 | uses: JamesIves/github-pages-deploy-action@v4 32 | with: 33 | folder: dist -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src", "tldraw-collections/src/BaseCollection.ts", "tldraw-collections/src/CollectionProvider.tsx", "tldraw-collections/src/useCollection.ts"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/css/physics-ui.css: -------------------------------------------------------------------------------- 1 | .custom-layout { 2 | position: absolute; 3 | inset: 0px; 4 | z-index: 300; 5 | pointer-events: none; 6 | } 7 | 8 | .custom-toolbar { 9 | position: absolute; 10 | top: 0px; 11 | left: 0px; 12 | width: 100%; 13 | padding: 8px; 14 | & > * { 15 | padding: 2px; 16 | font-family: monospace; 17 | color: #0000008a; 18 | gap: 8px; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | pointer-events: all; 23 | } 24 | } 25 | 26 | .custom-button { 27 | pointer-events: all; 28 | padding: 4px 12px; 29 | background: white; 30 | border: 1px solid rgba(0, 0, 0, 0.3); 31 | border-radius: 64px; 32 | &:hover { 33 | background-color: rgb(240, 240, 240); 34 | } 35 | } 36 | 37 | .custom-button[data-isactive="true"] { 38 | background-color: black; 39 | color: white; 40 | } 41 | -------------------------------------------------------------------------------- /src/physics/ui/overrides.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TLUiEventSource, 3 | TLUiMenuGroup, 4 | TLUiOverrides, 5 | TLUiTranslationKey, 6 | menuItem, 7 | } from "@tldraw/tldraw"; 8 | 9 | // In order to see select our custom shape tool, we need to add it to the ui. 10 | export const uiOverrides: TLUiOverrides = { 11 | actions(_editor, actions) { 12 | actions['toggle-physics'] = { 13 | id: 'toggle-physics', 14 | label: 'Toggle Physics' as TLUiTranslationKey, 15 | readonlyOk: true, 16 | kbd: 'p', 17 | onSelect(_source: TLUiEventSource) { 18 | const event = new CustomEvent('togglePhysicsEvent'); 19 | window.dispatchEvent(event); 20 | }, 21 | } 22 | return actions 23 | }, 24 | keyboardShortcutsMenu(_editor, shortcutsMenu, { actions }) { 25 | const editGroup = shortcutsMenu.find( 26 | (group) => group.id === 'shortcuts-dialog.tools' 27 | ) as TLUiMenuGroup 28 | 29 | editGroup.children.push(menuItem(actions['toggle-physics'])) 30 | return shortcutsMenu 31 | }, 32 | } -------------------------------------------------------------------------------- /tldraw-collections/src/useCollection.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { CollectionContext } from "./CollectionProvider"; 3 | import { BaseCollection } from "./BaseCollection"; 4 | 5 | export const useCollection = (collectionId: string): { collection: T; size: number } => { 6 | const context = useContext(CollectionContext); 7 | if (!context) { 8 | throw new Error("CollectionContext not found."); 9 | } 10 | 11 | const collection = context.get(collectionId); 12 | if (!collection) { 13 | throw new Error(`Collection with id '${collectionId}' not found`); 14 | } 15 | 16 | const [size, setSize] = useState(collection.size); 17 | 18 | useEffect(() => { 19 | // Subscribe to collection changes 20 | const unsubscribe = collection.subscribe(() => { 21 | setSize(collection.size); 22 | }); 23 | 24 | // Set initial size 25 | setSize(collection.size); 26 | 27 | return unsubscribe; // Cleanup on unmount 28 | }, [collection]); 29 | 30 | return { collection: collection as T, size }; 31 | }; 32 | 33 | -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap"); 2 | 3 | html, 4 | body { 5 | padding: 0; 6 | margin: 0; 7 | font-family: "Inter", sans-serif; 8 | overscroll-behavior: none; 9 | touch-action: none; 10 | min-height: 100vh; 11 | font-size: 16px; 12 | /* mobile viewport bug fix */ 13 | min-height: -webkit-fill-available; 14 | height: 100%; 15 | } 16 | 17 | html, 18 | * { 19 | box-sizing: border-box; 20 | } 21 | 22 | .tldraw__editor { 23 | position: fixed; 24 | inset: 0px; 25 | overflow: hidden; 26 | } 27 | 28 | .examples { 29 | padding: 16px; 30 | } 31 | 32 | .examples__header { 33 | width: fit-content; 34 | padding-bottom: 32px; 35 | } 36 | 37 | .examples__lockup { 38 | height: 56px; 39 | width: auto; 40 | } 41 | 42 | .examples__list { 43 | display: flex; 44 | flex-direction: column; 45 | padding: 0; 46 | margin: 0; 47 | list-style: none; 48 | } 49 | 50 | .examples__list__item { 51 | padding: 8px 12px; 52 | margin: 0px -12px; 53 | } 54 | 55 | .examples__list__item a { 56 | padding: 8px 12px; 57 | margin: 0px -12px; 58 | text-decoration: none; 59 | color: inherit; 60 | } 61 | 62 | .examples__list__item a:hover { 63 | text-decoration: underline; 64 | } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tldraw-physics", 3 | "private": true, 4 | "version": "0.0.1", 5 | "homepage": "https://OrionReed.github.io/tldraw-physics", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "concurrently \"vite\" \"HOST=localhost PORT=1234 npx y-websocket\" --kill-others", 9 | "dev:win": "concurrently \"vite\" \"set HOST=localhost&& set PORT=1234 && npx y-websocket\" --kill-others", 10 | "build": "tsc && vite build --base=./", 11 | "preview": "vite preview", 12 | "lint": "yarn dlx @biomejs/biome check --apply src", 13 | "deploy": "yarn build && npx partykit deploy" 14 | }, 15 | "dependencies": { 16 | "@dimforge/rapier2d": "^0.11.2", 17 | "@tldraw/tldraw": "2.0.0-beta.2", 18 | "partykit": "^0.0.27", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "y-partykit": "^0.0.7", 22 | "y-utility": "^0.1.3", 23 | "y-websocket": "^1.5.0", 24 | "yjs": "^13.6.8" 25 | }, 26 | "devDependencies": { 27 | "@biomejs/biome": "1.4.1", 28 | "@types/gh-pages": "^6", 29 | "@types/react": "^18.2.15", 30 | "@types/react-dom": "^18.2.7", 31 | "@vitejs/plugin-react": "^4.0.3", 32 | "concurrently": "^8.2.0", 33 | "gh-pages": "^6.1.1", 34 | "typescript": "^5.0.2", 35 | "vite": "^4.4.5", 36 | "vite-plugin-top-level-await": "^1.3.1", 37 | "vite-plugin-wasm": "^3.2.2" 38 | }, 39 | "packageManager": "yarn@4.0.2" 40 | } 41 | -------------------------------------------------------------------------------- /tldraw-collections/yarn.lock: -------------------------------------------------------------------------------- 1 | # This file is generated by running "yarn install" inside your project. 2 | # Manual changes might be lost - proceed with caution! 3 | 4 | __metadata: 5 | version: 8 6 | cacheKey: 10c0 7 | 8 | "@orion/tldraw-collections@workspace:.": 9 | version: 0.0.0-use.local 10 | resolution: "@orion/tldraw-collections@workspace:." 11 | dependencies: 12 | "@types/react": "npm:^18.2.15" 13 | typescript: "npm:^5.0.2" 14 | peerDependencies: 15 | "@tldraw/tldraw": 2.0.0-beta.2 16 | react: ^18.2.0 17 | languageName: unknown 18 | linkType: soft 19 | 20 | "@types/prop-types@npm:*": 21 | version: 15.7.12 22 | resolution: "@types/prop-types@npm:15.7.12" 23 | checksum: 1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8 24 | languageName: node 25 | linkType: hard 26 | 27 | "@types/react@npm:^18.2.15": 28 | version: 18.2.77 29 | resolution: "@types/react@npm:18.2.77" 30 | dependencies: 31 | "@types/prop-types": "npm:*" 32 | csstype: "npm:^3.0.2" 33 | checksum: 9114149933dbee3fdf5900786e660afe3146e8e277424aaf94dca50e29f1de56802b38a83bb65166ff53da086062bb8d001af563a6c906fe3540bdc46554d05a 34 | languageName: node 35 | linkType: hard 36 | 37 | "csstype@npm:^3.0.2": 38 | version: 3.1.3 39 | resolution: "csstype@npm:3.1.3" 40 | checksum: 80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248 41 | languageName: node 42 | linkType: hard 43 | 44 | "typescript@npm:^5.0.2": 45 | version: 5.4.5 46 | resolution: "typescript@npm:5.4.5" 47 | bin: 48 | tsc: bin/tsc 49 | tsserver: bin/tsserver 50 | checksum: 2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e 51 | languageName: node 52 | linkType: hard 53 | 54 | "typescript@patch:typescript@npm%3A^5.0.2#optional!builtin": 55 | version: 5.4.5 56 | resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=e012d7" 57 | bin: 58 | tsc: bin/tsc 59 | tsserver: bin/tsserver 60 | checksum: 9cf4c053893bcf327d101b6c024a55baf05430dc30263f9adb1bf354aeffc11306fe1f23ba2f9a0209674359f16219b5b7d229e923477b94831d07d5a33a4217 61 | languageName: node 62 | linkType: hard 63 | -------------------------------------------------------------------------------- /src/physics/ui/PhysicsUi.tsx: -------------------------------------------------------------------------------- 1 | import { track, useEditor } from "@tldraw/tldraw"; 2 | import { useEffect } from "react"; 3 | import "../../css/physics-ui.css"; 4 | import { useCollection } from "../../../tldraw-collections/src"; 5 | 6 | export const PhysicsUi = track(() => { 7 | const editor = useEditor(); 8 | const { collection, size } = useCollection('physics') 9 | 10 | const handleAdd = () => { 11 | if (collection) { 12 | collection.add(editor.getSelectedShapes()) 13 | editor.selectNone() 14 | } 15 | } 16 | 17 | const handleRemove = () => { 18 | if (collection) { 19 | collection.remove(editor.getSelectedShapes()) 20 | editor.selectNone() 21 | } 22 | } 23 | 24 | const handleShortcut = () => { 25 | if (!collection) return 26 | if (size === 0) 27 | collection.add(editor.getCurrentPageShapes()) 28 | else 29 | collection.clear() 30 | }; 31 | 32 | const handleHelp = () => { 33 | alert("Use the 'Add' and 'Remove' buttons to add/remove selected shapes, or hit 'P' to add/remove all shapes. \n\nUse the highlight button (🔦) to visualize shapes in the simulation. \n\nShapes' physical properties vary by color (Orange is bouncy, Blue is slippery, Violet is a keyboard-controlled character, etc). \n\nYou can group shapes for compound rigidbodies. \n\nFor more details, check the project's README."); 34 | } 35 | 36 | const handleHighlight = () => { 37 | if (collection) { 38 | editor.setHintingShapes([...collection.getShapes().values()]) 39 | } 40 | } 41 | 42 | useEffect(() => { 43 | window.addEventListener('togglePhysicsEvent', handleShortcut); 44 | return () => { 45 | window.removeEventListener('togglePhysicsEvent', handleShortcut); 46 | }; 47 | }, [handleShortcut]); 48 | 49 | return ( 50 |
51 |
52 |
53 | 54 | 62 | 70 | 78 | 86 |
87 | {size} shapes 88 |
89 |
90 | ); 91 | }); 92 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Editor, Tldraw, track, useEditor } from "@tldraw/tldraw"; 2 | import "@tldraw/tldraw/tldraw.css"; 3 | import { PhysicsUi } from "./physics/ui/PhysicsUi"; 4 | import { useYjsStore } from "./useYjsStore"; 5 | import { uiOverrides } from "./physics/ui/overrides"; 6 | import { CollectionProvider } from "../tldraw-collections/src"; 7 | import { PhysicsCollection } from "./physics/PhysicsCollection"; 8 | import { useState } from "react"; 9 | 10 | const collections = [PhysicsCollection] 11 | 12 | const store = () => { 13 | const hostUrl = import.meta.env.DEV 14 | ? "ws://localhost:1234" 15 | : import.meta.env.VITE_PRODUCTION_URL.replace("https://", "ws://"); // remove protocol just in case 16 | const roomId = 17 | new URLSearchParams(window.location.search).get("room") || "42"; 18 | return useYjsStore({ 19 | roomId: roomId, 20 | hostUrl: hostUrl, 21 | }); 22 | } 23 | 24 | export default function Canvas() { 25 | const [editor, setEditor] = useState(null) 26 | 27 | return ( 28 |
29 | } 33 | overrides={uiOverrides} 34 | onMount={setEditor} 35 | persistenceKey="tldraw-physics" 36 | > 37 | {editor && ( 38 | 39 | 40 | 41 | )} 42 | 43 |
44 | ); 45 | } 46 | 47 | const NameEditor = track(() => { 48 | const editor = useEditor(); 49 | 50 | const { color, name } = editor.user.getUserPreferences(); 51 | 52 | return ( 53 |
63 | { 73 | editor.user.updateUserPreferences({ 74 | color: e.currentTarget.value, 75 | }); 76 | }} 77 | /> 78 | { 88 | editor.user.updateUserPreferences({ 89 | name: e.currentTarget.value, 90 | }); 91 | }} 92 | /> 93 |
94 | ); 95 | }); 96 | -------------------------------------------------------------------------------- /tldraw-collections/src/CollectionProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useEffect, useMemo, useState } from 'react'; 2 | import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw'; 3 | import { BaseCollection } from './BaseCollection'; 4 | 5 | interface CollectionContextValue { 6 | get: (id: string) => BaseCollection | undefined; 7 | } 8 | 9 | type Collection = (new (editor: Editor) => BaseCollection) 10 | 11 | interface CollectionProviderProps { 12 | editor: Editor; 13 | collections: Collection[]; 14 | children: React.ReactNode; 15 | } 16 | 17 | const CollectionContext = createContext(undefined); 18 | 19 | const CollectionProvider: React.FC = ({ editor, collections: collectionClasses, children }) => { 20 | const [collections, setCollections] = useState | null>(null); 21 | 22 | // Handle shape property changes 23 | const handleShapeChange = (prev: TLShape, next: TLShape) => { 24 | if (!collections) return; // Ensure collections is not null 25 | for (const collection of collections.values()) { 26 | if (collection.getShapes().has(next.id)) { 27 | collection._onShapeChange(prev, next); 28 | } 29 | } 30 | }; 31 | 32 | // Handle shape deletions 33 | const handleShapeDelete = (shape: TLShape) => { 34 | if (!collections) return; // Ensure collections is not null 35 | for (const collection of collections.values()) { 36 | collection.remove([shape]); 37 | } 38 | }; 39 | 40 | useEffect(() => { 41 | if (editor) { 42 | const initializedCollections = new Map(); 43 | for (const ColClass of collectionClasses) { 44 | const instance = new ColClass(editor); 45 | initializedCollections.set(instance.id, instance); 46 | } 47 | setCollections(initializedCollections); 48 | } 49 | }, [editor, collectionClasses]); 50 | 51 | // Subscribe to shape changes in the editor 52 | useEffect(() => { 53 | 54 | if (editor && collections) { 55 | editor.store.onAfterChange = (prev: TLRecord, next: TLRecord) => { 56 | if (next.typeName !== 'shape') return; 57 | const prevShape = prev as TLShape; 58 | const nextShape = next as TLShape; 59 | handleShapeChange(prevShape, nextShape); 60 | }; 61 | } 62 | }, [editor, collections]); 63 | 64 | // Subscribe to shape deletions in the editor 65 | useEffect(() => { 66 | if (editor && collections) { 67 | editor.store.onAfterDelete = (prev: TLRecord, _: string) => { 68 | if (prev.typeName === 'shape') 69 | handleShapeDelete(prev); 70 | }; 71 | } 72 | }, [editor, collections]); 73 | 74 | 75 | 76 | const value = useMemo(() => ({ 77 | get: (id: string) => collections?.get(id), 78 | }), [collections]); 79 | 80 | return ( 81 | 82 | {collections ? children : null} 83 | 84 | ); 85 | }; 86 | 87 | export { CollectionContext, CollectionProvider, type Collection }; -------------------------------------------------------------------------------- /src/default_store.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_STORE = { 2 | store: { 3 | "document:document": { 4 | gridSize: 10, 5 | name: "", 6 | meta: {}, 7 | id: "document:document", 8 | typeName: "document", 9 | }, 10 | "pointer:pointer": { 11 | id: "pointer:pointer", 12 | typeName: "pointer", 13 | x: 0, 14 | y: 0, 15 | lastActivityTimestamp: 0, 16 | meta: {}, 17 | }, 18 | "page:page": { 19 | meta: {}, 20 | id: "page:page", 21 | name: "Page 1", 22 | index: "a1", 23 | typeName: "page", 24 | }, 25 | "camera:page:page": { 26 | x: 0, 27 | y: 0, 28 | z: 1, 29 | meta: {}, 30 | id: "camera:page:page", 31 | typeName: "camera", 32 | }, 33 | "instance_page_state:page:page": { 34 | editingShapeId: null, 35 | croppingShapeId: null, 36 | selectedShapeIds: [], 37 | hoveredShapeId: null, 38 | erasingShapeIds: [], 39 | hintingShapeIds: [], 40 | focusedGroupId: null, 41 | meta: {}, 42 | id: "instance_page_state:page:page", 43 | pageId: "page:page", 44 | typeName: "instance_page_state", 45 | }, 46 | "instance:instance": { 47 | followingUserId: null, 48 | opacityForNextShape: 1, 49 | stylesForNextShape: {}, 50 | brush: null, 51 | scribble: null, 52 | cursor: { 53 | type: "default", 54 | rotation: 0, 55 | }, 56 | isFocusMode: false, 57 | exportBackground: true, 58 | isDebugMode: false, 59 | isToolLocked: false, 60 | screenBounds: { 61 | x: 0, 62 | y: 0, 63 | w: 720, 64 | h: 400, 65 | }, 66 | zoomBrush: null, 67 | isGridMode: false, 68 | isPenMode: false, 69 | chatMessage: "", 70 | isChatting: false, 71 | highlightedUserIds: [], 72 | canMoveCamera: true, 73 | isFocused: true, 74 | devicePixelRatio: 2, 75 | isCoarsePointer: false, 76 | isHoveringCanvas: false, 77 | openMenus: [], 78 | isChangingStyle: false, 79 | isReadonly: false, 80 | meta: {}, 81 | id: "instance:instance", 82 | currentPageId: "page:page", 83 | typeName: "instance", 84 | }, 85 | }, 86 | schema: { 87 | schemaVersion: 1, 88 | storeVersion: 4, 89 | recordVersions: { 90 | asset: { 91 | version: 1, 92 | subTypeKey: "type", 93 | subTypeVersions: { 94 | image: 2, 95 | video: 2, 96 | bookmark: 0, 97 | }, 98 | }, 99 | camera: { 100 | version: 1, 101 | }, 102 | document: { 103 | version: 2, 104 | }, 105 | instance: { 106 | version: 21, 107 | }, 108 | instance_page_state: { 109 | version: 5, 110 | }, 111 | page: { 112 | version: 1, 113 | }, 114 | shape: { 115 | version: 3, 116 | subTypeKey: "type", 117 | subTypeVersions: { 118 | group: 0, 119 | text: 1, 120 | bookmark: 1, 121 | draw: 1, 122 | geo: 7, 123 | note: 4, 124 | line: 1, 125 | frame: 0, 126 | arrow: 1, 127 | highlight: 0, 128 | embed: 4, 129 | image: 2, 130 | video: 1, 131 | }, 132 | }, 133 | instance_presence: { 134 | version: 5, 135 | }, 136 | pointer: { 137 | version: 1, 138 | }, 139 | }, 140 | }, 141 | }; 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tldraw physics 2 | This repo demonstrates a simple physics integration with [tldraw](https://github.com/tldraw/tldraw). It uses [Rapier](https://rapier.rs), a rust-based physics engine compiled to [WASM](https://webassembly.org). It also uses a simple PoC 'Collections' system for TLDraw which can be found [here](https://github.com/OrionReed/tldraw-physics/tree/main/tldraw-collections). Try it out [in your browser](https://orionreed.github.io/tldraw-physics/). 3 | 4 | 5 | https://github.com/OrionReed/tldraw-physics/assets/16704290/0967881e-1faa-46fb-8204-7b99a5a3556b 6 | 7 | 8 | ## Usage 9 | - Hitting 'P' adds/removes all shapes from the physics simulation. 10 | - Select some shapes and hit the 'Add' or 'Remove' buttons to add/remove only those shapes 11 | - Hit the '🔦' button to highlight the shapes in the physics simulation (this uses tldraw's hinting, it's quite subtle) 12 | - The number of shapes currently in the sim is shown at the top 13 | - When a shape is selected it is made kinematic, i.e. it will no longer move from forces and will only move if you move it. 14 | 15 | #### Rules 16 | All shapes can be colliders, but only Geo and Draw shapes can be rigidbodies (this is an arbitrary choice). Physical properties are determined by the shape's color: 17 | - Black: Static Collider 18 | - Grey: Zero gravity 19 | - Blue: Zero friction (i.e. ice) 20 | - Orange: High restitution coefficient (i.e. bouncy rubber) 21 | - Violet: character controller (move with arrow keys) - 22 | - All other colors: Normal rigidbody with gravity 23 | 24 | #### Groups 25 | You can group shapes to create compound rigidbodies. Physical properties are still per-shape, so you can create shapes which are part ice and part rubber, for example. The center of mass is calculated based on the shapes' positions and areas and assumes all shapes are the same density. 26 | 27 | #### Notes and Gotchas 28 | - All geo shapes are treated as [convex hulls](https://en.wikipedia.org/wiki/Convex_hull) 29 | - Draw shapes use a very crude compound collider approach, where each vertex is turned into a sphere 30 | - There is no edge to the world, so rigidbodies will fall forever 31 | 32 | ## Development 33 | ```bash 34 | yarn install 35 | yarn dev 36 | ``` 37 | Then go to `http://localhost:5173` in your browser. 38 | 39 | Multiplayer is supported* using yjs and partykit. To deploy: 40 | ```bash 41 | yarn deploy 42 | ``` 43 | 44 | *Note that this is a _terrible_ way to do multiplayer and there is no handling for multiple clients with overlapping physics sims. It's essentially the same as a single client manually moving many shapes each frame, but it sure is fun! I have "disabled" multiplayer by default, you can uncomment line 25 of App.tsx (`// store={store()}`) to mess around. PRs for multiplayer fixes are **very** welcome! 45 | 46 | # Contributing 47 | Please open an issue or PR if you have any suggestions or improvements! Especially looking for: 48 | - Compound collider creation for DrawShapes (using a series of rectangles between points instead of spheres at points) 49 | - Performance improvements (and identifying performance bottlenenecks) 50 | - Bugfixes 51 | 52 | ## Known Issues (fixes welcome!) 53 | - Simulation speed is not always consistent as it's tied to refresh rate 54 | - Multiplayer hangs on connecting sometimes, for some browsers (Safari/Firefox) -------------------------------------------------------------------------------- /tldraw-collections/src/BaseCollection.ts: -------------------------------------------------------------------------------- 1 | import { Editor, TLShape, TLShapeId } from '@tldraw/tldraw'; 2 | 3 | /** 4 | * A PoC abstract collections class for @tldraw. 5 | */ 6 | export abstract class BaseCollection { 7 | /** A unique identifier for the collection. */ 8 | abstract id: string; 9 | /** A map containing the shapes that belong to this collection, keyed by their IDs. */ 10 | protected shapes: Map = new Map(); 11 | /** A reference to the \@tldraw Editor instance. */ 12 | protected editor: Editor; 13 | /** A set of listeners to be notified when the collection changes. */ 14 | private listeners = new Set<() => void>(); 15 | 16 | // TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it 17 | public constructor(editor: Editor) { 18 | this.editor = editor; 19 | } 20 | 21 | /** 22 | * Called when shapes are added to the collection. 23 | * @param shapes The shapes being added to the collection. 24 | */ 25 | protected onAdd(_shapes: TLShape[]): void { } 26 | 27 | /** 28 | * Called when shapes are removed from the collection. 29 | * @param shapes The shapes being removed from the collection. 30 | */ 31 | protected onRemove(_shapes: TLShape[]) { } 32 | 33 | /** 34 | * Called when the membership of the collection changes (i.e., when shapes are added or removed). 35 | */ 36 | protected onMembershipChange() { } 37 | 38 | 39 | /** 40 | * Called when the properties of a shape belonging to the collection change. 41 | * @param prev The previous version of the shape before the change. 42 | * @param next The updated version of the shape after the change. 43 | */ 44 | protected onShapeChange(_prev: TLShape, _next: TLShape) { } 45 | 46 | /** 47 | * Adds the specified shapes to the collection. 48 | * @param shapes The shapes to add to the collection. 49 | */ 50 | public add(shapes: TLShape[]) { 51 | shapes.forEach(shape => { 52 | this.shapes.set(shape.id, shape) 53 | }); 54 | this.onAdd(shapes); 55 | this.onMembershipChange(); 56 | this.notifyListeners(); 57 | } 58 | 59 | /** 60 | * Removes the specified shapes from the collection. 61 | * @param shapes The shapes to remove from the collection. 62 | */ 63 | public remove(shapes: TLShape[]) { 64 | shapes.forEach(shape => { 65 | this.shapes.delete(shape.id); 66 | }); 67 | this.onRemove(shapes); 68 | this.onMembershipChange(); 69 | this.notifyListeners(); 70 | } 71 | 72 | /** 73 | * Clears all shapes from the collection. 74 | */ 75 | public clear() { 76 | this.remove([...this.shapes.values()]) 77 | } 78 | 79 | /** 80 | * Returns the map of shapes in the collection. 81 | * @returns The map of shapes in the collection, keyed by their IDs. 82 | */ 83 | public getShapes(): Map { 84 | return this.shapes; 85 | } 86 | 87 | public get size(): number { 88 | return this.shapes.size; 89 | } 90 | 91 | public _onShapeChange(prev: TLShape, next: TLShape) { 92 | this.shapes.set(next.id, next) 93 | this.onShapeChange(prev, next) 94 | this.notifyListeners(); 95 | } 96 | 97 | private notifyListeners() { 98 | for (const listener of this.listeners) { 99 | listener(); 100 | } 101 | } 102 | 103 | public subscribe(listener: () => void): () => void { 104 | this.listeners.add(listener); 105 | return () => this.listeners.delete(listener); 106 | } 107 | } -------------------------------------------------------------------------------- /src/physics/utils.ts: -------------------------------------------------------------------------------- 1 | import { TLGeoShape, TLGroupShape, TLShape, Vec, VecLike } from "@tldraw/tldraw"; 2 | 3 | export const GRAVITY = { x: 0.0, y: 98 }; 4 | export const DEFAULT_RESTITUTION = 0; 5 | export const DEFAULT_FRICTION = 0.5; 6 | 7 | export function isRigidbody(color: string) { 8 | return !color || color === "black" ? false : true; 9 | } 10 | export function getGravityFromColor(color: string) { 11 | return color === 'grey' ? 0 : 1 12 | } 13 | export function getRestitutionFromColor(color: string) { 14 | return color === "orange" ? 0.9 : 0; 15 | } 16 | export function getFrictionFromColor(color: string) { 17 | return color === "blue" ? 0.1 : 0.8; 18 | } 19 | export const MATERIAL = { 20 | defaultRestitution: 0, 21 | defaultFriction: 0.1, 22 | }; 23 | export const CHARACTER = { 24 | up: { x: 0.0, y: -1.0 }, 25 | additionalMass: 20, 26 | maxSlopeClimbAngle: 1, 27 | slideEnabled: true, 28 | minSlopeSlideAngle: 0.9, 29 | applyImpulsesToDynamicBodies: true, 30 | autostepHeight: 5, 31 | autostepMaxClimbAngle: 1, 32 | snapToGroundDistance: 3, 33 | maxMoveSpeedX: 100, 34 | moveAcceleration: 600, 35 | moveDeceleration: 500, 36 | jumpVelocity: 300, 37 | gravityMultiplier: 10, 38 | }; 39 | 40 | 41 | type ShapeTransform = { 42 | x: number; 43 | y: number; 44 | width: number; 45 | height: number; 46 | rotation: number; 47 | parentGroupShape?: TLShape | undefined 48 | } 49 | 50 | // Define rotatePoint as a standalone function 51 | const rotatePoint = (cx: number, cy: number, x: number, y: number, angle: number) => { 52 | const cos = Math.cos(angle); 53 | const sin = Math.sin(angle); 54 | return { 55 | x: cos * (x - cx) - sin * (y - cy) + cx, 56 | y: sin * (x - cx) + cos * (y - cy) + cy, 57 | }; 58 | } 59 | 60 | export const cornerToCenter = ({ 61 | x, 62 | y, 63 | width, 64 | height, 65 | rotation, 66 | parentGroupShape 67 | }: ShapeTransform): { x: number; y: number } => { 68 | const centerX = x + width / 2; 69 | const centerY = y + height / 2; 70 | if (parentGroupShape) { 71 | return rotatePoint(parentGroupShape.x, parentGroupShape.y, centerX, centerY, rotation); 72 | } 73 | return rotatePoint(x, y, centerX, centerY, rotation); 74 | 75 | } 76 | 77 | export const centerToCorner = ({ 78 | x, 79 | y, 80 | width, 81 | height, 82 | rotation, 83 | }: ShapeTransform): { x: number; y: number } => { 84 | 85 | const cornerX = x - width / 2; 86 | const cornerY = y - height / 2; 87 | 88 | return rotatePoint(x, y, cornerX, cornerY, rotation); 89 | } 90 | 91 | export const getDisplacement = ( 92 | velocity: VecLike, 93 | acceleration: VecLike, 94 | timeStep: number, 95 | speedLimitX: number, 96 | decelerationX: number, 97 | ): VecLike => { 98 | let newVelocityX = 99 | acceleration.x === 0 && velocity.x !== 0 100 | ? Math.max(Math.abs(velocity.x) - decelerationX * timeStep, 0) * 101 | Math.sign(velocity.x) 102 | : velocity.x + acceleration.x * timeStep; 103 | 104 | newVelocityX = 105 | Math.min(Math.abs(newVelocityX), speedLimitX) * Math.sign(newVelocityX); 106 | 107 | const averageVelocityX = (velocity.x + newVelocityX) / 2; 108 | const x = averageVelocityX * timeStep; 109 | const y = 110 | velocity.y * timeStep + 0.5 * acceleration.y * timeStep ** 2; 111 | 112 | return { x, y } 113 | } 114 | 115 | export const convertVerticesToFloat32Array = ( 116 | vertices: Vec[], 117 | width: number, 118 | height: number, 119 | ) => { 120 | const vec2Array = new Float32Array(vertices.length * 2); 121 | const hX = width / 2; 122 | const hY = height / 2; 123 | 124 | for (let i = 0; i < vertices.length; i++) { 125 | vec2Array[i * 2] = vertices[i].x - hX; 126 | vec2Array[i * 2 + 1] = vertices[i].y - hY; 127 | } 128 | 129 | return vec2Array; 130 | } 131 | 132 | export const shouldConvexify = (shape: TLShape): boolean => { 133 | return !( 134 | shape.type === "geo" && (shape as TLGeoShape).props.geo === "rectangle" 135 | ); 136 | } -------------------------------------------------------------------------------- /src/useYjsStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InstancePresenceRecordType, 3 | TLAnyShapeUtilConstructor, 4 | TLInstancePresence, 5 | TLRecord, 6 | TLStoreWithStatus, 7 | computed, 8 | createPresenceStateDerivation, 9 | createTLStore, 10 | defaultShapeUtils, 11 | defaultUserPreferences, 12 | getUserPreferences, 13 | react, 14 | transact, 15 | } from "@tldraw/tldraw"; 16 | import { useEffect, useMemo, useState } from "react"; 17 | import YPartyKitProvider from "y-partykit/provider"; 18 | import { YKeyValue } from "y-utility/y-keyvalue"; 19 | import * as Y from "yjs"; 20 | import { DEFAULT_STORE } from "./default_store"; 21 | 22 | export function useYjsStore({ 23 | hostUrl, 24 | version = 1, 25 | roomId = "example", 26 | shapeUtils = [], 27 | }: { 28 | hostUrl: string; 29 | version?: number; 30 | roomId?: string; 31 | shapeUtils?: TLAnyShapeUtilConstructor[]; 32 | }) { 33 | const [store] = useState(() => { 34 | const store = createTLStore({ 35 | shapeUtils: [...defaultShapeUtils, ...shapeUtils], 36 | }); 37 | store.loadSnapshot(DEFAULT_STORE); 38 | return store; 39 | }); 40 | 41 | const [storeWithStatus, setStoreWithStatus] = useState({ 42 | status: "loading", 43 | }); 44 | 45 | const { yDoc, yStore, room } = useMemo(() => { 46 | const yDoc = new Y.Doc({ gc: true }); 47 | const yArr = yDoc.getArray<{ key: string; val: TLRecord }>(`tl_${roomId}`); 48 | const yStore = new YKeyValue(yArr); 49 | 50 | return { 51 | yDoc, 52 | yStore, 53 | room: new YPartyKitProvider(hostUrl, `${roomId}_${version}`, yDoc, { 54 | connect: true, 55 | }), 56 | }; 57 | }, [hostUrl, roomId, version]); 58 | 59 | useEffect(() => { 60 | setStoreWithStatus({ status: "loading" }); 61 | 62 | const unsubs: (() => void)[] = []; 63 | 64 | function handleSync() { 65 | // 1. 66 | // Connect store to yjs store and vis versa, for both the document and awareness 67 | 68 | /* -------------------- Document -------------------- */ 69 | 70 | // Sync store changes to the yjs doc 71 | unsubs.push( 72 | store.listen( 73 | function syncStoreChangesToYjsDoc({ changes }) { 74 | yDoc.transact(() => { 75 | Object.values(changes.added).forEach((record) => { 76 | yStore.set(record.id, record); 77 | }); 78 | 79 | Object.values(changes.updated).forEach(([_, record]) => { 80 | yStore.set(record.id, record); 81 | }); 82 | 83 | Object.values(changes.removed).forEach((record) => { 84 | yStore.delete(record.id); 85 | }); 86 | }); 87 | }, 88 | { source: "user", scope: "document" }, // only sync user's document changes 89 | ), 90 | ); 91 | 92 | // Sync the yjs doc changes to the store 93 | const handleChange = ( 94 | changes: Map< 95 | string, 96 | | { action: "delete"; oldValue: TLRecord } 97 | | { action: "update"; oldValue: TLRecord; newValue: TLRecord } 98 | | { action: "add"; newValue: TLRecord } 99 | >, 100 | transaction: Y.Transaction, 101 | ) => { 102 | if (transaction.local) return; 103 | 104 | const toRemove: TLRecord["id"][] = []; 105 | const toPut: TLRecord[] = []; 106 | 107 | changes.forEach((change, id) => { 108 | switch (change.action) { 109 | case "add": 110 | case "update": { 111 | const record = yStore.get(id); 112 | if (record) { 113 | toPut.push(record); 114 | } 115 | break; 116 | } 117 | case "delete": { 118 | toRemove.push(id as TLRecord["id"]); 119 | break; 120 | } 121 | } 122 | }); 123 | 124 | // put / remove the records in the store 125 | store.mergeRemoteChanges(() => { 126 | if (toRemove.length) store.remove(toRemove); 127 | if (toPut.length) store.put(toPut); 128 | }); 129 | }; 130 | 131 | yStore.on("change", handleChange); 132 | unsubs.push(() => yStore.off("change", handleChange)); 133 | 134 | /* -------------------- Awareness ------------------- */ 135 | 136 | const userPreferences = computed<{ 137 | id: string; 138 | color: string; 139 | name: string; 140 | }>("userPreferences", () => { 141 | const user = getUserPreferences(); 142 | return { 143 | id: user.id, 144 | color: user.color ?? defaultUserPreferences.color, 145 | name: user.name ?? defaultUserPreferences.name, 146 | }; 147 | }); 148 | 149 | // Create the instance presence derivation 150 | const yClientId = room.awareness.clientID.toString(); 151 | const presenceId = InstancePresenceRecordType.createId(yClientId); 152 | const presenceDerivation = 153 | createPresenceStateDerivation(userPreferences)(store); 154 | 155 | // Set our initial presence from the derivation's current value 156 | room.awareness.setLocalStateField("presence", presenceDerivation.get()); 157 | 158 | // When the derivation change, sync presence to to yjs awareness 159 | unsubs.push( 160 | react("when presence changes", () => { 161 | const presence = presenceDerivation.get(); 162 | requestAnimationFrame(() => { 163 | room.awareness.setLocalStateField("presence", presence); 164 | }); 165 | }), 166 | ); 167 | 168 | // Sync yjs awareness changes to the store 169 | const handleUpdate = (update: { 170 | added: number[]; 171 | updated: number[]; 172 | removed: number[]; 173 | }) => { 174 | const states = room.awareness.getStates() as Map< 175 | number, 176 | { presence: TLInstancePresence } 177 | >; 178 | 179 | const toRemove: TLInstancePresence["id"][] = []; 180 | const toPut: TLInstancePresence[] = []; 181 | 182 | // Connect records to put / remove 183 | for (const clientId of update.added) { 184 | const state = states.get(clientId); 185 | if (state?.presence && state.presence.id !== presenceId) { 186 | toPut.push(state.presence); 187 | } 188 | } 189 | 190 | for (const clientId of update.updated) { 191 | const state = states.get(clientId); 192 | if (state?.presence && state.presence.id !== presenceId) { 193 | toPut.push(state.presence); 194 | } 195 | } 196 | 197 | for (const clientId of update.removed) { 198 | toRemove.push( 199 | InstancePresenceRecordType.createId(clientId.toString()), 200 | ); 201 | } 202 | 203 | // put / remove the records in the store 204 | store.mergeRemoteChanges(() => { 205 | if (toRemove.length) store.remove(toRemove); 206 | if (toPut.length) store.put(toPut); 207 | }); 208 | }; 209 | 210 | room.awareness.on("update", handleUpdate); 211 | unsubs.push(() => room.awareness.off("update", handleUpdate)); 212 | 213 | // 2. 214 | // Initialize the store with the yjs doc records—or, if the yjs doc 215 | // is empty, initialize the yjs doc with the default store records. 216 | if (yStore.yarray.length) { 217 | // Replace the store records with the yjs doc records 218 | transact(() => { 219 | // The records here should be compatible with what's in the store 220 | store.clear(); 221 | const records = yStore.yarray.toJSON().map(({ val }) => val); 222 | store.put(records); 223 | }); 224 | } else { 225 | // Create the initial store records 226 | // Sync the store records to the yjs doc 227 | yDoc.transact(() => { 228 | for (const record of store.allRecords()) { 229 | yStore.set(record.id, record); 230 | } 231 | }); 232 | } 233 | 234 | setStoreWithStatus({ 235 | store, 236 | status: "synced-remote", 237 | connectionStatus: "online", 238 | }); 239 | } 240 | 241 | let hasConnectedBefore = false; 242 | 243 | function handleStatusChange({ 244 | status, 245 | }: { 246 | status: "disconnected" | "connected"; 247 | }) { 248 | // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline' 249 | if (status === "disconnected") { 250 | setStoreWithStatus({ 251 | store, 252 | status: "synced-remote", 253 | connectionStatus: "offline", 254 | }); 255 | return; 256 | } 257 | 258 | room.off("synced", handleSync); 259 | 260 | if (status === "connected") { 261 | if (hasConnectedBefore) return; 262 | hasConnectedBefore = true; 263 | room.on("synced", handleSync); 264 | unsubs.push(() => room.off("synced", handleSync)); 265 | } 266 | } 267 | 268 | room.on("status", handleStatusChange); 269 | unsubs.push(() => room.off("status", handleStatusChange)); 270 | 271 | return () => { 272 | unsubs.forEach((fn) => fn()); 273 | unsubs.length = 0; 274 | }; 275 | }, [room, yDoc, store, yStore]); 276 | 277 | return storeWithStatus; 278 | } 279 | -------------------------------------------------------------------------------- /src/physics/PhysicsCollection.tsx: -------------------------------------------------------------------------------- 1 | import { BaseCollection } from '../../tldraw-collections/src'; 2 | import { 3 | Editor, 4 | TLDrawShape, 5 | TLGeoShape, 6 | TLGroupShape, 7 | TLParentId, 8 | TLShape, 9 | TLShapeId, 10 | VecLike 11 | } from "@tldraw/tldraw"; 12 | import RAPIER from "@dimforge/rapier2d"; 13 | import { 14 | CHARACTER, 15 | GRAVITY, 16 | MATERIAL, 17 | centerToCorner, 18 | convertVerticesToFloat32Array, 19 | shouldConvexify, 20 | cornerToCenter, 21 | getDisplacement, 22 | getFrictionFromColor, 23 | getGravityFromColor, 24 | getRestitutionFromColor, 25 | isRigidbody 26 | } from "./utils"; 27 | 28 | type RigidbodyUserData = RAPIER.RigidBody & { id: TLShapeId; type: TLShape["type"]; w: number; h: number, rbType: RAPIER.RigidBodyType }; 29 | export class PhysicsCollection extends BaseCollection { 30 | override id = 'physics'; 31 | private world: RAPIER.World; 32 | private rigidbodyLookup: Map; 33 | private colliderLookup: Map; 34 | private characterLookup: Map; 35 | private animFrame = -1; // Store the animation frame id 36 | 37 | constructor(editor: Editor) { 38 | super(editor) 39 | this.world = new RAPIER.World(GRAVITY) 40 | this.rigidbodyLookup = new Map() 41 | this.colliderLookup = new Map() 42 | this.characterLookup = new Map() 43 | this.simStart() 44 | } 45 | 46 | override onAdd(shapes: TLShape[]) { 47 | const parentShapes = new Set() 48 | for (const shape of shapes) { 49 | if (shape.parentId !== 'page:page') { 50 | parentShapes.add(shape.parentId) 51 | continue; 52 | } 53 | if (shape.type === "group") { 54 | parentShapes.add(shape.id); 55 | continue; 56 | } 57 | if (this.colliderLookup.has(shape.id) || this.rigidbodyLookup.has(shape.id)) continue; 58 | if ('color' in shape.props && shape.props.color === "violet") { 59 | this.createCharacterObject(shape as TLGeoShape); 60 | } 61 | else switch (shape.type) { 62 | case "draw": 63 | this.createCompoundLineObject(shape as TLDrawShape); 64 | break; 65 | case "group": 66 | this.createGroupObject(shape as TLGroupShape); 67 | break; 68 | default: 69 | this.createShape(shape); 70 | break; 71 | } 72 | } 73 | for (const parent of parentShapes) { 74 | const parentShape = this.editor.getShape(parent) 75 | if (!parentShape || parentShape.type !== "group") continue; 76 | this.createGroupObject(parentShape as TLGroupShape) 77 | } 78 | } 79 | 80 | override onRemove(shapes: TLShape[]) { 81 | for (const shape of shapes) { 82 | if (this.rigidbodyLookup.has(shape.id)) { 83 | const rb = this.rigidbodyLookup.get(shape.id); 84 | if (!rb) continue; 85 | this.world.removeRigidBody(rb); 86 | this.rigidbodyLookup.delete(shape.id); 87 | } 88 | if (this.colliderLookup.has(shape.id)) { 89 | const col = this.colliderLookup.get(shape.id); 90 | if (!col) continue; 91 | this.world.removeCollider(col, true); 92 | this.colliderLookup.delete(shape.id); 93 | } 94 | if (this.characterLookup.has(shape.id)) { 95 | const char = this.characterLookup.get(shape.id); 96 | if (!char) continue; 97 | this.world.removeCharacterController(char.controller); 98 | this.characterLookup.delete(shape.id); 99 | } 100 | } 101 | } 102 | 103 | override onShapeChange(prev: TLShape, next: TLShape) { 104 | // @ts-ignore 105 | if (prev.props.color !== next.props.color) { 106 | // TODO: update properties n stuff i guess 107 | } 108 | } 109 | 110 | public simStart() { 111 | const simLoop = () => { 112 | this.world.step(); 113 | this.updateCharacterControllers(); 114 | this.updateRigidbodies(); 115 | this.updateSelected(); 116 | this.animFrame = requestAnimationFrame(simLoop); 117 | }; 118 | simLoop(); 119 | return () => cancelAnimationFrame(this.animFrame); 120 | }; 121 | 122 | public simStop() { 123 | if (this.animFrame !== -1) { 124 | cancelAnimationFrame(this.animFrame); 125 | this.animFrame = -1; 126 | } 127 | } 128 | 129 | addCollider(id: TLShapeId, desc: RAPIER.ColliderDesc, parentRigidBody?: RAPIER.RigidBody): RAPIER.Collider { 130 | const col = this.world.createCollider(desc, parentRigidBody); 131 | col && this.colliderLookup.set(id, col); 132 | return col; 133 | } 134 | 135 | addRigidbody(id: TLShapeId, desc: RAPIER.RigidBodyDesc) { 136 | const rb = this.world.createRigidBody(desc); 137 | rb && this.rigidbodyLookup.set(id, rb); 138 | return rb; 139 | } 140 | 141 | addCharacter(id: TLShapeId): RAPIER.KinematicCharacterController { 142 | const char = this.world.createCharacterController(0.1); 143 | char.setUp(CHARACTER.up); 144 | char.setMaxSlopeClimbAngle(CHARACTER.maxSlopeClimbAngle); 145 | char.setSlideEnabled(CHARACTER.slideEnabled); 146 | char.setMinSlopeSlideAngle(CHARACTER.minSlopeSlideAngle); 147 | char.setApplyImpulsesToDynamicBodies(CHARACTER.applyImpulsesToDynamicBodies); 148 | char.enableAutostep( 149 | CHARACTER.autostepHeight, 150 | CHARACTER.autostepMaxClimbAngle, 151 | true, 152 | ); 153 | char.enableSnapToGround(CHARACTER.snapToGroundDistance); 154 | this.characterLookup.set(id, { controller: char, id }); 155 | return char; 156 | } 157 | 158 | createShape(shape: TLShape) { 159 | if ("dash" in shape.props && shape.props.dash === "dashed") return; // Skip dashed shapes 160 | if ("color" in shape.props && isRigidbody(shape.props.color)) { 161 | const gravity = getGravityFromColor(shape.props.color) 162 | const rb = this.createRigidbodyObject(shape, gravity); 163 | this.createColliderObject(shape, rb); 164 | } else { 165 | this.createColliderObject(shape); 166 | } 167 | } 168 | 169 | createCharacterObject(characterShape: TLGeoShape) { 170 | const initialPosition = cornerToCenter({ 171 | x: characterShape.x, 172 | y: characterShape.y, 173 | width: characterShape.props.w, 174 | height: characterShape.props.h, 175 | rotation: characterShape.rotation, 176 | }); 177 | const vertices = this.editor.getShapeGeometry(characterShape).vertices; 178 | const vec2Array = convertVerticesToFloat32Array( 179 | vertices, 180 | characterShape.props.w, 181 | characterShape.props.h, 182 | ); 183 | const colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array); 184 | if (!colliderDesc) { 185 | console.error("Failed to create collider description."); 186 | return; 187 | } 188 | const rigidBodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased() 189 | .setTranslation(initialPosition.x, initialPosition.y) 190 | .setAdditionalMass(CHARACTER.additionalMass); 191 | rigidBodyDesc.userData = { 192 | id: characterShape.id, 193 | type: characterShape.type, 194 | w: characterShape.props.w, 195 | h: characterShape.props.h, 196 | rbType: RAPIER.RigidBodyType.KinematicPositionBased, 197 | } 198 | const rb = this.addRigidbody(characterShape.id, rigidBodyDesc); 199 | const col = this.addCollider(characterShape.id, colliderDesc, rb); 200 | this.addCharacter(characterShape.id); 201 | } 202 | 203 | createGroupObject(group: TLGroupShape) { 204 | 205 | // create rigidbody for group 206 | const rigidbody = this.createRigidbodyObject(group); 207 | 208 | this.editor.getSortedChildIdsForParent(group.id).forEach((childId) => { 209 | // create collider for each 210 | const child = this.editor.getShape(childId); 211 | if (!child) return; 212 | const isRb = "color" in child.props && isRigidbody(child.props.color); 213 | if (isRb) { 214 | this.createColliderObject(child, rigidbody, group); 215 | } else { 216 | this.createColliderObject(child); 217 | } 218 | }); 219 | } 220 | 221 | createCompoundLineObject(drawShape: TLDrawShape) { 222 | const rigidbody = this.createRigidbodyObject(drawShape); 223 | const drawnGeo = this.editor.getShapeGeometry(drawShape); 224 | const verts = drawnGeo.vertices; 225 | const isRb = 226 | "color" in drawShape.props && isRigidbody(drawShape.props.color); 227 | verts.forEach((point) => { 228 | if (isRb) this.createColliderRelativeToParentObject(point, drawShape, rigidbody); 229 | else this.createColliderRelativeToParentObject(point, drawShape); 230 | }); 231 | } 232 | 233 | private createRigidbodyObject( 234 | shape: TLShape, 235 | gravity = 1, 236 | ): RAPIER.RigidBody { 237 | const { w, h } = this.getShapeDimensionsOrBounds(shape); 238 | const centerPosition = cornerToCenter({ 239 | x: shape.x, 240 | y: shape.y, 241 | width: w, 242 | height: h, 243 | rotation: shape.rotation, 244 | }); 245 | const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic() 246 | .setTranslation(centerPosition.x, centerPosition.y) 247 | .setRotation(shape.rotation) 248 | .setGravityScale(gravity); 249 | rigidBodyDesc.userData = { 250 | id: shape.id, 251 | type: shape.type, 252 | w: w, 253 | h: h, 254 | rbType: RAPIER.RigidBodyType.Dynamic 255 | }; 256 | const rigidbody = this.addRigidbody(shape.id, rigidBodyDesc); 257 | return rigidbody; 258 | } 259 | 260 | private createColliderRelativeToParentObject( 261 | point: VecLike, 262 | relativeToParent: TLDrawShape, 263 | parentRigidBody: RAPIER.RigidBody | null = null, 264 | ) { 265 | const radius = 3; 266 | const center = cornerToCenter({ 267 | x: point.x, 268 | y: point.y, 269 | width: radius, 270 | height: radius, 271 | rotation: 0, 272 | parentGroupShape: relativeToParent, 273 | }); 274 | let colliderDesc: RAPIER.ColliderDesc | null = null; 275 | colliderDesc = RAPIER.ColliderDesc.ball(radius); 276 | 277 | if (!colliderDesc) { 278 | console.error("Failed to create collider description."); 279 | return; 280 | } 281 | 282 | if (parentRigidBody) { 283 | colliderDesc.setTranslation(center.x, center.y); 284 | this.addCollider(relativeToParent.id, colliderDesc, parentRigidBody); 285 | } else { 286 | colliderDesc.setTranslation( 287 | relativeToParent.x + center.x, 288 | relativeToParent.y + center.y, 289 | ); 290 | this.addCollider(relativeToParent.id, colliderDesc); 291 | } 292 | } 293 | private createColliderObject( 294 | shape: TLShape, 295 | parentRigidBody: RAPIER.RigidBody | null = null, 296 | parentGroup: TLGroupShape | undefined = undefined, 297 | ) { 298 | const { w, h } = this.getShapeDimensionsOrBounds(shape); 299 | const parentGroupShape = parentGroup ? this.editor.getShape(parentGroup.id) as TLGroupShape : undefined; 300 | const centerPosition = cornerToCenter({ 301 | x: shape.x, 302 | y: shape.y, 303 | width: w, 304 | height: h, 305 | rotation: shape.rotation, 306 | parentGroupShape: parentGroupShape, 307 | }); 308 | 309 | const restitution = 310 | "color" in shape.props 311 | ? getRestitutionFromColor(shape.props.color) 312 | : MATERIAL.defaultRestitution; 313 | const friction = 314 | "color" in shape.props 315 | ? getFrictionFromColor(shape.props.color) 316 | : MATERIAL.defaultFriction; 317 | 318 | let colliderDesc: RAPIER.ColliderDesc | null = null; 319 | 320 | if (shouldConvexify(shape)) { 321 | // Convert vertices for convex shapes 322 | const vertices = this.editor.getShapeGeometry(shape).vertices; 323 | const vec2Array = convertVerticesToFloat32Array(vertices, w, h,); 324 | colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array); 325 | } else { 326 | // Cuboid for rectangle shapes 327 | colliderDesc = RAPIER.ColliderDesc.cuboid(w / 2, h / 2,); 328 | } 329 | if (!colliderDesc) { 330 | console.error("Failed to create collider description."); 331 | return; 332 | } 333 | 334 | colliderDesc 335 | .setRestitution(restitution) 336 | .setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max) 337 | .setFriction(friction) 338 | .setFrictionCombineRule(RAPIER.CoefficientCombineRule.Min); 339 | if (parentRigidBody) { 340 | if (parentGroup) { 341 | colliderDesc.setTranslation(centerPosition.x, centerPosition.y); 342 | colliderDesc.setRotation(shape.rotation); 343 | } 344 | this.addCollider(shape.id, colliderDesc, parentRigidBody); 345 | } else { 346 | colliderDesc 347 | .setTranslation(centerPosition.x, centerPosition.y) 348 | .setRotation(shape.rotation); 349 | this.addCollider(shape.id, colliderDesc); 350 | } 351 | } 352 | 353 | updateRigidbodies() { 354 | this.world.bodies.forEach((rb) => { 355 | if (!rb.userData) return; 356 | const userData = rb.userData as RigidbodyUserData; 357 | if (this.editor.getSelectedShapeIds().includes(userData.id)) return 358 | 359 | rb.setBodyType(userData.rbType, true); 360 | const position = rb.translation(); 361 | const rotation = rb.rotation(); 362 | 363 | const cornerPos = centerToCorner({ 364 | x: position.x, 365 | y: position.y, 366 | width: userData.w, 367 | height: userData.h, 368 | rotation: rotation, 369 | }); 370 | 371 | this.editor.updateShape({ 372 | id: userData.id, 373 | type: userData.type, 374 | rotation: rotation, 375 | x: cornerPos.x, 376 | y: cornerPos.y, 377 | }); 378 | }); 379 | } 380 | 381 | updateSelected() { 382 | for (const id of this.editor.getSelectedShapeIds()) { 383 | const shape = this.editor.getShape(id); 384 | if (!shape) continue 385 | const col = this.colliderLookup.get(id); 386 | const rb = this.rigidbodyLookup.get(id); 387 | const { w, h } = this.getShapeDimensionsOrBounds(shape); 388 | 389 | const centerPos = cornerToCenter({ 390 | x: shape.x, 391 | y: shape.y, 392 | width: w, 393 | height: h, 394 | rotation: shape.rotation, 395 | }); 396 | 397 | // TODO: update dimensions for all shapes 398 | if (col && rb) { 399 | const userData = rb.userData as RigidbodyUserData; 400 | if (!rb.isKinematic()) rb.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); 401 | rb.setNextKinematicTranslation({ x: centerPos.x, y: centerPos.y }); 402 | rb.setNextKinematicRotation(shape.rotation); 403 | col.setHalfExtents({ x: w / 2, y: h / 2 }); 404 | userData.w = w; 405 | userData.h = h; 406 | continue; 407 | } 408 | if (rb) { 409 | if (!rb.isKinematic()) rb.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true); 410 | rb.setNextKinematicTranslation({ x: centerPos.x, y: centerPos.y }); 411 | rb.setNextKinematicRotation(shape.rotation); 412 | continue; 413 | } 414 | if (col) { 415 | col.setTranslation({ x: centerPos.x, y: centerPos.y }); 416 | col.setRotation(shape.rotation); 417 | col.setHalfExtents({ x: w / 2, y: h / 2 }); 418 | // TODO: update dimensions for all shapes 419 | } 420 | } 421 | } 422 | 423 | updateCharacterControllers() { 424 | const right = this.editor.inputs.keys.has("ArrowRight") ? 1 : 0; 425 | const left = this.editor.inputs.keys.has("ArrowLeft") ? -1 : 0; 426 | const acceleration: VecLike = { 427 | x: (right + left) * CHARACTER.moveAcceleration, 428 | y: CHARACTER.gravityMultiplier * GRAVITY.y, 429 | } 430 | 431 | for (const char of this.characterLookup.values()) { 432 | const charRigidbody = this.rigidbodyLookup.get(char.id); 433 | if (!charRigidbody) continue; 434 | const userData = charRigidbody.userData as RigidbodyUserData; 435 | const charCollider = this.colliderLookup.get(char.id) as RAPIER.Collider; 436 | const grounded = char.controller.computedGrounded(); 437 | // TODO: move this check so we can think about multiplayer physics control 438 | const isJumping = this.editor.inputs.keys.has("ArrowUp") && grounded; 439 | const velocity: VecLike = { 440 | x: charRigidbody.linvel().x, 441 | y: isJumping ? -CHARACTER.jumpVelocity : charRigidbody.linvel().y, 442 | } 443 | const displacement = getDisplacement( 444 | velocity, 445 | acceleration, 446 | 1 / 60, 447 | CHARACTER.maxMoveSpeedX, 448 | CHARACTER.moveDeceleration, 449 | ); 450 | 451 | char.controller.computeColliderMovement( 452 | charCollider as RAPIER.Collider, 453 | new RAPIER.Vector2(displacement.x, displacement.y), 454 | ); 455 | const correctedDisplacement = char.controller.computedMovement(); 456 | const currentPos = charRigidbody.translation(); 457 | const nextX = currentPos.x + correctedDisplacement.x; 458 | const nextY = currentPos.y + correctedDisplacement.y; 459 | charRigidbody?.setNextKinematicTranslation({ x: nextX, y: nextY }); 460 | 461 | const w = userData.w; 462 | const h = userData.h; 463 | this.editor.updateShape({ 464 | id: userData.id, 465 | type: userData.type, 466 | x: nextX - w / 2, 467 | y: nextY - h / 2, 468 | }); 469 | }; 470 | } 471 | 472 | private getShapeDimensionsOrBounds = ( 473 | shape: TLShape, 474 | ): { w: number; h: number } => { 475 | let w; 476 | let h; 477 | if (shape.type === 'geo') { 478 | const geoShape = shape as TLGeoShape; 479 | w = geoShape.props.w; 480 | h = geoShape.props.h; 481 | } else { 482 | const geo = this.editor.getShapeGeometry(shape); 483 | w = geo.bounds.x; 484 | h = geo.bounds.y; 485 | } 486 | return { w, h }; 487 | } 488 | } --------------------------------------------------------------------------------