From 31c77b0a72d884a4885e0eb5c9bb9b431c7962d0 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Wed, 17 Jun 2026 10:54:13 +0300 Subject: [PATCH] custom voxel mesh builder object visibility --- pnpm-lock.yaml | 38 ++++ webclient/package.json | 3 + webclient/src/components/CharacterView.tsx | 2 + webclient/src/components/DebugLabel.tsx | 12 +- webclient/src/components/DebugLabelsView.tsx | 3 +- webclient/src/components/GameObjectView.tsx | 2 + webclient/src/components/ObjectEditorView.tsx | 9 +- .../src/components/ObjectViewInternal.tsx | 50 ++++-- webclient/src/components/SceneEditorView.tsx | 14 +- webclient/src/components/SceneView.tsx | 15 +- webclient/src/components/ThreeView.tsx | 2 +- .../voxelEditor/VoxelEditorPage.tsx | 2 +- .../voxelEditor/VoxelEditorScene.tsx | 168 ++++++++++++------ webclient/src/model/gameFactory.ts | 4 +- webclient/src/state/gameState.ts | 28 +-- webclient/src/state/menuState.tsx | 9 +- webclient/src/state/worldEditorState.ts | 29 +-- webclient/src/types/3d.ts | 4 +- webclient/src/types/model/game.ts | 6 +- webclient/src/types/model/object.ts | 1 + webclient/src/types/model/scene.ts | 18 +- webclient/src/utils/format.ts | 9 + webclient/src/utils/graphics/greedyMesh.ts | 133 -------------- webclient/src/utils/graphics/voxelGroup.ts | 2 +- webclient/src/utils/graphics/voxelMesh.ts | 115 ++++++++++++ webclient/src/utils/index.ts | 2 + webclient/src/utils/runtime/object.ts | 2 +- 27 files changed, 416 insertions(+), 266 deletions(-) create mode 100644 webclient/src/utils/format.ts delete mode 100644 webclient/src/utils/graphics/greedyMesh.ts create mode 100644 webclient/src/utils/graphics/voxelMesh.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c4df4..bd26015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: blockly: specifier: ^12.5.1 version: 12.5.1 + earcut: + specifier: ^3.0.2 + version: 3.0.2 mobx: specifier: ^6.15.4 version: 6.15.4 @@ -75,6 +78,9 @@ importers: nipplejs: specifier: ^1.0.4 version: 1.0.4 + polygon-clipping: + specifier: ^0.15.7 + version: 0.15.7 r3f-perf: specifier: ^7.2.3 version: 7.2.3(@react-three/fiber@9.6.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0))(@types/react@19.2.15)(@types/three@0.184.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(three@0.184.0)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -94,6 +100,9 @@ importers: specifier: ^14.0.0 version: 14.0.0 devDependencies: + '@types/earcut': + specifier: ^3.0.0 + version: 3.0.0 '@types/react': specifier: ^19.2.14 version: 19.2.15 @@ -793,6 +802,9 @@ packages: '@types/draco3d@1.4.10': resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1061,6 +1073,9 @@ packages: draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} @@ -1539,6 +1554,9 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + polygon-clipping@0.15.7: + resolution: {integrity: sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1654,6 +1672,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rolldown@1.0.2: resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1715,6 +1736,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + splaytree@3.2.3: + resolution: {integrity: sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==} + engines: {node: '>=18.20 || >=20'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2606,6 +2631,8 @@ snapshots: '@types/draco3d@1.4.10': {} + '@types/earcut@3.0.0': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -2881,6 +2908,8 @@ snapshots: draco3d@1.5.7: {} + earcut@3.0.2: {} + electron-to-chromium@1.5.364: {} entities@6.0.1: {} @@ -3374,6 +3403,11 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + polygon-clipping@0.15.7: + dependencies: + robust-predicates: 3.0.3 + splaytree: 3.2.3 + postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -3469,6 +3503,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@3.0.3: {} + rolldown@1.0.2: dependencies: '@oxc-project/types': 0.132.0 @@ -3534,6 +3570,8 @@ snapshots: source-map-js@1.2.1: {} + splaytree@3.2.3: {} + split2@4.2.0: {} stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0): diff --git a/webclient/package.json b/webclient/package.json index d2b0c86..f87d554 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -15,10 +15,12 @@ "@tabler/icons-react": "^3.44.0", "@types/three": "^0.184.1", "blockly": "^12.5.1", + "earcut": "^3.0.2", "mobx": "^6.15.4", "mobx-react-lite": "^4.1.1", "mobx-utils": "^6.1.1", "nipplejs": "^1.0.4", + "polygon-clipping": "^0.15.7", "r3f-perf": "^7.2.3", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -27,6 +29,7 @@ "uuid": "^14.0.0" }, "devDependencies": { + "@types/earcut": "^3.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/webclient/src/components/CharacterView.tsx b/webclient/src/components/CharacterView.tsx index 742b955..d40422e 100644 --- a/webclient/src/components/CharacterView.tsx +++ b/webclient/src/components/CharacterView.tsx @@ -83,6 +83,8 @@ return; return () => { canvas.removeEventListener('click', onClick); document.removeEventListener('mousemove', onMouseMove); + if (document.pointerLockElement === canvas) + document.exitPointerLock(); }; }, [gl]); diff --git a/webclient/src/components/DebugLabel.tsx b/webclient/src/components/DebugLabel.tsx index 3f34983..c5b409c 100644 --- a/webclient/src/components/DebugLabel.tsx +++ b/webclient/src/components/DebugLabel.tsx @@ -6,6 +6,7 @@ import type { RuntimeObjectInstance } from "../types"; import { debugLabelsStore } from "../state/debugLabelsStore"; import { state } from "../state"; import { observer } from "mobx-react-lite"; +import { formatV3 } from "../utils"; export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) { const groupRef = useRef(null); @@ -13,8 +14,8 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan const rb = obj.rb(); const texts = [ `${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`, - `position: ${obj.position}`, - `rotation: ${obj.rotation}`, + `position: ${formatV3(obj.position)}`, + `rotation: ${formatV3(obj.rotation)}`, ...Object.entries(debugLabelsStore.getAll(obj.id)) .map(([k, v]) => `${k}: ${v}`), ]; @@ -51,13 +52,14 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
{text}
diff --git a/webclient/src/components/DebugLabelsView.tsx b/webclient/src/components/DebugLabelsView.tsx index 340a622..2769118 100644 --- a/webclient/src/components/DebugLabelsView.tsx +++ b/webclient/src/components/DebugLabelsView.tsx @@ -4,7 +4,7 @@ import { debugLabelsStore } from "../state/debugLabelsStore"; import { DebugLabel } from "./DebugLabel"; import type { RuntimeScene } from "../types"; -export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeScene }) { +export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: RuntimeScene, editMode?: boolean }) { useEffect( () => { return () => { debugLabelsStore.clear(); }; @@ -15,6 +15,7 @@ export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeSce return <> { Object.entries(scene.objects) + .filter(([_, obj]) => editMode || obj.visible) .map(([objectId, obj]) => { return ; }) diff --git a/webclient/src/components/GameObjectView.tsx b/webclient/src/components/GameObjectView.tsx index 08fe4fd..f385819 100644 --- a/webclient/src/components/GameObjectView.tsx +++ b/webclient/src/components/GameObjectView.tsx @@ -7,6 +7,7 @@ import { PlayerObjectView } from "./CharacterView"; export type GameObjectViewProps = { object: RuntimeGameObjectInstance; isPlayer: boolean; + isSceneEditor?: boolean; } export const GameObjectView = observer(function (props: GameObjectViewProps) { @@ -25,5 +26,6 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) { {...props} objectType={objectType} onClick={handleClick} + isSceneEditor={props.isSceneEditor} />; }); diff --git a/webclient/src/components/ObjectEditorView.tsx b/webclient/src/components/ObjectEditorView.tsx index a31324b..8038a0d 100644 --- a/webclient/src/components/ObjectEditorView.tsx +++ b/webclient/src/components/ObjectEditorView.tsx @@ -78,7 +78,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE function handleTransformChange() { const group = dataRef.current?.group; - if (!group) return; + if (!group) + return; const pos = group.getWorldPosition(new Vector3()); const quat = group.getWorldQuaternion(new Quaternion()); const euler = new Euler().setFromQuaternion(quat); @@ -90,7 +91,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE function handleTransformEnd() { const group = dataRef.current?.group; - if (!group) return; + if (!group) + return; const pos = group.getWorldPosition(new Vector3()); const quat = group.getWorldQuaternion(new Quaternion()); const euler = new Euler().setFromQuaternion(quat); @@ -113,8 +115,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE diff --git a/webclient/src/components/ObjectViewInternal.tsx b/webclient/src/components/ObjectViewInternal.tsx index b224a35..3b982c6 100644 --- a/webclient/src/components/ObjectViewInternal.tsx +++ b/webclient/src/components/ObjectViewInternal.tsx @@ -1,22 +1,24 @@ import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types"; import type { VoxelGroup } from "../types/runtime/voxelGroup"; import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react"; -import { Euler, Quaternion, type Group } from "three"; +import { Euler, Quaternion, Mesh, MeshStandardMaterial, type Group } from "three"; import type { ThreeEvent } from "@react-three/fiber"; import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier"; import { SyncRigidBody } from "./SyncRigidBody"; import { reaction, runInAction } from "mobx"; import { v3toRapier } from "../utils"; import { state } from "../state"; -import { greedyMesh } from "../utils/graphics/greedyMesh"; +import { buildVoxelMesh } from "../utils/graphics/voxelMesh"; -const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg }: { vg: VoxelGroup }) { - const geo = useMemo(() => greedyMesh(vg.positions), [vg]); +const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) { + const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]); useEffect(() => () => { geo.dispose(); }, [geo]); + const op = vg.opacity * opacity; + return ( - - + + ); }); @@ -29,8 +31,9 @@ export type ObjectViewInternalHandle = { type ObjectViewInternalProps = { object: Omit; objectType: ObjectType; - isEditor?: boolean; + editMode?: boolean; isPlayer?: boolean; + isSceneEditor?: boolean; onClick?: () => void; ref?: Ref; } @@ -72,6 +75,29 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props const gameObj = object as RuntimeGameObjectInstance; + useEffect( + () => { + return reaction( + () => object.visible, + (objectVisible) => { + if (!groupRef.current) + return; + groupRef.current.visible = props.isSceneEditor || objectVisible; + groupRef.current.traverse((child) => { + if (child instanceof Mesh && child.material instanceof MeshStandardMaterial) { + const baseOpacity: number = child.userData.baseOpacity ?? 1; + child.material.opacity = baseOpacity * (objectVisible ? 1 : 0.5); + child.material.transparent = child.material.opacity < 1; + child.material.needsUpdate = true; + } + }); + }, + { fireImmediately: true }, + ); + }, + [object, props.isSceneEditor], + ); + useEffect( () => { runInAction(() => { gameObj.rb = () => rbRef.current; }); @@ -97,7 +123,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props gravityScale={object.gravityScale} position={object.position} rotation={object.rotation} - onCollisionEnter={props.isEditor ? undefined : (payload) => { + onCollisionEnter={props.editMode ? undefined : (payload) => { const game = state.game; if (!game) return; @@ -114,7 +140,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props runInAction(() => { object.position = data.position; object.rotation = data.rotation; - if (!props.isEditor) { + if (!props.editMode) { (object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity; (object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity; } @@ -130,7 +156,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props > { object.cache.voxelGroups.map((vg) => - + ) } {object.cache.colliderMesh && } diff --git a/webclient/src/components/SceneEditorView.tsx b/webclient/src/components/SceneEditorView.tsx index 0b3546c..5e04b94 100644 --- a/webclient/src/components/SceneEditorView.tsx +++ b/webclient/src/components/SceneEditorView.tsx @@ -4,7 +4,7 @@ import { Grid, OrbitControls } from '@react-three/drei'; import { observer } from 'mobx-react-lite'; import { state } from '../state'; import { SceneView } from './SceneView'; -import { type OrthographicCamera, type PerspectiveCamera } from 'three'; +import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three'; import { CameraSync } from './tools/CameraSync'; import { Physics } from '@react-three/rapier'; @@ -49,17 +49,23 @@ export const SceneEditorView = observer(function () { onStart={handleStart} onEnd={handleEnd} makeDefault + mouseButtons={{ + LEFT: MOUSE.PAN, + MIDDLE: MOUSE.ROTATE, + RIGHT: MOUSE.ROTATE, + }} + rotateSpeed={0.2} enableDamping={false} /> - + /> */} - + ); diff --git a/webclient/src/components/SceneView.tsx b/webclient/src/components/SceneView.tsx index bd15947..348d204 100644 --- a/webclient/src/components/SceneView.tsx +++ b/webclient/src/components/SceneView.tsx @@ -6,7 +6,7 @@ import { DebugLabelsView } from "./DebugLabelsView"; type SceneViewProps = { scene: RuntimeScene, - editMode?: boolean; + isEditor?: boolean; debugOverlay?: boolean; } @@ -15,12 +15,13 @@ export const SceneView = observer(function (props: SceneViewProps) { { - Object.values(props.scene.objects).map((obj) => ( - !props.editMode - ? - : - )) + Object.values(props.scene.objects) + .map((obj) => ( + !props.isEditor + ? + : + )) } - {props.debugOverlay && } + {props.debugOverlay && } ); }); diff --git a/webclient/src/components/ThreeView.tsx b/webclient/src/components/ThreeView.tsx index 919d3d6..0f0e576 100644 --- a/webclient/src/components/ThreeView.tsx +++ b/webclient/src/components/ThreeView.tsx @@ -1,4 +1,4 @@ -import { Perf } from 'r3f-perf' +import { Perf } from 'r3f-perf'; import { Canvas } from '@react-three/fiber'; import { KeyboardControls } from '@react-three/drei'; import { observer } from 'mobx-react-lite'; diff --git a/webclient/src/components/voxelEditor/VoxelEditorPage.tsx b/webclient/src/components/voxelEditor/VoxelEditorPage.tsx index fbc82af..bab5708 100644 --- a/webclient/src/components/voxelEditor/VoxelEditorPage.tsx +++ b/webclient/src/components/voxelEditor/VoxelEditorPage.tsx @@ -134,7 +134,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
hi) { miss = true; break; } + } else { + const inv = 1 / dd[i]; + let t0 = (lo - od[i]) * inv; + let t1 = (hi - od[i]) * inv; + if (t0 > t1) { const tmp = t0; t0 = t1; t1 = tmp; } + if (t0 > tmin) { tmin = t0; hitAxis = i; } + tmax = Math.min(tmax, t1); + } + } + + if (miss || tmin > tmax || tmin < 0 || tmin >= closestT) + continue; + closestT = tmin; + const normal: V3 = [0, 0, 0]; + normal[hitAxis] = dd[hitAxis] < 0 ? 1 : -1; + result = { position: p, normal }; + } + + return result; +} + +interface MergedVoxelMeshProps { + color: string; + positions: V3[]; + onPointerMove: (e: ThreeEvent) => void; + onClick: (e: ThreeEvent) => void; + onPointerLeave: () => void; +} + +function MergedVoxelMesh({ color, positions, onPointerMove, onClick, onPointerLeave }: MergedVoxelMeshProps) { + const geo = useMemo( + () => buildVoxelMesh(positions), + [positions], + ); + useEffect(() => () => { geo.dispose(); }, [geo]); + + return (<> + + + + + ); +} + export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) { const [ghost, setGhost] = useState(null); - function handleVoxelClick(e: ThreeEvent, voxel: Voxel) { - if (e.delta > DRAG_THRESHOLD) - return; + const colorGroups = useMemo(() => { + const map = new Map(); + for (const v of voxels) { + const c = v.color ?? '#888888'; + if (!map.has(c)) + map.set(c, []); + map.get(c)!.push(v.position); + } + return [...map.entries()].map(([c, positions]) => ({ color: c, positions })); + }, [voxels]); + + function handleMeshMove(e: ThreeEvent) { e.stopPropagation(); + const hit = raycastVoxels(e.ray, voxels); + if (!hit) + return; if (mode === 'remove') { - onRemove(voxel.position); - setGhost(null); + setGhost(hit.position); } 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 }); + const [x, y, z] = hit.position; + const [nx, ny, nz] = hit.normal; + setGhost([x + nx, y + ny, z + nz]); } } - function handleVoxelMove(e: ThreeEvent, voxel: Voxel) { + function handleMeshClick(e: ThreeEvent) { + if (e.delta > DRAG_THRESHOLD) + return; e.stopPropagation(); + const hit = raycastVoxels(e.ray, voxels); + if (!hit) + return; if (mode === 'remove') { - setGhost(voxel.position); - } else { + onRemove(hit.position); setGhost(null); + } else { + const [x, y, z] = hit.position; + const [nx, ny, nz] = hit.normal; + onAdd({ typeId, position: [x + nx, y + ny, z + nz], color }); } } @@ -56,8 +126,7 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove 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 }); + onAdd({ typeId, position: [Math.floor(e.point.x), 0, Math.floor(e.point.z)], color }); } function handleFloorMove(e: ThreeEvent) { @@ -66,16 +135,16 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove setGhost([Math.floor(e.point.x), 0, Math.floor(e.point.z)]); } - const ghostOccupied = ghost && voxels.some(v => posEq(v.position, ghost)); + const ghostOccupied = ghost !== null && voxels.some( + v => v.position[0] === ghost[0] && v.position[1] === ghost[1] && v.position[2] === ghost[2], + ); return ( <> - - {/* Floor plane */} - {/* 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)} - > - - - - ); - })} + {colorGroups.map(({ color: groupColor, positions }) => ( + setGhost(null)} + /> + ))} - {/* Ghost voxel preview */} {ghost && mode === 'add' && !ghostOccupied && ( - null} - > + null}> )} + + {ghost && mode === 'remove' && ghostOccupied && ( + null}> + + + + )} ); } diff --git a/webclient/src/model/gameFactory.ts b/webclient/src/model/gameFactory.ts index 2f95837..8cd26f8 100644 --- a/webclient/src/model/gameFactory.ts +++ b/webclient/src/model/gameFactory.ts @@ -1,5 +1,5 @@ import { toJS } from "mobx"; -import type { Game, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types"; +import type { Game, GameScene, RuntimeGameObjectInstance, RuntimeGameScene, RuntimeScene, ScriptAction, ScriptCoroutineContext, World } from "../types"; import { clone } from "../utils"; import { populateRuntimeScene } from "../utils/runtime"; import { ScriptGameEventBus } from "../scripts/eventBus"; @@ -36,7 +36,7 @@ export class GameFactory { } } - public static save(game: Game): void { + public static save(game: Game): void { localStorage.setItem("game", JSON.stringify(game)); } diff --git a/webclient/src/state/gameState.ts b/webclient/src/state/gameState.ts index a868922..174f0bc 100644 --- a/webclient/src/state/gameState.ts +++ b/webclient/src/state/gameState.ts @@ -1,6 +1,6 @@ import { autorun, makeAutoObservable, toJS } from "mobx"; import type { WorldState } from "./worldState"; -import type { Game, GameObjectInstance, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } from "../types"; +import type { Game, GameObjectInstance, GameScene, Pos3, RuntimeGameScene, ScriptContext, ScriptCoroutineContext } from "../types"; import type { CameraProps } from "@react-three/fiber"; import type { ScriptGameEventBus } from "../scripts/eventBus"; import { GameFactory } from "../model/gameFactory"; @@ -18,7 +18,7 @@ export class GameState { private coroutines = new ScriptCoroutines(); private eventBus: ScriptGameEventBus; - private _buildGameObjects() { + private sceneObjectsAsSaveables(): GameScene['objects'] { return Object.fromEntries( Object.entries(this.scene.objects).map(([id, obj]) => [id, { id: obj.id, @@ -31,17 +31,25 @@ export class GameState { version: obj.version, linearVelocity: toJS(obj.linearVelocity), angularVelocity: toJS(obj.angularVelocity), + visible: toJS(obj.visible), } satisfies GameObjectInstance]) ) as unknown as RuntimeGameScene['objects']; } + public get asSaveableGame(): Game { + return { + paused: this.isPaused, + time: this.time, + scene: { + playerObjectId: this.scene.playerObjectId, + objects: this.sceneObjectsAsSaveables(), + }, + }; + } + private startAutoSave() { return autorun(() => { - GameFactory.save({ - paused: this.isPaused, - time: this.time, - scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() }, - }); + this.save(); }, { delay: 5000 }); } @@ -118,11 +126,7 @@ export class GameState { public save(): void { console.log('Saving game...'); - GameFactory.save({ - paused: this.isPaused, - time: this.time, - scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() }, - }); + GameFactory.save(this.asSaveableGame); } public resume(): void { diff --git a/webclient/src/state/menuState.tsx b/webclient/src/state/menuState.tsx index 031ddb9..e4b58de 100644 --- a/webclient/src/state/menuState.tsx +++ b/webclient/src/state/menuState.tsx @@ -1,7 +1,7 @@ import { makeAutoObservable } from "mobx"; import { state } from "./rootState"; import type { ReactNode } from "react"; -import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit } from '@tabler/icons-react'; +import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit, IconEye, IconEyeClosed } from '@tabler/icons-react'; export type MenuNodeAction = { id: string; @@ -53,6 +53,13 @@ export class MenuState { }); } + actions.push({ + id: 'toggle-object-instance-visibility', + content: o.visible ? : , + tooltip: 'Toggle visibility', + onClick: () => { editor.setObjectVisible(o.id, !o.visible); }, + }); + return { id: `o-${o.id}`, title: <> diff --git a/webclient/src/state/worldEditorState.ts b/webclient/src/state/worldEditorState.ts index 61a30ed..ed671e9 100644 --- a/webclient/src/state/worldEditorState.ts +++ b/webclient/src/state/worldEditorState.ts @@ -147,6 +147,11 @@ export class WorldEditorState { delete (this.scene.objects[objectId]); } + public setObjectVisible(objectId: string, value: boolean) { + if (this.scene.objects[objectId]) + this.scene.objects[objectId].visible = value; + } + public addObjectType(): ObjectType { const objectType: ObjectType = { id: randomId(), @@ -165,39 +170,39 @@ export class WorldEditorState { public addVoxelToObjectType(typeId: string, voxel: Voxel): void { const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) -return; + 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); + if (!occupied) + objectType.voxels.push(voxel); } public removeVoxelFromObjectType(typeId: string, position: V3): void { const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) -return; + 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); + if (idx !== -1) + objectType.voxels.splice(idx, 1); } public refreshObjectTypeCaches(typeId: string): void { const objectType = this.world.getObjectTypeById(typeId); - if (!objectType) -return; + 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; + if (obj.typeId !== typeId) + continue; obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes); obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes); obj.cache.boundingBox = computeBoundingBox(objectType); diff --git a/webclient/src/types/3d.ts b/webclient/src/types/3d.ts index 8e896de..2605155 100644 --- a/webclient/src/types/3d.ts +++ b/webclient/src/types/3d.ts @@ -1,6 +1,4 @@ -import type { Vector3Tuple } from "three"; - -export type V3 = Vector3Tuple; +export type V3 = [number, number, number]; export type R3 = V3; // Euler angles in radians, order XYZ diff --git a/webclient/src/types/model/game.ts b/webclient/src/types/model/game.ts index dd9f5d3..ca337ab 100644 --- a/webclient/src/types/model/game.ts +++ b/webclient/src/types/model/game.ts @@ -1,7 +1,7 @@ -import type { RuntimeGameScene } from "./scene"; +import type { GameScene, RuntimeGameScene } from "./scene"; -export type Game = { +export type Game = { paused: boolean; time: number; - scene: RuntimeGameScene; + scene: TScene; } diff --git a/webclient/src/types/model/object.ts b/webclient/src/types/model/object.ts index 16c3b9a..f05cd07 100644 --- a/webclient/src/types/model/object.ts +++ b/webclient/src/types/model/object.ts @@ -18,6 +18,7 @@ export type ObjectInstance = { position: V3; rotation: R3; scale: V3; + visible: boolean; } export type GameObjectData = { diff --git a/webclient/src/types/model/scene.ts b/webclient/src/types/model/scene.ts index 24e4f8b..4314e82 100644 --- a/webclient/src/types/model/scene.ts +++ b/webclient/src/types/model/scene.ts @@ -1,18 +1,10 @@ import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object"; -export type Scene = { - objects: Record; +export type Scene = { + objects: Record; playerObjectId: string | undefined; } -export type RuntimeScene = Scene & { - objects: Record; -} - -export type GameScene = Scene & { - objects: Record; -} - -export type RuntimeGameScene = Scene & { - objects: Record; -} +export type GameScene = Scene; +export type RuntimeScene = Scene; +export type RuntimeGameScene = Scene; diff --git a/webclient/src/utils/format.ts b/webclient/src/utils/format.ts new file mode 100644 index 0000000..b7c3268 --- /dev/null +++ b/webclient/src/utils/format.ts @@ -0,0 +1,9 @@ +import type { V3 } from "../types"; + +export function formatNumber(value: number, digits: number = 2) { + return value.toFixed(digits); +} + +export function formatV3(value: V3, digits: number = 2) { + return `[${formatNumber(value[0], digits)},${formatNumber(value[1], digits)},${formatNumber(value[2], digits)}]`; +} \ No newline at end of file diff --git a/webclient/src/utils/graphics/greedyMesh.ts b/webclient/src/utils/graphics/greedyMesh.ts deleted file mode 100644 index f1da52a..0000000 --- a/webclient/src/utils/graphics/greedyMesh.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { BufferGeometry, Float32BufferAttribute } from 'three'; -import type { V3 } from '../../types'; - -// tangent axes for each main axis -const U_OF = [1, 0, 0] as const; -const V_OF = [2, 2, 1] as const; - -/** - * Greedy meshing: merges face-adjacent same-material voxels into minimal quads. - * A 50×1×50 flat layer produces 6 quads (12 triangles), not 50×50×6×2. - * - * Only face-adjacency counts — diagonal neighbours do not merge. - */ -export function greedyMesh(positions: V3[]): BufferGeometry { - if (!positions.length) - return new BufferGeometry(); - - const occ = new Set(); - let x0 = Infinity, y0 = Infinity, z0 = Infinity; - let x1 = -Infinity, y1 = -Infinity, z1 = -Infinity; - - for (const [x, y, z] of positions) { - occ.add(`${x},${y},${z}`); - if (x < x0) - x0 = x; - if (x > x1) - x1 = x; - if (y < y0) - y0 = y; - if (y > y1) - y1 = y; - if (z < z0) - z0 = z; - if (z > z1) - z1 = z; - } - - const has = (x: number, y: number, z: number) => occ.has(`${x},${y},${z}`); - const lo = [x0, y0, z0]; - const hi = [x1, y1, z1]; - - const verts: number[] = []; - const inds: number[] = []; - - for (let axis = 0; axis < 3; axis++) { - const u = U_OF[axis]; - const v = V_OF[axis]; - const dLen = hi[axis] - lo[axis] + 1; - const uLen = hi[u] - lo[u] + 1; - const vLen = hi[v] - lo[v] + 1; - const mask = new Uint8Array(uLen * vLen); - const done = new Uint8Array(uLen * vLen); - - for (let dir = -1; dir <= 1; dir += 2) { - // For corner order (u0,v0)(u1,v0)(u1,v1)(u0,v1), - // cross product = +e_axis when axis≠Y, −e_Y when axis=Y. - // flip reverses winding to point outward for the given dir. - const flip = axis === 1 ? dir > 0 : dir < 0; - - for (let di = 0; di < dLen; di++) { - const D = lo[axis] + di; - - mask.fill(0); - done.fill(0); - - for (let vi = 0; vi < vLen; vi++) { - for (let ui = 0; ui < uLen; ui++) { - const p: [number, number, number] = [0, 0, 0]; - p[axis] = D; - p[u] = lo[u] + ui; - p[v] = lo[v] + vi; - if (!has(p[0], p[1], p[2])) - continue; - const np: [number, number, number] = [p[0], p[1], p[2]]; - np[axis] += dir; - if (!has(np[0], np[1], np[2])) - mask[ui + vi * uLen] = 1; - } - } - - for (let vi = 0; vi < vLen; vi++) { - for (let ui = 0; ui < uLen; ui++) { - if (!mask[ui + vi * uLen] || done[ui + vi * uLen]) - continue; - - let wu = 1; - while (ui + wu < uLen && mask[ui + wu + vi * uLen] && !done[ui + wu + vi * uLen]) - wu++; - - let wv = 1; - expand: while (vi + wv < vLen) { - for (let k = 0; k < wu; k++) - if (!mask[ui + k + (vi + wv) * uLen] || done[ui + k + (vi + wv) * uLen]) - break expand; - wv++; - } - - for (let dv = 0; dv < wv; dv++) - for (let du = 0; du < wu; du++) - done[ui + du + (vi + dv) * uLen] = 1; - - const dp = D + (dir > 0 ? 0.5 : -0.5); - const uA = lo[u] + ui - 0.5; - const uB = uA + wu; - const vA = lo[v] + vi - 0.5; - const vB = vA + wv; - - const base = verts.length / 3; - for (const [cu, cv] of [[uA, vA], [uB, vA], [uB, vB], [uA, vB]] as const) { - const pt: [number, number, number] = [0, 0, 0]; - pt[axis] = dp; - pt[u] = cu; - pt[v] = cv; - verts.push(pt[0], pt[1], pt[2]); - } - - if (flip) { - inds.push(base, base + 2, base + 1, base, base + 3, base + 2); - } else { - inds.push(base, base + 1, base + 2, base, base + 2, base + 3); - } - } - } - } - } - } - - const geo = new BufferGeometry(); - geo.setAttribute('position', new Float32BufferAttribute(verts, 3)); - geo.setIndex(inds); - geo.computeVertexNormals(); - return geo; -} diff --git a/webclient/src/utils/graphics/voxelGroup.ts b/webclient/src/utils/graphics/voxelGroup.ts index 7736567..49e28ff 100644 --- a/webclient/src/utils/graphics/voxelGroup.ts +++ b/webclient/src/utils/graphics/voxelGroup.ts @@ -14,7 +14,7 @@ export function getObjectVoxelGroups( if (!map.has(key)) map.set(key, { id: key, color, opacity, positions: [] }); const p = v.position; - map.get(key)!.positions.push([p[0] + 0.5, p[1] + 0.5, p[2] + 0.5]); + map.get(key)!.positions.push(p); } return [...map.values()]; } diff --git a/webclient/src/utils/graphics/voxelMesh.ts b/webclient/src/utils/graphics/voxelMesh.ts new file mode 100644 index 0000000..635de77 --- /dev/null +++ b/webclient/src/utils/graphics/voxelMesh.ts @@ -0,0 +1,115 @@ +import * as PolygonClipping from 'polygon-clipping'; +import { BufferGeometry, Float32BufferAttribute } from "three"; +import type { V3 } from "../../types"; +import earcut from 'earcut'; + +type V2 = [number, number]; + +export type Loop = V3[]; // closed, halfedges are v[idx]=>v[(idx+1)%size] + +function triangulatePolygon(polygon: PolygonClipping.Polygon): V2[] { + // polygon-clipping closes rings (last === first), earcut expects open rings + const outerRing = polygon[0].slice(0, -1); + const holes = polygon.slice(1).map(h => h.slice(0, -1)); + + const coords: number[] = outerRing.flat(); + const holeIndices: number[] = []; + for (const hole of holes) { + holeIndices.push(coords.length / 2); + coords.push(...hole.flat()); + } + + const indices = earcut(coords, holeIndices.length ? holeIndices : undefined, 2); + + const result: V2[] = []; + for (const idx of indices) { + result.push([coords[idx * 2], coords[idx * 2 + 1]]); + } + return result; +} + +export function buildVoxelMesh(voxels: V3[]): BufferGeometry { + + const vertices: V3[] = []; + const normals: V3[] = []; + + const occ = new Set(voxels.map(([x, y, z]) => `${x},${y},${z}`)); + const has = (pos: V3) => occ.has(`${pos[0]},${pos[1]},${pos[2]}`); + const isVisible = (pos: V3, normal: V3) => !has([pos[0] + normal[0], pos[1] + normal[1], pos[2] + normal[2]]); + + let min = [Infinity, Infinity, Infinity]; + let max = [-Infinity, -Infinity, -Infinity]; + + for (const v of voxels) { + min = min.map((c, idx) => Math.min(c, v[idx])); + max = max.map((c, idx) => Math.max(c, v[idx])); + } + + for (let axis = 0; axis < 3; axis++) { // x, y, z + // x: uv=yz, y: uv=zx, z: uv=xy + const uAxis = (axis + 1) % 3; + const vAxis = (axis + 2) % 3; + + const uSize = max[uAxis] - min[uAxis] + 1; + const vSize = max[vAxis] - min[vAxis] + 1; + const slicePixels = new Uint8Array(uSize * vSize); + + for (const dir of [-1, +1]) { + const normal = [0, 0, 0] as V3; + normal[axis] = dir; // [-1, 0, 0], [+1, 0, 0], [0, -1, 0], [0, +1, 0], [0, 0, -1], [0, 0, +1] + + for (let d = min[axis]; d <= max[axis]; d++) { //slice by slice + slicePixels.fill(0); + const sliceOrigin = [0, 0, 0] as V3; + sliceOrigin[axis] = d; + + const faceOrigin = [...sliceOrigin]; + if (dir > 0) + faceOrigin[axis]++; + + for (let v = 0; v < vSize; v++) + for (let u = 0; u < uSize; u++) { + + const pos = [...sliceOrigin] as V3; + pos[uAxis] = u + min[uAxis]; + pos[vAxis] = v + min[vAxis]; + + // if (has(pos)) + if (has(pos) && isVisible(pos, normal)) // voxel is not empty and face is not occluded by ajacent voxel + slicePixels[v * uSize + u] = 1; + } + + const pixelPolygons = Array.from(slicePixels) + .map((pixel, idx) => pixel ? idx : -1) // only indices of 1 pixels + .filter((idx) => idx >= 0) + .map((idx) => [idx % uSize, Math.floor(idx / uSize)]) // to [x,y] + .map(([x, y]) => [[[x, y], [x + 1, y], [x + 1, y + 1], [x, y + 1]]] as PolygonClipping.Polygon); // polygons + + const slicePolygons = PolygonClipping.union(pixelPolygons); + + slicePolygons.forEach((polygon) => { + const verts2d = triangulatePolygon(polygon); + if (dir < 0) + verts2d.reverse(); + + const verts3d = verts2d + .map((v) => { + const v3d = [...faceOrigin] as V3; + v3d[uAxis] = v[0] + min[uAxis]; + v3d[vAxis] = v[1] + min[vAxis]; + return v3d; + }); + + const norms = new Array(verts3d.length).fill(normal) as V3[]; + vertices.push(...verts3d); + normals.push(...norms); + }); + } + } + } + + const g = new BufferGeometry(); + g.setAttribute('position', new Float32BufferAttribute(vertices.flat(1), 3)); + g.setAttribute('normal', new Float32BufferAttribute(normals.flat(1), 3)); + return g; +} diff --git a/webclient/src/utils/index.ts b/webclient/src/utils/index.ts index 501c67f..afa2048 100644 --- a/webclient/src/utils/index.ts +++ b/webclient/src/utils/index.ts @@ -2,6 +2,8 @@ import { v4 as uuid } from 'uuid'; import type { V3 } from '../types'; import { type Vector3Object } from '@react-three/rapier'; +export * from './format'; + export function clone(obj: T): T { return JSON.parse(JSON.stringify(obj)); } diff --git a/webclient/src/utils/runtime/object.ts b/webclient/src/utils/runtime/object.ts index fffe241..5862900 100644 --- a/webclient/src/utils/runtime/object.ts +++ b/webclient/src/utils/runtime/object.ts @@ -1,5 +1,5 @@ import { getObjectVoxelGroups } from "../graphics/voxelGroup"; -import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance, V3, World } from "../../types"; +import type { GameObjectInstance, ObjectInstance, ObjectType, RuntimeObjectInstance, V3, World } from "../../types"; import { buildObjectTrimesh } from "../graphics/mesh"; export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {