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 { reaction } from 'mobx';
import { ThreeView } from './components/ThreeView';
import { Panel } from './components/Panel';
import { state } from './state';
import './App.scss';
import { LeftPanel } from './components/LeftPanel';
function StateToUrlSync() {
const navigate = useNavigate();
useEffect(() => reaction(
() => ({
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');
else if (selectedId) navigate(`/editor/object/${selectedId}`);
else if (selectedObjectId) navigate(`/editor/object/${selectedObjectId}`);
else if (selectedObjectTypeId) navigate(`/editor/objectType/${selectedObjectTypeId}`);
else navigate('/editor');
},
), []);
@ -25,7 +27,7 @@ function StateToUrlSync() {
function EditorRoute() {
useEffect(() => {
if (state.isGamePlaying) state.stopGame();
state.worldEditor.resetSelectedObject();
state.worldEditor.resetSelection();
}, []);
return null;
}
@ -35,10 +37,23 @@ function EditorObjectRoute() {
useEffect(() => {
if (!id) return;
if (state.isGamePlaying) state.stopGame();
const mode = state.worldEditor.selectedObjectId === id
? state.worldEditor.selectedObjectMode ?? 'translate'
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
? state.worldEditor.selection.editMode ?? '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]);
return null;
}
@ -59,7 +74,7 @@ function EditorLayout() {
<>
<Outlet />
<ThreeView />
<Panel side="left" />
<LeftPanel />
</>
);
}
@ -73,6 +88,7 @@ export const App = function () {
<Route element={<EditorLayout />}>
<Route path="/editor" element={<EditorRoute />} />
<Route path="/editor/object/:id" element={<EditorObjectRoute />} />
<Route path="/editor/objectType/:id" element={<EditorObjectTypeRoute />} />
<Route path="/game" element={<GameRoute />} />
</Route>
</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 type { ThreeEvent } from "@react-three/fiber";
import { state } from "../state";
import { nextSelectionEditMode } from "../state/worldEditorState";
import { nextObjectEditMode } from "../state/worldEditorState";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
type ObjectEditorViewProps = {
@ -23,8 +23,12 @@ type SelectionOverlayProps = {
// re-render on selection change, not all N objects in the scene.
const SelectionOverlay = observer(function ({ objectId, ref, onTransformEnd }: SelectionOverlayProps) {
const isSelected = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === objectId;
const selectionMode = isSelected ? state.worldEditor.selectedObjectMode : undefined;
state.worldEditor.selection?.type === 'object' &&
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
const groupRef = useMemo<RefObject<Group | null>>(
@ -60,10 +64,10 @@ export const ObjectEditorView = observer(function ({ object }: ObjectEditorViewP
e.stopPropagation();
// Reading selection state inside an event handler: not tracked by observer.
const currentMode = state.worldEditor.isEnabled &&
state.worldEditor.selectedObjectId === object.id
? state.worldEditor.selectedObjectMode
state.worldEditor.selection?.type === 'object' && state.worldEditor.selection?.id === object.id
? state.worldEditor.selection?.editMode
: undefined;
state.worldEditor.setSelectedObject(object.id, nextSelectionEditMode(currentMode));
state.worldEditor.setSelectedObject({ id: object.id, editMode: nextObjectEditMode(currentMode) });
}
function handleTransformEnd() {

View File

@ -1,39 +1,16 @@
import { observer } from "mobx-react-lite";
import './Panel.scss';
import { RenderInfoView } from "./RenderInfoView";
import { MenuView } from "./MenuView";
import { state } from "../state";
import type { ReactNode } from "react";
export type PanelProps = {
side?: 'left' | 'right';
children?: ReactNode;
}
export const Panel = observer(function ({ 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();
}
export const Panel = observer(function ({ children, side = 'left' }: PanelProps) {
return <div className={`panel ${side}`}>
<div className="container">
<MenuView />
<div className="gap" />
<button onClick={handleLoadWorld}>Load</button>
<button onClick={handleLoadMockWorld}>Load mock world</button>
<div className="debug"><RenderInfoView /></div>
{children}
</div>
</div >
});

View File

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

View File

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

View File

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

View File

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