8 Commits
Author SHA1 Message Date
azykov@mail.ru ea8f251599 player camera gizmo in editor 2026-07-01 10:31:35 +03:00
azykov@mail.ru 7ce9e6dca6 code cleanup 2026-06-27 21:27:35 +03:00
azykov@mail.ru b873286dfd minor stats changes 2026-06-26 20:56:10 +03:00
azykov@mail.ru bf7f803a99 object transform in editor 2026-06-22 15:30:58 +03:00
azykov@mail.ru 21c48e44ee object selection 2026-06-22 14:22:15 +03:00
azykov@mail.ru 0b7cdd9f05 rendering stats 2026-06-21 19:10:18 +03:00
azykov@mail.ru a60e01e4d1 layered geometries 2026-06-21 15:38:41 +03:00
azykov@mail.ru 98e387c490 typo fixed 2026-06-20 18:44:35 +03:00
51 changed files with 889 additions and 405 deletions
+5
View File
@@ -1,3 +1,8 @@
.editor-layout {
width: 100vw;
height: 100vh;
}
.counter {
font-size: 16px;
padding: 5px 10px;
+2 -2
View File
@@ -92,11 +92,11 @@ function HomePage() {
function EditorLayout() {
return (
<>
<div className="editor-layout">
<Outlet />
<LayeredThreeView />
<Panels />
</>
</div>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react-lite";
import type { ObjectType, RuntimeGameObjectInstance } from "../types";
import type { ObjectType, ObjectInstance } from "../types";
import { ObjectViewInternal, type ObjectViewInternalHandle } from "./ObjectViewInternal";
import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { Euler, Quaternion, Vector3 } from "three";
@@ -25,7 +25,7 @@ const _charPos = new Vector3();
const _lookAt = new Vector3();
type PlayerObjectViewProps = {
object: RuntimeGameObjectInstance;
object: ObjectInstance;
objectType: ObjectType;
}
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { debugLabelsStore } from "../state/debugLabelsState";
import { DebugLabel } from "./DebugLabel";
import type { Scene } from "../types";
export const DebugLabelsView = observer(function ({ scene, editMode }: { scene: Scene, editMode?: boolean }) {
useEffect(
@@ -2,6 +2,7 @@ import { observer } from "mobx-react-lite";
import { state } from "../state";
import { ObjectViewInternal } from "./ObjectViewInternal";
import { PlayerObjectView } from "./CharacterView";
import type { ObjectInstance } from "../types";
export type GameObjectViewProps = {
object: ObjectInstance;
@@ -94,9 +94,6 @@ export const ObjectEditorView = observer(function ({ object, isPlayer }: ObjectE
if (!objectType)
return null;
// Track cache so observer re-renders when refreshObjectTypeCaches is called
void object.cache.updatedAt;
function handleClick() {
if (state.worldEditor.isEnabled) {
// Reading selection state inside an event handler: not tracked by observer.
@@ -1,27 +1,12 @@
import type { ObjectInstance, ObjectType, RuntimeGameObjectInstance, RuntimeObjectInstance } from "../types";
import type { VoxelGroup } from "../types/runtime/voxelGroup";
import { useEffect, useImperativeHandle, useRef, type Ref, memo, useMemo } from "react";
import type { ObjectInstance, ObjectType } from "../types";
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
import { Euler, Quaternion, Mesh, MeshStandardMaterial, type Group } from "three";
import type { ThreeEvent } from "@react-three/fiber";
import { ConvexHullCollider, RapierRigidBody } from "@react-three/rapier";
import { RapierRigidBody } from "@react-three/rapier";
import { SyncRigidBody } from "./SyncRigidBody";
import { reaction, runInAction } from "mobx";
import { v3toRapier } from "../utils";
import { state } from "../state";
import { buildVoxelGroupMesh } from "../utils/graphics/voxelMesh";
const VoxelGroupMesh = memo(function VoxelGroupMesh({ vg, opacity }: { vg: VoxelGroup, opacity: number }) {
const geo = useMemo(() => buildVoxelGroupMesh(vg.positions), [vg]);
useEffect(() => () => { geo.dispose(); }, [geo]);
const op = vg.opacity * opacity;
return (
<mesh geometry={geo} userData={{ baseOpacity: vg.opacity }}>
<meshStandardMaterial color={vg.color} opacity={op} transparent={op < 1} />
</mesh>
);
});
export type ObjectViewInternalHandle = {
group: Group | null;
@@ -71,8 +56,6 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
[object, object.id, props.isPlayer]
);
const gameObj = object as RuntimeGameObjectInstance;
useEffect(
() => {
return reaction(
@@ -98,8 +81,8 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
useEffect(
() => {
runInAction(() => { gameObj.rb = () => rbRef.current; });
return () => { runInAction(() => { gameObj.rb = () => null; }); };
runInAction(() => { object.rb = () => rbRef.current; });
return () => { runInAction(() => { object.rb = () => null; }); };
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[object.id],
@@ -130,9 +113,11 @@ export const ObjectViewInternal = function ({ object, objectType, ref, ...props
return;
const otherObj = Object.values(game.scene.objects)
.find((o) => o.rb()?.handle === otherHandle);
game.emitEvent('objectTouchStart', { object: gameObj, other: otherObj });
const context = { object: { ...object, typeId: objectType.id }, other: otherObj };
game.emitEvent('objectTouchStart', context);
if (otherObj?.id === game.scene.playerObjectId)
game.emitEvent('playerTouchStart', { object: gameObj, other: otherObj });
game.emitEvent('playerTouchStart', context);
}}
onSync={(data) => {
runInAction(() => {
+1 -1
View File
@@ -1,5 +1,5 @@
.overlay-panels {
z-index: 15000;
z-index: 9000;
position: fixed;
top: 0;
left: 0;
+6 -24
View File
@@ -4,49 +4,32 @@ import { OrbitControls } from '@react-three/drei';
import { observer } from 'mobx-react-lite';
import { state } from '../state';
import { SceneView } from './SceneView';
import { MOUSE, type OrthographicCamera, type PerspectiveCamera } from 'three';
import { CameraSync } from './tools/CameraSync';
import { MOUSE } from 'three';
import { useSyncCamera } from '../utils/hooks/useSyncCamera';
import { Physics } from '@react-three/rapier';
export const SceneEditorView = observer(function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
const cameraAtStart = useRef<string>('');
function cameraSnapshot(camera: OrthographicCamera | PerspectiveCamera): string {
return JSON.stringify({
position: camera.position,
rotation: camera.rotation,
});
}
const handleStart = () => {
const controls = controlsRef.current;
if (!controls)
return;
cameraAtStart.current = cameraSnapshot(controls.object);
};
useSyncCamera(() => state.view.camera.camera);
const handleEnd = () => {
const controls = controlsRef.current;
if (!controls)
return;
const snapshot = cameraSnapshot(controls.object);
if (snapshot === cameraAtStart.current)
return;
const [x, y, z] = controls.object.rotation.toArray();
state.worldEditor.setCamera({
state.view.camera.camera = {
position: controls.object.position.toArray(),
look: [x, y, z],
});
};
};
return (<>
<Suspense>
<OrbitControls
ref={controlsRef}
onStart={handleStart}
onEnd={handleEnd}
makeDefault
mouseButtons={{
@@ -57,7 +40,6 @@ export const SceneEditorView = observer(function () {
rotateSpeed={0.2}
enableDamping={false}
/>
<CameraSync camera={state.worldEditor.camera} />
{/* <Grid
sectionThickness={0.5}
cellThickness={0}
+3 -3
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react-lite";
import type { RuntimeScene } from "../types";
import { GameObjectView } from "./GameObjectView";
import { ObjectEditorView } from "./ObjectEditorView";
import { DebugLabelsView } from "./DebugLabelsView";
import type { Scene } from "../types";
type SceneViewProps = {
scene: RuntimeScene,
scene: Scene,
isEditor?: boolean;
debugOverlay?: boolean;
}
@@ -18,7 +18,7 @@ export const SceneView = observer(function (props: SceneViewProps) {
Object.values(props.scene.objects)
.map((obj) => (
!props.isEditor
? <GameObjectView key={obj.id} object={obj as RuntimeGameObjectInstance} isPlayer={props.scene.playerObjectId === obj.id} isSceneEditor={props.isEditor} />
? <GameObjectView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} isSceneEditor={props.isEditor} />
: <ObjectEditorView key={obj.id} object={obj} isPlayer={props.scene.playerObjectId === obj.id} />
))
}
+1 -18
View File
@@ -8,7 +8,6 @@ import { SceneEditorView } from './SceneEditorView';
import { JoystickView } from './JoystickView';
import { useEffect } from 'react';
import { runInAction } from 'mobx';
import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled, IconPlayerSkipForwardFilled } from '@tabler/icons-react';
export const ThreeView = observer(function () {
const isGame = !!state.game;
@@ -39,23 +38,7 @@ export const ThreeView = observer(function () {
{isGame ? <GameView /> : <SceneEditorView />}
</Canvas>
{isGame && <JoystickView />}
<div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, fontSize: 14 }}>
{
state.game
? <>
<button onClick={() => state.stopGame()}><IconPlayerStopFilled size="1em" /></button>
{
state.game!.isPaused
? <>
<button onClick={() => state.game!.resume()}><IconPlayerPlayFilled size="1em" /></button>
<button onClick={() => state.game!.step()}><IconPlayerSkipForwardFilled size="1em" /></button>
</>
: <button onClick={() => state.game!.pause()}><IconPlayerPauseFilled size="1em" /></button>
}
</>
: <button onClick={() => state.startGame()}><IconPlayerPlayFilled size="1em" /></button>
}
</div>
</div>
</KeyboardControls>
);
@@ -1,5 +1,6 @@
import { observer } from "mobx-react-lite";
import { measuredRenderTime } from "../../utils/layerTimings";
export const DebugLabelsLayer = observer(function () {
export const DebugLabelsLayer = observer(measuredRenderTime("DebugLabels", function () {
return null; //TODO
});
}));
@@ -1,27 +0,0 @@
import { useFrame } from "@react-three/fiber";
import type { Pos3 } from "../../types";
import { Euler, Quaternion, Vector3 } from "three";
const _q = new Quaternion();
const _e = new Euler(0, 0, 0, 'YXZ');
const _offset = new Vector3();
const _charPos = new Vector3();
const _lookAt = new Vector3();
type Props = {
camera: Pos3;
};
export function CameraLayer({ camera: cameraPos }: Props) {
useFrame(({ camera }) => {
// const t = rb.translation();
// _charPos.set(t.x, t.y, t.z);
// _offset.copy(shoulderOffset).applyEuler(_e.set(pitchRef.current, yawRef.current, 0, 'YXZ'));
// camera.position.copy(_charPos).add(_offset);
// _lookAt.set(t.x, t.y + lookAtY, t.z);
camera.lookAt(_lookAt);
});
return null;
}
@@ -1,23 +1,27 @@
import { observer } from "mobx-react-lite";
import { ObjectGeomertiesLayer } from "./ObjectGeomertiesLayer";
import { ObjectGeometriesLayer } from "./ObjectGeometriesLayer";
import { Canvas } from "@react-three/fiber";
import { Perf } from "r3f-perf";
import { state } from "../../state";
import { CameraLayer } from "./GameraLayer";
import { SceneEditorCameraLayer } from "./SceneEditor/SceneEditorGameraLayer";
import { SceneEditorObjectSelectionLayer } from "./SceneEditor/SceneEditorObjectSelectionLayer";
import { LightsLayer } from "./LighsLayer";
import { StatsLayer } from './StatsLayer';
import { SceneEditorObjectGizmosLayer } from "./SceneEditor/SceneEditorObjectGizmosLayer";
import { PlayButtons } from "./PlayButtons";
import { SceneEditorPlayerGizmosLayer } from "./SceneEditor/SceneEditorPlayerGizmosLayer";
export const LayeredThreeView = observer(function () {
return <>
<Canvas
// camera={state.world.character.camera}
>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<Perf position="bottom-right" />
<CameraLayer camera={state.worldEditor.camera} />
<ObjectGeomertiesLayer />
<Canvas>
<LightsLayer />
<SceneEditorCameraLayer />
<ObjectGeometriesLayer>
<SceneEditorObjectSelectionLayer />
<SceneEditorObjectGizmosLayer />
</ObjectGeometriesLayer>
<SceneEditorPlayerGizmosLayer />
<StatsLayer />
</Canvas>
<PlayButtons />
</>;
});
@@ -0,0 +1,9 @@
import { observer } from "mobx-react-lite";
import { measuredRenderTime } from "../../utils/layerTimings";
export const LightsLayer = observer(measuredRenderTime("Lights", function () {
return <>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
</>;
}));
@@ -1,26 +0,0 @@
import { useEffect, useRef } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { ObjectGeometryList } from "../../rendering/ObjectGeometryList";
import { observer } from "mobx-react-lite";
import { state } from "../../state";
export const ObjectGeomertiesLayer = observer(function () {
const geometries = state.view.objectGeometries;
const threeScene = useThree(s => s.scene);
const list = useRef<ObjectGeometryList | null>(null);
useEffect(() => {
const l = new ObjectGeometryList(threeScene, geometries);
list.current = l;
return () => {
l.dispose();
list.current = null;
};
}, [threeScene, geometries]);
useFrame(() => list.current?.tick());
return null;
});
@@ -0,0 +1,37 @@
import { createContext, useContext, useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import { ObjectGeometryList } from "../../rendering/ObjectGeometryList";
import { observer } from "mobx-react-lite";
import { state } from "../../state";
import { useLayerFrame } from "../../utils/hooks/useLayerFrame";
import { measuredRenderTime } from "../../utils/layerTimings";
export const ObjectGeometryListContext = createContext<React.RefObject<ObjectGeometryList | null>>(
{ current: null },
);
export function useObjectGeometryList() {
return useContext(ObjectGeometryListContext);
}
export const ObjectGeometriesLayer = observer(measuredRenderTime("ObjectGeometries", function ({ children }: { children?: React.ReactNode }) {
const typeGeometries = state.view.objectTypeGeometries;
const threeScene = useThree(s => s.scene);
const list = useRef<ObjectGeometryList | null>(null);
useEffect(() => {
const l = new ObjectGeometryList(threeScene, typeGeometries);
list.current = l;
return () => {
l.dispose();
list.current = null;
};
}, [threeScene, typeGeometries]);
useLayerFrame("ObjectGeometries", () => list.current?.tick());
return <ObjectGeometryListContext.Provider value={list}>
{children}
</ObjectGeometryListContext.Provider>;
}));
@@ -0,0 +1,23 @@
import { observer } from "mobx-react-lite";
import { state } from "../../state";
import { IconPlayerPlayFilled, IconPlayerPauseFilled, IconPlayerStopFilled, IconPlayerSkipForwardFilled } from '@tabler/icons-react';
export const PlayButtons = observer(function () {
return <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, fontSize: 14 }}>
{
state.game
? <>
<button onClick={() => state.stopGame()}><IconPlayerStopFilled size="1em" /></button>
{
state.game!.isPaused
? <>
<button onClick={() => state.game!.resume()}><IconPlayerPlayFilled size="1em" /></button>
<button onClick={() => state.game!.step()}><IconPlayerSkipForwardFilled size="1em" /></button>
</>
: <button onClick={() => state.game!.pause()}><IconPlayerPauseFilled size="1em" /></button>
}
</>
: <button onClick={() => state.startGame()}><IconPlayerPlayFilled size="1em" /></button>
}
</div>;
});
@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { state } from "../../../state";
import type { ThreeEvent } from "@react-three/fiber";
import type { ObjectGeometryViewItem } from "../../../state/viewLayers";
export function ObjectClickTarget({ item }: { item: ObjectGeometryViewItem }) {
const obj = item.object;
const bb = item.objectTypeGeometry.boundingBox;
const size = useMemo(() => {
if (!bb)
return [1, 1, 1] as const;
return [bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]] as const;
}, [bb]);
const center = useMemo(() => {
if (!bb)
return [0.5, 0.5, 0.5] as const;
return [
(bb.min[0] + bb.max[0]) / 2,
(bb.min[1] + bb.max[1]) / 2,
(bb.min[2] + bb.max[2]) / 2,
] as const;
}, [bb]);
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
state.worldEditor.setSelectedObject({ id: obj.id, editMode: "translate" });
};
return <mesh
position={obj.position}
rotation={obj.rotation}
scale={obj.scale}
onClick={handleClick}
>
<mesh position={center}>
<boxGeometry args={size} />
<meshBasicMaterial visible={false} />
</mesh>
</mesh>;
}
@@ -0,0 +1,42 @@
import { OrbitControls } from "@react-three/drei";
import { state } from "../../../state";
import { useRef } from "react";
import { useSyncCamera } from "../../../utils/hooks/useSyncCamera";
import { MOUSE } from "three";
import { observer } from "mobx-react-lite";
import type { V3 } from "../../../types";
import { measuredRenderTime } from "../../../utils/layerTimings";
export const SceneEditorCameraLayer = observer(measuredRenderTime("Camera", function () {
const controlsRef = useRef<React.ComponentRef<typeof OrbitControls>>(null);
useSyncCamera(() => state.view.camera?.camera);
const handleEnd = () => {
const controls = controlsRef.current;
const camera = state.view.camera;
if (!controls || !camera)
return;
camera.camera = {
position: controls.object.position.toArray(),
look: controls.object.rotation.toArray().slice(0, 3) as V3,
};
};
if (!state.view.camera)
return null;
return <OrbitControls
ref={controlsRef}
onEnd={handleEnd}
makeDefault
mouseButtons={{
LEFT: MOUSE.ROTATE,
MIDDLE: MOUSE.PAN,
RIGHT: MOUSE.PAN,
}}
rotateSpeed={0.2}
enableDamping={false}
/>;
}));
@@ -0,0 +1,42 @@
import { useCallback } from "react";
import * as THREE from "three";
import { TransformControls } from "@react-three/drei";
import { state } from "../../../state";
import { observer } from "mobx-react-lite";
import { measuredRenderTime } from "../../../utils/layerTimings";
import type { V3, R3 } from "../../../types";
export const SceneEditorObjectGizmosLayer = observer(measuredRenderTime(
"ObjectGizmos",
function () {
const selection = state.view.selectedObject;
const onObjectChange = useCallback((e?: THREE.Event) => {
if (!selection || !e)
return;
const controlled = (e.target as { object?: THREE.Object3D }).object;
if (!controlled)
return;
selection.geometry.setObjectTransform(
controlled.position.toArray() as V3,
controlled.rotation.toArray().slice(0, 3) as R3,
controlled.scale.toArray() as V3,
);
}, [selection]);
if (!selection)
return null;
const obj = selection.geometry.object;
return <TransformControls
mode={selection.mode}
position={obj.position}
rotation={obj.rotation}
scale={obj.scale}
onObjectChange={onObjectChange}
/>;
},
));
@@ -0,0 +1,99 @@
import { useEffect, useCallback, useRef } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { state } from "../../../state";
import { nextObjectEditMode } from "../../../state/worldEditorState";
import { observer } from "mobx-react-lite";
import { SelectionBoundingBox } from "./SelectionBoundingBox";
import { measuredRenderTime } from "../../../utils/layerTimings";
import { useObjectGeometryList } from "../ObjectGeometriesLayer";
const DRAG_THRESHOLD = 4;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
export const SceneEditorObjectSelectionLayer = observer(measuredRenderTime(
"ObjectSelection",
function () {
const geometryListRef = useObjectGeometryList();
const selection = state.view.selectedObject;
const { camera, gl, size } = useThree();
const pointerDownPos = useRef<{ x: number; y: number } | null>(null);
const handlePointerDown = useCallback((e: PointerEvent) => {
if (e.button !== 0)
return;
pointerDownPos.current = { x: e.offsetX, y: e.offsetY };
}, []);
const handlePointerUp = useCallback((e: PointerEvent) => {
if (e.button !== 0)
return;
const down = pointerDownPos.current;
pointerDownPos.current = null;
if (!down)
return;
const dx = e.offsetX - down.x;
const dy = e.offsetY - down.y;
if (dx * dx + dy * dy > DRAG_THRESHOLD * DRAG_THRESHOLD)
return;
pointer.x = (e.offsetX / size.width) * 2 - 1;
pointer.y = -(e.offsetY / size.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const instancedMeshes = state.view.objectTypeGeometries.values.filter((ot) => ot.meshes?.length).flatMap((ot) => ot.meshes!);
const intersections = raycaster.intersectObjects(instancedMeshes, false);
if (intersections.length === 0) {
state.worldEditor.resetSelection();
return;
}
const hit = intersections[0];
const instanceId = hit.instanceId;
if (instanceId === undefined) {
state.worldEditor.resetSelection();
return;
}
const geometryList = geometryListRef.current;
if (!geometryList)
return;
const mesh = hit.object as THREE.InstancedMesh;
const instanceGeometry = geometryList.getObjectGeometryByMesh(mesh, instanceId);
if (instanceGeometry) {
const sel = state.worldEditor.selection;
const editMode = sel?.type === 'object' && sel.id === instanceGeometry.object.id
? nextObjectEditMode(sel.editMode)
: 'translate';
state.worldEditor.setSelectedObject({ id: instanceGeometry.object.id, editMode });
} else {
state.worldEditor.resetSelection();
}
}, [camera, size, geometryListRef]);
useEffect(() => {
const domElement = gl.domElement;
domElement.addEventListener("pointerdown", handlePointerDown);
domElement.addEventListener("pointerup", handlePointerUp);
return () => {
domElement.removeEventListener("pointerdown", handlePointerDown);
domElement.removeEventListener("pointerup", handlePointerUp);
};
}, [gl, handlePointerDown, handlePointerUp]);
return <>
{selection &&
<SelectionBoundingBox item={selection.geometry} />
}
</>;
},
));
@@ -0,0 +1,72 @@
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
import { useThree, useFrame } from "@react-three/fiber";
import { observer } from "mobx-react-lite";
import { state } from "../../../state";
import { computeBoundingBox } from "../../../utils/runtime";
const _offset = new THREE.Vector3();
const _lookAt = new THREE.Vector3();
const _euler = new THREE.Euler(0, 0, 0, 'YXZ');
export const SceneEditorPlayerGizmosLayer = observer(function () {
const scene = useThree(s => s.scene);
const helperRef = useRef<THREE.CameraHelper | null>(null);
const playerCam = useMemo(() => new THREE.PerspectiveCamera(75, 16 / 9, 0.1, 1000), []);
const playerObjectId = state.world.data.initialScene.playerObjectId;
const playerObject = playerObjectId
? state.world.data.initialScene.objects[playerObjectId]
: undefined;
const playerType = playerObject
? state.world.data.objectTypes[playerObject.typeId]
: undefined;
useEffect(() => {
if (!playerObject || !playerType)
return;
const helper = new THREE.CameraHelper(playerCam);
scene.add(helper);
helperRef.current = helper;
return () => {
scene.remove(helper);
helper.dispose();
helperRef.current = null;
};
}, [playerObject, playerType, playerCam, scene]);
useFrame(() => {
if (!playerObject || !playerType || !helperRef.current)
return;
const bb = computeBoundingBox(playerType);
const W = bb.max[0] - bb.min[0];
const H = bb.max[1] - bb.min[1];
const D = bb.max[2] - bb.min[2];
const radius = Math.sqrt(W * W + H * H + D * D);
const centerY = (bb.min[1] + bb.max[1]) / 2;
const yaw = playerObject.rotation[1];
_offset.set(W * 0.1, centerY + H * 0.3, bb.max[2] + radius);
_offset.applyEuler(_euler.set(0, yaw, 0, 'YXZ'));
playerCam.position.set(
playerObject.position[0] + _offset.x,
playerObject.position[1] + _offset.y,
playerObject.position[2] + _offset.z,
);
_lookAt.set(
playerObject.position[0],
playerObject.position[1] + centerY,
playerObject.position[2],
);
playerCam.lookAt(_lookAt);
playerCam.updateMatrixWorld(true);
helperRef.current.update();
});
return null;
});
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import * as THREE from "three";
import { observer } from "mobx-react-lite";
import type { ObjectGeometryViewItem } from "../../../state/viewLayers";
export const SelectionBoundingBox = observer(function({ item }: { item: ObjectGeometryViewItem }) {
const obj = item.object;
const bb = item.objectTypeGeometry.boundingBox;
const geometry = useMemo(() => {
if (!bb)
return undefined;
const box = new THREE.BoxGeometry(
bb.max[0] - bb.min[0],
bb.max[1] - bb.min[1],
bb.max[2] - bb.min[2],
);
return new THREE.EdgesGeometry(box);
}, [bb]);
const center = useMemo(() => {
if (!bb)
return [0.5, 0.5, 0.5] as const;
return [
(bb.min[0] + bb.max[0]) / 2,
(bb.min[1] + bb.max[1]) / 2,
(bb.min[2] + bb.max[2]) / 2,
] as const;
}, [bb]);
if (!geometry)
return null;
return <mesh
position={obj.position}
rotation={obj.rotation}
scale={obj.scale}
>
<lineSegments position={center} geometry={geometry}>
<lineBasicMaterial color="#ffff00" />
</lineSegments>
</mesh>;
});
@@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { layerTimings } from "../../utils/layerTimings";
export const StatsLayer = function () {
const divRef = useRef<HTMLDivElement | null>(null);
const fpsFrames = useRef(0);
const fpsLastTime = useRef(0);
const renderAcc = useRef(0);
useEffect(() => {
fpsLastTime.current = performance.now();
const div = document.createElement("div");
Object.assign(div.style, {
position: "absolute",
bottom: "8px",
right: "8px",
color: "#0f0",
fontSize: "10px",
fontFamily: "monospace",
zIndex: "9999",
pointerEvents: "none",
whiteSpace: "pre",
});
document.body.appendChild(div);
divRef.current = div;
return () => {
document.body.removeChild(div);
};
}, []);
useFrame(({ gl, scene, camera }) => {
const renderStart = performance.now();
gl.render(scene, camera);
renderAcc.current += performance.now() - renderStart;
fpsFrames.current++;
const now = performance.now();
const elapsed = now - fpsLastTime.current;
if (elapsed >= 1000) {
const frames = fpsFrames.current;
const fps = Math.round(frames * 1000 / elapsed);
const renderAvg = (renderAcc.current / frames).toFixed(3);
const lines: string[] = [
`${fps} FPS gl.render ${renderAvg} ms`,
];
for (const [name, entry] of Object.entries(layerTimings)) {
const frameTime = entry.frame.frames > 0 ? (entry.frame.acc / entry.frame.frames) : 0;
const renderTime = entry.render.frames > 0 ? (entry.render.acc / entry.render.frames) : 0;
lines.push(
`${name} ${(renderTime + frameTime).toFixed(3)} ms` +
(frameTime > 0
? ` (${frameTime.toFixed(3)} frame)`
: ''
)
);
entry.frame.acc = 0;
entry.frame.frames = 0;
entry.render.acc = 0;
entry.render.frames = 0;
}
if (divRef.current) {
divRef.current.textContent = lines.join("\n");
}
fpsFrames.current = 0;
renderAcc.current = 0;
fpsLastTime.current = now;
}
}, 1);
return null;
};
@@ -1,16 +0,0 @@
import { useThree } from "@react-three/fiber";
import type { Pos3 } from "../../types";
import { useLayoutEffect } from "react";
import { observer } from "mobx-react-lite";
export const CameraSync = observer(function ({ camera }: { camera: Pos3 }) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
});
return null;
});
+1 -2
View File
@@ -1,7 +1,6 @@
import { toJS } from "mobx";
import type { Game, Scene, ScriptAction, ScriptCoroutineContext, World } from "../types";
import { clone } from "../utils";
import { populateRuntimeScene } from "../utils/runtime";
import { ScriptGameEventBus } from "../scripts/eventBus";
import * as ScriptActions from "../scripts/actions";
import { positionOfObject } from "../scripts/positionOfObject";
@@ -19,7 +18,7 @@ import type { ScriptCoroutines } from "../scripts/coroutines";
export class GameFactory {
public static create(world: World): Game {
const scene = populateRuntimeScene(clone(world.initialScene), world);
const scene = clone(world.initialScene);
const game = {
paused: false,
+8 -9
View File
@@ -1,11 +1,10 @@
import { type RuntimeWorld, type World } from "../types";
import { depopulateRuntimeWorld, populateRuntimeWorld } from "../utils/runtime";
import { type World } from "../types";
import { DEFAULT_VOXEL_TYPES } from "./defaultVoxelTypes";
export class WorldFactory {
public static create(): RuntimeWorld {
return populateRuntimeWorld({
public static create(): World {
return {
objectTypes: {},
voxelTypes: DEFAULT_VOXEL_TYPES,
initialScene: {
@@ -19,17 +18,17 @@ export class WorldFactory {
position: [0, 5, 10],
look: [0, 0, 0],
},
});
};
}
public static load(): RuntimeWorld | undefined {
public static load(): World | undefined {
const json = localStorage.getItem("world");
if (json) {
return populateRuntimeWorld(JSON.parse(json) as World);
return JSON.parse(json) as World;
}
}
public static save(world: RuntimeWorld): void {
localStorage.setItem("world", JSON.stringify(depopulateRuntimeWorld(world)));
public static save(world: World): void {
localStorage.setItem("world", JSON.stringify(world));
}
}
+30 -39
View File
@@ -1,37 +1,28 @@
import * as THREE from 'three';
import { MobxReconcilerMap, type ReconcilerMap } from '../utils/reconciler';
import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewState';
import type { ObjectGeometryViewItem, ObjectTypeGeometryViewItem } from '../state/viewLayers';
export class ObjectGeometryList {
private items: ReconcilerMap<ObjectGeometryViewItem>;
private readonly typeRefCounts = new Map<string, number>();
private items: ReconcilerMap<ObjectTypeGeometryViewItem>;
private readonly instancesByMesh = new Map<THREE.InstancedMesh, ObjectGeometryViewItem[]>();
private readonly matrixObj = new THREE.Object3D();
private readonly _p = new THREE.Vector3();
private readonly _e = new THREE.Euler();
private readonly _q = new THREE.Quaternion();
constructor(scene: THREE.Scene, geometiries: Iterable<ObjectGeometryViewItem>) {
this.items = new MobxReconcilerMap<ObjectGeometryViewItem>({
reaction: () => geometiries,
keyOf: (item) => item.object.id,
constructor(scene: THREE.Scene, typeGeometries: Iterable<ObjectTypeGeometryViewItem>) {
this.items = new MobxReconcilerMap<ObjectTypeGeometryViewItem>({
reaction: () => typeGeometries,
keyOf: (item) => item.objectType.id,
initializeItem: (item) => {
const typeId = item.objectTypeGeometry.objectType.id;
const count = (this.typeRefCounts.get(typeId) ?? 0) + 1;
this.typeRefCounts.set(typeId, count);
if (count === 1) {
for (const mesh of item.objectTypeGeometry.meshes ?? [])
for (const mesh of item.meshes ?? [])
scene.add(mesh);
}
},
disposeItem: (item) => {
const typeId = item.objectTypeGeometry.objectType.id;
const count = (this.typeRefCounts.get(typeId) ?? 1) - 1;
this.typeRefCounts.set(typeId, count);
if (count === 0) {
for (const mesh of item.objectTypeGeometry.meshes ?? [])
for (const mesh of item.meshes ?? [])
scene.remove(mesh);
}
},
});
}
@@ -40,37 +31,37 @@ export class ObjectGeometryList {
this.items?.dispose();
}
// Called once per frame — reads MobX observables directly outside any reactive
// context, so no subscriptions are created and no re-renders are triggered.
tick(): void {
const touchedObjectTypes: Record<string, { typeGeometry: ObjectTypeGeometryViewItem, instanceCount: number }> = {};
getObjectGeometryByMesh(mesh: THREE.InstancedMesh, instanceId: number): ObjectGeometryViewItem | undefined {
return this.instancesByMesh.get(mesh)?.[instanceId];
}
this.items.values.forEach((item) => {
tick(): void {
this.instancesByMesh.clear();
this.items.values.forEach((typeGeometry) => {
const instances = typeGeometry.instances;
const meshes = typeGeometry.meshes ?? [];
instances.forEach((item, instanceIdx) => {
const obj = item.object;
this._p.set(obj.position[0], obj.position[1], obj.position[2]);
this._e.set(obj.rotation[0], obj.rotation[1], obj.rotation[2]);
this._p.set(...obj.position);
this._e.set(...obj.rotation);
this._q.setFromEuler(this._e);
const typeId = item.objectTypeGeometry.objectType.id;
if (!touchedObjectTypes[typeId])
touchedObjectTypes[typeId] = { typeGeometry: item.objectTypeGeometry, instanceCount: 0 };
const instanceIdx = touchedObjectTypes[typeId].instanceCount++;
for (const mesh of item.objectTypeGeometry.meshes ?? []) {
this.matrixObj.position.copy(this._p);
this.matrixObj.quaternion.copy(this._q);
this.matrixObj.scale.set(obj.scale[0], obj.scale[1], obj.scale[2]);
this.matrixObj.scale.set(...obj.scale);
this.matrixObj.updateMatrix();
for (const mesh of meshes)
mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix);
}
});
for (const objectType of Object.values(touchedObjectTypes)) {
for (const mesh of objectType.typeGeometry.meshes ?? []) {
mesh.count = objectType.instanceCount;
for (const mesh of meshes) {
this.instancesByMesh.set(mesh, typeGeometry.instances);
mesh.count = instances.length;
mesh.instanceMatrix.needsUpdate = true;
}
}
});
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { RuntimeGameObjectInstance, V3 } from "../types";
import type { ObjectInstance, V3 } from "../types";
export function positionOfObject(obj: RuntimeGameObjectInstance): V3 {
export function positionOfObject(obj: ObjectInstance): V3 {
const t = obj.rb()?.translation();
const result: V3 = t
? [t.x, t.y, t.z]
+2 -4
View File
@@ -15,15 +15,13 @@ export type RenderInfo = {
export class RootState {
public readonly world = new WorldState();
public readonly worldEditor: WorldEditorState;
public readonly worldEditor = new WorldEditorState(this.world);
public game: GameState | undefined;
public renderInfo: RenderInfo | undefined;
public readonly menu = new MenuState();
public readonly view = new ViewState(this.world);
public readonly view = new ViewState(this.world, this.worldEditor);
constructor() {
this.worldEditor = new WorldEditorState(this.world);
makeAutoObservable(
this,
{
+2
View File
@@ -0,0 +1,2 @@
export abstract class ViewItem {
}
+26
View File
@@ -0,0 +1,26 @@
import type { Pos3 } from "../../types";
import { state } from "../rootState";
import { ViewItem } from "./base";
export abstract class CameraViewItem extends ViewItem {
public abstract get camera(): Pos3;
public abstract set camera(value: Pos3);
}
export class ControlledCameraViewItem extends CameraViewItem {
constructor() {
super();
}
public get camera(): Pos3 {
return state.worldEditor.camera;
}
public set camera(value: Pos3) {
state.worldEditor.setCamera(value);
}
}
export abstract class ThirdPersonCameraViewItem extends CameraViewItem {
}
+4
View File
@@ -0,0 +1,4 @@
export * from './base';
export * from './objectTypeGeometry';
export * from './objectGeometry';
export * from './camera';
@@ -0,0 +1,21 @@
import type { ObjectInstance, ObjectInstanceRigidBody, V3 } from "../../types";
import { state } from "../rootState";
import { ViewItem } from "./base";
import type { ObjectTypeGeometryViewItem } from './objectTypeGeometry';
export class ObjectGeometryViewItem extends ViewItem {
public readonly object: ObjectInstance;
public readonly objectTypeGeometry: ObjectTypeGeometryViewItem;
public rb: (() => ObjectInstanceRigidBody | null) | undefined;
constructor(object: ObjectInstance, objectTypeGeometry: ObjectTypeGeometryViewItem) {
super();
this.object = object;
this.objectTypeGeometry = objectTypeGeometry;
}
setObjectTransform(position: V3, rotation: V3, scale: V3) {
state.worldEditor.setObjectTransform(this.object.id, position, rotation, scale);
}
}
@@ -0,0 +1,28 @@
import { action, makeObservable, observable } from "mobx";
import { nextObjectEditMode, type ObjectEditMode } from "../worldEditorState";
import { ViewItem } from "./base";
import type { ObjectGeometryViewItem } from "./objectGeometry";
export class ObjectSelectionViewItem extends ViewItem {
public readonly geometry: ObjectGeometryViewItem;
public mode: ObjectEditMode;
constructor(geometry: ObjectGeometryViewItem, mode: ObjectEditMode) {
super();
this.geometry = geometry;
this.mode = mode;
makeObservable(
this,
{
geometry: observable.ref,
mode: true,
nextMode: action,
},
);
}
public nextMode(): void {
this.mode = nextObjectEditMode(this.mode);
}
}
@@ -0,0 +1,90 @@
import * as THREE from 'three';
import type { ObjectType, V3, Voxel, VoxelGroup } from "../../types";
import { ViewItem } from "./base";
import { action, computed, makeObservable, observable, reaction, type IReactionDisposer } from 'mobx';
import { getObjectVoxelGroups } from '../../utils/graphics/voxelGroup';
import { buildVoxelGroupMesh } from '../../utils/graphics/voxelMesh';
import { state } from '../rootState';
import { buildObjectTrimesh } from '../../utils/graphics/mesh';
import { computeBoundingBox } from '../../utils/runtime';
import type { ObjectGeometryViewItem } from './objectGeometry';
import type { ViewState } from '../viewState';
export class ObjectTypeGeometryViewItem extends ViewItem {
public readonly owner: ViewState;
public readonly objectType: ObjectType;
public voxelGroups: VoxelGroup[] | undefined;
public colliderMesh: [Float32Array, Uint32Array] | undefined;
public boundingBox: { min: V3; max: V3 } | undefined;
public meshes: THREE.InstancedMesh[] | undefined;
private readonly _disposeVoxelReaction: IReactionDisposer;
constructor(owner: ViewState, objectType: ObjectType) {
super();
this.owner = owner;
this.objectType = objectType;
makeObservable(this, {
voxelGroups: observable.ref,
colliderMesh: observable.ref,
boundingBox: observable.ref,
meshes: observable.ref,
updateRuntimeData: action,
instances: computed,
});
this._disposeVoxelReaction = reaction(
() => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'),
() => this.updateRuntimeData(),
);
this.updateRuntimeData();
}
public dispose(): void {
this._disposeVoxelReaction();
}
public addVoxel(voxel: Voxel): boolean {
const occupied = this.objectType.voxels.some(v =>
v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2]
);
if (!occupied) {
this.objectType.voxels.push(voxel);
return true;
}
return false;
}
public removeVoxel(position: V3): Voxel | undefined {
const idx = this.objectType.voxels.findIndex(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (idx !== -1)
return this.objectType.voxels.splice(idx, 1)[0];
}
public updateRuntimeData(): void {
this.voxelGroups = getObjectVoxelGroups(this.objectType, state.world.data.voxelTypes);
this.meshes = [];
for (const vg of this.voxelGroups) {
const geo = buildVoxelGroupMesh(vg.positions);
const mat = new THREE.MeshStandardMaterial({
color: vg.color,
opacity: vg.opacity,
transparent: vg.opacity < 1,
});
const mesh = new THREE.InstancedMesh(geo, mat, 1000);
this.meshes.push(mesh);
}
this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes);
this.boundingBox = computeBoundingBox(this.objectType);
}
public get instances(): ObjectGeometryViewItem[] {
return this.owner.objectGeometries.values.filter((o) => o.objectTypeGeometry === this);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { ViewItem } from "./base";
export class PlayerViewItem extends ViewItem {
constructor() {
super();
}
}
+30 -98
View File
@@ -1,113 +1,31 @@
import * as THREE from 'three';
import { action, makeAutoObservable, makeObservable, observable, reaction, type IReactionDisposer } from "mobx";
import type { ObjectInstance, ObjectInstanceRigidBody, ObjectType, Scene, V3, Voxel, VoxelGroup } from "../types";
import { state } from "./rootState";
import { getObjectVoxelGroups } from "../utils/graphics/voxelGroup";
import { buildVoxelGroupMesh } from "../utils/graphics/voxelMesh";
import { buildObjectTrimesh } from '../utils/graphics/mesh';
import { computeBoundingBox } from '../utils/runtime';
import { makeAutoObservable } from "mobx";
import type { ObjectInstance, ObjectType } from "../types";
import { MobxReconcilerMap } from '../utils/reconciler';
export abstract class ViewItem {
}
export class ObjectTypeGeometryViewItem extends ViewItem {
public readonly objectType: ObjectType;
public voxelGroups: VoxelGroup[] | undefined;
public colliderMesh: [Float32Array, Uint32Array] | undefined;
public boundingBox: { min: V3; max: V3 } | undefined;
public meshes: THREE.InstancedMesh[] | undefined;
private readonly _disposeVoxelReaction: IReactionDisposer;
constructor(objectType: ObjectType) {
super();
this.objectType = objectType;
makeObservable(this, {
voxelGroups: observable.ref,
colliderMesh: observable.ref,
boundingBox: observable.ref,
meshes: observable.ref,
updateRuntimeData: action,
});
this._disposeVoxelReaction = reaction(
() => objectType.voxels.map(v => `${v.position}-${v.typeId}`).join('|'),
() => this.updateRuntimeData(),
);
this.updateRuntimeData();
}
public dispose(): void {
this._disposeVoxelReaction();
}
public addVoxel(voxel: Voxel): boolean {
const occupied = this.objectType.voxels.some(v =>
v.position[0] === voxel.position[0] &&
v.position[1] === voxel.position[1] &&
v.position[2] === voxel.position[2]
);
if (!occupied) {
this.objectType.voxels.push(voxel);
return true;
}
return false;
}
public removeVoxel(position: V3): Voxel | undefined {
const idx = this.objectType.voxels.findIndex(v =>
v.position[0] === position[0] &&
v.position[1] === position[1] &&
v.position[2] === position[2]
);
if (idx !== -1)
return this.objectType.voxels.splice(idx, 1)[0];
}
public updateRuntimeData(): void {
this.voxelGroups = getObjectVoxelGroups(this.objectType, state.world.data.voxelTypes);
this.meshes = [];
for (const vg of this.voxelGroups) {
const geo = buildVoxelGroupMesh(vg.positions);
const mat = new THREE.MeshStandardMaterial({
color: vg.color,
opacity: vg.opacity,
transparent: vg.opacity < 1,
});
const mesh = new THREE.InstancedMesh(geo, mat, 1000);
this.meshes.push(mesh);
}
this.colliderMesh = buildObjectTrimesh(this.objectType, state.world.data.voxelTypes);
this.boundingBox = computeBoundingBox(this.objectType);
}
}
export class ObjectGeometryViewItem extends ViewItem {
public readonly object: ObjectInstance;
public readonly objectTypeGeometry: ObjectTypeGeometryViewItem;
public rb: (() => ObjectInstanceRigidBody | null) | undefined;
constructor(object: ObjectInstance, objectTypeGeometry: ObjectTypeGeometryViewItem) {
super();
this.object = object;
this.objectTypeGeometry = objectTypeGeometry;
}
}
import { ObjectGeometryViewItem, ObjectTypeGeometryViewItem, ControlledCameraViewItem, ViewItem } from './viewLayers';
import type { WorldState } from "./worldState";
import { ObjectSelectionViewItem } from "./viewLayers/objectSelection";
import type { WorldEditorState } from "./worldEditorState";
export class ViewState {
public objectTypeGeometries: MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>;
public objectGeometries: MobxReconcilerMap<ObjectInstance, ObjectGeometryViewItem>;
public camera?: ControlledCameraViewItem;
private readonly editor: WorldEditorState;
constructor(world: WorldState, editor: WorldEditorState) {
this.editor = editor;
constructor(world: { data: { objectTypes: Record<string, ObjectType>; initialScene: Scene } }) {
makeAutoObservable(
this,
);
this.camera = new ControlledCameraViewItem();
this.objectTypeGeometries = new MobxReconcilerMap<ObjectType, ObjectTypeGeometryViewItem>({
reaction: () => Object.values(world.data.objectTypes),
keyOf: (ot) => ot.id,
mapItem: (ot) => new ObjectTypeGeometryViewItem(ot),
mapItem: (ot) => new ObjectTypeGeometryViewItem(this, ot),
updateItem: () => true,
disposeItem: (item) => { item.dispose(); },
});
@@ -126,10 +44,24 @@ export class ViewState {
this.objectGeometries.dispose();
}
public get selectedObject(): ObjectSelectionViewItem | undefined {
const sel = this.editor.selection;
if (sel?.type !== 'object')
return;
const geom = this.objectGeometries.get(sel.id);
if (!geom)
return;
return new ObjectSelectionViewItem(geom, sel.editMode);
}
public get items(): ViewItem[] {
return [
...this.objectTypeGeometries,
...this.objectGeometries,
...[this.camera ? [this.camera] : []],
...[this.selectedObject ? [this.selectedObject] : []],
];
}
}
+3 -2
View File
@@ -4,7 +4,6 @@ import { type ObjectInstance, type Pos3, type R3, type V3, type ObjectType, type
import { createObjectInstance } from "../utils/object";
import { randomId } from "../utils";
import { state } from "./rootState";
import { populateRuntimeObject } from "../utils/runtime";
import { DEFAULT_VOXEL_TYPE } from "../model/defaultVoxelTypes";
export const ObjectEditModeEnum = [
@@ -66,6 +65,8 @@ export class WorldEditorState {
}
public setCamera(value: Pos3): void {
if (JSON.stringify(value) === JSON.stringify(this.world.data.editorCamera))
return;
this.world.data.editorCamera = value;
}
@@ -128,7 +129,7 @@ export class WorldEditorState {
typeId: string,
pos: { x: number; y: number; z: number; },
): ObjectInstance {
const obj = populateRuntimeObject(createObjectInstance(randomId(), typeId), this.world.data);
const obj = createObjectInstance(randomId(), typeId);
obj.position = [pos.x, pos.y, pos.z];
obj.rotation = [0, 0, 0];
+4 -5
View File
@@ -1,14 +1,13 @@
import { autorun, makeAutoObservable, toJS } from "mobx";
import { WorldFactory } from "../model/worldFactory";
import { type ObjectInstance, type ObjectType, type World, type VoxelType, type RuntimeWorld } from "../types";
import { type ObjectInstance, type ObjectType, type World, type VoxelType } from "../types";
import { state } from "./rootState";
import { DEFAULT_VOXEL_TYPES } from "../model/defaultVoxelTypes";
import { wolf } from "../model/objectPrefabs/wolf";
import { terrainXZ } from "../model/objectPrefabs/terrain";
import { populateRuntimeWorld } from "../utils/runtime";
export class WorldState {
public data: RuntimeWorld = WorldFactory.create();
public data: World = WorldFactory.create();
private _buildSave(): World {
const d = this.data;
@@ -58,7 +57,7 @@ export class WorldState {
objects.map((obj) => [obj.id, obj]),
) as Record<string, ObjectInstance>;
this.data = populateRuntimeWorld({
this.data = {
objectTypes: {
wolf: {
id: 'wolf',
@@ -97,7 +96,7 @@ export class WorldState {
gameRules: {
gravity: true,
},
});
};
console.log(objects);
state.worldEditor.resetSelection();
+1 -5
View File
@@ -1,6 +1,6 @@
import type { GameRules } from "./gameRules";
import type { ObjectType } from "./object";
import type { RuntimeScene, Scene } from "./scene";
import type { Scene } from "./scene";
import type { Pos3 } from "../3d";
import type { VoxelType } from "./voxel";
@@ -11,7 +11,3 @@ export type World = {
initialScene: Scene;
gameRules: GameRules;
}
export type RuntimeWorld = World & {
initialScene: RuntimeScene;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import type { ObjectInstance, ObjectType, RuntimeGameObjectInstance, Scene, World } from "../model";
import type { ObjectInstance, ObjectType, Scene, World } from "../model";
import type { ScriptAction } from "./actions";
export type ScriptCoroutine = Generator<ScriptAction, void, void>;
@@ -9,15 +9,15 @@ export type NamedScriptCoroutine = {
}
export type ScriptCoroutineContext = {
thisObject: RuntimeGameObjectInstance,
thisObject: ObjectInstance,
thisObjectType: ObjectType;
};
export type ScriptContext = ScriptCoroutineContext & {
world: World;
scene: Scene;
object?: RuntimeGameObjectInstance;
other?: RuntimeGameObjectInstance;
object?: ObjectInstance;
other?: ObjectInstance;
}
export type ScriptGameEventHandler = (context: ScriptContext) => ScriptCoroutine;
@@ -0,0 +1,14 @@
import { useFrame, type RootState } from "@react-three/fiber";
import { recordLayerFrameTime } from "../layerTimings";
export function useLayerFrame(
name: string,
callback: (state: RootState, delta: number) => void,
priority?: number,
) {
useFrame((state, delta) => {
const start = performance.now();
callback(state, delta);
recordLayerFrameTime(name, performance.now() - start);
}, priority);
}
@@ -0,0 +1,17 @@
import { useThree } from "@react-three/fiber";
import type { Pos3 } from "../../types";
import { useLayoutEffect } from "react";
import { autorun } from "mobx";
export function useSyncCamera(getCamera: () => Pos3 | undefined) {
const { camera: threeCamera } = useThree();
useLayoutEffect(() => autorun(() => {
const camera = getCamera();
if (camera) {
threeCamera.position.set(camera.position[0], camera.position[1], camera.position[2]);
threeCamera.rotation.set(camera.look[0], camera.look[1], camera.look[2]);
threeCamera.updateProjectionMatrix();
}
}), [getCamera, threeCamera]);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
export type LayerTiming = {
frame: { acc: number; frames: number };
render: { acc: number; frames: number };
};
export const layerTimings: Record<string, LayerTiming> = {};
function getEntry(name: string): LayerTiming {
return layerTimings[name] ??= {
frame: { acc: 0, frames: 0 },
render: { acc: 0, frames: 0 },
};
}
export function recordLayerFrameTime(name: string, ms: number) {
const t = getEntry(name).frame;
t.acc += ms;
t.frames++;
}
export function startLayerRender(name: string): () => void {
const start = performance.now();
return () => {
const t = getEntry(name).render;
t.acc += performance.now() - start;
t.frames++;
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function measuredRenderTime<TArgs extends any[], TResult extends ReactNode>(
name: string,
callback: (...args: TArgs) => TResult
): ((...args: TArgs) => TResult) {
return (...args) => {
const endRender = startLayerRender(name);
const result = callback(...args);
endRender();
return result;
};
}
+3
View File
@@ -10,5 +10,8 @@ export function createObjectInstance(id: string, typeId: string): ObjectInstance
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
linearVelocity: [0, 0, 0],
angularVelocity: [0, 0, 0],
};
}
-2
View File
@@ -1,3 +1 @@
export * from './world';
export * from './scene';
export * from './object';
+1 -35
View File
@@ -1,6 +1,4 @@
import { getObjectVoxelGroups } from "../graphics/voxelGroup";
import type { ObjectInstance, ObjectType, RuntimeObjectInstance, RuntimeObjectType, V3, World } from "../../types";
import { buildObjectTrimesh } from "../graphics/mesh";
import type { ObjectType, V3 } from "../../types";
export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 } {
if (!objectType.voxels.length)
@@ -17,35 +15,3 @@ export function computeBoundingBox(objectType: ObjectType): { min: V3; max: V3 }
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
export function populateRuntimeObjectType(value: ObjectType, world: World): RuntimeObjectType {
return {
...value,
runtime: {
voxelGroups: getObjectVoxelGroups(value, world.voxelTypes),
colliderMesh: buildObjectTrimesh(value, world.voxelTypes),
boundingBox: computeBoundingBox(value),
updatedAt: 0,
},
};
}
export function populateRuntimeObject(value: ObjectInstance, _world: World): RuntimeObjectInstance {
return {
...value,
runtime: {
rb: () => null,
},
};
}
export function depopulateRuntimeObjectType(value: RuntimeObjectType, _world: World): ObjectType {
const { runtime: _, ...result } = value;
return result;
}
export function depopulateRuntimeObject(value: RuntimeObjectInstance, _world: World): ObjectInstance {
const { runtime: _, ...result } = value;
return result;
}
-16
View File
@@ -1,16 +0,0 @@
import type { RuntimeScene, Scene, World } from "../../types";
import { depopulateRuntimeObject, populateRuntimeObject } from "./object";
export function populateRuntimeScene(scene: Scene, world: World): RuntimeScene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, populateRuntimeObject(obj, world)])),
};
}
export function depopulateRuntimeScene(scene: RuntimeScene, world: World): Scene {
return {
...scene,
objects: Object.fromEntries(Object.entries(scene.objects).map(([id, obj]) => [id, depopulateRuntimeObject(obj, world)])),
};
}
-16
View File
@@ -1,16 +0,0 @@
import type { RuntimeWorld, World } from "../../types";
import { depopulateRuntimeScene, populateRuntimeScene } from "./scene";
export function populateRuntimeWorld(world: World): RuntimeWorld {
return {
...world,
initialScene: populateRuntimeScene(world.initialScene, world),
};
}
export function depopulateRuntimeWorld(world: RuntimeWorld): World {
return {
...world,
initialScene: depopulateRuntimeScene(world.initialScene, world),
};
}