diff --git a/webclient/src/App.tsx b/webclient/src/App.tsx index 8630c58..46bfece 100644 --- a/webclient/src/App.tsx +++ b/webclient/src/App.tsx @@ -2,10 +2,10 @@ import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, u import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage'; import { useEffect, useLayoutEffect, useRef } from 'react'; import { reaction } from 'mobx'; -import { ThreeView } from './components/ThreeView'; import { state } from './state'; import './App.scss'; import { Panels } from './components/Panels'; +import { LayeredThreeView } from './components/layers/LayeredThreeView'; function StateToUrlSync() { const navigate = useNavigate(); @@ -94,7 +94,7 @@ function EditorLayout() { return ( <> - + > ); diff --git a/webclient/src/components/DebugLabel.tsx b/webclient/src/components/DebugLabel.tsx index 391acb8..6178c74 100644 --- a/webclient/src/components/DebugLabel.tsx +++ b/webclient/src/components/DebugLabel.tsx @@ -2,13 +2,13 @@ import { Html } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import { useRef } from "react"; import * as THREE from "three"; -import type { RuntimeObjectInstance } from "../types"; import { debugLabelsStore } from "../state/debugLabelsState"; import { state } from "../state"; import { observer } from "mobx-react-lite"; import { formatV3 } from "../utils"; +import type { ObjectInstance } from "../types"; -export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) { +export const DebugLabel = observer(function ({ obj }: { obj: ObjectInstance; }) { const groupRef = useRef(null); const rb = obj.rb(); diff --git a/webclient/src/components/DebugLabelsView.tsx b/webclient/src/components/DebugLabelsView.tsx index bd8f6d0..1d84431 100644 --- a/webclient/src/components/DebugLabelsView.tsx +++ b/webclient/src/components/DebugLabelsView.tsx @@ -2,9 +2,8 @@ import { useEffect } from "react"; import { observer } from "mobx-react-lite"; import { debugLabelsStore } from "../state/debugLabelsState"; import { DebugLabel } from "./DebugLabel"; -import type { RuntimeScene } from "../types"; -export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: RuntimeScene, editMode?: boolean }) { +export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: Scene, editMode?: boolean }) { useEffect( () => { return () => { debugLabelsStore.clear(); }; diff --git a/webclient/src/components/GameObjectView.tsx b/webclient/src/components/GameObjectView.tsx index f385819..2f7e392 100644 --- a/webclient/src/components/GameObjectView.tsx +++ b/webclient/src/components/GameObjectView.tsx @@ -1,11 +1,10 @@ import { observer } from "mobx-react-lite"; -import type { RuntimeGameObjectInstance } from "../types"; import { state } from "../state"; import { ObjectViewInternal } from "./ObjectViewInternal"; import { PlayerObjectView } from "./CharacterView"; export type GameObjectViewProps = { - object: RuntimeGameObjectInstance; + object: ObjectInstance; isPlayer: boolean; isSceneEditor?: boolean; } @@ -16,7 +15,7 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) { return null; if (props.isPlayer) - return ; + return ; function handleClick(): void { state.game?.emitEvent('objectClicked', { object: props.object }); diff --git a/webclient/src/components/ObjectEditorView.tsx b/webclient/src/components/ObjectEditorView.tsx index 8528020..afb299f 100644 --- a/webclient/src/components/ObjectEditorView.tsx +++ b/webclient/src/components/ObjectEditorView.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react-lite"; import { runInAction } from "mobx"; -import type { ObjectInstance, R3, Runtime } from "../types"; +import type { ObjectInstance, R3 } from "../types"; import { useMemo, useRef, useEffect, useCallback, type RefObject } from "react"; import type { Group } from "three"; import { Euler, Quaternion, Vector3, BoxHelper, type LineBasicMaterial } from "three"; @@ -11,7 +11,7 @@ import { nextObjectEditMode } from "../state/worldEditorState"; import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal"; type ObjectEditorViewProps = { - object: Runtime; + object: ObjectInstance; isPlayer: boolean; } diff --git a/webclient/src/components/ObjectViewInternal.tsx b/webclient/src/components/ObjectViewInternal.tsx index 3b982c6..afd0eac 100644 --- a/webclient/src/components/ObjectViewInternal.tsx +++ b/webclient/src/components/ObjectViewInternal.tsx @@ -1,4 +1,4 @@ -import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types"; +import type { ObjectInstance, ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types"; import type { VoxelGroup } from "../types/runtime/voxelGroup"; import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react"; import { Euler, Quaternion, Mesh, MeshStandardMaterial, type Group } from "three"; @@ -8,10 +8,10 @@ import { SyncRigidBody } from "./SyncRigidBody"; import { reaction, runInAction } from "mobx"; import { v3toRapier } from "../utils"; import { state } from "../state"; -import { buildVoxelMesh } from "../utils/graphics/voxelMesh"; +import { buildVoxelGroupMesh } from "../utils/graphics/voxelMesh"; const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) { - const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]); + const geo = useMemo(() => buildVoxelGroupMesh(vg.positions), [vg]); useEffect(() => () => { geo.dispose(); }, [geo]); const op = vg.opacity * opacity; @@ -29,7 +29,7 @@ export type ObjectViewInternalHandle = { } type ObjectViewInternalProps = { - object: Omit; + object: Omit; objectType: ObjectType; editMode?: boolean; isPlayer?: boolean; @@ -54,19 +54,17 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props if (!rbRef.current) return; - const gameObj = object as RuntimeGameObjectInstance; + rbRef.current.setTranslation(v3toRapier(object.position), true); - rbRef.current.setTranslation(v3toRapier(gameObj.position), true); - - const euler = new Euler(gameObj.rotation[0], gameObj.rotation[1], gameObj.rotation[2]); + const euler = new Euler(...object.rotation); const q = new Quaternion().setFromEuler(euler); rbRef.current.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, true); - groupRef.current?.scale.set(...gameObj.scale); + groupRef.current?.scale.set(...object.scale); - rbRef.current.setLinvel(v3toRapier(gameObj.linearVelocity), true); + rbRef.current.setLinvel(v3toRapier(object.linearVelocity), true); - rbRef.current.setAngvel(v3toRapier(gameObj.angularVelocity), true); + rbRef.current.setAngvel(v3toRapier(object.angularVelocity), true); }, ); }, @@ -141,8 +139,8 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props object.position = data.position; object.rotation = data.rotation; if (!props.editMode) { - (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity; - (object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity; + (object as ObjectInstance).linearVelocity = data.linearVelocity; + (object as ObjectInstance).angularVelocity = data.radialVelocity; } // do not change object.version to not trigger rerender }); @@ -154,7 +152,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props scale={object.scale} onClick={handleClick} > - { + {/* { object.cache.voxelGroups.map((vg) => ) } - {object.cache.colliderMesh && } + {object.cache.colliderMesh && } */} ); diff --git a/webclient/src/components/SceneEditorView.tsx b/webclient/src/components/SceneEditorView.tsx index 5e04b94..aee90a1 100644 --- a/webclient/src/components/SceneEditorView.tsx +++ b/webclient/src/components/SceneEditorView.tsx @@ -1,6 +1,6 @@ import { Suspense, useRef } from 'react'; import type React from 'react'; -import { Grid, OrbitControls } from '@react-three/drei'; +import { OrbitControls } from '@react-three/drei'; import { observer } from 'mobx-react-lite'; import { state } from '../state'; import { SceneView } from './SceneView'; diff --git a/webclient/src/components/SceneView.tsx b/webclient/src/components/SceneView.tsx index 348d204..4bc85fa 100644 --- a/webclient/src/components/SceneView.tsx +++ b/webclient/src/components/SceneView.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react-lite"; -import type { RuntimeGameObjectInstance, RuntimeScene } from "../types"; +import type { RuntimeScene } from "../types"; import { GameObjectView } from "./GameObjectView"; import { ObjectEditorView } from "./ObjectEditorView"; import { DebugLabelsView } from "./DebugLabelsView"; diff --git a/webclient/src/components/layers/DebugLabelsLayer.tsx b/webclient/src/components/layers/DebugLabelsLayer.tsx new file mode 100644 index 0000000..091d4c2 --- /dev/null +++ b/webclient/src/components/layers/DebugLabelsLayer.tsx @@ -0,0 +1,5 @@ +import { observer } from "mobx-react-lite"; + +export const DebugLabelsLayer = observer(function () { + return null; //TODO +}); diff --git a/webclient/src/components/layers/GameraLayer.tsx b/webclient/src/components/layers/GameraLayer.tsx new file mode 100644 index 0000000..7d4e488 --- /dev/null +++ b/webclient/src/components/layers/GameraLayer.tsx @@ -0,0 +1,27 @@ +import { useFrame } from "@react-three/fiber"; +import type { Pos3 } from "../../types"; +import { Euler, Quaternion, Vector3 } from "three"; + +const _q = new Quaternion(); +const _e = new Euler(0, 0, 0, 'YXZ'); +const _offset = new Vector3(); +const _charPos = new Vector3(); +const _lookAt = new Vector3(); + +type Props = { + camera: Pos3; +}; + +export function CameraLayer({ camera: cameraPos }: Props) { + + useFrame(({ camera }) => { + // const t = rb.translation(); + // _charPos.set(t.x, t.y, t.z); + // _offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ')); + // camera.position.copy(_charPos).add(_offset); + // _lookAt.set(t.x, t.y + lookAtY, t.z); + camera.lookAt(_lookAt); + }); + + return null; +} diff --git a/webclient/src/components/layers/LayeredThreeView.tsx b/webclient/src/components/layers/LayeredThreeView.tsx new file mode 100644 index 0000000..d22447d --- /dev/null +++ b/webclient/src/components/layers/LayeredThreeView.tsx @@ -0,0 +1,23 @@ +import { observer } from "mobx-react-lite"; +import { ObjectGeomertiesLayer } from "./ObjectGeomertiesLayer"; +import { Canvas } from "@react-three/fiber"; +import { Perf } from "r3f-perf"; +import { state } from "../../state"; +import { CameraLayer } from "./GameraLayer"; + +export const LayeredThreeView = observer(function () { + + return <> + + + + + + + + + + >; +}); diff --git a/webclient/src/components/layers/ObjectGeomertiesLayer.tsx b/webclient/src/components/layers/ObjectGeomertiesLayer.tsx new file mode 100644 index 0000000..52a97dc --- /dev/null +++ b/webclient/src/components/layers/ObjectGeomertiesLayer.tsx @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; +import { useThree, useFrame } from "@react-three/fiber"; +import { ObjectGeometryList } from "../../rendering/ObjectGeometryList"; +import { observer } from "mobx-react-lite"; +import { state } from "../../state"; + +export const ObjectGeomertiesLayer = observer(function () { + + const geometries = state.view.objectGeometries; + + const threeScene = useThree(s => s.scene); + const list = useRef(null); + + useEffect(() => { + const l = new ObjectGeometryList(threeScene, geometries); + list.current = l; + return () => { + l.dispose(); + list.current = null; + }; + }, [threeScene, geometries]); + + useFrame(() => list.current?.tick()); + + return null; +}); diff --git a/webclient/src/components/voxelEditor/VoxelEditorPage.tsx b/webclient/src/components/voxelEditor/VoxelEditorPage.tsx index bab5708..906fb92 100644 --- a/webclient/src/components/voxelEditor/VoxelEditorPage.tsx +++ b/webclient/src/components/voxelEditor/VoxelEditorPage.tsx @@ -1,11 +1,11 @@ import { Canvas } from '@react-three/fiber'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useNavigate, Link, useParams } from 'react-router-dom'; import { observer } from 'mobx-react-lite'; import { useState, useCallback, useEffect, useRef } from 'react'; -import { state } from '../../state'; import type { Voxel, V3 } from '../../types'; import { VoxelEditorScene } from './VoxelEditorScene'; import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react'; +import { state } from '../../state'; const PALETTE = [ '#ffffff', '#cccccc', '#999999', '#555555', '#222222', @@ -19,7 +19,10 @@ type HistoryEntry = | { type: 'remove'; voxel: Voxel }; export const VoxelEditorPage = observer(function () { + const { id } = useParams<{ id: string }>(); + const objectType = id ? state.view.objectTypeGeometries.get(id) : undefined; + const navigate = useNavigate(); const [mode, setMode] = useState<'add' | 'remove'>('add'); const [color, setColor] = useState('#808080'); @@ -28,14 +31,11 @@ export const VoxelEditorPage = observer(function () { const history = useRef([]); const future = useRef([]); - const objectType = id ? state.world.getObjectTypeById(id) : undefined; - useEffect(() => { return () => { - if (id) -state.worldEditor.refreshObjectTypeCaches(id); + objectType?.updateRuntimeData(); }; - }, [id]); + }, [objectType]); function pushHistory(entry: HistoryEntry) { history.current.push(entry); @@ -46,31 +46,31 @@ state.worldEditor.refreshObjectTypeCaches(id); const undo = useCallback(() => { const entry = history.current.pop(); - if (!entry || !id) + if (!entry) return; if (entry.type === 'add') { - state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position); + objectType?.removeVoxel(entry.voxel.position); } else { - state.worldEditor.addVoxelToObjectType(id, entry.voxel); + objectType?.addVoxel(entry.voxel); } future.current.push(entry); setCanUndo(history.current.length > 0); setCanRedo(true); - }, [id]); + }, [objectType]); const redo = useCallback(() => { const entry = future.current.pop(); - if (!entry || !id) + if (!entry) return; if (entry.type === 'add') { - state.worldEditor.addVoxelToObjectType(id, entry.voxel); + objectType?.addVoxel(entry.voxel); } else { - state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position); + objectType?.removeVoxel(entry.voxel.position); } history.current.push(entry); setCanUndo(true); setCanRedo(future.current.length > 0); - }, [id]); + }, [objectType]); useEffect(() => { function onKeyDown(e: KeyboardEvent) { @@ -84,24 +84,14 @@ state.worldEditor.refreshObjectTypeCaches(id); }, [undo, redo]); function handleAdd(voxel: Voxel) { - if (!id) - return; - state.worldEditor.addVoxelToObjectType(id, voxel); - pushHistory({ type: 'add', voxel }); + if (objectType?.addVoxel(voxel)) + pushHistory({ type: 'add', voxel }); } function handleRemove(position: V3) { - if (!id || !objectType) - return; - const voxel = objectType.voxels.find(v => - v.position[0] === position[0] && - v.position[1] === position[1] && - v.position[2] === position[2] - ); - if (!voxel) - return; - state.worldEditor.removeVoxelFromObjectType(id, position); - pushHistory({ type: 'remove', voxel }); + const voxel = objectType?.removeVoxel(position); + if (voxel) + pushHistory({ type: 'remove', voxel }); } if (!objectType) { @@ -116,11 +106,11 @@ state.worldEditor.refreshObjectTypeCaches(id); {/* Top bar */} - navigate(`/editor/objectType/${id}`)} style={btnStyle}> + navigate(`/editor/objectType/${objectType.objectType.id}`)} style={btnStyle}> - {objectType.name} - {objectType.voxels.length} voxels + {objectType.objectType.name} + {objectType.objectType.voxels.length} voxels @@ -134,7 +124,7 @@ state.worldEditor.refreshObjectTypeCaches(id); buildVoxelMesh(positions), + () => buildVoxelGroupMesh(positions), [positions], ); useEffect(() => () => { geo.dispose(); }, [geo]); diff --git a/webclient/src/model/gameFactory.ts b/webclient/src/model/gameFactory.ts index 9d9b773..2c77087 100644 --- a/webclient/src/model/gameFactory.ts +++ b/webclient/src/model/gameFactory.ts @@ -1,5 +1,5 @@ import { toJS } from "mobx"; -import type { Game, GameScene, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types"; +import type { Game, Scene, ScriptAction, ScriptCoroutineContext, World } from "../types"; import { clone } from "../utils"; import { populateRuntimeScene } from "../utils/runtime"; import { ScriptGameEventBus } from "../scripts/eventBus"; @@ -24,7 +24,7 @@ export class GameFactory { const game = { paused: false, time: 0, - scene: GameFactory.initGameScene(scene), + scene, }; return game; } @@ -36,17 +36,13 @@ export class GameFactory { } } - public static save(game: Game): void { + public static save(game: Game): void { localStorage.setItem("game", JSON.stringify(game)); } - private static initGameScene(scene: RuntimeScene): RuntimeGameScene { - return scene as RuntimeGameScene; - } - public static evalGameScripts( world: World, - scene: RuntimeGameScene, + scene: Scene, coroutines: ScriptCoroutines, ): ScriptGameEventBus { @@ -80,7 +76,7 @@ export class GameFactory { Object.values(scene.objects) .filter((o) => o.typeId == ot.id) .map((liveObj) => { - const snapshot = toJS(liveObj) as RuntimeGameObjectInstance; + const snapshot = toJS(liveObj); snapshot.rb = () => liveObj.rb(); return snapshot; }) diff --git a/webclient/src/model/worldFactory.ts b/webclient/src/model/worldFactory.ts index 2b0df7d..257c301 100644 --- a/webclient/src/model/worldFactory.ts +++ b/webclient/src/model/worldFactory.ts @@ -10,6 +10,7 @@ export class WorldFactory { voxelTypes: DEFAULT_VOXEL_TYPES, initialScene: { objects: {}, + playerObjectId: undefined, }, gameRules: { gravity: true, diff --git a/webclient/src/rendering/ObjectGeometryList.ts b/webclient/src/rendering/ObjectGeometryList.ts new file mode 100644 index 0000000..90f4052 --- /dev/null +++ b/webclient/src/rendering/ObjectGeometryList.ts @@ -0,0 +1,76 @@ +import * as THREE from 'three'; +import { MobxReconcilerMap, type ReconcilerMap } from '../utils/reconciler'; +import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewState'; + +export class ObjectGeometryList { + private items: ReconcilerMap; + private readonly typeRefCounts = new Map(); + + private readonly matrixObj = new THREE.Object3D(); + private readonly _p = new THREE.Vector3(); + private readonly _e = new THREE.Euler(); + private readonly _q = new THREE.Quaternion(); + + constructor(scene: THREE.Scene, geometiries: Iterable) { + this.items = new MobxReconcilerMap({ + reaction: () => geometiries, + keyOf: (item) => item.object.id, + initializeItem: (item) => { + const typeId = item.objectTypeGeometry.objectType.id; + const count = (this.typeRefCounts.get(typeId) ?? 0) + 1; + this.typeRefCounts.set(typeId, count); + if (count === 1) { + for (const mesh of item.objectTypeGeometry.meshes ?? []) + scene.add(mesh); + } + }, + disposeItem: (item) => { + const typeId = item.objectTypeGeometry.objectType.id; + const count = (this.typeRefCounts.get(typeId) ?? 1) - 1; + this.typeRefCounts.set(typeId, count); + if (count === 0) { + for (const mesh of item.objectTypeGeometry.meshes ?? []) + scene.remove(mesh); + } + }, + }); + } + + dispose(): void { + this.items?.dispose(); + } + + // Called once per frame — reads MobX observables directly outside any reactive + // context, so no subscriptions are created and no re-renders are triggered. + tick(): void { + const touchedObjectTypes: Record = {}; + + this.items.values.forEach((item) => { + const obj = item.object; + + this._p.set(obj.position[0], obj.position[1], obj.position[2]); + this._e.set(obj.rotation[0], obj.rotation[1], obj.rotation[2]); + this._q.setFromEuler(this._e); + + const typeId = item.objectTypeGeometry.objectType.id; + if (!touchedObjectTypes[typeId]) + touchedObjectTypes[typeId] = { typeGeometry: item.objectTypeGeometry, instanceCount: 0 }; + const instanceIdx = touchedObjectTypes[typeId].instanceCount++; + + for (const mesh of item.objectTypeGeometry.meshes ?? []) { + this.matrixObj.position.copy(this._p); + this.matrixObj.quaternion.copy(this._q); + this.matrixObj.scale.set(obj.scale[0], obj.scale[1], obj.scale[2]); + this.matrixObj.updateMatrix(); + mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix); + } + }); + + for (const objectType of Object.values(touchedObjectTypes)) { + for (const mesh of objectType.typeGeometry.meshes ?? []) { + mesh.count = objectType.instanceCount; + mesh.instanceMatrix.needsUpdate = true; + } + } + } +} diff --git a/webclient/src/state/gameState.ts b/webclient/src/state/gameState.ts index 174f0bc..826038e 100644 --- a/webclient/src/state/gameState.ts +++ b/webclient/src/state/gameState.ts @@ -1,6 +1,6 @@ import { autorun, makeAutoObservable, toJS } from "mobx"; import type { WorldState } from "./worldState"; -import type { Game, GameObjectInstance, GameScene, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } from "../types"; +import type { Game, Pos3, Scene, ScriptContext, ScriptCoroutineContext } from "../types"; import type { CameraProps } from "@react-three/fiber"; import type { ScriptGameEventBus } from "../scripts/eventBus"; import { GameFactory } from "../model/gameFactory"; @@ -13,37 +13,16 @@ export class GameState { public debugOverlay: boolean = false; public time: number = 0; public isFirstTick: boolean; - public scene: RuntimeGameScene; + public scene: Scene; private coroutines = new ScriptCoroutines(); private eventBus: ScriptGameEventBus; - private sceneObjectsAsSaveables(): GameScene['objects'] { - return Object.fromEntries( - Object.entries(this.scene.objects).map(([id, obj]) => [id, { - id: obj.id, - typeId: obj.typeId, - physics: obj.physics, - gravityScale: obj.gravityScale, - position: toJS(obj.position), - rotation: toJS(obj.rotation), - scale: toJS(obj.scale), - version: obj.version, - linearVelocity: toJS(obj.linearVelocity), - angularVelocity: toJS(obj.angularVelocity), - visible: toJS(obj.visible), - } satisfies GameObjectInstance]) - ) as unknown as RuntimeGameScene['objects']; - } - - public get asSaveableGame(): Game { + public get asGame(): Game { return { paused: this.isPaused, time: this.time, - scene: { - playerObjectId: this.scene.playerObjectId, - objects: this.sceneObjectsAsSaveables(), - }, + scene: this.scene, }; } @@ -126,7 +105,7 @@ export class GameState { public save(): void { console.log('Saving game...'); - GameFactory.save(this.asSaveableGame); + GameFactory.save(this.asGame); } public resume(): void { diff --git a/webclient/src/state/rootState.ts b/webclient/src/state/rootState.ts index 5e5d4a2..36ca013 100644 --- a/webclient/src/state/rootState.ts +++ b/webclient/src/state/rootState.ts @@ -3,7 +3,7 @@ import { WorldState } from "./worldState"; import { WorldEditorState } from "./worldEditorState"; import { GameState } from "./gameState"; import { MenuState } from "./menuState"; -import type { RuntimeObjectInstance } from "../types"; +import { ViewState } from "./viewState"; export type RenderInfo = { calls: number, @@ -19,6 +19,7 @@ export class RootState { public game: GameState | undefined; public renderInfo: RenderInfo | undefined; public readonly menu = new MenuState(); + public readonly view = new ViewState(this.world); constructor() { this.worldEditor = new WorldEditorState(this.world); @@ -52,11 +53,11 @@ export class RootState { this.renderInfo = value; } - public markObjectAsPlayer(object: RuntimeObjectInstance) { + public markObjectAsPlayer(objectId: string) { if (this.game) - this.game.scene.playerObjectId = object.id; + this.game.scene.playerObjectId = objectId; else - this.world.data.initialScene.playerObjectId = object.id; + this.world.data.initialScene.playerObjectId = objectId; } } diff --git a/webclient/src/state/viewState.ts b/webclient/src/state/viewState.ts new file mode 100644 index 0000000..4b886cd --- /dev/null +++ b/webclient/src/state/viewState.ts @@ -0,0 +1,135 @@ +import * as THREE from 'three'; +import { action, makeAutoObservable, makeObservable, observable, reaction, type IReactionDisposer } from "mobx"; +import type { ObjectInstance, ObjectInstanceRigidBody, ObjectType, Scene, V3, Voxel, VoxelGroup } from "../types"; +import { state } from "./rootState"; +import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup"; +import { buildVoxelGroupMesh } from "../utils/graphics/voxelMesh"; +import { buildObjectTrimesh } from '../utils/graphics/mesh'; +import { computeBoundingBox } from '../utils/runtime'; +import { MobxReconcilerMap } from '../utils/reconciler'; + +export abstract class ViewItem { +} + +export class ObjectTypeGeometryViewItem extends ViewItem { + public readonly objectType: ObjectType; + + public voxelGroups: VoxelGroup[] | undefined; + public colliderMesh: [Float32Array, Uint32Array] | undefined; + public boundingBox: { min: V3; max: V3 } | undefined; + public meshes: THREE.InstancedMesh[] | undefined; + + private readonly _disposeVoxelReaction: IReactionDisposer; + + constructor(objectType: ObjectType) { + super(); + this.objectType = objectType; + makeObservable(this, { + voxelGroups: observable.ref, + colliderMesh: observable.ref, + boundingBox: observable.ref, + meshes: observable.ref, + updateRuntimeData: action, + }); + this._disposeVoxelReaction = reaction( + () => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'), + () => this.updateRuntimeData(), + ); + this.updateRuntimeData(); + } + + public dispose(): void { + this._disposeVoxelReaction(); + } + + public addVoxel(voxel: Voxel): boolean { + const occupied = this.objectType.voxels.some(v => + v.position[0] === voxel.position[0] && + v.position[1] === voxel.position[1] && + v.position[2] === voxel.position[2] + ); + if (!occupied) { + this.objectType.voxels.push(voxel); + return true; + } + return false; + } + + public removeVoxel(position: V3): Voxel | undefined { + const idx = this.objectType.voxels.findIndex(v => + v.position[0] === position[0] && + v.position[1] === position[1] && + v.position[2] === position[2] + ); + if (idx !== -1) + return this.objectType.voxels.splice(idx, 1)[0]; + } + + public updateRuntimeData(): void { + this.voxelGroups = getObjectVoxelGroups(this.objectType, state.world.data.voxelTypes); + this.meshes = []; + for (const vg of this.voxelGroups) { + const geo = buildVoxelGroupMesh(vg.positions); + const mat = new THREE.MeshStandardMaterial({ + color: vg.color, + opacity: vg.opacity, + transparent: vg.opacity < 1, + }); + const mesh = new THREE.InstancedMesh(geo, mat, 1000); + this.meshes.push(mesh); + } + this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes); + this.boundingBox = computeBoundingBox(this.objectType); + } +} + +export class ObjectGeometryViewItem extends ViewItem { + public readonly object: ObjectInstance; + public readonly objectTypeGeometry: ObjectTypeGeometryViewItem; + + public rb: (() => ObjectInstanceRigidBody | null) | undefined; + + constructor(object: ObjectInstance, objectTypeGeometry: ObjectTypeGeometryViewItem) { + super(); + this.object = object; + this.objectTypeGeometry = objectTypeGeometry; + } +} + +export class ViewState { + public objectTypeGeometries: MobxReconcilerMap; + public objectGeometries: MobxReconcilerMap; + + constructor(world: { data: { objectTypes: Record; initialScene: Scene } }) { + makeAutoObservable( + this, + ); + this.objectTypeGeometries = new MobxReconcilerMap({ + reaction: () => Object.values(world.data.objectTypes), + keyOf: (ot) => ot.id, + mapItem: (ot) => new ObjectTypeGeometryViewItem(ot), + updateItem: () => true, + disposeItem: (item) => { item.dispose(); }, + }); + + this.objectGeometries = new MobxReconcilerMap({ + reaction: () => Object.values(world.data.initialScene.objects) + .filter(obj => obj.visible) + .filter(obj => !!world.data.objectTypes[obj.typeId]), + keyOf: (obj) => obj.id, + mapItem: (obj) => new ObjectGeometryViewItem(obj, this.objectTypeGeometries.get(obj.typeId)!), + }); + } + + public dispose(): void { + this.objectTypeGeometries.dispose(); + this.objectGeometries.dispose(); + } + + public get items(): ViewItem[] { + return [ + ...this.objectTypeGeometries, + ...this.objectGeometries, + ]; + } +} diff --git a/webclient/src/state/worldEditorState.ts b/webclient/src/state/worldEditorState.ts index ed671e9..c64fae5 100644 --- a/webclient/src/state/worldEditorState.ts +++ b/webclient/src/state/worldEditorState.ts @@ -1,13 +1,11 @@ import { makeAutoObservable, runInAction } from "mobx"; import type { WorldState } from "./worldState"; -import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType, type Voxel } from "../types"; +import { type ObjectInstance, type Pos3, type R3, type V3, type ObjectType, type Scene } from "../types"; import { createObjectInstance } from "../utils/object"; import { randomId } from "../utils"; import { state } from "./rootState"; -import { populateRuntimeObject, computeBoundingBox } from "../utils/runtime"; +import { populateRuntimeObject } from "../utils/runtime"; import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes"; -import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup"; -import { buildObjectTrimesh } from "../utils/graphics/mesh"; export const ObjectEditModeEnum = [ 'translate', @@ -99,7 +97,7 @@ export class WorldEditorState { this.setSelection(undefined); } - public get scene(): RuntimeScene { + public get scene(): Scene { return this.world.data.initialScene; } @@ -168,48 +166,6 @@ export class WorldEditorState { return objectType; } - public addVoxelToObjectType(typeId: string, voxel: Voxel): void { - const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) - return; - const occupied = objectType.voxels.some(v => - v.position[0] === voxel.position[0] && - v.position[1] === voxel.position[1] && - v.position[2] === voxel.position[2] - ); - if (!occupied) - objectType.voxels.push(voxel); - } - - public removeVoxelFromObjectType(typeId: string, position: V3): void { - const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) - return; - const idx = objectType.voxels.findIndex(v => - v.position[0] === position[0] && - v.position[1] === position[1] && - v.position[2] === position[2] - ); - if (idx !== -1) - objectType.voxels.splice(idx, 1); - } - - public refreshObjectTypeCaches(typeId: string): void { - const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) - return; - const world = this.world.data; - const now = Date.now(); - for (const obj of Object.values(this.scene.objects)) { - if (obj.typeId !== typeId) - continue; - obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes); - obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes); - obj.cache.boundingBox = computeBoundingBox(objectType); - obj.cache.updatedAt = now; - } - } - public deleteObjectType(typeId: string) { if (this.selection?.type === 'objectType' && this.selection.id === typeId) this.resetSelection(); diff --git a/webclient/src/state/worldState.ts b/webclient/src/state/worldState.ts index ee8a679..ad9bb4e 100644 --- a/webclient/src/state/worldState.ts +++ b/webclient/src/state/worldState.ts @@ -12,30 +12,11 @@ export class WorldState { private _buildSave(): World { const d = this.data; - return { - objectTypes: toJS(d.objectTypes), - voxelTypes: toJS(d.voxelTypes), - editorCamera: toJS(d.editorCamera), - gameRules: toJS(d.gameRules), - initialScene: { - playerObjectId: d.initialScene.playerObjectId, - objects: Object.fromEntries( - Object.entries(d.initialScene.objects).map(([id, obj]) => [id, { - id: obj.id, - typeId: obj.typeId, - physics: obj.physics, - gravityScale: obj.gravityScale, - position: toJS(obj.position), - rotation: toJS(obj.rotation), - scale: toJS(obj.scale), - } satisfies ObjectInstance]) - ), - }, - }; + return toJS(d); } private startAutoSave() { - return autorun(() => WorldFactory.save(this._buildSave()), { delay: 500 }); + return autorun(() => this.save(), { delay: 500 }); } private withoutAutoSave(fn: () => void) { @@ -105,6 +86,9 @@ export class WorldState { position: [0, -1, 0], rotation: [0, 0, 0], scale: [1, 1, 1], + visible: true, + linearVelocity: [0, 0, 0], + angularVelocity: [0, 0, 0], }, ...objectMap, }, @@ -128,6 +112,7 @@ export class WorldState { } public save(): void { + console.log('Saving world...'); WorldFactory.save(this._buildSave()); } diff --git a/webclient/src/types/model/game.ts b/webclient/src/types/model/game.ts index ca337ab..5877dee 100644 --- a/webclient/src/types/model/game.ts +++ b/webclient/src/types/model/game.ts @@ -1,7 +1,7 @@ -import type { GameScene, RuntimeGameScene } from "./scene"; +import type { Scene } from "./scene"; -export type Game = { +export type Game = { paused: boolean; time: number; - scene: TScene; + scene: Scene; } diff --git a/webclient/src/types/model/object.ts b/webclient/src/types/model/object.ts index f05cd07..28e3a06 100644 --- a/webclient/src/types/model/object.ts +++ b/webclient/src/types/model/object.ts @@ -1,5 +1,4 @@ import type { R3, V3 } from "../3d"; -import type { Runtime } from "./runtime"; import type { Voxel } from "./voxel"; export type ObjectType = { @@ -8,7 +7,7 @@ export type ObjectType = { voxels: Voxel[]; program?: string; javascript?: string; -} +}; export type ObjectInstance = { id: string; @@ -19,15 +18,7 @@ export type ObjectInstance = { rotation: R3; scale: V3; visible: boolean; -} -export type GameObjectData = { - version: number; // increment to force three and physics changes linearVelocity: V3; - angularVelocity: R3; -} - -export type GameObjectInstance = ObjectInstance & GameObjectData; - -export type RuntimeObjectInstance = Runtime; -export type RuntimeGameObjectInstance = Runtime; + angularVelocity: V3; +}; diff --git a/webclient/src/types/model/runtime.ts b/webclient/src/types/model/runtime.ts index cce95ef..5f75786 100644 --- a/webclient/src/types/model/runtime.ts +++ b/webclient/src/types/model/runtime.ts @@ -1,6 +1,5 @@ import type { V3 } from "../3d"; import type { VoxelGroup } from "../runtime"; -import type { ObjectInstance } from "./object"; export type ObjectInstanceRigidBody = { handle: number; // rapier RigidBodyHandle @@ -13,13 +12,12 @@ export type ObjectInstanceRigidBody = { }; export type ObjectInstanceRuntimeData = { - cache: { - voxelGroups: VoxelGroup[]; - colliderMesh: [Float32Array, Uint32Array] | null; - boundingBox: { min: V3; max: V3 }; - updatedAt: number; - }; rb: () => ObjectInstanceRigidBody | null; -} +}; -export type Runtime = T & ObjectInstanceRuntimeData; +export type ObjectTypeRuntimeData = { + voxelGroups: VoxelGroup[]; + colliderMesh: [Float32Array, Uint32Array] | null; + boundingBox: { min: V3; max: V3 }; + updatedAt: number; +} diff --git a/webclient/src/types/model/scene.ts b/webclient/src/types/model/scene.ts index 4314e82..dd7fcf3 100644 --- a/webclient/src/types/model/scene.ts +++ b/webclient/src/types/model/scene.ts @@ -1,10 +1,6 @@ -import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object"; +import type { ObjectInstance } from "./object"; -export type Scene = { - objects: Record; +export type Scene = { + objects: Record; playerObjectId: string | undefined; } - -export type GameScene = Scene; -export type RuntimeScene = Scene; -export type RuntimeGameScene = Scene; diff --git a/webclient/src/utils/graphics/mesh.ts b/webclient/src/utils/graphics/mesh.ts index f868343..a1d4614 100644 --- a/webclient/src/utils/graphics/mesh.ts +++ b/webclient/src/utils/graphics/mesh.ts @@ -1,10 +1,10 @@ import type { ObjectType, VoxelType } from "../../types"; -export function buildObjectTrimesh(objectType: ObjectType, voxelTypes: Record): [Float32Array, Uint32Array] | null { +export function buildObjectTrimesh(objectType: ObjectType, voxelTypes: Record): [Float32Array, Uint32Array] | undefined { const collidable = objectType.voxels.filter((v) => voxelTypes[v.typeId]?.collidable !== false); if (!collidable.length) - return null; + return; const n = collidable.length; const verts = new Float32Array(n * 8 * 3); diff --git a/webclient/src/utils/graphics/voxelMesh.ts b/webclient/src/utils/graphics/voxelMesh.ts index 635de77..bb25e70 100644 --- a/webclient/src/utils/graphics/voxelMesh.ts +++ b/webclient/src/utils/graphics/voxelMesh.ts @@ -28,7 +28,7 @@ function triangulatePolygon(polygon: PolygonClipping.Polygon): V2[] { return result; } -export function buildVoxelMesh(voxels: V3[]): BufferGeometry { +export function buildVoxelGroupMesh(voxels: V3[]): BufferGeometry { const vertices: V3[] = []; const normals: V3[] = []; diff --git a/webclient/src/utils/object.ts b/webclient/src/utils/object.ts index 46cbae0..661e7e9 100644 --- a/webclient/src/utils/object.ts +++ b/webclient/src/utils/object.ts @@ -6,6 +6,7 @@ export function createObjectInstance(id: string, typeId: string): ObjectInstance typeId: typeId, physics: true, gravityScale: 1, + visible: true, position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], diff --git a/webclient/src/utils/reconciler.ts b/webclient/src/utils/reconciler.ts new file mode 100644 index 0000000..a344f20 --- /dev/null +++ b/webclient/src/utils/reconciler.ts @@ -0,0 +1,113 @@ +import { observable, runInAction, reaction, type IReactionDisposer } from "mobx"; + +type MapItemOption = + TStored extends TInput ? TInput extends TStored + ? { mapItem?: never } + : { mapItem(value: TInput): TStored } + : { mapItem(value: TInput): TStored }; + +export type ReconcilerMapOptions = + { + keyOf(item: TInput): TKey; + initializeItem?(item: TStored, source: TInput): void; + updateItem?(current: TStored, next: TStored, nextSource: TInput): void; + disposeItem?(item: TStored): void; + } & MapItemOption; + +export class ReconcilerMap { + private readonly options: ReconcilerMapOptions; + private readonly items = observable.map(); + + constructor( + options: ReconcilerMapOptions, + ) { + this.options = options; + } + + public get values(): readonly TStored[] { + return [...this.items.values()].map(e => e.stored); + } + + public get length(): number { + return this.items.size; + } + + public has(key: TKey): boolean { + return this.items.has(key); + } + + public get(key: TKey): TStored | undefined { + return this.items.get(key)?.stored; + } + + public *[Symbol.iterator](): Iterator { + for (const { stored } of this.items.values()) + yield stored; + } + + public reconcile(nextItems: Iterable): void { + const nextMap = new Map(); + + for (const item of nextItems) { + nextMap.set(this.options.keyOf(item), item); + } + + // Remove disappeared items + for (const [key, { stored }] of this.items) { + if (!nextMap.has(key)) { + runInAction(() => this.items.delete(key)); + this.options.disposeItem?.(stored); + } + } + + // Add or update existing items + for (const [key, next] of nextMap) { + const existing = this.items.get(key); + + if (existing === undefined) { + // New key — always call mapItem (or identity when TStored = TInput) + const nextMapped = (this.options.mapItem?.(next) ?? next) as TStored; + runInAction(() => this.items.set(key, { input: next, stored: nextMapped })); + this.options.initializeItem?.(nextMapped, next); + } else if (existing.input !== next) { + // Same key, different input reference — call mapItem and update + const nextMapped = (this.options.mapItem?.(next) ?? next) as TStored; + runInAction(() => this.items.set(key, { input: next, stored: nextMapped })); + if (this.options.updateItem?.(existing.stored, nextMapped, next)) + this.options.disposeItem?.(existing.stored); + } + // Same key, same input reference — skip mapItem entirely + } + } + + public dispose(): void { + const stored = [...this]; + runInAction(() => this.items.clear()); + for (const item of stored) + this.options.disposeItem?.(item); + } +} + +export type MobxReconcilerMapOptions = + ReconcilerMapOptions & { reaction(): Iterable }; + +export class MobxReconcilerMap extends ReconcilerMap { + private readonly disposeReaction: IReactionDisposer; + + constructor(options: MobxReconcilerMapOptions) { + super(options); + + // reaction data fn (tracked) fires only on object list structural changes. + // effect fn (untracked) feeds current instances to the reconciler. + this.disposeReaction = reaction( + () => Array.from(options.reaction()), + () => { this.reconcile(options.reaction()); }, + { fireImmediately: true }, + ); + } + + public dispose(): void { + this.disposeReaction(); + super.dispose(); + } +} diff --git a/webclient/src/utils/runtime/object.ts b/webclient/src/utils/runtime/object.ts index 5862900..44491a3 100644 --- a/webclient/src/utils/runtime/object.ts +++ b/webclient/src/utils/runtime/object.ts @@ -1,5 +1,5 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup"; -import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types"; +import type { ObjectInstance, ObjectType, RuntimeObjectInstance, RuntimeObjectType, V3, World } from "../../types"; import { buildObjectTrimesh } from "../graphics/mesh"; export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } { @@ -18,27 +18,34 @@ export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] }; } -export function populateRuntimeObject(object: ObjectInstance, world: World): RuntimeObjectInstance { - const objectType = world.objectTypes[object.typeId]; - +export function populateRuntimeObjectType(value: ObjectType, world: World): RuntimeObjectType { return { - ...object, - cache: { - voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes), - colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes), - boundingBox: computeBoundingBox(objectType), + ...value, + runtime: { + voxelGroups: getObjectVoxelGroups(value, world.voxelTypes), + colliderMesh: buildObjectTrimesh(value, world.voxelTypes), + boundingBox: computeBoundingBox(value), updatedAt: 0, }, - rb: () => null, }; } -export function depopulateRuntimeObject(object: RuntimeObjectInstance, _world: World): ObjectInstance { - const { cache: _cache, rb: _rb, ...result } = object; +export function populateRuntimeObject(value: ObjectInstance, _world: World): RuntimeObjectInstance { + return { + ...value, + runtime: { + rb: () => null, + }, + }; +} + +export function depopulateRuntimeObjectType(value: RuntimeObjectType, _world: World): ObjectType { + const { runtime: _, ...result } = value; return result; } -export function isGameObject(obj: ObjectInstance): obj is GameObjectInstance { - return 'version' in obj; +export function depopulateRuntimeObject(value: RuntimeObjectInstance, _world: World): ObjectInstance { + const { runtime: _, ...result } = value; + return result; }