layered three view

game object geometries layer
This commit is contained in:
azykov@mail.ru
2026-06-19 20:54:27 +03:00
parent 076cded364
commit 579fe36e6c
31 changed files with 521 additions and 219 deletions
+2 -2
View File
@@ -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 -2
View File
@@ -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();
+1 -2
View File
@@ -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(); };
+2 -3
View File
@@ -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;
}
+13 -15
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,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);
<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]);
+5 -9
View File
@@ -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;
})
+1
View File
@@ -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;
}
}
}
}
+5 -26
View File
@@ -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 {
+5 -4
View File
@@ -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;
}
}
+135
View File
@@ -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,
];
}
}
+3 -47
View File
@@ -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();
+6 -21
View File
@@ -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());
}
+3 -3
View File
@@ -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;
}
+3 -12
View File
@@ -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;
};
+7 -9
View File
@@ -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 extends ObjectInstance> = T & ObjectInstanceRuntimeData;
export type ObjectTypeRuntimeData = {
voxelGroups: VoxelGroup[];
colliderMesh: [Float32Array, Uint32Array] | null;
boundingBox: { min: V3; max: V3 };
updatedAt: number;
}
+3 -7
View File
@@ -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>;
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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[] = [];
+1
View File
@@ -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],
+113
View File
@@ -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();
}
}
+21 -14
View File
@@ -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;
}