Compare commits

...

2 Commits

Author SHA1 Message Date
azykov@mail.ru 9145277681
world autosave on any edit/change 2026-06-02 19:07:30 +03:00
azykov@mail.ru 9217220505
observable world optimization 2026-06-02 18:36:13 +03:00
8 changed files with 95 additions and 92 deletions

View File

@ -16,6 +16,7 @@
"install": "^0.13.0", "install": "^0.13.0",
"mobx": "^6.15.4", "mobx": "^6.15.4",
"mobx-react-lite": "^4.1.1", "mobx-react-lite": "^4.1.1",
"mobx-utils": "^6.1.1",
"npm": "^11.16.0", "npm": "^11.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",

View File

@ -26,6 +26,9 @@ importers:
mobx-react-lite: mobx-react-lite:
specifier: ^4.1.1 specifier: ^4.1.1
version: 4.1.1(mobx@6.15.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 4.1.1(mobx@6.15.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mobx-utils:
specifier: ^6.1.1
version: 6.1.1(mobx@6.15.4)
npm: npm:
specifier: ^11.16.0 specifier: ^11.16.0
version: 11.16.0 version: 11.16.0
@ -1017,6 +1020,11 @@ packages:
react-native: react-native:
optional: true optional: true
mobx-utils@6.1.1:
resolution: {integrity: sha512-ZR4tOKucWAHOdMjqElRl2BEvrzK7duuDdKmsbEbt2kzgVpuLuoYLiDCjc3QwWQl8CmOlxPgaZQpZ7emwNqPkIg==}
peerDependencies:
mobx: ^6.0.0
mobx@6.15.4: mobx@6.15.4:
resolution: {integrity: sha512-do+2UsEKRVT70W/QqP2F2sju2x4p2xZo+5/azXqKjXgTk2jfmzsLjzwW0YI8CBEjy4ZUdU8EunXocXXwJdCrtw==} resolution: {integrity: sha512-do+2UsEKRVT70W/QqP2F2sju2x4p2xZo+5/azXqKjXgTk2jfmzsLjzwW0YI8CBEjy4ZUdU8EunXocXXwJdCrtw==}
@ -2289,6 +2297,10 @@ snapshots:
optionalDependencies: optionalDependencies:
react-dom: 19.2.6(react@19.2.6) react-dom: 19.2.6(react@19.2.6)
mobx-utils@6.1.1(mobx@6.15.4):
dependencies:
mobx: 6.15.4
mobx@6.15.4: {} mobx@6.15.4: {}
ms@2.1.3: {} ms@2.1.3: {}

View File

@ -1,6 +1,6 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import type { ObjectInstance } from "../types"; import type { ObjectInstance } from "../types";
import { useEffect, useRef, type RefObject } from "react"; import { useRef, type RefObject } from "react";
import type { Group } from "three"; import type { Group } from "three";
import { TransformControls, useHelper } from "@react-three/drei"; import { TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three"; import { BoxHelper } from "three";
@ -15,7 +15,6 @@ type ObjectViewProps = {
export const ObjectView = observer(function ({ object }: ObjectViewProps) { export const ObjectView = observer(function ({ object }: ObjectViewProps) {
const groupRef = useRef<Group>(null); const groupRef = useRef<Group>(null);
const controlsRef = useRef<any>(null);
const isSelected = state.worldEditor.isEnabled && const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === object.id; state.worldEditor.selectedObjectId === object.id;
@ -29,48 +28,33 @@ export const ObjectView = observer(function ({ object }: ObjectViewProps) {
? state.worldEditor.selectedObjectMode ? state.worldEditor.selectedObjectMode
: undefined; : undefined;
useEffect(() => { function handleClick(e: ThreeEvent<MouseEvent>) {
if (!isSelected)
return;
const controls = controlsRef.current;
if (!controls)
return;
const onDragChanged = (e: { value: boolean }) => {
state.worldEditor.setIsDragging(e.value);
if (!e.value) {
const group = groupRef.current;
if (group)
state.worldEditor.setObjectTransform(
object.id,
group.position.toArray(),
group.rotation.toArray().slice(0, 3) as R3,
group.scale.toArray(),
);
}
};
controls.addEventListener('dragging-changed', onDragChanged);
return () => {
controls.removeEventListener('dragging-changed', onDragChanged);
state.worldEditor.setIsDragging(false);
};
}, [isSelected]);
const handleClick = (e: ThreeEvent<MouseEvent>) => {
if (!state.worldEditor.isEnabled) if (!state.worldEditor.isEnabled)
return; return;
if (e.delta > 5) if (e.delta > 5)
return; return;
e.stopPropagation(); e.stopPropagation();
state.worldEditor.setSelectedObject(object.id, nextSelectionEditMode(selectionMode)); state.worldEditor.setSelectedObject(object.id, nextSelectionEditMode(selectionMode));
}; };
function handleTransformEnd() {
const group = groupRef.current;
if (group)
state.worldEditor.setObjectTransform(
object.id,
group.position.toArray(),
group.rotation.toArray().slice(0, 3) as R3,
group.scale.toArray(),
);
};
return (<> return (<>
{ {
(isSelected && (selectionMode !== undefined) && groupRef.current) && (isSelected && (selectionMode !== undefined) && groupRef.current) &&
<TransformControls <TransformControls
object={groupRef as RefObject<Group>} object={groupRef as RefObject<Group>}
mode={selectionMode} mode={selectionMode}
onMouseUp={handleTransformEnd}
/> />
} }
<group <group

View File

@ -6,6 +6,7 @@ import { observer } from 'mobx-react-lite';
import { state } from '../state'; import { state } from '../state';
import { SceneView } from './SceneView'; import { SceneView } from './SceneView';
import type { Pos3 } from '../types/3d'; import type { Pos3 } from '../types/3d';
import type { OrthographicCamera, PerspectiveCamera } from 'three';
const CameraSync = observer(function ({ camera }: { camera: Pos3 }) { const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree(); const { camera: threeCamera } = useThree();
@ -22,10 +23,29 @@ const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
export const SceneEditorView = observer(function () { export const SceneEditorView = observer(function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null); const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
const cameraAtStart = useRef<string>('');
function cameraSnapshot(camera: OrthographicCamera | PerspectiveCamera): string {
return JSON.stringify({
position: camera.position,
rotation: camera.rotation,
});
}
const handleStart = () => {
const controls = controlsRef.current;
if (!controls)
return;
cameraAtStart.current = cameraSnapshot(controls.object);
};
const handleEnd = () => { const handleEnd = () => {
const controls = controlsRef.current; const controls = controlsRef.current;
if (!controls) if (!controls)
return; return;
const snapshot = cameraSnapshot(controls.object);
if (snapshot === cameraAtStart.current)
return;
const [x, y, z] = controls.object.rotation.toArray(); const [x, y, z] = controls.object.rotation.toArray();
state.worldEditor.setCamera({ state.worldEditor.setCamera({
position: controls.object.position.toArray(), position: controls.object.position.toArray(),
@ -36,7 +56,7 @@ export const SceneEditorView = observer(function () {
return (<> return (<>
<OrbitControls <OrbitControls
ref={controlsRef} ref={controlsRef}
enabled={!state.worldEditor.isDragging} onStart={handleStart}
onEnd={handleEnd} onEnd={handleEnd}
makeDefault makeDefault
enableDamping={false} enableDamping={false}

View File

@ -8,11 +8,16 @@ export const Toolbar = observer(function () {
state.worldEditor.addObjectCloneAtRandomPosition('test1'); state.worldEditor.addObjectCloneAtRandomPosition('test1');
} }
function handleLoadWorld(): void {
state.world.load();
}
function handleLoadMockWorld(): void { function handleLoadMockWorld(): void {
state.world.loadMock(); state.world.loadMock();
} }
return <div className="toolbar"> return <div className="toolbar">
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button> <button onClick={handleLoadMockWorld}>Load mock world</button>
<button onClick={handleCloneTest1Object}>Clone test1</button> <button onClick={handleCloneTest1Object}>Clone test1</button>
</div> </div>

View File

@ -1,7 +1,6 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import type { RunningGameState, Scene } from "../types"; import type { RunningGameState, Scene } from "../types";
import { clone } from "../utils";
import type { Pos3 } from "../types/3d"; import type { Pos3 } from "../types/3d";
import type { CameraProps } from "@react-three/fiber"; import type { CameraProps } from "@react-three/fiber";
@ -39,15 +38,11 @@ export class GameState {
} }
public resume(): void { public resume(): void {
const state = clone(this.world.data.state) as RunningGameState; this.state.paused = false;
state.paused = false;
this.world.data.state = state;
} }
public pause(): void { public pause(): void {
const state = clone(this.world.data.state) as RunningGameState; this.state.paused = true;
state.paused = true;
this.world.data.state = state;
} }
public stop(): void { public stop(): void {

View File

@ -1,6 +1,6 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import type { WorldState } from "./worldState"; import type { WorldState } from "./worldState";
import type { ObjectInstance, Scene, World } from "../types"; import type { ObjectInstance, Scene } from "../types";
import { createObjectInstance } from "../utils/object"; import { createObjectInstance } from "../utils/object";
import { randomId } from "../utils"; import { randomId } from "../utils";
import type { Pos3, R3, V3 } from "../types/3d"; import type { Pos3, R3, V3 } from "../types/3d";
@ -27,7 +27,6 @@ export class WorldEditorState {
public selectedObjectId: string | undefined; public selectedObjectId: string | undefined;
public selectedObjectMode: SelectionEditMode | undefined; public selectedObjectMode: SelectionEditMode | undefined;
public isDragging: boolean = false;
constructor(world: WorldState) { constructor(world: WorldState) {
this.world = world; this.world = world;
@ -40,7 +39,6 @@ export class WorldEditorState {
public setCamera(value: Pos3): void { public setCamera(value: Pos3): void {
this.world.data.editorCamera = value; this.world.data.editorCamera = value;
this.world.save();
} }
public setSelectedObject(id: string, mode: SelectionEditMode): void { public setSelectedObject(id: string, mode: SelectionEditMode): void {
@ -53,12 +51,6 @@ export class WorldEditorState {
this.selectedObjectMode = undefined; this.selectedObjectMode = undefined;
} }
public setIsDragging(value: boolean): void {
this.isDragging = value;
if (!value)
this.world.save();
}
public get scene(): Scene { public get scene(): Scene {
return this.world.data.initialScene; return this.world.data.initialScene;
} }
@ -67,49 +59,15 @@ export class WorldEditorState {
return this.world.data.editorCamera; return this.world.data.editorCamera;
} }
public mutateWorld(mutation: Partial<World>): void { public setObjectTransform(id: string, position: V3, rotation: R3, scale: V3): void {
this.world.data = {
...this.world.data,
...mutation,
}
}
public mutateScene(mutation: Partial<Scene>): void {
this.mutateWorld({
initialScene: {
...this.world.data.initialScene,
...mutation,
}
});
}
public mutateObject(mutation: Partial<ObjectInstance> & Pick<ObjectInstance, 'id'>): void {
this.mutateScene({
objects: {
...this.world.data.initialScene.objects,
[mutation.id]: {
...this.world.data.initialScene.objects[mutation.id],
...mutation,
},
},
});
}
public setObjectTransform(
id: string,
position: V3,
rotation: R3,
scale: V3,
): void {
const obj = this.scene.objects[id]; const obj = this.scene.objects[id];
if (!obj) if (!obj)
return; return;
this.mutateObject({ runInAction(() => { // all in one go
...obj, obj.position = position;
position, obj.rotation = rotation;
rotation, obj.scale = scale;
scale,
}); });
} }

View File

@ -1,4 +1,5 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable, reaction, toJS } from "mobx";
import { deepObserve } from "mobx-utils";
import { WorldFactory } from "../model/worldFactory"; import { WorldFactory } from "../model/worldFactory";
import type { ObjectType, World } from "../types"; import type { ObjectType, World } from "../types";
import type { VoxelType } from "../types/voxel"; import type { VoxelType } from "../types/voxel";
@ -10,16 +11,35 @@ import { clone } from "../utils";
export class WorldState { export class WorldState {
public data: World = WorldFactory.create(); public data: World = WorldFactory.create();
private saveTimer: ReturnType<typeof setTimeout> | undefined;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
this.setupAutoSave();
}
private setupAutoSave() {
let disposeDeep: (() => void) | undefined;
reaction(
() => this.data,
(data) => {
disposeDeep?.();
disposeDeep = deepObserve(data, () => this.save());
},
{ fireImmediately: true },
);
} }
public reset() { public reset() {
state.worldEditor.resetSelectedObject(); console.log('Resetting world...');
this.data = WorldFactory.create(); this.data = WorldFactory.create();
state.worldEditor.resetSelectedObject();
} }
public loadMock() { public loadMock() {
console.log('Mocking world...');
const objectId1 = 'object1'; const objectId1 = 'object1';
this.data = { this.data = {
@ -59,16 +79,24 @@ export class WorldState {
}, },
}; };
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelectedObject();
this.save();
} }
public load() { public load() {
console.log('Loading world...');
this.data = WorldFactory.load() ?? WorldFactory.create(); this.data = WorldFactory.load() ?? WorldFactory.create();
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelectedObject();
} }
public save(): void { public save(): void {
WorldFactory.save(this.data); console.log('Saving world...');
console.log((new Error('').stack!));
const { objectTypes, voxelTypes, ...debug } = toJS(this.data);
console.log(JSON.stringify(debug, undefined, 4));
// debounce
clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
WorldFactory.save(toJS(this.data));
}, 500);
} }
public getObjectTypeById(id: string): ObjectType | undefined { public getObjectTypeById(id: string): ObjectType | undefined {
@ -92,4 +120,4 @@ export class WorldState {
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelectedObject();
} }
} }
} }