Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
110594483c | ||
|
|
619f54c49c | ||
|
|
bc9c275776 | ||
|
|
36dcdffff2 | ||
|
|
f6680161d7 | ||
|
|
2e28c9c3d5 | ||
|
|
b0503669b0 | ||
|
|
224ae60cc8 | ||
|
|
beaa656026 | ||
|
|
793a7c9da3 | ||
|
|
bc98e23f6a | ||
|
|
de292361a2 | ||
|
|
f071bbe9f7 | ||
|
|
ff5d1d52ef | ||
|
|
cad617000d | ||
|
|
e7897e8bb1 | ||
|
|
fcb8c2a78b | ||
|
|
1ba60a5dc3 | ||
|
|
e2e9a357de | ||
|
|
d224c033ca | ||
|
|
5f2d86368b | ||
|
|
bf7f803a99 | ||
|
|
21c48e44ee | ||
|
|
0b7cdd9f05 | ||
|
|
a60e01e4d1 | ||
|
|
98e387c490 |
@@ -0,0 +1,7 @@
|
||||
# Скопируй в .env и заполни
|
||||
SERVER_PORT=3000
|
||||
CLIENT_PORT=5000
|
||||
|
||||
# Добавь свои переменные ниже
|
||||
# DATABASE_URL=
|
||||
# JWT_SECRET=
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push
|
||||
|
||||
env:
|
||||
REGISTRY: git.aggtaa.com
|
||||
IMAGE_PREFIX: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
# ── BUILD SERVER ──────────────────────────────────────────────────────────────
|
||||
build-server:
|
||||
runs-on: docker1
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
working-directory: /workspace
|
||||
steps:
|
||||
- name: Check paths
|
||||
run: |
|
||||
ls -la /data/tools/ || echo "no /data/tools"
|
||||
mount | grep overlay || echo "no overlay"
|
||||
ls /bin/
|
||||
ls /usr/bin/ | grep -E "bash|sh"
|
||||
- name: Check docker
|
||||
run: |
|
||||
find /usr -name "docker-buildx" 2>/dev/null || echo "not found in /usr"
|
||||
find /root -name "docker-buildx" 2>/dev/null || echo "not found in /root"
|
||||
ls /usr/libexec/ 2>/dev/null || echo "no /usr/libexec"
|
||||
- name: Checkout
|
||||
working-directory: /
|
||||
run: |
|
||||
mkdir -p /workspace
|
||||
if [ -d /workspace/.git ]; then
|
||||
cd /workspace && git fetch origin && git reset --hard origin/${{ gitea.ref_name }}
|
||||
else
|
||||
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git /workspace
|
||||
fi
|
||||
|
||||
- name: Build & push server
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
/data/tools/docker-buildx build \
|
||||
--cache-from type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:cache \
|
||||
--cache-to type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:cache,mode=max \
|
||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:latest \
|
||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/server:${{ gitea.sha }} \
|
||||
--file server.Dockerfile \
|
||||
--push \
|
||||
.
|
||||
|
||||
# ── BUILD WEBCLIENT ───────────────────────────────────────────────────────────
|
||||
build-webclient:
|
||||
runs-on: docker1
|
||||
defaults:
|
||||
run:
|
||||
working-directory: /workspace
|
||||
steps:
|
||||
- name: Checkout
|
||||
working-directory: /
|
||||
run: |
|
||||
mkdir -p /workspace
|
||||
if [ -d /workspace/.git ]; then
|
||||
cd /workspace && git fetch origin && git reset --hard origin/${{ gitea.ref_name }}
|
||||
else
|
||||
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git /workspace
|
||||
fi
|
||||
|
||||
- name: Build & push webclient
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
/data/tools/docker-buildx build \
|
||||
--cache-from type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:cache \
|
||||
--cache-to type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:cache,mode=max \
|
||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:latest \
|
||||
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/webclient:${{ gitea.sha }} \
|
||||
--file webclient.Dockerfile \
|
||||
--push \
|
||||
.
|
||||
|
||||
# ── DEPLOY ────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
runs-on: docker1
|
||||
needs: [build-server, build-webclient]
|
||||
if: always() && !failure() && !cancelled()
|
||||
steps:
|
||||
- name: Deploy
|
||||
run: |
|
||||
mkdir -p "/app-cache/${{ gitea.repository }}"
|
||||
cd "/app-cache/${{ gitea.repository }}"
|
||||
if [ ! -d .git ]; then
|
||||
git clone ${{ gitea.server_url }}/${{ gitea.repository }}.git .
|
||||
else
|
||||
git pull origin main
|
||||
fi
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server.Dockerfile
|
||||
cache_from:
|
||||
- ${CI_REGISTRY_IMAGE:-blockly3d}/server:cache
|
||||
image: ${CI_REGISTRY_IMAGE:-blockly3d}/server:${CI_COMMIT_SHA:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SERVER_PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- blockly3d
|
||||
|
||||
webclient:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: webclient.Dockerfile
|
||||
cache_from:
|
||||
- ${CI_REGISTRY_IMAGE:-blockly3d}/webclient:cache
|
||||
image: ${CI_REGISTRY_IMAGE:-blockly3d}/webclient:${CI_COMMIT_SHA:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CLIENT_PORT:-5000}:5000"
|
||||
networks:
|
||||
- blockly3d
|
||||
|
||||
networks:
|
||||
blockly3d:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22.22.3-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# ── deps ──────────────────────────────────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем только манифесты — слой кешируется пока lock не менялся
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY server/package.json ./server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter server...
|
||||
|
||||
# ── build ─────────────────────────────────────────────────────────────────────
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY server ./server
|
||||
|
||||
RUN pnpm --filter server build
|
||||
|
||||
# ── runtime ───────────────────────────────────────────────────────────────────
|
||||
FROM node:22.22.3-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY server/package.json ./server/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter server... --prod
|
||||
|
||||
COPY --from=builder /app/server/dist ./server/dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22.22.3-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# ── deps ──────────────────────────────────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY webclient/package.json ./webclient/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter webclient...
|
||||
|
||||
# ── build ─────────────────────────────────────────────────────────────────────
|
||||
FROM deps AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY webclient ./webclient
|
||||
|
||||
RUN pnpm --filter webclient build
|
||||
|
||||
# ── runtime ───────────────────────────────────────────────────────────────────
|
||||
FROM node:22.22.3-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g serve
|
||||
|
||||
COPY --from=builder /app/webclient/dist ./dist
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["serve", "-s", "dist", "-l", "5000"]
|
||||
@@ -1,3 +1,8 @@
|
||||
.editor-layout {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
|
||||
@@ -92,11 +92,11 @@ function HomePage() {
|
||||
|
||||
function EditorLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="editor-layout">
|
||||
<Outlet />
|
||||
<LayeredThreeView />
|
||||
<Panels />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.overlay-panels {
|
||||
z-index: 15000;
|
||||
z-index: 9000;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -5,48 +5,31 @@ 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 { 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}
|
||||
|
||||
@@ -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,24 @@
|
||||
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";
|
||||
|
||||
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 />
|
||||
{/* <Perf position="bottom-right" /> */}
|
||||
<SceneEditorCameraLayer />
|
||||
<ObjectGeometriesLayer>
|
||||
<SceneEditorObjectSelectionLayer />
|
||||
<SceneEditorObjectGizmosLayer />
|
||||
</ObjectGeometriesLayer>
|
||||
<StatsLayer />
|
||||
</Canvas>
|
||||
</>;
|
||||
});
|
||||
|
||||
@@ -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,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,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,74 @@
|
||||
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 parts: string[] = [];
|
||||
parts.push(`frame ${(entry.frame.frames > 0 ? (entry.frame.acc / entry.frame.frames) : 0).toFixed(3)}`);
|
||||
parts.push(`render ${(entry.render.frames > 0 ? (entry.render.acc / entry.render.frames) : 0).toFixed(3)}`);
|
||||
lines.push(`${name} ${parts.join(" ")} ms`);
|
||||
|
||||
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,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 ?? [])
|
||||
scene.add(mesh);
|
||||
}
|
||||
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 ?? [])
|
||||
scene.remove(mesh);
|
||||
}
|
||||
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.
|
||||
getObjectGeometryByMesh(mesh: THREE.InstancedMesh, instanceId: number): ObjectGeometryViewItem | undefined {
|
||||
return this.instancesByMesh.get(mesh)?.[instanceId];
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
const touchedObjectTypes: Record<string, { typeGeometry: ObjectTypeGeometryViewItem, instanceCount: number }> = {};
|
||||
this.instancesByMesh.clear();
|
||||
|
||||
this.items.values.forEach((item) => {
|
||||
const obj = item.object;
|
||||
this.items.values.forEach((typeGeometry) => {
|
||||
const instances = typeGeometry.instances;
|
||||
const meshes = typeGeometry.meshes ?? [];
|
||||
|
||||
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._q.setFromEuler(this._e);
|
||||
instances.forEach((item, instanceIdx) => {
|
||||
const obj = item.object;
|
||||
|
||||
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._p.set(...obj.position);
|
||||
this._e.set(...obj.rotation);
|
||||
this._q.setFromEuler(this._e);
|
||||
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();
|
||||
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)
|
||||
mesh.setMatrixAt(instanceIdx, this.matrixObj.matrix);
|
||||
});
|
||||
|
||||
for (const mesh of meshes) {
|
||||
this.instancesByMesh.set(mesh, typeGeometry.instances);
|
||||
mesh.count = instances.length;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export abstract class ViewItem {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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] : []],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
@@ -8,7 +8,7 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user