custom voxel mesh builder
object visibility
This commit is contained in:
Generated
+38
@@ -63,6 +63,9 @@ importers:
|
|||||||
blockly:
|
blockly:
|
||||||
specifier: ^12.5.1
|
specifier: ^12.5.1
|
||||||
version: 12.5.1
|
version: 12.5.1
|
||||||
|
earcut:
|
||||||
|
specifier: ^3.0.2
|
||||||
|
version: 3.0.2
|
||||||
mobx:
|
mobx:
|
||||||
specifier: ^6.15.4
|
specifier: ^6.15.4
|
||||||
version: 6.15.4
|
version: 6.15.4
|
||||||
@@ -75,6 +78,9 @@ importers:
|
|||||||
nipplejs:
|
nipplejs:
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4
|
version: 1.0.4
|
||||||
|
polygon-clipping:
|
||||||
|
specifier: ^0.15.7
|
||||||
|
version: 0.15.7
|
||||||
r3f-perf:
|
r3f-perf:
|
||||||
specifier: ^7.2.3
|
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))
|
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
|
specifier: ^14.0.0
|
||||||
version: 14.0.0
|
version: 14.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/earcut':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.14
|
specifier: ^19.2.14
|
||||||
version: 19.2.15
|
version: 19.2.15
|
||||||
@@ -793,6 +802,9 @@ packages:
|
|||||||
'@types/draco3d@1.4.10':
|
'@types/draco3d@1.4.10':
|
||||||
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
|
||||||
|
|
||||||
|
'@types/earcut@3.0.0':
|
||||||
|
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1':
|
'@types/esrecurse@4.3.1':
|
||||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||||
|
|
||||||
@@ -1061,6 +1073,9 @@ packages:
|
|||||||
draco3d@1.5.7:
|
draco3d@1.5.7:
|
||||||
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
|
||||||
|
|
||||||
|
earcut@3.0.2:
|
||||||
|
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.364:
|
electron-to-chromium@1.5.364:
|
||||||
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
|
resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==}
|
||||||
|
|
||||||
@@ -1539,6 +1554,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
polygon-clipping@0.15.7:
|
||||||
|
resolution: {integrity: sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==}
|
||||||
|
|
||||||
postcss@8.5.15:
|
postcss@8.5.15:
|
||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -1654,6 +1672,9 @@ packages:
|
|||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
|
robust-predicates@3.0.3:
|
||||||
|
resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
|
||||||
|
|
||||||
rolldown@1.0.2:
|
rolldown@1.0.2:
|
||||||
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
|
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1715,6 +1736,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
splaytree@3.2.3:
|
||||||
|
resolution: {integrity: sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==}
|
||||||
|
engines: {node: '>=18.20 || >=20'}
|
||||||
|
|
||||||
split2@4.2.0:
|
split2@4.2.0:
|
||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
@@ -2606,6 +2631,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/draco3d@1.4.10': {}
|
'@types/draco3d@1.4.10': {}
|
||||||
|
|
||||||
|
'@types/earcut@3.0.0': {}
|
||||||
|
|
||||||
'@types/esrecurse@4.3.1': {}
|
'@types/esrecurse@4.3.1': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
@@ -2881,6 +2908,8 @@ snapshots:
|
|||||||
|
|
||||||
draco3d@1.5.7: {}
|
draco3d@1.5.7: {}
|
||||||
|
|
||||||
|
earcut@3.0.2: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.364: {}
|
electron-to-chromium@1.5.364: {}
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
@@ -3374,6 +3403,11 @@ snapshots:
|
|||||||
sonic-boom: 4.2.1
|
sonic-boom: 4.2.1
|
||||||
thread-stream: 4.2.0
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
|
polygon-clipping@0.15.7:
|
||||||
|
dependencies:
|
||||||
|
robust-predicates: 3.0.3
|
||||||
|
splaytree: 3.2.3
|
||||||
|
|
||||||
postcss@8.5.15:
|
postcss@8.5.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.12
|
nanoid: 3.3.12
|
||||||
@@ -3469,6 +3503,8 @@ snapshots:
|
|||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
|
robust-predicates@3.0.3: {}
|
||||||
|
|
||||||
rolldown@1.0.2:
|
rolldown@1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.132.0
|
'@oxc-project/types': 0.132.0
|
||||||
@@ -3534,6 +3570,8 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
splaytree@3.2.3: {}
|
||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
|
stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"@tabler/icons-react": "^3.44.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.184.1",
|
||||||
"blockly": "^12.5.1",
|
"blockly": "^12.5.1",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
"mobx": "^6.15.4",
|
"mobx": "^6.15.4",
|
||||||
"mobx-react-lite": "^4.1.1",
|
"mobx-react-lite": "^4.1.1",
|
||||||
"mobx-utils": "^6.1.1",
|
"mobx-utils": "^6.1.1",
|
||||||
"nipplejs": "^1.0.4",
|
"nipplejs": "^1.0.4",
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"r3f-perf": "^7.2.3",
|
"r3f-perf": "^7.2.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/earcut": "^3.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ return;
|
|||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('click', onClick);
|
canvas.removeEventListener('click', onClick);
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
if (document.pointerLockElement === canvas)
|
||||||
|
document.exitPointerLock();
|
||||||
};
|
};
|
||||||
}, [gl]);
|
}, [gl]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { RuntimeObjectInstance } from "../types";
|
|||||||
import { debugLabelsStore } from "../state/debugLabelsStore";
|
import { debugLabelsStore } from "../state/debugLabelsStore";
|
||||||
import { state } from "../state";
|
import { state } from "../state";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { formatV3 } from "../utils";
|
||||||
|
|
||||||
export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) {
|
export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstance; }) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
@@ -13,8 +14,8 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
|
|||||||
const rb = obj.rb();
|
const rb = obj.rb();
|
||||||
const texts = [
|
const texts = [
|
||||||
`${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`,
|
`${state.world.getObjectTypeById(obj.typeId)?.name} ${obj.id}`,
|
||||||
`position: ${obj.position}`,
|
`position: ${formatV3(obj.position)}`,
|
||||||
`rotation: ${obj.rotation}`,
|
`rotation: ${formatV3(obj.rotation)}`,
|
||||||
...Object.entries(debugLabelsStore.getAll(obj.id))
|
...Object.entries(debugLabelsStore.getAll(obj.id))
|
||||||
.map(([k, v]) => `${k}: ${v}`),
|
.map(([k, v]) => `${k}: ${v}`),
|
||||||
];
|
];
|
||||||
@@ -51,13 +52,14 @@ export const DebugLabel = observer(function ({ obj }: { obj: RuntimeObjectInstan
|
|||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(0,0,0,0.65)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
padding: '2px 7px',
|
padding: 10,
|
||||||
borderRadius: 4,
|
borderRadius: 10,
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
opacity: 0.3,
|
||||||
}}>
|
}}>
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { debugLabelsStore } from "../state/debugLabelsStore";
|
|||||||
import { DebugLabel } from "./DebugLabel";
|
import { DebugLabel } from "./DebugLabel";
|
||||||
import type { RuntimeScene } from "../types";
|
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(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
return () => { debugLabelsStore.clear(); };
|
return () => { debugLabelsStore.clear(); };
|
||||||
@@ -15,6 +15,7 @@ export const DebugLabelsView = observer(function ({ scene }: { scene: RuntimeSce
|
|||||||
return <>
|
return <>
|
||||||
{
|
{
|
||||||
Object.entries(scene.objects)
|
Object.entries(scene.objects)
|
||||||
|
.filter(([_, obj]) => editMode || obj.visible)
|
||||||
.map(([objectId, obj]) => {
|
.map(([objectId, obj]) => {
|
||||||
return <DebugLabel key={objectId} obj={obj} />;
|
return <DebugLabel key={objectId} obj={obj} />;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PlayerObjectView } from "./CharacterView";
|
|||||||
export type GameObjectViewProps = {
|
export type GameObjectViewProps = {
|
||||||
object: RuntimeGameObjectInstance;
|
object: RuntimeGameObjectInstance;
|
||||||
isPlayer: boolean;
|
isPlayer: boolean;
|
||||||
|
isSceneEditor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameObjectView = observer(function (props: GameObjectViewProps) {
|
export const GameObjectView = observer(function (props: GameObjectViewProps) {
|
||||||
@@ -25,5 +26,6 @@ export const GameObjectView = observer(function (props: GameObjectViewProps) {
|
|||||||
{...props}
|
{...props}
|
||||||
objectType={objectType}
|
objectType={objectType}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
isSceneEditor={props.isSceneEditor}
|
||||||
/>;
|
/>;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
|||||||
|
|
||||||
function handleTransformChange() {
|
function handleTransformChange() {
|
||||||
const group = dataRef.current?.group;
|
const group = dataRef.current?.group;
|
||||||
if (!group) return;
|
if (!group)
|
||||||
|
return;
|
||||||
const pos = group.getWorldPosition(new Vector3());
|
const pos = group.getWorldPosition(new Vector3());
|
||||||
const quat = group.getWorldQuaternion(new Quaternion());
|
const quat = group.getWorldQuaternion(new Quaternion());
|
||||||
const euler = new Euler().setFromQuaternion(quat);
|
const euler = new Euler().setFromQuaternion(quat);
|
||||||
@@ -90,7 +91,8 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
|||||||
|
|
||||||
function handleTransformEnd() {
|
function handleTransformEnd() {
|
||||||
const group = dataRef.current?.group;
|
const group = dataRef.current?.group;
|
||||||
if (!group) return;
|
if (!group)
|
||||||
|
return;
|
||||||
const pos = group.getWorldPosition(new Vector3());
|
const pos = group.getWorldPosition(new Vector3());
|
||||||
const quat = group.getWorldQuaternion(new Quaternion());
|
const quat = group.getWorldQuaternion(new Quaternion());
|
||||||
const euler = new Euler().setFromQuaternion(quat);
|
const euler = new Euler().setFromQuaternion(quat);
|
||||||
@@ -113,8 +115,9 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
|
|||||||
<ObjectViewInternal
|
<ObjectViewInternal
|
||||||
ref={dataRef}
|
ref={dataRef}
|
||||||
object={object}
|
object={object}
|
||||||
isEditor
|
editMode
|
||||||
isPlayer={isPlayer}
|
isPlayer={isPlayer}
|
||||||
|
isSceneEditor
|
||||||
objectType={objectType}
|
objectType={objectType}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
|
import type { ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
|
||||||
import type { VoxelGroup } from "../types/runtime/voxelGroup";
|
import type { VoxelGroup } from "../types/runtime/voxelGroup";
|
||||||
import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react";
|
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 type { ThreeEvent } from "@react-three/fiber";
|
||||||
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
|
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
|
||||||
import { SyncRigidBody } from "./SyncRigidBody";
|
import { SyncRigidBody } from "./SyncRigidBody";
|
||||||
import { reaction, runInAction } from "mobx";
|
import { reaction, runInAction } from "mobx";
|
||||||
import { v3toRapier } from "../utils";
|
import { v3toRapier } from "../utils";
|
||||||
import { state } from "../state";
|
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 VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) {
|
||||||
const geo = useMemo(() => greedyMesh(vg.positions), [vg]);
|
const geo = useMemo(() => buildVoxelMesh(vg.positions), [vg]);
|
||||||
useEffect(() => () => { geo.dispose(); }, [geo]);
|
useEffect(() => () => { geo.dispose(); }, [geo]);
|
||||||
|
|
||||||
|
const op = vg.opacity * opacity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh geometry={geo}>
|
<mesh geometry={geo} userData={{ baseOpacity: vg.opacity }}>
|
||||||
<meshStandardMaterial color={vg.color} opacity={vg.opacity} transparent={vg.opacity < 1} />
|
<meshStandardMaterial color={vg.color} opacity={op} transparent={op < 1} />
|
||||||
</mesh>
|
</mesh>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -29,8 +31,9 @@ export type ObjectViewInternalHandle = {
|
|||||||
type ObjectViewInternalProps = {
|
type ObjectViewInternalProps = {
|
||||||
object: Omit<RuntimeObjectInstance, 'typeId'>;
|
object: Omit<RuntimeObjectInstance, 'typeId'>;
|
||||||
objectType: ObjectType;
|
objectType: ObjectType;
|
||||||
isEditor?: boolean;
|
editMode?: boolean;
|
||||||
isPlayer?: boolean;
|
isPlayer?: boolean;
|
||||||
|
isSceneEditor?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
ref?: Ref<ObjectViewInternalHandle>;
|
ref?: Ref<ObjectViewInternalHandle>;
|
||||||
}
|
}
|
||||||
@@ -72,6 +75,29 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
|||||||
|
|
||||||
const gameObj = object as RuntimeGameObjectInstance;
|
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(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
runInAction(() => { gameObj.rb = () => rbRef.current; });
|
runInAction(() => { gameObj.rb = () => rbRef.current; });
|
||||||
@@ -97,7 +123,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
|||||||
gravityScale={object.gravityScale}
|
gravityScale={object.gravityScale}
|
||||||
position={object.position}
|
position={object.position}
|
||||||
rotation={object.rotation}
|
rotation={object.rotation}
|
||||||
onCollisionEnter={props.isEditor ? undefined : (payload) => {
|
onCollisionEnter={props.editMode ? undefined : (payload) => {
|
||||||
const game = state.game;
|
const game = state.game;
|
||||||
if (!game)
|
if (!game)
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +140,7 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
|
|||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
object.position = data.position;
|
object.position = data.position;
|
||||||
object.rotation = data.rotation;
|
object.rotation = data.rotation;
|
||||||
if (!props.isEditor) {
|
if (!props.editMode) {
|
||||||
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
|
(object as RuntimeGameObjectInstance).linearVelocity = data.linearVelocity;
|
||||||
(object as RuntimeGameObjectInstance).angularVelocity = data.radialVelocity;
|
(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.voxelGroups.map((vg) =>
|
||||||
<VoxelGroupMesh key={`${vg.id}-${object.cache.updatedAt}`} vg={vg} />
|
<VoxelGroupMesh
|
||||||
|
key={`${vg.id}-${object.cache.updatedAt}`}
|
||||||
|
vg={vg}
|
||||||
|
opacity={object.visible ? 1 : 0.5}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
|
{object.cache.colliderMesh && <ConvexHullCollider args={[object.cache.colliderMesh[0]]} />}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Grid, OrbitControls } from '@react-three/drei';
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { state } from '../state';
|
import { state } from '../state';
|
||||||
import { SceneView } from './SceneView';
|
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 { CameraSync } from './tools/CameraSync';
|
||||||
import { Physics } from '@react-three/rapier';
|
import { Physics } from '@react-three/rapier';
|
||||||
|
|
||||||
@@ -49,17 +49,23 @@ export const SceneEditorView = observer(function () {
|
|||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
onEnd={handleEnd}
|
onEnd={handleEnd}
|
||||||
makeDefault
|
makeDefault
|
||||||
|
mouseButtons={{
|
||||||
|
LEFT: MOUSE.PAN,
|
||||||
|
MIDDLE: MOUSE.ROTATE,
|
||||||
|
RIGHT: MOUSE.ROTATE,
|
||||||
|
}}
|
||||||
|
rotateSpeed={0.2}
|
||||||
enableDamping={false}
|
enableDamping={false}
|
||||||
/>
|
/>
|
||||||
<CameraSync camera={state.worldEditor.camera} />
|
<CameraSync camera={state.worldEditor.camera} />
|
||||||
<Grid
|
{/* <Grid
|
||||||
sectionThickness={0.5}
|
sectionThickness={0.5}
|
||||||
cellThickness={0}
|
cellThickness={0}
|
||||||
sectionColor="white"
|
sectionColor="white"
|
||||||
infiniteGrid
|
infiniteGrid
|
||||||
/>
|
/> */}
|
||||||
<Physics colliders={false} paused={true}>
|
<Physics colliders={false} paused={true}>
|
||||||
<SceneView scene={state.worldEditor.scene} editMode debugOverlay />
|
<SceneView scene={state.worldEditor.scene} isEditor debugOverlay />
|
||||||
</Physics>
|
</Physics>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>);
|
</>);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { DebugLabelsView } from "./DebugLabelsView";
|
|||||||
|
|
||||||
type SceneViewProps = {
|
type SceneViewProps = {
|
||||||
scene: RuntimeScene,
|
scene: RuntimeScene,
|
||||||
editMode?: boolean;
|
isEditor?: boolean;
|
||||||
debugOverlay?: boolean;
|
debugOverlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,12 +15,13 @@ export const SceneView = observer(function (props: SceneViewProps) {
|
|||||||
<ambientLight intensity={0.5} />
|
<ambientLight intensity={0.5} />
|
||||||
<directionalLight position={[5, 5, 5]} intensity={1} />
|
<directionalLight position={[5, 5, 5]} intensity={1} />
|
||||||
{
|
{
|
||||||
Object.values(props.scene.objects).map((obj) => (
|
Object.values(props.scene.objects)
|
||||||
!props.editMode
|
.map((obj) => (
|
||||||
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} />
|
!props.isEditor
|
||||||
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
|
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} isSceneEditor={props.isEditor} />
|
||||||
))
|
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
|
||||||
|
))
|
||||||
}
|
}
|
||||||
{props.debugOverlay && <DebugLabelsView scene={props.scene} />}
|
{props.debugOverlay && <DebugLabelsView scene={props.scene} editMode={props.isEditor} />}
|
||||||
</>);
|
</>);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Perf } from 'r3f-perf'
|
import { Perf } from 'r3f-perf';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas } from '@react-three/fiber';
|
||||||
import { KeyboardControls } from '@react-three/drei';
|
import { KeyboardControls } from '@react-three/drei';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ state.worldEditor.refreshObjectTypeCaches(id);
|
|||||||
<div style={{ flex: 1, position: 'relative' }}>
|
<div style={{ flex: 1, position: 'relative' }}>
|
||||||
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
|
<Canvas camera={{ position: [12, 10, 12], fov: 50 }}>
|
||||||
<VoxelEditorScene
|
<VoxelEditorScene
|
||||||
voxels={objectType.voxels}
|
voxels={objectType.voxels.slice()}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
color={color}
|
color={color}
|
||||||
typeId="stone"
|
typeId="stone"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { OrbitControls } from '@react-three/drei';
|
import { OrbitControls } from '@react-three/drei';
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import type { ThreeEvent } from '@react-three/fiber';
|
import type { ThreeEvent } from '@react-three/fiber';
|
||||||
|
import type { Ray } from 'three';
|
||||||
import type { Voxel, V3 } from '../../types';
|
import type { Voxel, V3 } from '../../types';
|
||||||
|
import { buildVoxelMesh } from '../../utils/graphics/voxelMesh';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
voxels: Voxel[];
|
voxels: Voxel[];
|
||||||
@@ -13,42 +15,110 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FLOOR_Y = 0;
|
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;
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
|
function raycastVoxels(ray: Ray, voxels: Voxel[]): { position: V3; normal: V3 } | null {
|
||||||
|
const od = [ray.origin.x, ray.origin.y, ray.origin.z];
|
||||||
|
const dd = [ray.direction.x, ray.direction.y, ray.direction.z];
|
||||||
|
let closestT = Infinity;
|
||||||
|
let result: { position: V3; normal: V3 } | null = null;
|
||||||
|
|
||||||
|
for (const v of voxels) {
|
||||||
|
const p = v.position;
|
||||||
|
let tmin = -Infinity, tmax = Infinity;
|
||||||
|
let hitAxis = 0;
|
||||||
|
let miss = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const lo = p[i], hi = p[i] + 1;
|
||||||
|
if (Math.abs(dd[i]) < 1e-8) {
|
||||||
|
if (od[i] < lo || od[i] > 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<PointerEvent>) => void;
|
||||||
|
onClick: (e: ThreeEvent<MouseEvent>) => void;
|
||||||
|
onPointerLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MergedVoxelMesh({ color, positions, onPointerMove, onClick, onPointerLeave }: MergedVoxelMeshProps) {
|
||||||
|
const geo = useMemo(
|
||||||
|
() => buildVoxelMesh(positions),
|
||||||
|
[positions],
|
||||||
|
);
|
||||||
|
useEffect(() => () => { geo.dispose(); }, [geo]);
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<mesh geometry={geo} onClick={onClick} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
|
||||||
|
<meshStandardMaterial color={color} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
|
export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove }: Props) {
|
||||||
const [ghost, setGhost] = useState<V3 | null>(null);
|
const [ghost, setGhost] = useState<V3 | null>(null);
|
||||||
|
|
||||||
function handleVoxelClick(e: ThreeEvent<MouseEvent>, voxel: Voxel) {
|
const colorGroups = useMemo(() => {
|
||||||
if (e.delta > DRAG_THRESHOLD)
|
const map = new Map<string, V3[]>();
|
||||||
return;
|
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<PointerEvent>) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const hit = raycastVoxels(e.ray, voxels);
|
||||||
|
if (!hit)
|
||||||
|
return;
|
||||||
if (mode === 'remove') {
|
if (mode === 'remove') {
|
||||||
onRemove(voxel.position);
|
setGhost(hit.position);
|
||||||
setGhost(null);
|
|
||||||
} else {
|
} else {
|
||||||
if (!e.face)
|
const [x, y, z] = hit.position;
|
||||||
return;
|
const [nx, ny, nz] = hit.normal;
|
||||||
const { x, y, z } = e.face.normal;
|
setGhost([x + nx, y + ny, z + nz]);
|
||||||
const pos = adjPosition(voxel.position, x, y, z);
|
|
||||||
onAdd({ typeId, position: pos, color });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVoxelMove(e: ThreeEvent<PointerEvent>, voxel: Voxel) {
|
function handleMeshClick(e: ThreeEvent<MouseEvent>) {
|
||||||
|
if (e.delta > DRAG_THRESHOLD)
|
||||||
|
return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const hit = raycastVoxels(e.ray, voxels);
|
||||||
|
if (!hit)
|
||||||
|
return;
|
||||||
if (mode === 'remove') {
|
if (mode === 'remove') {
|
||||||
setGhost(voxel.position);
|
onRemove(hit.position);
|
||||||
} else {
|
|
||||||
setGhost(null);
|
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')
|
if (e.delta > DRAG_THRESHOLD || mode !== 'add')
|
||||||
return;
|
return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const pos: V3 = [Math.floor(e.point.x), 0, Math.floor(e.point.z)];
|
onAdd({ typeId, position: [Math.floor(e.point.x), 0, Math.floor(e.point.z)], color });
|
||||||
onAdd({ typeId, position: pos, color });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
|
function handleFloorMove(e: ThreeEvent<PointerEvent>) {
|
||||||
@@ -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)]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ambientLight intensity={0.7} />
|
<ambientLight intensity={0.7} />
|
||||||
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
|
<directionalLight position={[8, 12, 6]} intensity={1} castShadow />
|
||||||
|
|
||||||
<OrbitControls makeDefault />
|
<OrbitControls makeDefault />
|
||||||
|
|
||||||
{/* Floor plane */}
|
|
||||||
<mesh
|
<mesh
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
position={[0, FLOOR_Y, 0]}
|
position={[0, FLOOR_Y, 0]}
|
||||||
@@ -89,37 +158,30 @@ export function VoxelEditorScene({ voxels, mode, color, typeId, onAdd, onRemove
|
|||||||
|
|
||||||
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
|
<gridHelper args={[40, 40, '#555', '#333']} position={[0, FLOOR_Y, 0]} />
|
||||||
|
|
||||||
{/* Voxels */}
|
{colorGroups.map(({ color: groupColor, positions }) => (
|
||||||
{voxels.map((v, i) => {
|
<MergedVoxelMesh
|
||||||
const isHovered = !!ghost && mode === 'remove' && posEq(v.position, ghost);
|
key={groupColor}
|
||||||
return (
|
color={groupColor}
|
||||||
<mesh
|
positions={positions}
|
||||||
key={i}
|
onPointerMove={handleMeshMove}
|
||||||
position={[v.position[0] + 0.5, v.position[1] + 0.5, v.position[2] + 0.5]}
|
onClick={handleMeshClick}
|
||||||
onClick={(e) => handleVoxelClick(e, v)}
|
onPointerLeave={() => setGhost(null)}
|
||||||
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 && (
|
{ghost && mode === 'add' && !ghostOccupied && (
|
||||||
<mesh
|
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
|
||||||
position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]}
|
|
||||||
raycast={() => null}
|
|
||||||
>
|
|
||||||
<boxGeometry />
|
<boxGeometry />
|
||||||
<meshStandardMaterial color={color} opacity={0.45} transparent />
|
<meshStandardMaterial color={color} opacity={0.45} transparent />
|
||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ghost && mode === 'remove' && ghostOccupied && (
|
||||||
|
<mesh position={[ghost[0] + 0.5, ghost[1] + 0.5, ghost[2] + 0.5]} raycast={() => null}>
|
||||||
|
<boxGeometry args={[1.02, 1.02, 1.02]} />
|
||||||
|
<meshStandardMaterial color="#ff4444" opacity={0.4} transparent />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toJS } from "mobx";
|
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 { clone } from "../utils";
|
||||||
import { populateRuntimeScene } from "../utils/runtime";
|
import { populateRuntimeScene } from "../utils/runtime";
|
||||||
import { ScriptGameEventBus } from "../scripts/eventBus";
|
import { ScriptGameEventBus } from "../scripts/eventBus";
|
||||||
@@ -36,7 +36,7 @@ export class GameFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static save(game: Game): void {
|
public static save(game: Game<GameScene>): void {
|
||||||
localStorage.setItem("game", JSON.stringify(game));
|
localStorage.setItem("game", JSON.stringify(game));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { autorun, makeAutoObservable, toJS } from "mobx";
|
import { autorun, makeAutoObservable, toJS } from "mobx";
|
||||||
import type { WorldState } from "./worldState";
|
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 { CameraProps } from "@react-three/fiber";
|
||||||
import type { ScriptGameEventBus } from "../scripts/eventBus";
|
import type { ScriptGameEventBus } from "../scripts/eventBus";
|
||||||
import { GameFactory } from "../model/gameFactory";
|
import { GameFactory } from "../model/gameFactory";
|
||||||
@@ -18,7 +18,7 @@ export class GameState {
|
|||||||
private coroutines = new ScriptCoroutines();
|
private coroutines = new ScriptCoroutines();
|
||||||
private eventBus: ScriptGameEventBus;
|
private eventBus: ScriptGameEventBus;
|
||||||
|
|
||||||
private _buildGameObjects() {
|
private sceneObjectsAsSaveables(): GameScene['objects'] {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(this.scene.objects).map(([id, obj]) => [id, {
|
Object.entries(this.scene.objects).map(([id, obj]) => [id, {
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
@@ -31,17 +31,25 @@ export class GameState {
|
|||||||
version: obj.version,
|
version: obj.version,
|
||||||
linearVelocity: toJS(obj.linearVelocity),
|
linearVelocity: toJS(obj.linearVelocity),
|
||||||
angularVelocity: toJS(obj.angularVelocity),
|
angularVelocity: toJS(obj.angularVelocity),
|
||||||
|
visible: toJS(obj.visible),
|
||||||
} satisfies GameObjectInstance])
|
} satisfies GameObjectInstance])
|
||||||
) as unknown as RuntimeGameScene['objects'];
|
) as unknown as RuntimeGameScene['objects'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get asSaveableGame(): Game<GameScene> {
|
||||||
|
return {
|
||||||
|
paused: this.isPaused,
|
||||||
|
time: this.time,
|
||||||
|
scene: {
|
||||||
|
playerObjectId: this.scene.playerObjectId,
|
||||||
|
objects: this.sceneObjectsAsSaveables(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private startAutoSave() {
|
private startAutoSave() {
|
||||||
return autorun(() => {
|
return autorun(() => {
|
||||||
GameFactory.save({
|
this.save();
|
||||||
paused: this.isPaused,
|
|
||||||
time: this.time,
|
|
||||||
scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() },
|
|
||||||
});
|
|
||||||
}, { delay: 5000 });
|
}, { delay: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +126,7 @@ export class GameState {
|
|||||||
|
|
||||||
public save(): void {
|
public save(): void {
|
||||||
console.log('Saving game...');
|
console.log('Saving game...');
|
||||||
GameFactory.save({
|
GameFactory.save(this.asSaveableGame);
|
||||||
paused: this.isPaused,
|
|
||||||
time: this.time,
|
|
||||||
scene: { playerObjectId: this.scene.playerObjectId, objects: this._buildGameObjects() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resume(): void {
|
public resume(): void {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, IconEdit } from '@tabler/icons-react';
|
import { IconRun, IconTrash, IconPlus, IconCubePlus, IconCubeOff, IconEdit, IconEye, IconEyeClosed } from '@tabler/icons-react';
|
||||||
|
|
||||||
export type MenuNodeAction = {
|
export type MenuNodeAction = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +53,13 @@ export class MenuState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
id: 'toggle-object-instance-visibility',
|
||||||
|
content: o.visible ? <IconEye size="1em" /> : <IconEyeClosed size="1em" />,
|
||||||
|
tooltip: 'Toggle visibility',
|
||||||
|
onClick: () => { editor.setObjectVisible(o.id, !o.visible); },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `o-${o.id}`,
|
id: `o-${o.id}`,
|
||||||
title: <>
|
title: <>
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export class WorldEditorState {
|
|||||||
delete (this.scene.objects[objectId]);
|
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 {
|
public addObjectType(): ObjectType {
|
||||||
const objectType: ObjectType = {
|
const objectType: ObjectType = {
|
||||||
id: randomId(),
|
id: randomId(),
|
||||||
@@ -166,38 +171,38 @@ export class WorldEditorState {
|
|||||||
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
|
public addVoxelToObjectType(typeId: string, voxel: Voxel): void {
|
||||||
const objectType = this.world.getObjectTypeById(typeId);
|
const objectType = this.world.getObjectTypeById(typeId);
|
||||||
if (!objectType)
|
if (!objectType)
|
||||||
return;
|
return;
|
||||||
const occupied = objectType.voxels.some(v =>
|
const occupied = objectType.voxels.some(v =>
|
||||||
v.position[0] === voxel.position[0] &&
|
v.position[0] === voxel.position[0] &&
|
||||||
v.position[1] === voxel.position[1] &&
|
v.position[1] === voxel.position[1] &&
|
||||||
v.position[2] === voxel.position[2]
|
v.position[2] === voxel.position[2]
|
||||||
);
|
);
|
||||||
if (!occupied)
|
if (!occupied)
|
||||||
objectType.voxels.push(voxel);
|
objectType.voxels.push(voxel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeVoxelFromObjectType(typeId: string, position: V3): void {
|
public removeVoxelFromObjectType(typeId: string, position: V3): void {
|
||||||
const objectType = this.world.getObjectTypeById(typeId);
|
const objectType = this.world.getObjectTypeById(typeId);
|
||||||
if (!objectType)
|
if (!objectType)
|
||||||
return;
|
return;
|
||||||
const idx = objectType.voxels.findIndex(v =>
|
const idx = objectType.voxels.findIndex(v =>
|
||||||
v.position[0] === position[0] &&
|
v.position[0] === position[0] &&
|
||||||
v.position[1] === position[1] &&
|
v.position[1] === position[1] &&
|
||||||
v.position[2] === position[2]
|
v.position[2] === position[2]
|
||||||
);
|
);
|
||||||
if (idx !== -1)
|
if (idx !== -1)
|
||||||
objectType.voxels.splice(idx, 1);
|
objectType.voxels.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public refreshObjectTypeCaches(typeId: string): void {
|
public refreshObjectTypeCaches(typeId: string): void {
|
||||||
const objectType = this.world.getObjectTypeById(typeId);
|
const objectType = this.world.getObjectTypeById(typeId);
|
||||||
if (!objectType)
|
if (!objectType)
|
||||||
return;
|
return;
|
||||||
const world = this.world.data;
|
const world = this.world.data;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const obj of Object.values(this.scene.objects)) {
|
for (const obj of Object.values(this.scene.objects)) {
|
||||||
if (obj.typeId !== typeId)
|
if (obj.typeId !== typeId)
|
||||||
continue;
|
continue;
|
||||||
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
|
obj.cache.voxelGroups = getObjectVoxelGroups(objectType, world.voxelTypes);
|
||||||
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
|
obj.cache.colliderMesh = buildObjectTrimesh(objectType, world.voxelTypes);
|
||||||
obj.cache.boundingBox = computeBoundingBox(objectType);
|
obj.cache.boundingBox = computeBoundingBox(objectType);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Vector3Tuple } from "three";
|
export type V3 = [number, number, number];
|
||||||
|
|
||||||
export type V3 = Vector3Tuple;
|
|
||||||
|
|
||||||
export type R3 = V3; // Euler angles in radians, order XYZ
|
export type R3 = V3; // Euler angles in radians, order XYZ
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeGameScene } from "./scene";
|
import type { GameScene, RuntimeGameScene } from "./scene";
|
||||||
|
|
||||||
export type Game = {
|
export type Game<TScene extends GameScene = RuntimeGameScene> = {
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
time: number;
|
time: number;
|
||||||
scene: RuntimeGameScene;
|
scene: TScene;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ObjectInstance = {
|
|||||||
position: V3;
|
position: V3;
|
||||||
rotation: R3;
|
rotation: R3;
|
||||||
scale: V3;
|
scale: V3;
|
||||||
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameObjectData = {
|
export type GameObjectData = {
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
|
import type { GameObjectInstance, ObjectInstance, RuntimeGameObjectInstance, RuntimeObjectInstance } from "./object";
|
||||||
|
|
||||||
export type Scene = {
|
export type Scene<TObject extends ObjectInstance> = {
|
||||||
objects: Record<string, ObjectInstance>;
|
objects: Record<string, TObject>;
|
||||||
playerObjectId: string | undefined;
|
playerObjectId: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeScene = Scene & {
|
export type GameScene = Scene<GameObjectInstance>;
|
||||||
objects: Record<string, RuntimeObjectInstance>;
|
export type RuntimeScene = Scene<RuntimeObjectInstance>;
|
||||||
}
|
export type RuntimeGameScene = Scene<RuntimeGameObjectInstance>;
|
||||||
|
|
||||||
export type GameScene = Scene & {
|
|
||||||
objects: Record<string, GameObjectInstance>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RuntimeGameScene = Scene & {
|
|
||||||
objects: Record<string, RuntimeGameObjectInstance>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)}]`;
|
||||||
|
}
|
||||||
@@ -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<string>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ export function getObjectVoxelGroups(
|
|||||||
if (!map.has(key))
|
if (!map.has(key))
|
||||||
map.set(key, { id: key, color, opacity, positions: [] });
|
map.set(key, { id: key, color, opacity, positions: [] });
|
||||||
const p = v.position;
|
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()];
|
return [...map.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import type { V3 } from '../types';
|
import type { V3 } from '../types';
|
||||||
import { type Vector3Object } from '@react-three/rapier';
|
import { type Vector3Object } from '@react-three/rapier';
|
||||||
|
|
||||||
|
export * from './format';
|
||||||
|
|
||||||
export function clone<T>(obj: T): T {
|
export function clone<T>(obj: T): T {
|
||||||
return JSON.parse(JSON.stringify(obj));
|
return JSON.parse(JSON.stringify(obj));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getObjectVoxelGroups } from "../graphics/voxelGroup";
|
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";
|
import { buildObjectTrimesh } from "../graphics/mesh";
|
||||||
|
|
||||||
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
|
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
|
||||||
|
|||||||
Reference in New Issue
Block a user