diff --git a/src/App.tsx b/src/App.tsx index 411f674..b56d3cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,34 @@ -import { BrowserRouter, Link, Outlet, Route, Routes, useNavigate, useParams } from 'react-router-dom'; -import { useEffect } from 'react'; +import { BrowserRouter, Link, Outlet, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'; +import { VoxelEditorPage } from './components/voxelEditor/VoxelEditorPage'; +import { useEffect, useRef } from 'react'; import { reaction } from 'mobx'; import { ThreeView } from './components/ThreeView'; import { state } from './state'; import './App.scss'; -import { LeftPanel } from './components/LeftPanel'; import { Panels } from './components/Panels'; function StateToUrlSync() { const navigate = useNavigate(); + const { pathname } = useLocation(); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + useEffect(() => reaction( () => ({ - isGame: state.isGamePlaying, + isGame: !!state.game, selectedObjectId: state.worldEditor.selection?.type === 'object' && state.worldEditor.selection.id, selectedObjectTypeId: state.worldEditor.selection?.type === 'objectType' && state.worldEditor.selection.id, }), ({ isGame, selectedObjectId, selectedObjectTypeId }) => { - if (isGame) navigate('/game'); - else if (selectedObjectId) navigate(`/editor/object/${selectedObjectId}`); - else if (selectedObjectTypeId) navigate(`/editor/objectType/${selectedObjectTypeId}`); - else navigate('/editor'); + let target: string; + if (isGame) target = '/game'; + else if (selectedObjectId) target = `/editor/object/${selectedObjectId}`; + 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; @@ -27,7 +36,7 @@ function StateToUrlSync() { function EditorRoute() { useEffect(() => { - if (state.isGamePlaying) state.stopGame(); + if (!!state.game) state.stopGame(); state.worldEditor.resetSelection(); }, []); return null; @@ -37,7 +46,8 @@ function EditorObjectRoute() { const { id } = useParams<{ id: string }>(); useEffect(() => { 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 ? state.worldEditor.selection.editMode ?? 'translate' : 'translate'; @@ -50,7 +60,7 @@ function EditorObjectTypeRoute() { const { id } = useParams<{ id: string }>(); useEffect(() => { 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 ? state.worldEditor.selection.editMode ?? 'scripts' : 'scripts'; @@ -61,7 +71,8 @@ function EditorObjectTypeRoute() { function GameRoute() { useEffect(() => { - if (!state.isGamePlaying) state.startGame(); + if (!state.game) + state.startGame(); }, []); return null; } @@ -86,6 +97,7 @@ export const App = function () { } /> + } /> }> } /> } /> diff --git a/src/components/MenuView.tsx b/src/components/MenuView.tsx index 9c994c5..b7145d4 100644 --- a/src/components/MenuView.tsx +++ b/src/components/MenuView.tsx @@ -3,9 +3,11 @@ import { useEffect, useRef } from 'react'; import { state } from '../state'; import './MenuView.scss'; import type { MenuNode } from '../state/menuState'; +import { useNavigate } from 'react-router-dom'; export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) { const forceOpen = state.menu.nodeContainsSelected(node); + const navigate = useNavigate(); const ref = useRef(null); @@ -32,7 +34,13 @@ export const MenuNodeView = observer(function ({ node }: { node: MenuNode }) { { e.stopPropagation(); e.preventDefault(); action.onClick(); }} + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + action.onClick?.(); + if (action.link) + navigate(action.link); + }} > {action.content} diff --git a/src/components/ObjectEditorView.tsx b/src/components/ObjectEditorView.tsx index dcd50d4..cd2204a 100644 --- a/src/components/ObjectEditorView.tsx +++ b/src/components/ObjectEditorView.tsx @@ -59,6 +59,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE if (!objectType) return null; + // Track cache so observer re-renders when refreshObjectTypeCaches is called + void object.cache.updatedAt; + function handleClick() { if (state.worldEditor.isEnabled) { // Reading selection state inside an event handler: not tracked by observer. diff --git a/src/components/ObjectViewInternal.tsx b/src/components/ObjectViewInternal.tsx index a7bea32..32c12e8 100644 --- a/src/components/ObjectViewInternal.tsx +++ b/src/components/ObjectViewInternal.tsx @@ -115,7 +115,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props > { object.cache.voxelGroups.map((vg) => - + {vg.positions.map((pos, i) => )} diff --git a/src/components/voxelEditor/VoxelEditorPage.tsx b/src/components/voxelEditor/VoxelEditorPage.tsx new file mode 100644 index 0000000..397985f --- /dev/null +++ b/src/components/voxelEditor/VoxelEditorPage.tsx @@ -0,0 +1,127 @@ +import { Canvas } from '@react-three/fiber'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { observer } from 'mobx-react-lite'; +import { useState, useCallback, useEffect } from 'react'; +import { state } from '../../state'; +import type { Voxel, V3 } from '../../types'; +import { VoxelEditorScene } from './VoxelEditorScene'; +import { IconArrowLeft, 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', +]; + +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 objectType = id ? state.world.getObjectTypeById(id) : undefined; + + useEffect(() => { + return () => { + if (id) state.worldEditor.refreshObjectTypeCaches(id); + }; + }, [id]); + + const handleAdd = useCallback((voxel: Voxel) => { + if (id) state.worldEditor.addVoxelToObjectType(id, voxel); + }, [id]); + + const handleRemove = useCallback((position: V3) => { + if (id) state.worldEditor.removeVoxelFromObjectType(id, position); + }, [id]); + + if (!objectType) { + return ( + + Object type not found. Back to editor + + ); + } + + return ( + + {/* Top bar */} + + navigate(`/editor/objectType/${id}`)} + style={btnStyle} + > + + + {objectType.name} + {objectType.voxels.length} voxels + + + {/* 3D canvas */} + + + + + + + {/* Bottom bar */} + + {/* Color palette */} + + {PALETTE.map(c => ( + { 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, + }} + /> + ))} + + + {/* Mode toggle */} + + 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' }} + > + Add + + 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' }} + > + Remove + + + + + ); +}); + +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, +}; diff --git a/src/components/voxelEditor/VoxelEditorScene.tsx b/src/components/voxelEditor/VoxelEditorScene.tsx new file mode 100644 index 0000000..0cd5132 --- /dev/null +++ b/src/components/voxelEditor/VoxelEditorScene.tsx @@ -0,0 +1,123 @@ +import { OrbitControls } from '@react-three/drei'; +import { useState, useRef } 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.5; + +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]; +} + +export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) { + const [ghost, setGhost] = useState(null); + const isDragging = useRef(false); + + function handleVoxelClick(e: ThreeEvent, voxel: Voxel) { + if (isDragging.current) 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, voxel: Voxel) { + if (mode === 'remove') { + setGhost(voxel.position); + } else { + setGhost(null); + } + } + + function handleFloorClick(e: ThreeEvent) { + if (isDragging.current || 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) { + 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 ( + <> + + + + { isDragging.current = false; }} + onChange={() => { isDragging.current = true; }} + /> + + {/* Floor plane */} + setGhost(null)} + > + + + + + + + {/* Voxels */} + {voxels.map((v, i) => { + const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost); + return ( + handleVoxelClick(e, v)} + onPointerMove={(e) => handleVoxelMove(e, v)} + onPointerLeave={() => setGhost(null)} + > + + + + ); + })} + + {/* Ghost voxel preview */} + {ghost && mode === 'add' && !ghostOccupied && ( + null} + > + + + + )} + > + ); +} diff --git a/src/state/menuState.tsx b/src/state/menuState.tsx index 3be0fef..66a2bf4 100644 --- a/src/state/menuState.tsx +++ b/src/state/menuState.tsx @@ -1,13 +1,14 @@ import { makeAutoObservable } from "mobx"; import { state } from "./rootState"; 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 = { id: string; content: ReactNode; tooltip: string; - onClick: () => void; + onClick?: () => void; + link?: string; } export type MenuNode = { @@ -75,6 +76,12 @@ export class MenuState { tooltip: 'Create new object instance', onClick: () => { editor.addObjectInstanceAtRandomPosition(ot.id); }, }, + { + id: 'edit-object-type-voxels', + content: , + tooltip: 'Edit object type voxels', + link: `/editor/objectType/${ot.id}/voxels`, + }, { id: 'delete-object-type', content: , diff --git a/src/state/worldEditorState.ts b/src/state/worldEditorState.ts index e4e14c1..b4a6dc5 100644 --- a/src/state/worldEditorState.ts +++ b/src/state/worldEditorState.ts @@ -1,11 +1,13 @@ import { makeAutoObservable, runInAction } from "mobx"; 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 { randomId } from "../utils"; import { state } from "./rootState"; -import { populateRuntimeObject } from "../utils/runtime"; +import { populateRuntimeObject, computeBoundingBox } 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', @@ -161,6 +163,42 @@ 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(); diff --git a/src/types/model/runtime.ts b/src/types/model/runtime.ts index 18667fb..32dc31b 100644 --- a/src/types/model/runtime.ts +++ b/src/types/model/runtime.ts @@ -7,6 +7,7 @@ export type ObjectInstanceRuntimeData = { voxelGroups: VoxelGroup[]; colliderMesh: [Float32Array, Uint32Array] | null; boundingBox: { min: V3; max: V3 }; + updatedAt: number; }; pendingActions: { impulse?: { direction: V3, amplitude: number }; diff --git a/src/utils/object.ts b/src/utils/object.ts index 2bc3dfb..46cbae0 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -4,7 +4,7 @@ export function createObjectInstance(id: string, typeId: string): ObjectInstance return { id, typeId: typeId, - physics: false, + physics: true, gravityScale: 1, position: [0, 0, 0], rotation: [0, 0, 0], diff --git a/src/utils/runtime/object.ts b/src/utils/runtime/object.ts index 0d65d45..eb12c76 100644 --- a/src/utils/runtime/object.ts +++ b/src/utils/runtime/object.ts @@ -2,7 +2,7 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup"; import type { ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types"; 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) return { min: [0, 0, 0], max: [1, 1, 1] }; let minX = Infinity, minY = Infinity, minZ = Infinity; @@ -27,6 +27,7 @@ export function populateRuntimeObject(object: ObjectInstance, world: World): Run voxelGroups: getObjectVoxelGroups(objectType, world.voxelTypes), colliderMesh: buildObjectTrimesh(objectType, world.voxelTypes), boundingBox: computeBoundingBox(objectType), + updatedAt: 0, }, pendingActions: {}, };