editor now allows object type selection

This commit is contained in:
azykov@mail.ru 2026-06-04 16:44:46 +03:00
parent e08f80fc19
commit 37e8f5ccd3
No known key found for this signature in database
8 changed files with 139 additions and 66 deletions

View File

@ -2,20 +2,22 @@ import { BrowserRouter, Link, Outlet, Route, Routes, useNavigate, useParams } fr
import { useEffect } from 'react'; import { useEffect } from 'react';
import { reaction } from 'mobx'; import { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView'; import { ThreeView } from './components/ThreeView';
import { Panel } from './components/Panel';
import { state } from './state'; import { state } from './state';
import './App.scss'; import './App.scss';
import { LeftPanel } from './components/LeftPanel';
function StateToUrlSync() { function StateToUrlSync() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => reaction( useEffect(() => reaction(
() => ({ () => ({
isGame: state.isGamePlaying, isGame: state.isGamePlaying,
selectedId: state.worldEditor.selectedObjectId, selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id,
selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id,
}), }),
({ isGame, selectedId }) => { ({ isGame, selectedObjectId, selectedObjectTypeId}) => {
if (isGame) navigate('/game'); if (isGame) navigate('/game');
else if (selectedId) navigate(`/editor/object/${selectedId}`); else if (selectedObjectId) navigate(`/editor/object/${selectedObjectId}`);
else if (selectedObjectTypeId) navigate(`/editor/objectType/${selectedObjectTypeId}`);
else navigate('/editor'); else navigate('/editor');
}, },
), []); ), []);
@ -25,7 +27,7 @@ function StateToUrlSync() {
function EditorRoute() { function EditorRoute() {
useEffect(() => { useEffect(() => {
if (state.isGamePlaying) state.stopGame(); if (state.isGamePlaying) state.stopGame();
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelection();
}, []); }, []);
return null; return null;
} }
@ -35,10 +37,23 @@ function EditorObjectRoute() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
if (state.isGamePlaying) state.stopGame(); if (state.isGamePlaying) state.stopGame();
const mode = state.worldEditor.selectedObjectId === id const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
? state.worldEditor.selectedObjectMode ?? 'translate' ? state.worldEditor.selection.editMode ?? 'translate'
: 'translate'; : 'translate';
state.worldEditor.setSelectedObject(id, mode); state.worldEditor.setSelectedObject({ id, editMode });
}, [id]);
return null;
}
function EditorObjectTypeRoute() {
const { id } = useParams<{ id: string }>();
useEffect(() => {
if (!id) return;
if (state.isGamePlaying) state.stopGame();
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? 'scripts'
: 'scripts';
state.worldEditor.setSelectedObjectType({ id, editMode });
}, [id]); }, [id]);
return null; return null;
} }
@ -59,7 +74,7 @@ function EditorLayout() {
<> <>
<Outlet /> <Outlet />
<ThreeView /> <ThreeView />
<Panel side="left" /> <LeftPanel />
</> </>
); );
} }
@ -73,6 +88,7 @@ export const App = function () {
<Route element={<EditorLayout />}> <Route element={<EditorLayout />}>
<Route path="/editor" element={<EditorRoute />} /> <Route path="/editor" element={<EditorRoute />} />
<Route path="/editor/object/:id" element={<EditorObjectRoute />} /> <Route path="/editor/object/:id" element={<EditorObjectRoute />} />
<Route path="/editor/objectType/:id" element={<EditorObjectTypeRoute />} />
<Route path="/game" element={<GameRoute />} /> <Route path="/game" element={<GameRoute />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -0,0 +1,29 @@
import { observer } from "mobx-react-lite";
import './Panel.scss';
import { RenderInfoView } from "./RenderInfoView";
import { MenuView } from "./MenuView";
import { state } from "../state";
import { Panel } from "./Panel";
export const LeftPanel = observer(function () {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
function handleLoadWorld(): void {
state.world.load();
}
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <Panel side='left'>
<MenuView />
<div className="gap" />
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
</Panel>
});

View File

@ -6,7 +6,7 @@ import { TransformControls, useHelper } from "@react-three/drei";
import { BoxHelper } from "three"; import { BoxHelper } from "three";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import { state } from "../state"; import { state } from "../state";
import { nextSelectionEditMode } from "../state/worldEditorState"; import { nextObjectEditMode } from "../state/worldEditorState";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal"; import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
type ObjectEditorViewProps = { type ObjectEditorViewProps = {
@ -23,8 +23,12 @@ type SelectionOverlayProps = {
// re-render on selection change, not all N objects in the scene. // re-render on selection change, not all N objects in the scene.
const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) { const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) {
const isSelected = state.worldEditor.isEnabled && const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === objectId; state.worldEditor.selection?.type === 'object' &&
const selectionMode = isSelected ? state.worldEditor.selectedObjectMode : undefined; state.worldEditor.selection.id === objectId;
const selectionMode = isSelected &&
state.worldEditor.selection?.type === 'object'
? state.worldEditor.selection?.editMode
: undefined;
// Stable virtual ref that reads through to the group inside the handle // Stable virtual ref that reads through to the group inside the handle
const groupRef = useMemo<RefObject<Group | null>>( const groupRef = useMemo<RefObject<Group | null>>(
@ -60,10 +64,10 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
e.stopPropagation(); e.stopPropagation();
// Reading selection state inside an event handler: not tracked by observer. // Reading selection state inside an event handler: not tracked by observer.
const currentMode = state.worldEditor.isEnabled && const currentMode = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === object.id state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id
? state.worldEditor.selectedObjectMode ? state.worldEditor.selection?.editMode
: undefined; : undefined;
state.worldEditor.setSelectedObject(object.id, nextSelectionEditMode(currentMode)); state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
} }
function handleTransformEnd() { function handleTransformEnd() {

View File

@ -1,39 +1,16 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import './Panel.scss'; import './Panel.scss';
import { RenderInfoView } from "./RenderInfoView"; import type { ReactNode } from "react";
import { MenuView } from "./MenuView";
import { state } from "../state";
export type PanelProps = { export type PanelProps = {
side?: 'left' | 'right'; side?: 'left' | 'right';
children?: ReactNode;
} }
export const Panel = observer(function ({ side = 'left' }: PanelProps) { export const Panel = observer(function ({ children, side = 'left' }: PanelProps) {
const isGame = state.game && !state.game.isPaused;
if (isGame)
return null;
function handleCloneTest1Object(): void {
state.worldEditor.addObjectCloneAtRandomPosition('test1');
}
function handleLoadWorld(): void {
state.world.load();
}
function handleLoadMockWorld(): void {
state.world.loadMock();
}
return <div className={`panel ${side}`}> return <div className={`panel ${side}`}>
<div className="container"> <div className="container">
<MenuView /> {children}
<div className="gap" />
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
</div> </div>
</div > </div >
}); });

View File

@ -44,7 +44,7 @@ export const ThreeView = observer(function () {
<div style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}> <div style={{ position: 'fixed', inset: 0, overflow: 'hidden' }}>
<Canvas <Canvas
// camera={state.world.character.camera} // camera={state.world.character.camera}
onPointerMissed={() => state.worldEditor.resetSelectedObject()} onPointerMissed={() => state.worldEditor.resetSelection()}
> >
<Stats parent={chartRef as RefObject<HTMLElement>} /> <Stats parent={chartRef as RefObject<HTMLElement>} />
<RenderInfoUpdater /> <RenderInfoUpdater />

View File

@ -1,5 +1,4 @@
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
import type { ObjectType, RuntimeObjectInstance } from "../types";
import { state } from "./rootState"; import { state } from "./rootState";
export type MenuNode = { export type MenuNode = {
@ -20,13 +19,15 @@ export class MenuState {
.map((ot) => ({ .map((ot) => ({
id: `ot-${ot.id}`, id: `ot-${ot.id}`,
title: ot.name, title: ot.name,
onClick: () => { state.worldEditor.setSelectedObjectType({ id: ot.id, editMode: 'scripts' }) },
selected: () => state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection?.id === ot.id,
children: Object.values(state.worldEditor.scene.objects) children: Object.values(state.worldEditor.scene.objects)
.filter((o) => o.typeId === ot.id) .filter((o) => o.typeId === ot.id)
.map((o) => ({ .map((o) => ({
id: `o-${o.id}`, id: `o-${o.id}`,
title: o.id, title: o.id,
onClick: () => { state.worldEditor.setSelectedObject(o.id, 'translate') }, onClick: () => { state.worldEditor.setSelectedObject({ id: o.id, editMode: 'translate' }) },
selected: () => state.worldEditor.selectedObjectId === o.id, selected: () => state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === o.id,
})) }))
})); }));
} }
@ -36,8 +37,8 @@ export class MenuState {
{ {
id: 'editor-objects-menu', id: 'editor-objects-menu',
title: 'Objects', title: 'Objects',
onClick: () => { state.worldEditor.resetSelectedObject() }, onClick: () => { state.worldEditor.resetSelection() },
selected: () => state.worldEditor.selectedObjectId === undefined, selected: () => !state.worldEditor.selection,
children: this.editorObjectTypesMenu, children: this.editorObjectTypesMenu,
} }
] ]

View File

@ -6,27 +6,54 @@ import { randomId } from "../utils";
import { state } from "./rootState"; import { state } from "./rootState";
import { populateRuntimeObject } from "../utils/runtime"; import { populateRuntimeObject } from "../utils/runtime";
export const SelectionEditModeEnum = [ export const ObjectEditModeEnum = [
'translate', 'translate',
'rotate', 'rotate',
'scale', 'scale',
] as const; ] as const;
type SelectionEditModeTuple = typeof SelectionEditModeEnum; type ObjectEditModeTuple = typeof ObjectEditModeEnum;
export type SelectionEditMode = SelectionEditModeTuple[number]; export type ObjectEditMode = ObjectEditModeTuple[number];
export function nextSelectionEditMode(mode: SelectionEditMode | undefined): SelectionEditMode { export function nextObjectEditMode(mode: ObjectEditMode | undefined): ObjectEditMode {
if (mode === undefined) if (mode === undefined)
return 'translate'; return ObjectEditModeEnum[0];
const idx = SelectionEditModeEnum.indexOf(mode); const idx = ObjectEditModeEnum.indexOf(mode);
return SelectionEditModeEnum[(idx + 1) % SelectionEditModeEnum.length]; return ObjectEditModeEnum[(idx + 1) % ObjectEditModeEnum.length];
} }
export const ObjectTypeEditModeEnum = [
'scripts',
] as const;
type ObjectTypeEditModeTuple = typeof ObjectTypeEditModeEnum;
export type ObjectTypeEditMode = ObjectTypeEditModeTuple[number];
export function nextObjectTypeEditMode(mode: ObjectTypeEditMode | undefined): ObjectTypeEditMode {
if (mode === undefined)
return ObjectTypeEditModeEnum[0];
const idx = ObjectTypeEditModeEnum.indexOf(mode);
return ObjectTypeEditModeEnum[(idx + 1) % ObjectTypeEditModeEnum.length];
}
export type SelectedObject = {
type: 'object',
id: string;
editMode: ObjectEditMode;
}
export type SelectedObjectType = {
type: 'objectType',
id: string;
editMode: ObjectTypeEditMode;
}
export type Selection = SelectedObject | SelectedObjectType;
export class WorldEditorState { export class WorldEditorState {
private readonly world: WorldState; private readonly world: WorldState;
public selectedObjectId: string | undefined; public selection: Selection | undefined;
public selectedObjectMode: SelectionEditMode | undefined;
constructor(world: WorldState) { constructor(world: WorldState) {
this.world = world; this.world = world;
@ -41,14 +68,33 @@ export class WorldEditorState {
this.world.data.editorCamera = value; this.world.data.editorCamera = value;
} }
public setSelectedObject(id: string, mode: SelectionEditMode): void { public setSelection(value: Selection | undefined): void {
this.selectedObjectId = id; this.selection = value;
this.selectedObjectMode = mode; console.log(JSON.stringify(this.selection));
} }
public resetSelectedObject(): void { public setSelectedObject(value: Omit<SelectedObject, 'type'> | undefined): void {
this.selectedObjectId = undefined; this.setSelection(value
this.selectedObjectMode = undefined; ? {
type: 'object',
...value,
}
: undefined,
);
}
public setSelectedObjectType(value: Omit<SelectedObjectType, 'type'> | undefined): void {
this.setSelection(value
? {
type: 'objectType',
...value,
}
: undefined,
);
}
public resetSelection() {
this.setSelection(undefined);
} }
public get scene(): RuntimeScene { public get scene(): RuntimeScene {

View File

@ -33,7 +33,7 @@ export class WorldState {
this.withoutAutoSave(() => { this.withoutAutoSave(() => {
this.data = WorldFactory.create(); this.data = WorldFactory.create();
}); });
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelection();
} }
public loadMock() { public loadMock() {
@ -97,7 +97,7 @@ export class WorldState {
}); });
console.log(objects); console.log(objects);
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelection();
} }
public load() { public load() {
@ -105,7 +105,7 @@ export class WorldState {
this.withoutAutoSave(() => { this.withoutAutoSave(() => {
this.data = WorldFactory.load() ?? WorldFactory.create(); this.data = WorldFactory.load() ?? WorldFactory.create();
}); });
state.worldEditor.resetSelectedObject(); state.worldEditor.resetSelection();
} }
private saveData(data: World): void { private saveData(data: World): void {