diff --git a/package.json b/package.json index 48974ba..5e4de86 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", + "@react-three/rapier": "^2.2.0", "@types/three": "^0.184.1", "install": "^0.13.0", "mobx": "^6.15.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e398580..55f7fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@react-three/fiber': specifier: ^9.6.1 version: 9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0) + '@react-three/rapier': + specifier: ^2.2.0 + version: 2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0) '@types/three': specifier: ^0.184.1 version: 0.184.1 @@ -161,6 +164,9 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dimforge/rapier3d-compat@0.19.2': + resolution: {integrity: sha512-AZHL1jqUF55QJkJyU1yKeh4ImX2J93bVLIezT1+o0FZqTix6O06MOaqpKoJ4MmbDCsoZmwO+qc471/SDMDm2AA==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -386,6 +392,13 @@ packages: react-native: optional: true + '@react-three/rapier@2.2.0': + resolution: {integrity: sha512-mVsqbKXlGZoN+XrqdhzFZUQmy8pibEOVzl4k7LC+LHe84bQnYBSagy1Hvbda6bL1PJDdTFyiDiBk5buKFinNIQ==} + peerDependencies: + '@react-three/fiber': ^9.0.4 + react: ^19 + three: '>=0.159.0' + '@rolldown/binding-android-arm64@1.0.2': resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1532,6 +1545,8 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@dimforge/rapier3d-compat@0.19.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1747,6 +1762,15 @@ snapshots: - '@types/react' - immer + '@react-three/rapier@2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(react@19.2.6)(three@0.184.0)': + dependencies: + '@dimforge/rapier3d-compat': 0.19.2 + '@react-three/fiber': 9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0) + react: 19.2.6 + suspend-react: 0.1.3(react@19.2.6) + three: 0.184.0 + three-stdlib: 2.36.1(three@0.184.0) + '@rolldown/binding-android-arm64@1.0.2': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 248c5b2..696c825 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,2 @@ allowBuilds: - '@parcel/watcher': set this to true or false + '@parcel/watcher': false diff --git a/src/components/CharacterView.tsx b/src/components/CharacterView.tsx index 260cf6c..9087c3a 100644 --- a/src/components/CharacterView.tsx +++ b/src/components/CharacterView.tsx @@ -1,22 +1,35 @@ import { observer } from "mobx-react-lite"; import type { Character } from "../types"; +import { SyncRigidBody } from "./SyncRigidBody"; +import { state } from "../state"; export const CharacterView = observer(function ({ character }: { character: Character }) { - const pos = character.transform.position; - return - {/* - - - */} - - - - - + return ( + { + state.game?.setCharacterTransform( + { + position: data.position, + look: character.transform.look, + }, + data.linearVelocity, + undefined, // do not change radial velocity + ); + }} + > + + + + + + + + ); }); - diff --git a/src/components/GameView.tsx b/src/components/GameView.tsx index 18b6082..087add3 100644 --- a/src/components/GameView.tsx +++ b/src/components/GameView.tsx @@ -3,28 +3,35 @@ import { SceneView } from "./SceneView"; import { state } from "../state"; import { useFrame, useThree } from "@react-three/fiber"; import { PointerLockControls, useKeyboardControls } from "@react-three/drei"; -import { useEffect, useRef } from "react"; +import { Suspense, useEffect, useRef } from "react"; +import { Physics } from "@react-three/rapier"; function PlayerMovement() { const [, get] = useKeyboardControls(); const dirty = useRef(false); useFrame(({ camera }, dt) => { + if (state.game?.isPaused) + return; + const { forward, backward, left, right } = get(); const speed = 5 * dt; - if (forward) { camera.translateZ(-speed); dirty.current = true; } - if (backward) { camera.translateZ( speed); dirty.current = true; } - if (left) { camera.translateX(-speed); dirty.current = true; } - if (right) { camera.translateX( speed); dirty.current = true; } + if (forward) { camera.translateZ(-speed); dirty.current = true; } + if (backward) { camera.translateZ(speed); dirty.current = true; } + if (left) { camera.translateX(-speed); dirty.current = true; } + if (right) { camera.translateX(speed); dirty.current = true; } if (!dirty.current) return; dirty.current = false; const [rx, ry, rz] = camera.rotation.toArray(); - state.game?.setCharacterTransform({ - position: camera.position.toArray(), - look: [rx, ry, rz], - }); + state.game?.setCharacterTransform( + { + position: camera.position.toArray(), + look: [rx, ry, rz], + }, + // do not change velocities + ); }); return { dirty.current = true; }} />; @@ -50,7 +57,11 @@ export const GameView = observer(function () { return null; return (<> - - + + {/* */} + + + + ); }); diff --git a/src/components/ObjectView.tsx b/src/components/ObjectView.tsx index 6245240..cd042a0 100644 --- a/src/components/ObjectView.tsx +++ b/src/components/ObjectView.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react-lite"; -import type { ObjectInstance, ObjectType } from "../types"; -import { useRef, type RefObject } from "react"; +import type { ObjectType, ObjectInstance } from "../types"; +import { useRef, useMemo, type RefObject } from "react"; import type { Group } from "three"; import { Instance, Instances, TransformControls, useHelper } from "@react-three/drei"; import { BoxHelper } from "three"; @@ -8,35 +8,59 @@ import type { ThreeEvent } from "@react-three/fiber"; import { state } from "../state"; import { nextSelectionEditMode } from "../state/worldEditorState"; import type { R3 } from "../types/3d"; - -type VoxelGroup = { - id: string; - color: string; - opacity: number; - positions: [number, number, number][]; -}; - -function voxelGroups(objectType: ObjectType): VoxelGroup[] { - const map = new Map(); - for (const idx in objectType.voxels) { - const v = objectType.voxels[idx]; - const vt = state.world.getVoxelTypeById(v.typeId); - const color = (v.color ?? vt?.color) ?? 'white'; - const opacity = (v.opacity ?? vt?.opacity) ?? 1; - const key = `${color}-${opacity}`; - if (!map.has(key)) - map.set(key, { id: key, color, opacity, positions: [] }); - const p = v.position; - map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]); - } - return [...map.values()]; -} +import { TrimeshCollider } from "@react-three/rapier"; +import { SyncRigidBody } from "./SyncRigidBody"; +import { runInAction } from "mobx"; +import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup"; type ObjectViewProps = { object: ObjectInstance; } +function buildTrimesh(objectType: ObjectType, voxelTypes: typeof state.world.data.voxelTypes): [Float32Array, Uint32Array] | null { + const collidable = objectType.voxels.filter( + v => voxelTypes[v.typeId]?.collidable !== false + ); + if (!collidable.length) return null; + + const n = collidable.length; + const verts = new Float32Array(n * 8 * 3); + const idxs = new Uint32Array(n * 36); + + for (let i = 0; i < n; i++) { + const p = collidable[i].position; + const vb = i * 8; + + // 8 corners of unit box at position p (no +0.5 — group transform handles offset) + verts.set([ + p[0], p[1], p[2], + p[0] + 1, p[1], p[2], + p[0], p[1] + 1, p[2], + p[0] + 1, p[1] + 1, p[2], + p[0], p[1], p[2] + 1, + p[0] + 1, p[1], p[2] + 1, + p[0], p[1] + 1, p[2] + 1, + p[0] + 1, p[1] + 1, p[2] + 1, + ], vb * 3); + + // 12 triangles (CCW outward normals) + idxs.set([ + vb, vb + 2, vb + 3, vb, vb + 3, vb + 1, // -Z + vb + 4, vb + 5, vb + 7, vb + 4, vb + 7, vb + 6, // +Z + vb, vb + 1, vb + 5, vb, vb + 5, vb + 4, // -Y + vb + 2, vb + 6, vb + 7, vb + 2, vb + 7, vb + 3, // +Y + vb, vb + 4, vb + 6, vb, vb + 6, vb + 2, // -X + vb + 1, vb + 3, vb + 7, vb + 1, vb + 7, vb + 5, // +X + ], i * 36); + } + + return [verts, idxs]; +} + export const ObjectView = observer(function ({ object }: ObjectViewProps) { + + const objectType = state.world.getObjectTypeById(object.typeId); + const groupRef = useRef(null); const isSelected = state.worldEditor.isEnabled && @@ -44,9 +68,18 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) { useHelper(isSelected ? groupRef : { current: null } as any, BoxHelper, 'white'); - const objectType = state.world.getObjectTypeById(object.typeId); + // Must be before early return to satisfy hooks rules + const trimeshArgs = useMemo( + () => objectType + ? buildTrimesh(objectType, state.world.data.voxelTypes) + : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [objectType?.id] + ); + if (!objectType) return null; + const selectionMode = isSelected ? state.worldEditor.selectedObjectMode : undefined; @@ -80,26 +113,41 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) { onMouseUp={handleTransformEnd} /> } - { + // console.log(`changed object ${object.id} ${JSON.stringify(data)}`); + runInAction(() => { + const obj = state.game?.scene.objects[object.id]; + if (obj) { + obj.position = data.position; + obj.rotation = data.rotation; + obj.linearVelocity = data.linearVelocity; + obj.radialVelocity = data.radialVelocity; + } + }); + }} > - { - voxelGroups(objectType).map((vg) => { - return + + {getObjectVoxelGroups(objectType, state.world.data.voxelTypes).map((vg) => + - { - vg.positions - .map((pos, i) => ) - } + {vg.positions.map((pos, i) => )} - }) - } - + )} + {trimeshArgs && } + + ); }); diff --git a/src/components/SceneEditorView.tsx b/src/components/SceneEditorView.tsx index 230da2b..d887425 100644 --- a/src/components/SceneEditorView.tsx +++ b/src/components/SceneEditorView.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { Suspense, useRef } from 'react'; import type React from 'react'; import { Grid, OrbitControls } from '@react-three/drei'; import { observer } from 'mobx-react-lite'; @@ -6,6 +6,7 @@ import { state } from '../state'; import { SceneView } from './SceneView'; import { type OrthographicCamera, type PerspectiveCamera } from 'three'; import { CameraSync } from './tools/CameraSync'; +import { Physics } from '@react-three/rapier'; export const SceneEditorView = observer(function () { @@ -42,20 +43,24 @@ export const SceneEditorView = observer(function () { }; return (<> - - - - + + + + + + + + ); }); diff --git a/src/components/SceneView.tsx b/src/components/SceneView.tsx index 0cfb1e0..9b696f7 100644 --- a/src/components/SceneView.tsx +++ b/src/components/SceneView.tsx @@ -2,18 +2,35 @@ import { observer } from "mobx-react-lite"; import type { Scene } from "../types"; import { CharacterView } from "./CharacterView"; import { ObjectView } from "./ObjectView"; +import { useFrame } from "@react-three/fiber"; +import { useRapier } from "@react-three/rapier"; +import { state } from "../state"; type SceneViewProps = { scene: Scene, - renderCharacter: boolean; + editMode?: boolean; } -export const SceneView = observer(function ({ scene, renderCharacter }: SceneViewProps) { +export const SceneView = observer(function (props: SceneViewProps) { + const rapier = useRapier(); + + useFrame((_, dt) => { + // if (props.editMode) + // return; + + // const game = state.game; + // if (!game || game.isPaused) + // return; + + // rapier.step(dt); + }) + return (<> - {Object.values(scene.objects).map((obj) => + {Object.values(props.scene.objects).map((obj) => )} - {renderCharacter && } + {/* {props.editMode && } */} + {} ); }); diff --git a/src/components/SyncRigidBody.tsx b/src/components/SyncRigidBody.tsx new file mode 100644 index 0000000..ade0e54 --- /dev/null +++ b/src/components/SyncRigidBody.tsx @@ -0,0 +1,74 @@ +import { RigidBody, type RigidBodyProps, type RapierRigidBody } from "@react-three/rapier"; +import { useFrame } from "@react-three/fiber"; +import { useRef } from "react"; +import { Euler, Quaternion } from "three"; +import type { R3, V3 } from "../types/3d"; + +export type SyncRigidBodyData = { + position: V3; + rotation: R3; + linearVelocity: V3; + radialVelocity: R3; +} + +export type SyncRigidBodyOnSyncFunction = (data: SyncRigidBodyData) => void; + +type SyncRigidBodyProps = RigidBodyProps & { + onSync: SyncRigidBodyOnSyncFunction; +}; + +const _q = new Quaternion(); +const _e = new Euler(); +const EPS = 1e-6; + +// indices: 0-2 position, 3-5 rotation, 6-8 linearVelocity, 9-11 radialVelocity +const PREV_INIT = new Float64Array(12).fill(Infinity); + +function syncRigidBodyDataToArray(data: SyncRigidBodyData, arr: Float64Array): void { + arr[0] = data.position[0]; arr[1] = data.position[1]; arr[2] = data.position[2]; + arr[3] = data.rotation[0]; arr[4] = data.rotation[1]; arr[5] = data.rotation[2]; + arr[6] = data.linearVelocity[0]; arr[7] = data.linearVelocity[1]; arr[8] = data.linearVelocity[2]; + arr[9] = data.radialVelocity[0]; arr[10] = data.radialVelocity[1]; arr[11] = data.radialVelocity[2]; +} + +function compareTwoFloatArrays(a: Float64Array, b: Float64Array, epsilon: number): boolean { + for (let i = 0; i < a.length; i++) + if (Math.abs(a[i] - b[i]) > epsilon) + return true; + return false; +} + +export function SyncRigidBody({ onSync, children, ...props }: SyncRigidBodyProps) { + const rbRef = useRef(null); + const prevData = useRef(PREV_INIT.slice()); + const currentData = useRef(PREV_INIT.slice()); + + useFrame(() => { + const body = rbRef.current; + if (!body) + return; + + const { x, y, z } = body.translation(); + const rot = body.rotation(); + _q.set(rot.x, rot.y, rot.z, rot.w); + _e.setFromQuaternion(_q); + const lv = body.linvel(); + const av = body.angvel(); + + const data: SyncRigidBodyData = { + position: [x, y, z], + rotation: [_e.x, _e.y, _e.z], + linearVelocity: [lv.x, lv.y, lv.z], + radialVelocity: [av.x, av.y, av.z], + }; + + syncRigidBodyDataToArray(data, currentData.current); + + if (compareTwoFloatArrays(currentData.current, prevData.current, EPS)) { + prevData.current.set(currentData.current); + onSync(data); + } + }); + + return {children}; +} diff --git a/src/model/defaultVoxelTypes.ts b/src/model/defaultVoxelTypes.ts index b72664a..bde74cd 100644 --- a/src/model/defaultVoxelTypes.ts +++ b/src/model/defaultVoxelTypes.ts @@ -11,7 +11,7 @@ const stone: VoxelType = { const dirt: VoxelType = { id: 'dirt', name: 'Dirt', - opacity: 1, + opacity: 0.8, collidable: true, color: '#302520', }; diff --git a/src/model/gameFactory.ts b/src/model/gameFactory.ts index 9acac42..53b30bb 100644 --- a/src/model/gameFactory.ts +++ b/src/model/gameFactory.ts @@ -1,4 +1,4 @@ -import type { Game, World } from "../types"; +import type { Game, RuntimeScene, World } from "../types"; import { clone } from "../utils"; export class GameFactory { @@ -7,7 +7,7 @@ export class GameFactory { return { paused: false, time: 0, - scene: clone(world.initialScene), + scene: clone(world.initialScene) as RuntimeScene, } } diff --git a/src/state/gameState.ts b/src/state/gameState.ts index bf56f62..5893126 100644 --- a/src/state/gameState.ts +++ b/src/state/gameState.ts @@ -1,7 +1,7 @@ import { makeAutoObservable, reaction, toJS } from "mobx"; import type { WorldState } from "./worldState"; -import type { Game, Scene } from "../types"; -import type { Pos3, R3, V3 } from "../types/3d"; +import type { Game, RuntimeScene } from "../types"; +import type { Pos3, V3, R3 } from "../types/3d"; import type { CameraProps } from "@react-three/fiber"; import { GameFactory } from "../model/gameFactory"; @@ -34,11 +34,12 @@ export class GameState { return this.data.paused; } - public get scene(): Scene { + public get scene(): RuntimeScene { return this.data.scene; } public get camera(): Pos3 { + return this.world.data.editorCamera; return this.scene.character.transform; } @@ -78,11 +79,21 @@ export class GameState { this.data.paused = true; } - public setCharacterTransform(transform: Pos3): void { + public setCharacterTransform( + transform: Pos3, + linearVelocity?: V3, + radialVelocity?: R3, + ): void { if (this.isPaused) return; this.scene.character.transform = transform; + if (linearVelocity) + this.scene.character.linearVelocity = linearVelocity; + if (radialVelocity) + this.scene.character.radialVelocity = radialVelocity; + + // console.log(`changed character to ${JSON.stringify(this.scene.character)}`); } public tick(deltaTime: number): void { diff --git a/src/state/worldState.ts b/src/state/worldState.ts index aa7cc61..1fbaa1d 100644 --- a/src/state/worldState.ts +++ b/src/state/worldState.ts @@ -39,14 +39,15 @@ export class WorldState { public loadMock() { console.log('Mocking world...'); - const objects = Array(3).fill(0) + const objects = Array(0).fill(0) .map((_, idx) => ({ id: `obj${idx}`, typeId: 'wolf', + gravityScale: 1, position: [idx * 10 - 10, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], - })); + } as ObjectInstance)); const objectMap = Object.fromEntries( objects.map((obj) => [obj.id, obj]), ) as Record @@ -74,12 +75,14 @@ export class WorldState { transform: { position: [0, 5, 20], look: [0, 0, 0], - } + }, }, objects: { terrain: { id: 'terrain', typeId: 'terrain', + physics: false, // pinned + gravityScale: 0, // pinned position: [0, -1, 0], rotation: [0, 0, 0], scale: [1, 1, 1], diff --git a/src/types/character.ts b/src/types/character.ts index ba7422a..ab9c92d 100644 --- a/src/types/character.ts +++ b/src/types/character.ts @@ -1,5 +1,8 @@ import type { Pos3 } from "./3d"; +import type { RuntimeObjectData } from "./object"; export type Character = { transform: Pos3, } + +export type RuntimeCharacter = Character & RuntimeObjectData; diff --git a/src/types/game.ts b/src/types/game.ts index 5877dee..e48f5d1 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -1,7 +1,7 @@ -import type { Scene } from "./scene"; +import type { RuntimeScene } from "./scene"; export type Game = { paused: boolean; time: number; - scene: Scene; + scene: RuntimeScene; } diff --git a/src/types/object.ts b/src/types/object.ts index 6434ec5..cf8fb52 100644 --- a/src/types/object.ts +++ b/src/types/object.ts @@ -10,7 +10,16 @@ export type ObjectType = { export type ObjectInstance = { id: string; typeId: string; + physics: boolean; + gravityScale: number; position: V3; rotation: R3; scale: V3; -} \ No newline at end of file +} + +export type RuntimeObjectData = { + linearVelocity: V3; + radialVelocity: R3; +} + +export type RuntimeObjectInstance = ObjectInstance & RuntimeObjectData; diff --git a/src/types/scene.ts b/src/types/scene.ts index afe918a..9e74291 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -1,7 +1,12 @@ -import type { Character } from "./character"; -import type { ObjectInstance } from "./object"; +import type { Character, RuntimeCharacter } from "./character"; +import type { ObjectInstance, RuntimeObjectInstance } from "./object"; export type Scene = { character: Character; objects: Record; -} \ No newline at end of file +} + +export type RuntimeScene = { + character: RuntimeCharacter; + objects: Record; +} diff --git a/src/utils/graphics/voxelGroup.ts b/src/utils/graphics/voxelGroup.ts new file mode 100644 index 0000000..6f04d33 --- /dev/null +++ b/src/utils/graphics/voxelGroup.ts @@ -0,0 +1,28 @@ +import type { ObjectType } from "../../types"; +import type { VoxelType } from "../../types/voxel"; + +export type VoxelGroup = { + id: string; + color: string; + opacity: number; + positions: [number, number, number][]; +}; + +export function getObjectVoxelGroups( + object: ObjectType, + voxelTypes: Record, +): VoxelGroup[] { + const map = new Map(); + for (const idx in object.voxels) { + const v = object.voxels[idx]; + const vt = voxelTypes[v.typeId]; + const color = (v.color ?? vt?.color) ?? 'white'; + const opacity = (v.opacity ?? vt?.opacity) ?? 1; + const key = `${color}-${opacity}`; + if (!map.has(key)) + map.set(key, { id: key, color, opacity, positions: [] }); + const p = v.position; + map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]); + } + return [...map.values()]; +}