26 Commits
Author SHA1 Message Date
azykov@mail.ru 110594483c test 9 2026-06-27 20:43:37 +03:00
azykov@mail.ru 619f54c49c test 8 2026-06-27 20:41:03 +03:00
azykov@mail.ru bc9c275776 test 7 2026-06-27 20:38:30 +03:00
azykov@mail.ru 36dcdffff2 test 6 2026-06-27 20:37:15 +03:00
azykov@mail.ru f6680161d7 test 5 2026-06-27 19:21:14 +03:00
azykov@mail.ru 2e28c9c3d5 test 4 2026-06-27 19:20:22 +03:00
azykov@mail.ru b0503669b0 test 3 2026-06-27 19:16:55 +03:00
azykov@mail.ru 224ae60cc8 test 2 2026-06-27 19:13:50 +03:00
azykov@mail.ru beaa656026 test 2026-06-27 13:37:27 +03:00
azykov@mail.ru 793a7c9da3 deploy directly to docker, not ssh 2026-06-27 12:04:58 +03:00
azykov@mail.ru bc98e23f6a Merge branch 'deploy' of ssh://10.0.64.33:49022/anton/blockly3d into deploy 2026-06-27 11:51:56 +03:00
azykov@mail.ru de292361a2 docker-cli is now baked into runner's job base image 2026-06-27 11:51:40 +03:00
azykov@mail.ru f071bbe9f7 docker-cli is now baked in runner's job base image 2026-06-27 11:48:24 +03:00
azykov@mail.ru ff5d1d52ef old buildx in alpine fix 2026-06-27 10:58:44 +03:00
azykov@mail.ru cad617000d fixe for docker cli installation 2026-06-27 10:56:15 +03:00
azykov@mail.ru e7897e8bb1 docker cli installation in container 2026-06-27 10:52:43 +03:00
azykov@mail.ru fcb8c2a78b no base actions 2026-06-26 23:12:13 +03:00
azykov@mail.ru 1ba60a5dc3 no ifs, deploy without appleboy/ssh-action 2026-06-26 22:46:12 +03:00
azykov@mail.ru e2e9a357de runs on: docker1 2026-06-26 21:57:34 +03:00
azykov@mail.ru d224c033ca any branch deploy 2026-06-26 21:29:05 +03:00
azykov@mail.ru 5f2d86368b deploy barebones 2026-06-26 20:49:23 +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
37 changed files with 951 additions and 263 deletions
+7
View File
@@ -0,0 +1,7 @@
# Скопируй в .env и заполни
SERVER_PORT=3000
CLIENT_PORT=5000
# Добавь свои переменные ниже
# DATABASE_URL=
# JWT_SECRET=
+102
View File
@@ -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
+34
View File
@@ -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
+37
View File
@@ -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"]
+32
View File
@@ -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"]
+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>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
.overlay-panels {
z-index: 15000;
z-index: 9000;
position: fixed;
top: 0;
left: 0;
+5 -23
View File
@@ -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;
});
+34 -43
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 ?? [])
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;
}
}
});
}
}
+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);
}
}
+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] : []],
];
}
}
+2
View File
@@ -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;
}
+2 -2
View File
@@ -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 -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;
}
@@ -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;
};
}