122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|