layered three view
game object geometries layer
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<Outlet />
|
||||
<ThreeView />
|
||||
<LayeredThreeView />
|
||||
<Panels />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<THREE.Group>(null);
|
||||
|
||||
const rb = obj.rb();
|
||||
|
||||
@@ -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(); };
|
||||
|
||||
@@ -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 <PlayerObjectView object={props.object as RuntimeGameObjectInstance} objectType={objectType} />;
|
||||
return <PlayerObjectView object={props.object} objectType={objectType} />;
|
||||
|
||||
function handleClick(): void {
|
||||
state.game?.emitEvent('objectClicked', { object: props.object });
|
||||
|
||||
@@ -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<ObjectInstance>;
|
||||
object: ObjectInstance;
|
||||
isPlayer: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RuntimeObjectInstance, 'typeId'>;
|
||||
object: Omit<ObjectInstance, 'typeId'>;
|
||||
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) =>
|
||||
<VoxelGroupMesh
|
||||
key={`${vg.id}-${object.cache.updatedAt}`}
|
||||
@@ -163,7 +161,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||
/>
|
||||
)
|
||||
}
|
||||
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
|
||||
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />} */}
|
||||
</group>
|
||||
</SyncRigidBody>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const DebugLabelsLayer = observer(function () {
|
||||
return null; //TODO
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <>
|
||||
<Canvas
|
||||
// camera={state.world.character.camera}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={1} />
|
||||
|
||||
<Perf position="bottom-right" />
|
||||
|
||||
<CameraLayer camera={state.worldEditor.camera} />
|
||||
<ObjectGeomertiesLayer />
|
||||
</Canvas>
|
||||
</>;
|
||||
});
|
||||
@@ -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<ObjectGeometryList | null>(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;
|
||||
});
|
||||
@@ -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<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
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,23 +84,13 @@ state.worldEditor.refreshObjectTypeCaches(id);
|
||||
}, [undo, redo]);
|
||||
|
||||
function handleAdd(voxel: Voxel) {
|
||||
if (!id)
|
||||
return;
|
||||
state.worldEditor.addVoxelToObjectType(id, 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);
|
||||
const voxel = objectType?.removeVoxel(position);
|
||||
if (voxel)
|
||||
pushHistory({ type: 'remove', voxel });
|
||||
}
|
||||
|
||||
@@ -116,11 +106,11 @@ state.worldEditor.refreshObjectTypeCaches(id);
|
||||
<div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Top bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}>
|
||||
<button onClick={() => navigate(`/editor/objectType/${id}`)} style={btnStyle}>
|
||||
<button onClick={() => navigate(`/editor/objectType/${objectType.objectType.id}`)} style={btnStyle}>
|
||||
<IconArrowLeft size={20} />
|
||||
</button>
|
||||
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
|
||||
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
|
||||
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.objectType.name}</span>
|
||||
<span style={{ color: '#666', fontSize: 13 }}>{objectType.objectType.voxels.length} voxels</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
|
||||
<IconArrowBackUp size={20} />
|
||||
@@ -134,7 +124,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
|
||||
<VoxelEditorScene
|
||||
voxels={objectType.voxels.slice()}
|
||||
voxels={objectType.objectType.voxels.slice()}
|
||||
mode={mode}
|
||||
color={color}
|
||||
typeId="stone"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useMemo, useEffect } from 'react';
|
||||
import type { ThreeEvent } from '@react-three/fiber';
|
||||
import type { Ray } from 'three';
|
||||
import type { Voxel, V3 } from '../../types';
|
||||
import { buildVoxelMesh } from '../../utils/graphics/voxelMesh';
|
||||
import { buildVoxelGroupMesh } from '../../utils/graphics/voxelMesh';
|
||||
|
||||
type Props = {
|
||||
voxels: Voxel[];
|
||||
@@ -64,7 +64,7 @@ interface MergedVoxelMeshProps {
|
||||
|
||||
function MergedVoxelMesh({ color, positions, onPointerMove, onClick, onPointerLeave }: MergedVoxelMeshProps) {
|
||||
const geo = useMemo(
|
||||
() => buildVoxelMesh(positions),
|
||||
() => buildVoxelGroupMesh(positions),
|
||||
[positions],
|
||||
);
|
||||
useEffect(() => () => { geo.dispose(); }, [geo]);
|
||||
|
||||
@@ -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<GameScene>): 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;
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ export class WorldFactory {
|
||||
voxelTypes: DEFAULT_VOXEL_TYPES,
|
||||
initialScene: {
|
||||
objects: {},
|
||||
playerObjectId: undefined,
|
||||
},
|
||||
gameRules: {
|
||||
gravity: true,
|
||||
|
||||
@@ -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<ObjectGeometryViewItem>;
|
||||
private readonly typeRefCounts = new Map<string, number>();
|
||||
|
||||
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<ObjectGeometryViewItem>) {
|
||||
this.items = new MobxReconcilerMap<ObjectGeometryViewItem>({
|
||||
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<string, { typeGeometry: ObjectTypeGeometryViewItem, instanceCount: number }> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GameScene> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ObjectType, ObjectTypeGeometryViewItem>;
|
||||
public objectGeometries: MobxReconcilerMap<ObjectInstance, ObjectGeometryViewItem>;
|
||||
|
||||
constructor(world: { data: { objectTypes: Record<string, ObjectType>; initialScene: Scene } }) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
);
|
||||
this.objectTypeGeometries = new MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>({
|
||||
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<ObjectInstance, ObjectGeometryViewItem>({
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GameScene, RuntimeGameScene } from "./scene";
|
||||
import type { Scene } from "./scene";
|
||||
|
||||
export type Game<TScene extends GameScene = RuntimeGameScene> = {
|
||||
export type Game = {
|
||||
paused: boolean;
|
||||
time: number;
|
||||
scene: TScene;
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
@@ -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<ObjectInstance>;
|
||||
export type RuntimeGameObjectInstance = Runtime<GameObjectInstance>;
|
||||
angularVelocity: V3;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
rb: () => ObjectInstanceRigidBody | null;
|
||||
};
|
||||
|
||||
export type ObjectTypeRuntimeData = {
|
||||
voxelGroups: VoxelGroup[];
|
||||
colliderMesh: [Float32Array, Uint32Array] | null;
|
||||
boundingBox: { min: V3; max: V3 };
|
||||
updatedAt: number;
|
||||
};
|
||||
rb: () => ObjectInstanceRigidBody | null;
|
||||
}
|
||||
|
||||
export type Runtime<T extends ObjectInstance> = T & ObjectInstanceRuntimeData;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
|
||||
import type { ObjectInstance } from "./object";
|
||||
|
||||
export type Scene<TObject extends ObjectInstance> = {
|
||||
objects: Record<string, TObject>;
|
||||
export type Scene = {
|
||||
objects: Record<string, ObjectInstance>;
|
||||
playerObjectId: string | undefined;
|
||||
}
|
||||
|
||||
export type GameScene = Scene<GameObjectInstance>;
|
||||
export type RuntimeScene = Scene<RuntimeObjectInstance>;
|
||||
export type RuntimeGameScene = Scene<RuntimeGameObjectInstance>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ObjectType, VoxelType } from "../../types";
|
||||
|
||||
export function buildObjectTrimesh(objectType: ObjectType, voxelTypes: Record<string, VoxelType>): [Float32Array, Uint32Array] | null {
|
||||
export function buildObjectTrimesh(objectType: ObjectType, voxelTypes: Record<string, VoxelType>): [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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { observable, runInAction, reaction, type IReactionDisposer } from "mobx";
|
||||
|
||||
type MapItemOption<TInput, TStored> =
|
||||
TStored extends TInput ? TInput extends TStored
|
||||
? { mapItem?: never }
|
||||
: { mapItem(value: TInput): TStored }
|
||||
: { mapItem(value: TInput): TStored };
|
||||
|
||||
export type ReconcilerMapOptions<TInput, TStored, TKey extends string> =
|
||||
{
|
||||
keyOf(item: TInput): TKey;
|
||||
initializeItem?(item: TStored, source: TInput): void;
|
||||
updateItem?(current: TStored, next: TStored, nextSource: TInput): void;
|
||||
disposeItem?(item: TStored): void;
|
||||
} & MapItemOption<TInput, TStored>;
|
||||
|
||||
export class ReconcilerMap<TInput, TStored = TInput, TKey extends string = string> {
|
||||
private readonly options: ReconcilerMapOptions<TInput, TStored, TKey>;
|
||||
private readonly items = observable.map<TKey, { input: TInput; stored: TStored }>();
|
||||
|
||||
constructor(
|
||||
options: ReconcilerMapOptions<TInput, TStored, TKey>,
|
||||
) {
|
||||
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<TStored> {
|
||||
for (const { stored } of this.items.values())
|
||||
yield stored;
|
||||
}
|
||||
|
||||
public reconcile(nextItems: Iterable<TInput>): void {
|
||||
const nextMap = new Map<TKey, TInput>();
|
||||
|
||||
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<TInput, TStored, TKey extends string> =
|
||||
ReconcilerMapOptions<TInput, TStored, TKey> & { reaction(): Iterable<TInput> };
|
||||
|
||||
export class MobxReconcilerMap<TInput, TStored = TInput, TKey extends string = string> extends ReconcilerMap<TInput, TStored, TKey> {
|
||||
private readonly disposeReaction: IReactionDisposer;
|
||||
|
||||
constructor(options: MobxReconcilerMapOptions<TInput, TStored, TKey>) {
|
||||
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<TInput[], true>(
|
||||
() => Array.from(options.reaction()),
|
||||
() => { this.reconcile(options.reaction()); },
|
||||
{ fireImmediately: true },
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.disposeReaction();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user