Compare commits
4 Commits
ff5528008b
...
830c2bdde6
| Author | SHA1 | Date |
|---|---|---|
|
|
830c2bdde6 | |
|
|
1d13cfac36 | |
|
|
52caee642e | |
|
|
30ab3e4d7e |
36
src/App.tsx
36
src/App.tsx
|
|
@ -1,25 +1,34 @@
|
||||||
import { BrowserRouter, Link, Outlet, Route, Routes, useNavigate, useParams } from 'react-router-dom';
|
import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { reaction } from 'mobx';
|
import { reaction } from 'mobx';
|
||||||
import { ThreeView } from './components/ThreeView';
|
import { ThreeView } from './components/ThreeView';
|
||||||
import { state } from './state';
|
import { state } from './state';
|
||||||
import './App.scss';
|
import './App.scss';
|
||||||
import { LeftPanel } from './components/LeftPanel';
|
|
||||||
import { Panels } from './components/Panels';
|
import { Panels } from './components/Panels';
|
||||||
|
|
||||||
function StateToUrlSync() {
|
function StateToUrlSync() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const pathnameRef = useRef(pathname);
|
||||||
|
pathnameRef.current = pathname;
|
||||||
|
|
||||||
useEffect(() => reaction(
|
useEffect(() => reaction(
|
||||||
() => ({
|
() => ({
|
||||||
isGame: state.isGamePlaying,
|
isGame: !!state.game,
|
||||||
selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id,
|
selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id,
|
||||||
selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id,
|
selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id,
|
||||||
}),
|
}),
|
||||||
({ isGame, selectedObjectId, selectedObjectTypeId }) => {
|
({ isGame, selectedObjectId, selectedObjectTypeId }) => {
|
||||||
if (isGame) navigate('/game');
|
let target: string;
|
||||||
else if (selectedObjectId) navigate(`/editor/object/${selectedObjectId}`);
|
if (isGame) target = '/game';
|
||||||
else if (selectedObjectTypeId) navigate(`/editor/objectType/${selectedObjectTypeId}`);
|
else if (selectedObjectId) target = `/editor/object/${selectedObjectId}`;
|
||||||
else navigate('/editor');
|
else if (selectedObjectTypeId) target = `/editor/objectType/${selectedObjectTypeId}`;
|
||||||
|
else target = '/editor';
|
||||||
|
|
||||||
|
const current = pathnameRef.current;
|
||||||
|
if (current === target || current.startsWith(target + '/')) return;
|
||||||
|
navigate(target);
|
||||||
},
|
},
|
||||||
), []);
|
), []);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -27,7 +36,7 @@ function StateToUrlSync() {
|
||||||
|
|
||||||
function EditorRoute() {
|
function EditorRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.isGamePlaying) state.stopGame();
|
if (!!state.game) state.stopGame();
|
||||||
state.worldEditor.resetSelection();
|
state.worldEditor.resetSelection();
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -37,7 +46,8 @@ function EditorObjectRoute() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (state.isGamePlaying) state.stopGame();
|
if (!!state.game)
|
||||||
|
state.stopGame();
|
||||||
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
|
const editMode = state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id === id
|
||||||
? state.worldEditor.selection.editMode ?? 'translate'
|
? state.worldEditor.selection.editMode ?? 'translate'
|
||||||
: 'translate';
|
: 'translate';
|
||||||
|
|
@ -50,7 +60,7 @@ function EditorObjectTypeRoute() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (state.isGamePlaying) state.stopGame();
|
if (!!state.game) state.stopGame();
|
||||||
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
|
const editMode = state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id === id
|
||||||
? state.worldEditor.selection.editMode ?? 'scripts'
|
? state.worldEditor.selection.editMode ?? 'scripts'
|
||||||
: 'scripts';
|
: 'scripts';
|
||||||
|
|
@ -61,7 +71,8 @@ function EditorObjectTypeRoute() {
|
||||||
|
|
||||||
function GameRoute() {
|
function GameRoute() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isGamePlaying) state.startGame();
|
if (!state.game)
|
||||||
|
state.startGame();
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +97,7 @@ export const App = function () {
|
||||||
<StateToUrlSync />
|
<StateToUrlSync />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/editor/objectType/:id/voxels" element={<VoxelEditorPage />} />
|
||||||
<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 />} />
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { useEffect, useRef } from 'react';
|
||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import './MenuView.scss';
|
import './MenuView.scss';
|
||||||
import type { MenuNode } from '../state/menuState';
|
import type { MenuNode } from '../state/menuState';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
|
export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
|
||||||
const forceOpen = state.menu.nodeContainsSelected(node);
|
const forceOpen = state.menu.nodeContainsSelected(node);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const ref = useRef<HTMLDetailsElement>(null);
|
const ref = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
|
|
@ -32,7 +34,13 @@ export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) {
|
||||||
<div
|
<div
|
||||||
key={action.id}
|
key={action.id}
|
||||||
title={action.tooltip}
|
title={action.tooltip}
|
||||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); action.onClick(); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
action.onClick?.();
|
||||||
|
if (action.link)
|
||||||
|
navigate(action.link);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{action.content}
|
{action.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
||||||
if (!objectType)
|
if (!objectType)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// Track cache so observer re-renders when refreshObjectTypeCaches is called
|
||||||
|
void object.cache.updatedAt;
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (state.worldEditor.isEnabled) {
|
if (state.worldEditor.isEnabled) {
|
||||||
// Reading selection state inside an event handler: not tracked by observer.
|
// Reading selection state inside an event handler: not tracked by observer.
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
object.cache.voxelGroups.map((vg) =>
|
object.cache.voxelGroups.map((vg) =>
|
||||||
<Instances key={vg.id} limit={vg.positions.length}>
|
<Instances key={`${vg.id}-${object.cache.updatedAt}`} limit={vg.positions.length}>
|
||||||
<boxGeometry args={[1, 1, 1]} />
|
<boxGeometry args={[1, 1, 1]} />
|
||||||
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
|
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
|
||||||
{vg.positions.map((pos, i) => <Instance key={i} position={pos} />)}
|
{vg.positions.map((pos, i) => <Instance key={i} position={pos} />)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
import { useParams, useNavigate, Link } 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';
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
'#ffffff', '#cccccc', '#999999', '#555555', '#222222',
|
||||||
|
'#e05555', '#e08844', '#e0cc44', '#55cc55', '#55cccc',
|
||||||
|
'#5588e0', '#aa55e0', '#a0522d', '#228b22', '#c06858',
|
||||||
|
'#e8b820', '#484040', '#d0c8b8', '#808080', '#181414',
|
||||||
|
];
|
||||||
|
|
||||||
|
type HistoryEntry =
|
||||||
|
| { type: 'add'; voxel: Voxel }
|
||||||
|
| { type: 'remove'; voxel: Voxel };
|
||||||
|
|
||||||
|
export const VoxelEditorPage = observer(function () {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [mode, setMode] = useState<'add' | 'remove'>('add');
|
||||||
|
const [color, setColor] = useState('#808080');
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
const history = useRef<HistoryEntry[]>([]);
|
||||||
|
const future = useRef<HistoryEntry[]>([]);
|
||||||
|
|
||||||
|
const objectType = id ? state.world.getObjectTypeById(id) : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (id) state.worldEditor.refreshObjectTypeCaches(id);
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
function pushHistory(entry: HistoryEntry) {
|
||||||
|
history.current.push(entry);
|
||||||
|
future.current = [];
|
||||||
|
setCanUndo(true);
|
||||||
|
setCanRedo(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
const entry = history.current.pop();
|
||||||
|
if (!entry || !id)
|
||||||
|
return;
|
||||||
|
if (entry.type === 'add') {
|
||||||
|
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
|
||||||
|
} else {
|
||||||
|
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
|
||||||
|
}
|
||||||
|
future.current.push(entry);
|
||||||
|
setCanUndo(history.current.length > 0);
|
||||||
|
setCanRedo(true);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
const entry = future.current.pop();
|
||||||
|
if (!entry || !id)
|
||||||
|
return;
|
||||||
|
if (entry.type === 'add') {
|
||||||
|
state.worldEditor.addVoxelToObjectType(id, entry.voxel);
|
||||||
|
} else {
|
||||||
|
state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position);
|
||||||
|
}
|
||||||
|
history.current.push(entry);
|
||||||
|
setCanUndo(true);
|
||||||
|
setCanRedo(future.current.length > 0);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!e.ctrlKey)
|
||||||
|
return;
|
||||||
|
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
|
||||||
|
if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [undo, redo]);
|
||||||
|
|
||||||
|
function handleAdd(voxel: Voxel) {
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
state.worldEditor.addVoxelToObjectType(id, 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!objectType) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'white', padding: 24 }}>
|
||||||
|
Object type not found. <Link to="/editor" style={{ color: '#88aaff' }}>Back to editor</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}>
|
||||||
|
<IconArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
|
||||||
|
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
|
||||||
|
<IconArrowBackUp size={20} />
|
||||||
|
</button>
|
||||||
|
<button onClick={redo} disabled={!canRedo} style={{ ...btnStyle, opacity: canRedo ? 1 : 0.35 }}>
|
||||||
|
<IconArrowForwardUp size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3D canvas */}
|
||||||
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
|
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
|
||||||
|
<VoxelEditorScene
|
||||||
|
voxels={objectType.voxels}
|
||||||
|
mode={mode}
|
||||||
|
color={color}
|
||||||
|
typeId="stone"
|
||||||
|
onAdd={handleAdd}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div style={{ background: '#11111f', padding: '10px 12px', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{/* Color palette */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{PALETTE.map(c => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => { setColor(c); setMode('add'); }}
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36,
|
||||||
|
background: c,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: c === color ? '3px solid white' : '3px solid #333',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode toggle */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('add')}
|
||||||
|
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'add' ? '#2d6a2d' : '#2a2a3a', border: mode === 'add' ? '2px solid #4CAF50' : '2px solid #444' }}
|
||||||
|
>
|
||||||
|
<IconPlus size={18} /> Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('remove')}
|
||||||
|
style={{ ...btnStyle, flex: 1, padding: '12px 0', gap: 6, background: mode === 'remove' ? '#6a2d2d' : '#2a2a3a', border: mode === 'remove' ? '2px solid #f44336' : '2px solid #444' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={18} /> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#2a2a3a',
|
||||||
|
color: '#ccc',
|
||||||
|
border: '2px solid #444',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '6px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
gap: 4,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { OrbitControls } from '@react-three/drei';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ThreeEvent } from '@react-three/fiber';
|
||||||
|
import type { Voxel, V3 } from '../../types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
voxels: Voxel[];
|
||||||
|
mode: 'add' | 'remove';
|
||||||
|
color: string;
|
||||||
|
typeId: string;
|
||||||
|
onAdd: (voxel: Voxel) => void;
|
||||||
|
onRemove: (position: V3) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLOOR_Y = 0;
|
||||||
|
|
||||||
|
function adjPosition(pos: V3, nx: number, ny: number, nz: number): V3 {
|
||||||
|
return [pos[0] + Math.round(nx), pos[1] + Math.round(ny), pos[2] + Math.round(nz)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function posEq(a: V3, b: V3) {
|
||||||
|
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
|
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
|
||||||
|
const [ghost, setGhost] = useState<V3 | null>(null);
|
||||||
|
|
||||||
|
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
|
||||||
|
if (e.delta > DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
|
e.stopPropagation();
|
||||||
|
if (mode === 'remove') {
|
||||||
|
onRemove(voxel.position);
|
||||||
|
setGhost(null);
|
||||||
|
} else {
|
||||||
|
if (!e.face)
|
||||||
|
return;
|
||||||
|
const { x, y, z } = e.face.normal;
|
||||||
|
const pos = adjPosition(voxel.position, x, y, z);
|
||||||
|
onAdd({ typeId, position: pos, color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (mode === 'remove') {
|
||||||
|
setGhost(voxel.position);
|
||||||
|
} else {
|
||||||
|
setGhost(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFloorClick(e: ThreeEvent<MouseEvent>) {
|
||||||
|
if (e.delta > DRAG_THRESHOLD || mode !== 'add')
|
||||||
|
return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
|
||||||
|
onAdd({ typeId, position: pos, color });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
|
||||||
|
if (mode !== 'add')
|
||||||
|
return;
|
||||||
|
setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.7} />
|
||||||
|
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
|
||||||
|
|
||||||
|
<OrbitControls makeDefault />
|
||||||
|
|
||||||
|
{/* Floor plane */}
|
||||||
|
<mesh
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
position={[0, FLOOR_Y, 0]}
|
||||||
|
onClick={handleFloorClick}
|
||||||
|
onPointerMove={handleFloorMove}
|
||||||
|
onPointerLeave={() => setGhost(null)}
|
||||||
|
>
|
||||||
|
<planeGeometry args={[40, 40]} />
|
||||||
|
<meshStandardMaterial color="#2a2a3a" />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
|
||||||
|
|
||||||
|
{/* Voxels */}
|
||||||
|
{voxels.map((v, i) => {
|
||||||
|
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
|
||||||
|
onClick={(e) => handleVoxelClick(e, v)}
|
||||||
|
onPointerMove={(e) => handleVoxelMove(e, v)}
|
||||||
|
onPointerLeave={() => setGhost(null)}
|
||||||
|
>
|
||||||
|
<boxGeometry />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={v.color ?? '#888888'}
|
||||||
|
opacity={isHovered ? 0.4 : 1}
|
||||||
|
transparent={isHovered}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Ghost voxel preview */}
|
||||||
|
{ghost && mode === 'add' && !ghostOccupied && (
|
||||||
|
<mesh
|
||||||
|
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
|
||||||
|
raycast={() => null}
|
||||||
|
>
|
||||||
|
<boxGeometry />
|
||||||
|
<meshStandardMaterial color={color} opacity={0.45} transparent />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { makeAutoObservable } from "mobx";
|
import { makeAutoObservable } from "mobx";
|
||||||
import { state } from "./rootState";
|
import { state } from "./rootState";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff } from '@tabler/icons-react';
|
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react';
|
||||||
|
|
||||||
export type MenuNodeAction = {
|
export type MenuNodeAction = {
|
||||||
id: string;
|
id: string;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
onClick: () => void;
|
onClick?: () => void;
|
||||||
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuNode = {
|
export type MenuNode = {
|
||||||
|
|
@ -75,6 +76,12 @@ export class MenuState {
|
||||||
tooltip: 'Create new object instance',
|
tooltip: 'Create new object instance',
|
||||||
onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); },
|
onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'edit-object-type-voxels',
|
||||||
|
content: <IconEdit size="1em" />,
|
||||||
|
tooltip: 'Edit object type voxels',
|
||||||
|
link: `/editor/objectType/${ot.id}/voxels`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'delete-object-type',
|
id: 'delete-object-type',
|
||||||
content: <IconCubeOff size="1em" />,
|
content: <IconCubeOff size="1em" />,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
import type { WorldState } from "./worldState";
|
import type { WorldState } from "./worldState";
|
||||||
import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType } from "../types";
|
import { type ObjectInstance, type Pos3, type R3, type V3, type RuntimeScene, type ObjectType, type Voxel } from "../types";
|
||||||
import { createObjectInstance } from "../utils/object";
|
import { createObjectInstance } from "../utils/object";
|
||||||
import { randomId } from "../utils";
|
import { randomId } from "../utils";
|
||||||
import { state } from "./rootState";
|
import { state } from "./rootState";
|
||||||
import { populateRuntimeObject } from "../utils/runtime";
|
import { populateRuntimeObject, computeBoundingBox } from "../utils/runtime";
|
||||||
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
|
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
|
||||||
|
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
|
||||||
|
import { buildObjectTrimesh } from "../utils/graphics/mesh";
|
||||||
|
|
||||||
export const ObjectEditModeEnum = [
|
export const ObjectEditModeEnum = [
|
||||||
'translate',
|
'translate',
|
||||||
|
|
@ -161,6 +163,42 @@ export class WorldEditorState {
|
||||||
return objectType;
|
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) {
|
public deleteObjectType(typeId: string) {
|
||||||
if (this.selection?.type === 'objectType' && this.selection.id === typeId)
|
if (this.selection?.type === 'objectType' && this.selection.id === typeId)
|
||||||
this.resetSelection();
|
this.resetSelection();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export type ObjectInstanceRuntimeData = {
|
||||||
voxelGroups: VoxelGroup[];
|
voxelGroups: VoxelGroup[];
|
||||||
colliderMesh: [Float32Array, Uint32Array] | null;
|
colliderMesh: [Float32Array, Uint32Array] | null;
|
||||||
boundingBox: { min: V3; max: V3 };
|
boundingBox: { min: V3; max: V3 };
|
||||||
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
pendingActions: {
|
pendingActions: {
|
||||||
impulse?: { direction: V3, amplitude: number };
|
impulse?: { direction: V3, amplitude: number };
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export function createObjectInstance(id: string, typeId: string): ObjectInstance
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
typeId: typeId,
|
typeId: typeId,
|
||||||
physics: false,
|
physics: true,
|
||||||
gravityScale: 1,
|
gravityScale: 1,
|
||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup";
|
||||||
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
|
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types";
|
||||||
import { buildObjectTrimesh } from "../graphics/mesh";
|
import { buildObjectTrimesh } from "../graphics/mesh";
|
||||||
|
|
||||||
function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
|
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
|
||||||
if (!objectType.voxels.length)
|
if (!objectType.voxels.length)
|
||||||
return { min: [0, 0, 0], max: [1, 1, 1] };
|
return { min: [0, 0, 0], max: [1, 1, 1] };
|
||||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||||
|
|
@ -27,6 +27,7 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run
|
||||||
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
|
voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes),
|
||||||
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
|
colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes),
|
||||||
boundingBox: computeBoundingBox(objectType),
|
boundingBox: computeBoundingBox(objectType),
|
||||||
|
updatedAt: 0,
|
||||||
},
|
},
|
||||||
pendingActions: {},
|
pendingActions: {},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue