├── src
├── vite-env.d.ts
├── Modules
│ └── SceneEditor
│ │ ├── index.ts
│ │ └── components
│ │ ├── ResizableDivider.css
│ │ ├── TransformToolbar.tsx
│ │ ├── ResizableDivider.tsx
│ │ ├── SceneEditor.module.css
│ │ ├── SceneEditor.tsx
│ │ ├── EditMode.ts
│ │ ├── SceneTree.tsx
│ │ └── Viewport.tsx
├── Core
│ ├── Runtime
│ │ ├── index.ts
│ │ ├── stateMachine.ts
│ │ ├── AssetsRegistry.ts
│ │ ├── ThirdPersonController.ts
│ │ ├── SceneLoader.ts
│ │ └── AssetLoader.ts
│ ├── Behaviors
│ │ ├── MoveUp.ts
│ │ ├── FadeOut.ts
│ │ ├── Behavior.ts
│ │ ├── MoveTo.ts
│ │ ├── MoveByCheckppoints.ts
│ │ ├── MeleeAttacker.ts
│ │ ├── TurnTo.ts
│ │ ├── Sine.ts
│ │ ├── Destructable.ts
│ │ ├── HitEffectBehavior.ts
│ │ ├── Follow.ts
│ │ └── TweenBehavior.ts
│ ├── ObjectTypes
│ │ ├── GameObjectCollider.ts
│ │ ├── Node3d.ts
│ │ ├── CameraObject.ts
│ │ ├── UiLayer.ts
│ │ ├── GameScene.ts
│ │ ├── GameObject.ts
│ │ └── TextSprite.ts
│ ├── SceneSaver.ts
│ └── SceneManager.ts
├── main.tsx
├── types.ts
├── components
│ ├── FileItem.tsx
│ ├── StartScreen.tsx
│ ├── FileThumbnail.tsx
│ ├── FileDetailsPanel.tsx
│ ├── FileBrowser.module.css
│ └── FileBrowser.tsx
├── hooks
│ └── useSceneManager.ts
├── services
│ ├── interfaces.ts
│ └── ThreeJSEngineService.ts
├── stores
│ ├── sceneStore.ts
│ ├── types.ts
│ └── projectStore.ts
├── contexts
│ └── ServiceProvider.tsx
├── types
│ ├── global.d.ts
│ └── file-system-access.d.ts
├── App.tsx
├── theme.css
├── App.css
├── index.css
├── utils
│ └── idbHelper.ts
└── assets
│ └── react.svg
├── tsconfig.app.json
├── .gitignore
├── index.html
├── tsconfig.json
├── eslint.config.js
├── tsconfig.node.json
├── vite.config.ts
├── package.json
├── public
└── vite.svg
├── README.md
└── docs
├── ARCHITECTURE.md
└── TESTING.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './components/SceneEditor';
2 |
3 | export * from './components/SceneEditor';
4 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "types": ["vite/client"]
6 | },
7 | "include": ["src"],
8 | "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"]
9 | }
10 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/ResizableDivider.css:
--------------------------------------------------------------------------------
1 | .resizable-divider {
2 | width: 6px;
3 | cursor: col-resize;
4 | background: transparent;
5 | transition: background 0.2s;
6 | z-index: 10;
7 | position: relative;
8 | }
9 | .resizable-divider:hover, .resizable-divider:focus {
10 | background: #333;
11 | }
12 |
--------------------------------------------------------------------------------
/src/Core/Runtime/index.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from "three";
2 |
3 | export function delay(ms: number) {
4 | return new Promise(resolve => setTimeout(resolve, ms));
5 | }
6 |
7 | export interface Transform {
8 | position: THREE.Vector3;
9 | rotation: THREE.Euler;
10 | scale: THREE.Vector3;
11 | quaternion: THREE.Quaternion;
12 | }
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/MoveUp.ts:
--------------------------------------------------------------------------------
1 | import { Behavior } from './Behavior';
2 |
3 |
4 |
5 |
6 | export class MoveUp extends Behavior {
7 | speed: number;
8 |
9 | constructor(speed: number) {
10 | super();
11 | this.speed = speed;
12 | }
13 |
14 | onTick(dt: any): void {
15 | if (!this.isEnabled) return;
16 | this.object3d.position.y += this.speed * dt;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/GameObjectCollider.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import GameObject from './GameObject';
3 |
4 | export class GameObjectCollider extends THREE.Mesh {
5 |
6 | gameObject?: GameObject;
7 |
8 | constructor() {
9 | const geometry = new THREE.BoxGeometry(1.2, 1.2, 1.2);
10 | const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
11 | super(geometry, material);
12 | this.visible = false;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/Node3d.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import type { Behavior } from '../Behaviors/Behavior';
3 | import { Transform } from '../Runtime';
4 |
5 | export default class Node3d extends THREE.Object3D {
6 | behaviors: Behavior[] = [];
7 | isActive: boolean = true;
8 |
9 | constructor() {
10 | super();
11 | }
12 |
13 | setTransform(transform: Transform) {
14 | this.position.copy(transform.position);
15 | this.rotation.copy(transform.rotation);
16 | this.scale.copy(transform.scale);
17 | //this.quaternion.copy(transform.quaternion);
18 | }
19 |
20 | update(dt: number) {
21 | }
22 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import '@ant-design/v5-patch-for-react-19';
2 | import React from 'react';
3 | import { createRoot } from 'react-dom/client';
4 | import { ConfigProvider, theme } from 'antd';
5 | import App from './App.tsx';
6 | import 'antd/dist/reset.css';
7 | import './index.css';
8 |
9 | createRoot(document.getElementById('root')!).render(
10 |
11 |
22 |
23 |
24 | ,
25 | )
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["ESNext", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "moduleResolution": "bundler",
9 | "allowImportingTsExtensions": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": true,
12 | "noEmit": true,
13 | "jsx": "react-jsx",
14 | "strict": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "types": ["vite/client"],
19 | "typeRoots": ["./node_modules/@types", "./src/types"],
20 | "esModuleInterop": true,
21 | "forceConsistentCasingInFileNames": true
22 | },
23 | "include": ["src"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "composite": true,
21 | "allowSyntheticDefaultImports": true,
22 | "esModuleInterop": true,
23 | "erasableSyntaxOnly": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true
26 | },
27 | "include": ["vite.config.ts", "vite.config.*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/TransformToolbar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styles from './SceneEditor.module.css';
3 |
4 | interface TransformToolbarProps {
5 | mode: 'translate' | 'rotate' | 'scale';
6 | onModeChange: (mode: 'translate' | 'rotate' | 'scale') => void;
7 | }
8 |
9 | const buttons: { mode: 'translate' | 'rotate' | 'scale'; icon: string; label: string }[] = [
10 | { mode: 'translate', icon: '↔', label: 'Move' },
11 | { mode: 'rotate', icon: '⟳', label: 'Rotate' },
12 | { mode: 'scale', icon: '⤢', label: 'Scale' },
13 | ];
14 |
15 | export const TransformToolbar: React.FC = ({ mode, onModeChange }) => (
16 |
17 | {buttons.map(btn => (
18 |
26 | ))}
27 |
28 | );
29 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { VitePWA } from 'vite-plugin-pwa';
3 | import react from '@vitejs/plugin-react-swc';
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | VitePWA({
10 | registerType: 'autoUpdate',
11 | includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
12 | manifest: {
13 | name: 'Pix3D - 3D Model Viewer',
14 | short_name: 'Pix3D',
15 | description: 'A 3D model viewer and editor',
16 | theme_color: '#1890ff',
17 | icons: [
18 | {
19 | src: '/android-chrome-192x192.png',
20 | sizes: '192x192',
21 | type: 'image/png',
22 | },
23 | {
24 | src: '/android-chrome-512x512.png',
25 | sizes: '512x512',
26 | type: 'image/png',
27 | },
28 | ],
29 | },
30 | }),
31 | ],
32 | css: {
33 | preprocessorOptions: {
34 | less: {
35 | javascriptEnabled: true,
36 | },
37 | },
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface BaseFileEntry {
2 | name: string;
3 | path: string;
4 | isDirectory: boolean;
5 | handle: FileSystemHandle;
6 | }
7 |
8 | export interface DirectoryEntry extends BaseFileEntry {
9 | isDirectory: true;
10 | handle: FileSystemDirectoryHandle;
11 | }
12 |
13 | export interface FileEntry extends BaseFileEntry {
14 | isDirectory: false;
15 | file: File;
16 | size: number;
17 | lastModified: number;
18 | type: string;
19 | handle: FileSystemFileHandle;
20 | }
21 |
22 | export type FileSystemEntry = DirectoryEntry | FileEntry;
23 |
24 | export interface RecentProject {
25 | name: string;
26 | idbKey: IDBValidKey; // Key to retrieve the FileSystemDirectoryHandle from IndexedDB
27 | lastOpened: string;
28 | }
29 |
30 | export interface StartScreenProps {
31 | onOpenProject: () => void;
32 | recentProjects: RecentProject[];
33 | onOpenRecentProject: (project: RecentProject) => void;
34 | }
35 |
36 | export interface FileBrowserProps {
37 | currentPath: string;
38 | currentDirectory: FileSystemDirectoryHandle | null;
39 | onPathChange: (path: string, dirHandle: FileSystemDirectoryHandle) => void;
40 | }
41 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/FadeOut.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from './Behavior';
3 |
4 |
5 | export class FadeOut extends Behavior {
6 |
7 | duration = 1;
8 | phase = 1;
9 | destroyOnFade = false;
10 |
11 | constructor(duration, destroyOnFade = false) {
12 | super();
13 |
14 | this.duration = duration;
15 | this.phase = duration;
16 | this.destroyOnFade = destroyOnFade;
17 | }
18 |
19 | restart() {
20 | this.phase = this.duration;
21 | }
22 |
23 | onTick(dt: any): void {
24 | if (!this.isEnabled) return;
25 |
26 | if (this.phase > 0) {
27 | this.phase -= dt;
28 |
29 | const mat = (this.object3d).material as THREE.Material;
30 | if (mat != undefined && mat.opacity != null) {
31 | mat.opacity = this.phase / this.duration;
32 | }
33 | } else if (this.destroyOnFade) {
34 | this.isEnabled = false;
35 | this.object3d.parent?.remove(this.object3d);
36 | const mat = (this.object3d).material as THREE.Material;
37 | if (mat != undefined) {
38 | const texture = (mat).map as THREE.Texture;
39 | if (texture) {
40 | texture.dispose();
41 | }
42 | mat.dispose();
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/FileItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 | import { FileThumbnail } from './FileThumbnail';
4 | import type { FileSystemEntry, FileEntry } from '../types';
5 | import styles from './FileBrowser.module.css';
6 |
7 | interface FileItemProps {
8 | entry: FileSystemEntry;
9 | selected: boolean;
10 | onClick: (entry: FileSystemEntry) => void;
11 | onDoubleClick: (entry: FileSystemEntry) => void;
12 | }
13 |
14 | export const FileItem: React.FC = ({ entry, selected, onClick, onDoubleClick }) => {
15 | return (
16 | onClick(entry)}
20 | onDoubleClick={() => onDoubleClick(entry)}
21 | title={entry.name}
22 | >
23 |
24 |
{entry.name}
25 |
{entry.isDirectory ? 'Folder' : formatFileSize((entry as FileEntry).size)}
26 |
27 | );
28 | };
29 |
30 | function formatFileSize(bytes: number): string {
31 | if (bytes === 0) return '0 B';
32 | const k = 1024;
33 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
34 | const i = Math.floor(Math.log(bytes) / Math.log(k));
35 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pix3d",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "lint:fix": "eslint . --fix",
11 | "preview": "vite preview",
12 | "test": "vitest",
13 | "test:ui": "vitest --ui",
14 | "type-check": "tsc --noEmit"
15 | },
16 | "dependencies": {
17 | "@ant-design/icons": "^6.0.0",
18 | "@ant-design/v5-patch-for-react-19": "^1.0.3",
19 | "@types/three": "^0.177.0",
20 | "@vitejs/plugin-react-swc": "^3.10.2",
21 | "antd": "^5.26.1",
22 | "base64-arraybuffer": "^1.0.2",
23 | "drei": "^2.2.21",
24 | "howler": "^2.2.4",
25 | "jszip": "^3.10.1",
26 | "react": "^19.1.0",
27 | "react-dom": "^19.1.0",
28 | "three": "^0.177.0",
29 | "vite-plugin-pwa": "^1.0.0",
30 | "zustand": "^5.0.6"
31 | },
32 | "devDependencies": {
33 | "@eslint/js": "^9.25.0",
34 | "@types/howler": "^2.2.12",
35 | "@types/react": "^19.1.2",
36 | "@types/react-dom": "^19.1.2",
37 | "@types/wicg-file-system-access": "^2023.10.6",
38 | "@vitejs/plugin-react": "^4.4.1",
39 | "eslint": "^9.25.0",
40 | "eslint-plugin-react-hooks": "^5.2.0",
41 | "eslint-plugin-react-refresh": "^0.4.19",
42 | "globals": "^16.0.0",
43 | "typescript": "~5.8.3",
44 | "typescript-eslint": "^8.30.1",
45 | "vite": "^6.3.5"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/ResizableDivider.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './ResizableDivider.css';
3 |
4 | interface ResizableDividerProps {
5 | onDrag: (deltaX: number) => void;
6 | style?: React.CSSProperties;
7 | }
8 |
9 | const ResizableDivider: React.FC = ({ onDrag, style }) => {
10 | const dragging = React.useRef(false);
11 | const lastX = React.useRef(0);
12 |
13 | const onMouseDown = (e: React.MouseEvent) => {
14 | dragging.current = true;
15 | lastX.current = e.clientX;
16 | document.body.style.cursor = 'col-resize';
17 | window.addEventListener('mousemove', onMouseMove);
18 | window.addEventListener('mouseup', onMouseUp);
19 | };
20 |
21 | const onMouseMove = (e: MouseEvent) => {
22 | if (!dragging.current) return;
23 | const deltaX = e.clientX - lastX.current;
24 | lastX.current = e.clientX;
25 | onDrag(deltaX);
26 | };
27 |
28 | const onMouseUp = () => {
29 | dragging.current = false;
30 | document.body.style.cursor = '';
31 | window.removeEventListener('mousemove', onMouseMove);
32 | window.removeEventListener('mouseup', onMouseUp);
33 | };
34 |
35 | return (
36 |
44 | );
45 | };
46 |
47 | export default ResizableDivider;
48 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/hooks/useSceneManager.ts:
--------------------------------------------------------------------------------
1 | // Custom hooks for business logic separation
2 | import { useEffect } from 'react';
3 | import * as THREE from 'three';
4 | import { useSceneStore } from '../stores/sceneStore';
5 | import type { I3DEngineService } from '../services/interfaces';
6 |
7 | export function useSceneManager(engineService: I3DEngineService) {
8 | const {
9 | scene,
10 | selectedObject,
11 | isLoading,
12 | setScene,
13 | setSelectedObject,
14 | addModelToScene,
15 | } = useSceneStore();
16 |
17 | // Subscribe to engine changes
18 | useEffect(() => {
19 | const unsubscribeScene = engineService.subscribeToSceneChanges(setScene);
20 | const unsubscribeSelection = engineService.subscribeToSelectionChanges(setSelectedObject);
21 |
22 | return () => {
23 | unsubscribeScene();
24 | unsubscribeSelection();
25 | };
26 | }, [engineService, setScene, setSelectedObject]);
27 |
28 | // Initialize default scene if none exists
29 | useEffect(() => {
30 | if (!scene) {
31 | const defaultScene = engineService.createDefaultScene();
32 | setScene(defaultScene);
33 | }
34 | }, [scene, engineService, setScene]);
35 |
36 | return {
37 | scene,
38 | selectedObject,
39 | isLoading,
40 | addModelToScene,
41 | setSelectedObject: (obj: THREE.Object3D | null) => {
42 | engineService.setSelectedObject(obj);
43 | },
44 | clearScene: () => {
45 | engineService.clearScene();
46 | },
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/services/interfaces.ts:
--------------------------------------------------------------------------------
1 | // Service interfaces for dependency inversion
2 | import * as THREE from 'three';
3 | import type GameScene from '../Core/ObjectTypes/GameScene';
4 |
5 | export interface I3DEngineService {
6 | createDefaultScene(): GameScene;
7 | setScene(scene: GameScene): void;
8 | clearScene(): void;
9 | addModelToScene(key: string, url: string, position?: THREE.Vector3): Promise;
10 | getSelectedObject(): THREE.Object3D | null;
11 | setSelectedObject(object: THREE.Object3D | null): void;
12 | subscribeToSceneChanges(callback: (scene: GameScene | null) => void): () => void;
13 | subscribeToSelectionChanges(callback: (object: THREE.Object3D | null) => void): () => void;
14 | }
15 |
16 | export interface IAssetService {
17 | loadModel(key: string, url: string): Promise;
18 | addAsset(type: string, key: string, url: string): void;
19 | getAsset(type: string, key: string): any;
20 | }
21 |
22 | export interface IFileSystemService {
23 | openDirectory(): Promise;
24 | readDirectory(handle: FileSystemDirectoryHandle): Promise;
25 | saveHandle(handle: FileSystemDirectoryHandle): Promise;
26 | getHandle(key: IDBValidKey): Promise;
27 | deleteHandle(key: IDBValidKey): Promise;
28 | }
29 |
30 | export interface FileSystemEntry {
31 | name: string;
32 | path: string;
33 | isDirectory: boolean;
34 | handle: FileSystemHandle;
35 | file?: File;
36 | size?: number;
37 | lastModified?: number;
38 | type?: string;
39 | }
40 |
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/CameraObject.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import Node3d from './Node3d';
3 |
4 | export default class CameraObject extends Node3d {
5 | public type: string = "CameraObject";
6 |
7 | camera: THREE.PerspectiveCamera;
8 | cameraHelper: THREE.CameraHelper;
9 | fov: number;
10 | aspect: number;
11 | near: number;
12 | far: number;
13 |
14 | constructor(
15 | fov: number = 50,
16 | aspect: number = 1.0,
17 | near: number = 0.1,
18 | far: number = 2000
19 | ) {
20 | super();
21 | this.fov = fov;
22 | this.aspect = aspect;
23 | this.near = near;
24 | this.far = far;
25 | this.camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
26 | this.add(this.camera);
27 | this.cameraHelper = new THREE.CameraHelper(this.camera);
28 | }
29 |
30 | /**
31 | * Adds the camera helper to the given THREE.Scene.
32 | * @param scene The THREE.Scene to add the helper to.
33 | */
34 | addHelperToScene(scene: THREE.Scene) {
35 | scene.add(this.cameraHelper);
36 | }
37 |
38 | updateCameraParams(fov: number, aspect: number, near: number, far: number) {
39 | this.fov = fov;
40 | this.aspect = aspect;
41 | this.near = near;
42 | this.far = far;
43 | this.camera.fov = fov;
44 | this.camera.aspect = aspect;
45 | this.camera.near = near;
46 | this.camera.far = far;
47 | this.camera.updateProjectionMatrix();
48 | this.cameraHelper.update();
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/services/ThreeJSEngineService.ts:
--------------------------------------------------------------------------------
1 | // Concrete implementation of 3D engine service
2 | import * as THREE from 'three';
3 | import type GameScene from '../Core/ObjectTypes/GameScene';
4 | import type { I3DEngineService } from './interfaces';
5 | import SceneManager from '../Core/SceneManager';
6 |
7 | export class ThreeJSEngineService implements I3DEngineService {
8 | private sceneManager: typeof SceneManager.instance;
9 |
10 | constructor() {
11 | this.sceneManager = SceneManager.instance;
12 | }
13 |
14 | createDefaultScene(): GameScene {
15 | return this.sceneManager.createDefaultScene();
16 | }
17 |
18 | setScene(scene: GameScene): void {
19 | this.sceneManager.setScene(scene);
20 | }
21 |
22 | clearScene(): void {
23 | this.sceneManager.clearScene();
24 | }
25 |
26 | async addModelToScene(key: string, url: string, position?: THREE.Vector3): Promise {
27 | await this.sceneManager.AddModelToScene(key, url, position);
28 | return this.sceneManager.selected!;
29 | }
30 |
31 | getSelectedObject(): THREE.Object3D | null {
32 | return this.sceneManager.selected;
33 | }
34 |
35 | setSelectedObject(object: THREE.Object3D | null): void {
36 | this.sceneManager.setSelected(object);
37 | }
38 |
39 | subscribeToSceneChanges(callback: (scene: GameScene | null) => void): () => void {
40 | return this.sceneManager.subscribeScene(callback);
41 | }
42 |
43 | subscribeToSelectionChanges(callback: (object: THREE.Object3D | null) => void): () => void {
44 | return this.sceneManager.subscribeSelection(callback);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/stores/sceneStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { subscribeWithSelector } from 'zustand/middleware';
3 | import * as THREE from 'three';
4 | import type GameScene from '../Core/ObjectTypes/GameScene';
5 | import type { SceneState, SceneActions } from './types';
6 |
7 | interface SceneStore extends SceneState, SceneActions {}
8 |
9 | export const useSceneStore = create()(
10 | subscribeWithSelector((set, get) => ({
11 | // State
12 | scene: null,
13 | selectedObject: null,
14 | isLoading: false,
15 |
16 | // Actions
17 | setScene: (scene: GameScene | null) => {
18 | set({ scene });
19 | },
20 |
21 | setSelectedObject: (object: THREE.Object3D | null) => {
22 | set({ selectedObject: object });
23 | },
24 |
25 | setLoading: (loading: boolean) => {
26 | set({ isLoading: loading });
27 | },
28 |
29 | addModelToScene: async (key: string, url: string, position?: THREE.Vector3) => {
30 | const state = get();
31 | if (!state.scene) return;
32 |
33 | try {
34 | set({ isLoading: true });
35 |
36 | // This would be injected via a service in the full implementation
37 | const SceneManager = await import('../Core/SceneManager');
38 | await SceneManager.default.instance.AddModelToScene(key, url, position);
39 |
40 | // Update selected object
41 | set({ selectedObject: SceneManager.default.instance.selected });
42 | } catch (error) {
43 | console.error('Failed to add model to scene:', error);
44 | } finally {
45 | set({ isLoading: false });
46 | }
47 | },
48 | }))
49 | );
50 |
--------------------------------------------------------------------------------
/src/stores/types.ts:
--------------------------------------------------------------------------------
1 | // Store interfaces for type safety and dependency inversion
2 | import * as THREE from 'three';
3 | import type { FileSystemEntry } from '../types';
4 | import type GameScene from '../Core/ObjectTypes/GameScene';
5 |
6 | export interface SceneState {
7 | scene: GameScene | null;
8 | selectedObject: THREE.Object3D | null;
9 | isLoading: boolean;
10 | }
11 |
12 | export interface ProjectState {
13 | hasProjectOpen: boolean;
14 | currentDirectory: FileSystemDirectoryHandle | null;
15 | currentPath: string;
16 | recentProjects: RecentProject[];
17 | }
18 |
19 | export interface FileSystemState {
20 | files: FileSystemEntry[];
21 | selectedFile: FileSystemEntry | null;
22 | isLoading: boolean;
23 | }
24 |
25 | export interface RecentProject {
26 | name: string;
27 | idbKey: IDBValidKey;
28 | lastOpened: string;
29 | }
30 |
31 | // Store actions
32 | export interface SceneActions {
33 | setScene: (scene: GameScene | null) => void;
34 | setSelectedObject: (object: THREE.Object3D | null) => void;
35 | setLoading: (loading: boolean) => void;
36 | addModelToScene: (key: string, url: string, position?: THREE.Vector3) => Promise;
37 | }
38 |
39 | export interface ProjectActions {
40 | openProject: () => Promise;
41 | openRecentProject: (project: RecentProject) => Promise;
42 | closeProject: () => void;
43 | setCurrentPath: (path: string, dirHandle: FileSystemDirectoryHandle) => void;
44 | }
45 |
46 | export interface FileSystemActions {
47 | loadFiles: (directory: FileSystemDirectoryHandle, path: string) => Promise;
48 | selectFile: (file: FileSystemEntry | null) => void;
49 | setLoading: (loading: boolean) => void;
50 | }
51 |
--------------------------------------------------------------------------------
/src/contexts/ServiceProvider.tsx:
--------------------------------------------------------------------------------
1 | // Service provider for dependency injection
2 | import React, { createContext, useContext } from 'react';
3 | import { ThreeJSEngineService } from '../services/ThreeJSEngineService';
4 | import type { I3DEngineService } from '../services/interfaces';
5 |
6 | interface ServiceContainer {
7 | engineService: I3DEngineService;
8 | // Add other services as they are implemented
9 | // assetService: IAssetService;
10 | // fileSystemService: IFileSystemService;
11 | }
12 |
13 | const ServiceContext = createContext(null);
14 |
15 | interface ServiceProviderProps {
16 | children: React.ReactNode;
17 | services?: Partial;
18 | }
19 |
20 | export function ServiceProvider({ children, services }: ServiceProviderProps) {
21 | // Create default services if not provided (for production)
22 | const defaultServices: ServiceContainer = {
23 | engineService: new ThreeJSEngineService(),
24 | // Add other default services here
25 | };
26 |
27 | // Merge provided services with defaults (useful for testing)
28 | const containerServices = { ...defaultServices, ...services };
29 |
30 | return (
31 |
32 | {children}
33 |
34 | );
35 | }
36 |
37 | export function useServices(): ServiceContainer {
38 | const services = useContext(ServiceContext);
39 | if (!services) {
40 | throw new Error('useServices must be used within a ServiceProvider');
41 | }
42 | return services;
43 | }
44 |
45 | // Convenience hooks for individual services
46 | export function useEngineService(): I3DEngineService {
47 | return useServices().engineService;
48 | }
49 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/Behavior.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 |
3 | export class Behavior {
4 | isBehavior = true;
5 | object3d: THREE.Object3D;
6 |
7 | isEnabled = true;
8 |
9 | constructor() {
10 | }
11 |
12 | onTick(dt): void {
13 |
14 | }
15 |
16 | onAttach(): void { }
17 |
18 | static ObjectBehaviorsOnTick(object: object, dt: number) {
19 | if (object)
20 | Behavior.GetBehaviors(object)?.forEach(x => x.onTick(dt));
21 | }
22 |
23 | static AddTo(object3d, behavior: Behavior): Behavior {
24 | object3d.behaviors ??= [];
25 |
26 | behavior.object3d = object3d;
27 | object3d.behaviors.push(behavior);
28 |
29 | // Add to user data, used in lyout manager
30 | object3d.userData.behaviors = object3d.userData.behaviors || [];
31 | object3d.userData.behaviors.push(behavior);
32 |
33 | behavior.onAttach();
34 | return behavior;
35 | }
36 |
37 | static OnAttached(object: THREE.Object3D) {
38 | if (object)
39 | Behavior.GetBehaviors(object)?.forEach(x => x.onAttach());
40 | }
41 |
42 | static RemoveFrom(object3d, condition: (b: Behavior) => boolean) {
43 | var behavior = object3d.behaviors.find(condition);
44 | if (behavior != undefined) {
45 | behavior.disable();
46 | object3d.behaviors.splice(object3d.behaviors.indexOf(behavior), 1);
47 | }
48 | }
49 |
50 | static GetBehaviors(object3d): Behavior[] {
51 | return object3d.behaviors;
52 | }
53 |
54 | disable() {
55 | this.isEnabled = false;
56 | }
57 |
58 | enable() {
59 | this.isEnabled = true;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/StartScreen.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Typography, List } from 'antd';
2 | import { FolderOpenOutlined } from '@ant-design/icons';
3 | import type { StartScreenProps } from '../types';
4 |
5 | export function StartScreen({
6 | onOpenProject,
7 | recentProjects,
8 | onOpenRecentProject
9 | }: StartScreenProps) {
10 | return (
11 |
12 |
13 | Welcome to Pix3D
14 |
15 | Open a project folder to get started
16 |
17 | }
21 | onClick={onOpenProject}
22 | >
23 | Open Project Folder
24 |
25 |
26 |
27 | {recentProjects.length > 0 && (
28 |
29 | Recent Projects
30 | (
33 | onOpenRecentProject(project)}
35 | style={{ cursor: 'pointer', padding: '8px 0' }}
36 | >
37 |
41 |
42 | )}
43 | />
44 |
45 | )}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/MoveTo.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from "./Behavior";
3 |
4 |
5 | export class MoveTo extends Behavior {
6 | speed: number;
7 | dest: THREE.Vector3;
8 | direction: THREE.Vector3;
9 | isMoving: boolean;
10 |
11 | onArrivedCallback: Function;
12 | arriveResolver: (value: void | PromiseLike) => void;
13 |
14 | constructor(speed: number) {
15 | super();
16 | this.speed = speed;
17 | }
18 |
19 | move(dest: THREE.Vector3) {
20 | this.dest = dest.clone();
21 | this.direction = this.dest.clone().sub(this.object3d.position).normalize();
22 |
23 | this.isMoving = true;
24 | }
25 |
26 | stop() {
27 | this.dest = this.object3d.position.clone();
28 | this.isMoving = false;
29 | this.onArrived();
30 | }
31 |
32 | moveAsync(dest: THREE.Vector3): Promise {
33 | return new Promise(resolve => {
34 | this.move(dest);
35 | this.arriveResolver = resolve;
36 | });
37 | }
38 |
39 | onTick(dt: any): void {
40 | if (!this.isEnabled || !this.isMoving)
41 | return;
42 |
43 | const step = this.speed * dt;
44 |
45 | this.object3d.position.add(this.direction.clone().multiplyScalar(step));
46 |
47 | const distance = this.object3d.position.distanceTo(this.dest);
48 |
49 | if (distance <= step) {
50 | this.object3d.position.copy(this.dest);
51 | this.isMoving = false;
52 |
53 | this.onArrived();
54 | }
55 | }
56 |
57 | onArrived() {
58 | if (this.arriveResolver != undefined) {
59 | this.arriveResolver();
60 | }
61 |
62 | if (this.onArrivedCallback != undefined)
63 | this.onArrivedCallback();
64 | }
65 | }
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | // This file contains type declarations for the global scope
2 |
3 | // Extend the Window interface to include the File System Access API
4 | declare interface Window {
5 | showDirectoryPicker: (options?: {
6 | id?: string;
7 | mode?: 'read' | 'readwrite';
8 | startIn?: FileSystemHandle | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos';
9 | }) => Promise;
10 | }
11 |
12 | // Declare the FileSystemHandle interface
13 | declare interface FileSystemHandle {
14 | readonly kind: 'file' | 'directory';
15 | readonly name: string;
16 | isSameEntry: (other: FileSystemHandle) => Promise;
17 | queryPermission: (options: { mode: 'read' | 'readwrite' }) => Promise;
18 | requestPermission: (options: { mode: 'read' | 'readwrite' }) => Promise;
19 | }
20 |
21 | // Declare the FileSystemDirectoryHandle interface
22 | declare interface FileSystemDirectoryHandle extends FileSystemHandle {
23 | readonly kind: 'directory';
24 | getDirectoryHandle: (name: string, options?: { create?: boolean }) => Promise;
25 | getFileHandle: (name: string, options?: { create?: boolean }) => Promise;
26 | removeEntry: (name: string, options?: { recursive?: boolean }) => Promise;
27 | resolve: (possibleDescendant: FileSystemHandle) => Promise;
28 | keys: () => AsyncIterableIterator;
29 | values: () => AsyncIterableIterator;
30 | entries: () => AsyncIterableIterator<[string, FileSystemHandle]>;
31 | [Symbol.asyncIterator]: () => AsyncIterableIterator<[string, FileSystemHandle]>;
32 | }
33 |
34 | // Declare the FileSystemFileHandle interface
35 | declare interface FileSystemFileHandle extends FileSystemHandle {
36 | readonly kind: 'file';
37 | getFile: () => Promise;
38 | createWritable: (options?: { keepExistingData?: boolean }) => Promise;
39 | }
40 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/SceneEditor.module.css:
--------------------------------------------------------------------------------
1 | .sceneEditorContainer {
2 | position: relative;
3 | display: flex;
4 | flex-direction: row;
5 | height: 100%;
6 | width: 100%;
7 | border: 1px solid var(--color-panels-border);
8 | background: var(--color-scene-background);
9 | }
10 |
11 | .sceneHierarchy {
12 | width: 260px;
13 | border-right: 1px solid #222;
14 | background: #181818;
15 | color: var(--color-foreground);
16 | overflow-y: auto;
17 | padding: 8px 0;
18 | }
19 |
20 | .treeView {
21 | font-size: 14px;
22 | user-select: none;
23 | }
24 |
25 | .treeNode {
26 | padding: 2px 8px;
27 | cursor: pointer;
28 | border-radius: 3px;
29 | margin: 2px 0;
30 | }
31 |
32 | .treeNode:hover {
33 | background: var(--color-button-hover);
34 | }
35 |
36 | .selected {
37 | background: var(--color-my-accent);
38 | color: var(--color-foreground);
39 | }
40 |
41 | .treeChildren {
42 | margin-left: 16px;
43 | }
44 |
45 | .viewport {
46 | flex: 1 1 auto;
47 | min-width: 0;
48 | position: relative;
49 | background: #111;
50 | }
51 |
52 | .toolbarOverlay {
53 | position: absolute;
54 | top: 16px;
55 | left: 16px;
56 | background: var(--color-button-background);
57 | color: var(--color-foreground);
58 | padding: 8px 10px;
59 | border-radius: 6px;
60 | z-index: 10;
61 | display: flex;
62 | gap: 8px;
63 | align-items: center;
64 | }
65 |
66 | .toolbarButton {
67 | background: none;
68 | border: none;
69 | color: var(--color-foreground);
70 | font-size: 22px;
71 | padding: 4px 8px;
72 | border-radius: 4px;
73 | cursor: pointer;
74 | transition: background 0.2s;
75 | }
76 | .toolbarButton:hover {
77 | background: var(--color-button-hover);
78 | }
79 | .toolbarButtonActive {
80 | background: var(--color-my-accent);
81 | color: var(--color-foreground);
82 | font-weight: bold;
83 | }
84 |
85 | .objectInspector {
86 | width: 320px;
87 | border-left: 1px solid #222;
88 | background: #202020;
89 | overflow-y: auto;
90 | }
91 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/MoveByCheckppoints.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { MoveTo } from './MoveTo';
3 | import { Transform } from '../Runtime';
4 |
5 | export class MoveByCheckpoints extends MoveTo {
6 | currentCheckpointIndex = 0;
7 | checkpoints: Transform[] = [];
8 | rotationSpeed = 0.5;
9 | isRotating = false;
10 | targetQuaternion: THREE.Quaternion = new THREE.Quaternion();
11 | currentCheckpoint: Transform;
12 |
13 | constructor(config?: any) {
14 | super(config.speed);
15 |
16 | this.checkpoints = config.checkpoints.map((item: any) => ({
17 | position: new THREE.Vector3(item.position.x, item.position.y, item.position.z),
18 | rotation: new THREE.Euler(item.rotation.x, item.rotation.y, item.rotation.z),
19 | }));
20 |
21 | this.onArrivedCallback = () => {
22 | const checkpoint = this.currentCheckpoint;
23 | if (checkpoint.rotation) {
24 | this.targetQuaternion.setFromEuler(checkpoint.rotation);
25 | this.isRotating = true;
26 | }
27 | }
28 | }
29 |
30 | onTick(deltaTime: number) {
31 | if (!this.isEnabled) return;
32 |
33 | super.onTick(deltaTime);
34 |
35 | if (this.isRotating) {
36 | if (this.object3d.quaternion.angleTo(this.targetQuaternion) > 0.3) {
37 | this.object3d.quaternion.slerp(this.targetQuaternion, this.rotationSpeed);
38 | } else {
39 | this.object3d.quaternion.copy(this.targetQuaternion);
40 | this.isRotating = false;
41 | }
42 | }
43 | }
44 |
45 | moveToNextCheckpoint() {
46 | this.currentCheckpoint = this.checkpoints[this.currentCheckpointIndex];
47 | this.currentCheckpointIndex++;
48 |
49 | if (this.currentCheckpoint) {
50 | //console.log(this.currentCheckpointIndex, this.currentCheckpoint);
51 | this.move(this.currentCheckpoint.position);
52 | }
53 | }
54 | }
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/UiLayer.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { UiElementLayout, UiLayoutManager } from '../Runtime/LayoutManager';
3 | import AssetLoader from '../Runtime/AssetLoader';
4 | import { IGameScene } from './GameScene';
5 | import { Behavior } from '../Behaviors/Behavior';
6 |
7 | export default class UiLayer extends THREE.Scene implements IGameScene {
8 | private layoutManager: UiLayoutManager;
9 | assetLoader: AssetLoader;
10 |
11 | constructor(assetLoader: AssetLoader) {
12 | super();
13 | this.assetLoader = assetLoader;
14 | this.layoutManager = new UiLayoutManager(this, assetLoader);
15 | }
16 |
17 | onTick(dt) {
18 | this.children.forEach(x => {
19 | Behavior.ObjectBehaviorsOnTick(x, dt);
20 | });
21 | }
22 |
23 | init() {
24 | // Load layout from JSON
25 | const layoutJson = this.assetLoader.uiLayerJson;
26 | this.layoutManager.loadLayout(layoutJson);
27 | this.updateLayout();
28 |
29 | this.initializeBehaviors();
30 | }
31 |
32 | initializeBehaviors() {
33 | this.children.forEach(x => {
34 | Behavior.OnAttached(x);
35 | });
36 | }
37 |
38 | updateLayout() {
39 | this.layoutManager.updateLayout();
40 | }
41 |
42 | getUiScale() {
43 | return this.layoutManager.getScale();
44 | }
45 |
46 | hideItem(elementName: string) {
47 | this.layoutManager.hideElement(elementName);
48 | }
49 |
50 | hideGroup(groupName: string) {
51 | this.layoutManager.hideGroup(groupName);
52 | }
53 |
54 | showGroup(groupName: string) {
55 | this.layoutManager.showGroup(groupName);
56 | }
57 | showItem(elementName: string) {
58 | this.layoutManager.showElement(elementName);
59 | }
60 |
61 | ivalidateLayout() {
62 | this.layoutManager.invalidate();
63 | }
64 |
65 | getUiElementByName(name: string): UiElementLayout | undefined {
66 | return this.layoutManager.getUiElement(name);
67 | }
68 | }
--------------------------------------------------------------------------------
/src/components/FileThumbnail.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { FileOutlined, FolderOpenOutlined } from '@ant-design/icons';
3 | import type { FileEntry, FileSystemEntry } from '../types';
4 |
5 | interface FileThumbnailProps {
6 | entry: FileSystemEntry;
7 | size?: number;
8 | }
9 |
10 | const imageTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
11 |
12 | export const FileThumbnail: React.FC = ({ entry, size = 32 }) => {
13 | const [thumbUrl, setThumbUrl] = useState(null);
14 |
15 | useEffect(() => {
16 | let url: string | null = null;
17 | if (!entry.isDirectory && imageTypes.includes((entry as FileEntry).type)) {
18 | const file = (entry as FileEntry).file;
19 | url = URL.createObjectURL(file);
20 | setThumbUrl(url);
21 | return () => {
22 | if (url) URL.revokeObjectURL(url);
23 | };
24 | } else {
25 | setThumbUrl(null);
26 | }
27 | // Cleanup
28 | return () => {
29 | if (url) URL.revokeObjectURL(url);
30 | };
31 | }, [entry]);
32 |
33 | const boxStyle = { width: 100, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-panels-background)', borderRadius: 4, margin: 0 };
34 |
35 | if (entry.isDirectory) {
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 | if (thumbUrl) {
43 | return (
44 |
45 |

50 |
51 | );
52 | }
53 | return (
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { FileBrowser } from './components/FileBrowser';
2 | import { StartScreen } from './components/StartScreen';
3 | import SceneEditor from './Modules/SceneEditor';
4 | import { ServiceProvider } from './contexts/ServiceProvider';
5 | import { useProjectStore } from './stores/projectStore';
6 | import './App.css';
7 |
8 | function AppContent() {
9 | const {
10 | hasProjectOpen,
11 | currentDirectory,
12 | currentPath,
13 | recentProjects,
14 | openProject,
15 | openRecentProject,
16 | closeProject,
17 | setCurrentPath,
18 | } = useProjectStore();
19 |
20 | const handlePathChange = (newPath: string, dirHandle: FileSystemDirectoryHandle) => {
21 | setCurrentPath(newPath, dirHandle);
22 | };
23 |
24 | return (
25 |
26 |
27 | Pix3D
28 | {hasProjectOpen && (
29 |
35 | )}
36 |
37 |
38 | {!hasProjectOpen ? (
39 |
44 | ) : (
45 |
46 |
47 |
48 |
49 |
50 |
55 |
56 |
57 | )}
58 |
59 |
60 | );
61 | }
62 |
63 | function App() {
64 | return (
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | export default App;
72 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
13 |
14 | ```js
15 | export default tseslint.config({
16 | extends: [
17 | // Remove ...tseslint.configs.recommended and replace with this
18 | ...tseslint.configs.recommendedTypeChecked,
19 | // Alternatively, use this for stricter rules
20 | ...tseslint.configs.strictTypeChecked,
21 | // Optionally, add this for stylistic rules
22 | ...tseslint.configs.stylisticTypeChecked,
23 | ],
24 | languageOptions: {
25 | // other options...
26 | parserOptions: {
27 | project: ['./tsconfig.node.json', './tsconfig.app.json'],
28 | tsconfigRootDir: import.meta.dirname,
29 | },
30 | },
31 | })
32 | ```
33 |
34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
35 |
36 | ```js
37 | // eslint.config.js
38 | import reactX from 'eslint-plugin-react-x'
39 | import reactDom from 'eslint-plugin-react-dom'
40 |
41 | export default tseslint.config({
42 | plugins: {
43 | // Add the react-x and react-dom plugins
44 | 'react-x': reactX,
45 | 'react-dom': reactDom,
46 | },
47 | rules: {
48 | // other rules...
49 | // Enable its recommended typescript rules
50 | ...reactX.configs['recommended-typescript'].rules,
51 | ...reactDom.configs.recommended.rules,
52 | },
53 | })
54 | ```
55 |
--------------------------------------------------------------------------------
/src/theme.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* Color Variables */
3 | --color-foreground: rgba(255, 255, 255, 0.7);
4 | --color-scene-background: #1C1C1E;
5 | --color-panels-border: #2C2C2E;
6 | --color-panels-background: rgba(6, 6, 8, 0.95);
7 | --color-main-background: #2d2d2f;
8 | --color-main-menu-background: #1C1C1E;
9 | --color-modal-overlay: rgba(45, 45, 47, 0.53);
10 | --color-title-bar-background: #1d1d1f;
11 | --color-selected-item: rgba(255, 255, 255, 0.2);
12 | --color-my-accent: rgba(67, 132, 222, 1.0);
13 | --color-button-background: rgba(6, 6, 8, 0.8);
14 | --color-button-hover: rgba(255, 255, 255, 0.3);
15 | --color-button-active: #5883bf;
16 | --color-my-link-highlight: rgba(107, 167, 249, 1.0);
17 | --color-brush-button: rgba(255, 255, 255, 0.2);
18 | --color-brush-item: rgba(255, 255, 255, 0.1);
19 | --color-inner-panel-background: #3a3f46;
20 |
21 | /* Brush Variables */
22 | --brush-selected-tool: linear-gradient(135deg, #FF6B00 0%, #E5B407 100%);
23 | --brush-selected-tool-border: rgba(255, 255, 255, 0.3);
24 | --brush-foreground: var(--color-foreground);
25 | --brush-panels-background: var(--color-panels-background);
26 | --brush-panels-border: var(--color-panels-border);
27 | --brush-main-background: var(--color-main-background);
28 | --brush-main-menu-background: var(--color-main-menu-background);
29 | --brush-selected-item: var(--color-selected-item);
30 | --brush-accent: var(--brush-selected-tool);
31 | --brush-button-background: var(--color-button-background);
32 | --brush-button-hover: var(--color-button-hover);
33 | --brush-button-active: var(--color-button-active);
34 | --brush-button-solid: var(--color-main-background);
35 | --brush-link-highlight: var(--color-my-link-highlight);
36 | --brush-actions-bar-background: #444E59;
37 | --brush-modal-overlay: var(--color-modal-overlay);
38 | --brush-selected-toggle-button: var(--color-brush-button);
39 | --brush-brush-button: var(--color-brush-button);
40 | --brush-brush-item: var(--color-brush-item);
41 | --brush-inner-panel-background: var(--color-inner-panel-background);
42 | }
43 |
44 | /* General body and app backgrounds for immediate effect */
45 | body {
46 | color: var(--color-foreground);
47 | background: var(--color-main-background);
48 | }
49 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/MeleeAttacker.ts:
--------------------------------------------------------------------------------
1 | import { t } from "../Runtime/stateMachine";
2 | import { Behavior } from "./Behavior";
3 |
4 | export interface MeleeAttackerConfig {
5 | damage: number;
6 | attackPeriod: number; // Time between attacks in seconds
7 | onAttack?: () => void; // Callback for attack animation/effects
8 | }
9 |
10 | export class MeleeAttacker extends Behavior {
11 | private damage: number;
12 | private attackPeriod: number;
13 | private lastAttackTime: number = 0;
14 | private onAttack?: () => void;
15 | private isAttacking: boolean = false;
16 | private offset: number = 0;
17 | attacked: boolean;
18 |
19 | constructor(config: MeleeAttackerConfig) {
20 | super();
21 | this.damage = config.damage;
22 | this.attackPeriod = config.attackPeriod;
23 | this.onAttack = config.onAttack;
24 | }
25 |
26 | startAttacking(): void {
27 | this.isEnabled = true;
28 | this.isAttacking = true;
29 | this.lastAttackTime = performance.now() / 1000;
30 | }
31 |
32 | stopAttacking(): void {
33 | this.isEnabled = false;
34 | this.isAttacking = false;
35 | }
36 |
37 | onTick(dt: number): void {
38 | if (!this.isEnabled || !this.isAttacking) return;
39 |
40 | const currentTime = performance.now() / 1000;
41 | if (!this.attacked && currentTime - this.lastAttackTime >= this.attackPeriod + this.offset) {
42 | this.attacked = true;
43 | this.performAttack();
44 | }
45 | if (currentTime - this.lastAttackTime >= this.attackPeriod) {
46 | this.lastAttackTime = currentTime;
47 | this.attacked = false;
48 | }
49 | }
50 |
51 | private performAttack(): void {
52 | if (this.onAttack) {
53 | this.onAttack();
54 | }
55 | }
56 |
57 | // Utility methods
58 | setAttackPeriod(period: number, offset: number): void {
59 | this.attackPeriod = period;
60 | this.offset = offset;
61 | }
62 |
63 | setDamage(damage: number): void {
64 | this.damage = damage;
65 | }
66 |
67 | getDamage(): number {
68 | return this.damage;
69 | }
70 |
71 | setOnAttack(attackCallback: () => void) {
72 | this.onAttack = attackCallback;
73 | }
74 |
75 | }
--------------------------------------------------------------------------------
/src/types/file-system-access.d.ts:
--------------------------------------------------------------------------------
1 | // Extend the FileSystemHandle interface to include the queryPermission method
2 | declare interface FileSystemHandle {
3 | queryPermission: (options: { mode: 'read' | 'readwrite' }) => Promise;
4 | requestPermission: (options: { mode: 'read' | 'readwrite' }) => Promise;
5 | readonly kind: 'file' | 'directory';
6 | readonly name: string;
7 | isSameEntry: (other: FileSystemHandle) => Promise;
8 | }
9 |
10 | declare interface FileSystemDirectoryHandle extends FileSystemHandle {
11 | readonly kind: 'directory';
12 | getDirectoryHandle: (name: string, options?: { create?: boolean }) => Promise;
13 | getFileHandle: (name: string, options?: { create?: boolean }) => Promise;
14 | removeEntry: (name: string, options?: { recursive?: boolean }) => Promise;
15 | resolve: (possibleDescendant: FileSystemHandle) => Promise;
16 | keys: () => AsyncIterableIterator;
17 | values: () => AsyncIterableIterator;
18 | entries: () => AsyncIterableIterator<[string, FileSystemHandle]>;
19 | [Symbol.asyncIterator]: () => AsyncIterableIterator<[string, FileSystemHandle]>;
20 | }
21 |
22 | declare interface FileSystemFileHandle extends FileSystemHandle {
23 | readonly kind: 'file';
24 | getFile: () => Promise;
25 | createWritable: (options?: { keepExistingData?: boolean }) => Promise;
26 | }
27 |
28 | // Extend the Window interface to include showDirectoryPicker
29 | declare global {
30 | interface Window {
31 | showDirectoryPicker: (options?: {
32 | id?: string;
33 | mode?: 'read' | 'readwrite';
34 | startIn?: FileSystemHandle | 'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos';
35 | }) => Promise;
36 | }
37 |
38 | // This is needed to make TypeScript recognize the global types
39 | const FileSystemHandle: {
40 | prototype: FileSystemHandle;
41 | new(): FileSystemHandle;
42 | };
43 |
44 | const FileSystemDirectoryHandle: {
45 | prototype: FileSystemDirectoryHandle;
46 | new(): FileSystemDirectoryHandle;
47 | };
48 |
49 | const FileSystemFileHandle: {
50 | prototype: FileSystemFileHandle;
51 | new(): FileSystemFileHandle;
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | /* Base styles */
2 | :root {
3 | --header-height: 48px;
4 | --file-browser-height: 200px;
5 | }
6 |
7 | * {
8 | box-sizing: border-box;
9 | margin: 0;
10 | padding: 0;
11 | }
12 |
13 | html, body, #root {
14 | width: 100%;
15 | height: 100%;
16 | min-width: 0;
17 | min-height: 0;
18 | margin: 0;
19 | padding: 0;
20 | overflow: hidden;
21 | background: var(--color-main-background-dark);
22 | color: var(--color-foreground);
23 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
24 | }
25 |
26 | /* App container */
27 | .app-container {
28 | display: flex;
29 | flex-direction: column;
30 | width: 100%;
31 | height: 100%;
32 | overflow: hidden;
33 | }
34 |
35 | /* Header */
36 | .app-header {
37 | display: flex;
38 | align-items: center;
39 | height: var(--header-height);
40 | padding: 0 16px;
41 | background: #001529;
42 | color: white;
43 | border-bottom: 1px solid rgba(0, 0, 0, 0.1);
44 | }
45 |
46 | .app-title {
47 | margin: 0;
48 | font-size: 18px;
49 | font-weight: 500;
50 | color: inherit;
51 | }
52 |
53 | .back-button {
54 | margin-left: auto;
55 | background: none;
56 | border: none;
57 | color: inherit;
58 | cursor: pointer;
59 | padding: 4px 8px;
60 | border-radius: 4px;
61 | }
62 |
63 | .back-button:hover {
64 | background: rgba(255, 255, 255, 0.1);
65 | }
66 |
67 | /* Main content */
68 | .app-content {
69 | flex: 1;
70 | min-height: 0;
71 | overflow: hidden;
72 | }
73 |
74 | /* Editor container */
75 | .editor-container {
76 | display: flex;
77 | flex-direction: column;
78 | height: 100%;
79 | width: 100%;
80 | overflow: hidden;
81 | gap: 0; /* Ensure no gap between children */
82 | }
83 |
84 | .scene-editor-wrapper {
85 | flex: 1;
86 | min-height: 0;
87 | overflow: hidden;
88 | margin: 0;
89 | padding: 0;
90 | }
91 |
92 | .file-browser-wrapper {
93 | min-height: 0;
94 | overflow: hidden;
95 | border-top: 1px solid rgba(0, 0, 0, 0.1);
96 | margin: 0;
97 | padding: 0;
98 | }
99 |
100 | /* Animation for loading/logo */
101 | @keyframes logo-spin {
102 | from { transform: rotate(0deg); }
103 | to { transform: rotate(360deg); }
104 | }
105 |
106 | /* Responsive adjustments */
107 | @media (max-width: 768px) {
108 | :root {
109 | }
110 |
111 | .app-header {
112 | padding: 0 12px;
113 | }
114 |
115 | .app-title {
116 | font-size: 16px;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/TurnTo.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from "./Behavior";
3 |
4 |
5 | export class TurnTo extends Behavior {
6 | speed: number;
7 | dest: THREE.Quaternion;
8 | direction: THREE.Vector3;
9 | isTurning: boolean;
10 |
11 | onTurnCallback: Function;
12 | turnedResolver: (value: void | PromiseLike) => void;
13 | t: number;
14 |
15 | constructor(speed: number) {
16 | super();
17 | this.speed = speed;
18 | }
19 |
20 | turn(destQuaternion: THREE.Quaternion, t = 0.1) {
21 | this.t = t;
22 | this.dest = destQuaternion.clone();
23 | this.isTurning = true;
24 | }
25 |
26 | turnTo(destLookAt: THREE.Vector3, t = 0.1) {
27 | this.t = t;
28 | const currentPosition = this.object3d.position;
29 |
30 | // Calculate direction vector from current position to target
31 | const toTarget = new THREE.Vector3()
32 | .subVectors(currentPosition, destLookAt);
33 |
34 | // Get the angle in the XZ plane (Y-rotation)
35 | const angleY = Math.atan2(toTarget.x, toTarget.z);
36 |
37 | // Create quaternion for Y-rotation only
38 | const destQuaternion = new THREE.Quaternion();
39 | destQuaternion.setFromAxisAngle(new THREE.Vector3(0, 1, 0), angleY);
40 |
41 | this.dest = destQuaternion;
42 | this.isTurning = true;
43 | }
44 |
45 | stop() {
46 | this.dest = this.object3d.quaternion.clone();
47 | this.isTurning = false;
48 | this.onTurned();
49 | }
50 |
51 | moveAsync(destQuaternion: THREE.Quaternion): Promise {
52 | return new Promise(resolve => {
53 | this.turn(destQuaternion);
54 | this.turnedResolver = resolve;
55 | });
56 | }
57 |
58 | onTick(dt: any): void {
59 | if (!this.isEnabled || !this.isTurning)
60 | return;
61 |
62 | this.object3d.quaternion.slerp(this.dest, this.t);
63 |
64 | const distance = this.object3d.quaternion.angleTo(this.dest);
65 |
66 | if (distance <= 0.01) {
67 | this.object3d.quaternion.copy(this.dest);
68 | this.isTurning = false;
69 |
70 | this.onTurned();
71 | }
72 | }
73 |
74 | onTurned() {
75 | if (this.turnedResolver != undefined) {
76 | this.turnedResolver();
77 | }
78 |
79 | if (this.onTurnCallback != undefined)
80 | this.onTurnCallback();
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/FileDetailsPanel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button, Typography } from 'antd';
3 | import type { FileEntry, FileSystemEntry } from '../types';
4 | import SceneManager from '../Core/SceneManager';
5 | import * as THREE from 'three';
6 |
7 |
8 | interface FileDetailsPanelProps {
9 | file: FileSystemEntry;
10 | onSelectObject?: (obj: THREE.Object3D | null) => void;
11 | }
12 |
13 |
14 | export const FileDetailsPanel: React.FC = ({ file, onSelectObject }) => {
15 | const isGlb = !file.isDirectory && (file as FileEntry).name.toLowerCase().endsWith('.glb');
16 | const isJson = !file.isDirectory && (file as FileEntry).name.toLowerCase().endsWith('.json');
17 |
18 | const handleAddToScene = async () => {
19 | if (!isGlb) return;
20 | try {
21 | const fileEntry = file as FileEntry;
22 | const url = URL.createObjectURL(fileEntry.file);
23 | const key = fileEntry.path;
24 | await SceneManager.instance.AddModelToScene(key, url);
25 | const selectedObject = SceneManager.instance.selected;
26 | if (onSelectObject) {
27 | onSelectObject(selectedObject);
28 | }
29 | setTimeout(() => URL.revokeObjectURL(url), 10000);
30 | } catch (error) {
31 | console.error('Failed to add model to scene:', error);
32 | }
33 | };
34 |
35 | const handleLoadScene = async () => {
36 | if (!isJson) return;
37 | try {
38 | const fileEntry = file as FileEntry;
39 | const text = await fileEntry.file.text();
40 | const json = JSON.parse(text);
41 | await SceneManager.instance.loadSceneFromJson(json);
42 | } catch (error) {
43 | console.error('Failed to load scene from JSON:', error);
44 | }
45 | };
46 |
47 | return (
48 |
49 |
{file.name}
50 |
Path: {file.path}
51 |
Type: {file.isDirectory ? 'Directory' : 'File'}
52 | {!file.isDirectory && (
53 |
Size: {(file as FileEntry).size} bytes
54 | )}
55 | {isGlb && (
56 |
59 | )}
60 | {isJson && (
61 |
64 | )}
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import './theme.css';
2 |
3 | /* Global styles */
4 |
5 | :root {
6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
7 | line-height: 1.5;
8 | font-weight: 400;
9 | color: rgba(0, 0, 0, 0.88);
10 | background-color: #f5f5f5;
11 | font-synthesis: none;
12 | text-rendering: optimizeLegibility;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | }
16 |
17 | body {
18 | margin: 0;
19 | padding: 0;
20 | min-height: 100vh;
21 | }
22 |
23 | #root {
24 | height: 100vh;
25 | display: flex;
26 | flex-direction: column;
27 | }
28 |
29 | .ant-layout {
30 | background: var(--color-main-background);
31 | }
32 |
33 | .ant-layout-header {
34 | background: var(--color-title-bar-background) !important;
35 | color: var(--color-foreground) !important;
36 | padding: 0 24px;
37 | box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
38 | position: relative;
39 | z-index: 1;
40 | }
41 |
42 | .ant-layout-content {
43 | padding: 24px;
44 | margin: 16px 16px 0;
45 | min-height: 280px;
46 | background: var(--color-panels-background);
47 | border-radius: 8px;
48 | }
49 |
50 | .start-screen {
51 | display: flex;
52 | flex-direction: column;
53 | align-items: center;
54 | justify-content: center;
55 | height: 100%;
56 | text-align: center;
57 | padding: 20px;
58 | }
59 |
60 | .recent-projects {
61 | width: 100%;
62 | max-width: 800px;
63 | margin-top: 24px;
64 | }
65 |
66 | .file-browser {
67 | display: flex;
68 | height: calc(100vh - 64px);
69 | background: #fff;
70 | }
71 |
72 | .file-tree {
73 | width: 250px;
74 | border-right: 1px solid #f0f0f0;
75 | overflow-y: auto;
76 | padding: 16px;
77 | }
78 |
79 | .file-content {
80 | flex: 1;
81 | padding: 16px;
82 | overflow-y: auto;
83 | background: var(--color-main-background);
84 | color: var(--color-foreground);
85 | }
86 |
87 | a {
88 | font-weight: 500;
89 | color: #646cff;
90 | text-decoration: inherit;
91 | }
92 | a:hover {
93 | color: var(--color-my-link-highlight);
94 | }
95 |
96 | h1 {
97 | font-size: 3.2em;
98 | line-height: 1.1;
99 | }
100 |
101 | button {
102 | border-radius: 8px;
103 | border: 1px solid transparent;
104 | padding: 0.6em 1.2em;
105 | font-size: 1em;
106 | font-weight: 500;
107 | font-family: inherit;
108 | background-color: #1a1a1a;
109 | cursor: pointer;
110 | transition: border-color 0.25s;
111 | }
112 | button:hover {
113 | border-color: #646cff;
114 | }
115 | button:focus,
116 | button:focus-visible {
117 | outline: 4px auto -webkit-focus-ring-color;
118 | }
119 |
120 |
121 |
--------------------------------------------------------------------------------
/docs/ARCHITECTURE.md:
--------------------------------------------------------------------------------
1 | # Pix3D Architecture Refinement
2 |
3 | ## Overview
4 | This document outlines the refined architecture for better maintainability, testability, and scalability.
5 |
6 | ## Current Issues
7 | 1. **Tight Coupling**: Direct SceneManager singleton usage
8 | 2. **Mixed Concerns**: UI and 3D logic intertwined
9 | 3. **Scattered State**: No centralized state management
10 | 4. **Hard Dependencies**: Direct imports between layers
11 | 5. **Testing Challenges**: Singleton patterns make testing difficult
12 |
13 | ## Proposed Architecture
14 |
15 | ### 1. Layered Architecture
16 | ```
17 | ┌─────────────────────────────────────┐
18 | │ Presentation Layer │ (React Components)
19 | ├─────────────────────────────────────┤
20 | │ Application Layer │ (Hooks, Contexts, Services)
21 | ├─────────────────────────────────────┤
22 | │ Domain Layer │ (Core Business Logic)
23 | ├─────────────────────────────────────┤
24 | │ Infrastructure Layer │ (External APIs, Storage)
25 | └─────────────────────────────────────┘
26 | ```
27 |
28 | ### 2. Key Principles
29 | - **Dependency Inversion**: High-level modules don't depend on low-level modules
30 | - **Single Responsibility**: Each module has one reason to change
31 | - **Interface Segregation**: Small, focused interfaces
32 | - **Composition over Inheritance**: Use composition for flexibility
33 |
34 | ### 3. Core Components
35 |
36 | #### Application State Management
37 | - Replace scattered React state with centralized store
38 | - Use Context API or Zustand for state management
39 | - Separate concerns: UI state vs. 3D scene state vs. File system state
40 |
41 | #### 3D Engine Abstraction
42 | - Abstract Three.js behind interfaces
43 | - Dependency injection for 3D services
44 | - Event-driven architecture for 3D scene changes
45 |
46 | #### File System Service
47 | - Abstract File System Access API
48 | - Separate persistence layer
49 | - Testable file operations
50 |
51 | #### Component Architecture
52 | - Smart/Dumb component pattern
53 | - Custom hooks for business logic
54 | - Minimal prop drilling
55 |
56 | ## Implementation Plan
57 |
58 | ### Phase 1: State Management
59 | 1. Create centralized stores
60 | 2. Replace direct SceneManager usage
61 | 3. Implement proper event handling
62 |
63 | ### Phase 2: Service Layer
64 | 1. Create service interfaces
65 | 2. Implement dependency injection
66 | 3. Abstract external dependencies
67 |
68 | ### Phase 3: Component Refactoring
69 | 1. Extract business logic to hooks
70 | 2. Simplify component props
71 | 3. Improve component composition
72 |
73 | ### Phase 4: Testing Infrastructure
74 | 1. Add unit tests for services
75 | 2. Component testing with mocked dependencies
76 | 3. Integration tests for critical paths
77 |
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/GameScene.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import Node3d from './Node3d';
3 | import { Behavior } from '../Behaviors/Behavior';
4 |
5 | export interface IGameScene {
6 | onTick(dt: number): void;
7 | }
8 |
9 | export default class GameScene extends THREE.Scene implements IGameScene {
10 | private nodes: Node3d[] = [];
11 |
12 | constructor() {
13 | super();
14 |
15 | this.background = new THREE.Color(0x444444);
16 | }
17 |
18 | init() {
19 | this.addBasicLights();
20 | this.addHemisphereLight();
21 | }
22 |
23 | addBasicLights() {
24 | // Ambient light - provides global illumination
25 | const ambientLight = new THREE.AmbientLight('#FFFFFF', 1.6); // color, intensity
26 | ambientLight.name = 'ambient_light';
27 |
28 | // Directional light - mimics sunlight
29 | const directionalLight = new THREE.DirectionalLight('#FFFFFF', 2 * Math.PI); // color, intensity
30 | directionalLight.name = 'main_light';
31 | //const directionalHelper = new THREE.DirectionalLightHelper(directionalLight, 5); // size of the helper
32 | //this.add(directionalHelper);
33 |
34 | this.add(ambientLight);
35 | directionalLight.position.set(0.5, 4.3, 0.866); // ~60º angle
36 | //directionalLight.rotation.set(camera.rotation.x, camera.rotation.y, camera.rotation.z);
37 | this.add(directionalLight);
38 |
39 |
40 | return [ambientLight, directionalLight];
41 | }
42 |
43 | // Option 2: Simple hemisphere lighting setup
44 | addHemisphereLight() {
45 | const hemiLight = new THREE.HemisphereLight();
46 | hemiLight.name = 'hemi_light';
47 | this.add(hemiLight);
48 |
49 | return [hemiLight];
50 | }
51 | setGameObjects(nodes: Node3d[]) {
52 | this.nodes = nodes;
53 |
54 | nodes.forEach(x => {
55 | if (x) this.add(x);
56 | });
57 | }
58 |
59 | addGameObject(node: Node3d) {
60 | if (node) {
61 | this.nodes.push(node);
62 | this.add(node);
63 | }
64 | }
65 |
66 | getGameObjects() {
67 | return this.nodes;
68 | }
69 |
70 | findbjectByName(name: string): Node3d {
71 | const result = this.nodes.find(x => x.name == name);
72 | if (result)
73 | return result;
74 | else
75 | throw new Error(`GameObject ${name} not found`);
76 | }
77 |
78 | onTick(dt: number) {
79 | this.children.forEach(x => {
80 | if (x instanceof Node3d && x.isActive) {
81 | x.update(dt);
82 | }
83 |
84 | Behavior.ObjectBehaviorsOnTick(x, dt);
85 | });
86 | }
87 |
88 | removeGameObject(node: Node3d) {
89 | this.remove(node);
90 | this.nodes = this.nodes.filter(obj => obj !== node);
91 | }
92 | }
--------------------------------------------------------------------------------
/docs/TESTING.md:
--------------------------------------------------------------------------------
1 | # Testing Strategy for Refined Architecture
2 |
3 | ## Overview
4 | The new architecture enables comprehensive testing through dependency injection and proper separation of concerns.
5 |
6 | ## Testing Levels
7 |
8 | ### 1. Unit Tests
9 | - **Services**: Test business logic in isolation
10 | - **Stores**: Test state management with mock dependencies
11 | - **Hooks**: Test custom hooks with mock services
12 | - **Components**: Test UI components with mock dependencies
13 |
14 | ### 2. Integration Tests
15 | - **Service Integration**: Test services working together
16 | - **Store Integration**: Test state synchronization
17 | - **Component Integration**: Test component interactions
18 |
19 | ### 3. End-to-End Tests
20 | - **User Workflows**: Test complete user scenarios
21 | - **3D Scene Operations**: Test 3D model loading and manipulation
22 | - **File System Operations**: Test project management workflows
23 |
24 | ## Mock Implementation Examples
25 |
26 | ### Mock 3D Engine Service
27 | ```typescript
28 | export class Mock3DEngineService implements I3DEngineService {
29 | private scene: GameScene | null = null;
30 | private selectedObject: THREE.Object3D | null = null;
31 | private sceneCallbacks: ((scene: GameScene | null) => void)[] = [];
32 | private selectionCallbacks: ((object: THREE.Object3D | null) => void)[] = [];
33 |
34 | createDefaultScene(): GameScene {
35 | const mockScene = new GameScene();
36 | this.setScene(mockScene);
37 | return mockScene;
38 | }
39 |
40 | setScene(scene: GameScene): void {
41 | this.scene = scene;
42 | this.sceneCallbacks.forEach(cb => cb(scene));
43 | }
44 |
45 | // ... other mock implementations
46 | }
47 | ```
48 |
49 | ### Component Testing with Mocks
50 | ```typescript
51 | describe('FileDetailsPanel', () => {
52 | it('should add model to scene when button clicked', async () => {
53 | const mockEngineService = new Mock3DEngineService();
54 | const mockFile = createMockFileEntry('test.glb');
55 |
56 | render(
57 |
58 |
59 |
60 | );
61 |
62 | // Test implementation
63 | });
64 | });
65 | ```
66 |
67 | ## Test Setup Requirements
68 |
69 | 1. Install testing dependencies:
70 | ```bash
71 | npm install --save-dev @testing-library/react @testing-library/jest-dom
72 | ```
73 |
74 | 2. Create mock services for each interface
75 |
76 | 3. Set up test utilities for common scenarios
77 |
78 | 4. Configure Jest for TypeScript and React
79 |
80 | ## Benefits of New Architecture for Testing
81 |
82 | 1. **Isolation**: Components can be tested without real 3D engine
83 | 2. **Predictability**: Mock services provide consistent behavior
84 | 3. **Coverage**: Business logic in services can be thoroughly tested
85 | 4. **Reliability**: Tests are not affected by external dependencies
86 | 5. **Speed**: Mock implementations are faster than real services
87 |
--------------------------------------------------------------------------------
/src/Core/Runtime/stateMachine.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * StateMachine.ts
3 | * TypeScript finite state machine class with async transformations using promises.
4 | */
5 |
6 | export interface ITransition {
7 | fromState: STATE;
8 | event: EVENT;
9 | toState: STATE;
10 | cb?: (...args: unknown[]) => Promise;
11 | }
12 |
13 | export function t(
14 | fromState: STATE, toState: STATE, event: EVENT,
15 | cb?: (...args: unknown[]) => Promise): ITransition {
16 | return { fromState, event, toState, cb };
17 | }
18 |
19 | export class StateMachine {
20 |
21 | protected current: STATE;
22 |
23 | // initalize the state-machine
24 | constructor(
25 | initState: STATE,
26 | protected transitions: ITransition[] = [],
27 | ) {
28 | this.current = initState;
29 | }
30 |
31 | addTransitions(transitions: ITransition[]): void {
32 | transitions.forEach((tran) => this.transitions.push(tran));
33 | }
34 |
35 | getState(): STATE { return this.current; }
36 |
37 | can(event: EVENT): boolean {
38 | return this.transitions.some((trans) => (trans.fromState === this.current && trans.event === event));
39 | }
40 |
41 | isFinal(): boolean {
42 | // search for a transition that starts from current state.
43 | // if none is found it's a terminal state.
44 | return this.transitions.every((trans) => (trans.fromState !== this.current));
45 | }
46 |
47 | // post event asynch
48 | async dispatch(event: EVENT, ...args: unknown[]): Promise {
49 | return new Promise((resolve, reject) => {
50 |
51 | // delay execution to make it async
52 | setTimeout((me: this) => {
53 |
54 | // find transition
55 | const found = this.transitions.some((tran) => {
56 | if (tran.fromState === me.current && tran.event === event) {
57 | me.current = tran.toState;
58 | if (tran.cb) {
59 | try {
60 | tran.cb(args)
61 | .then(resolve)
62 | .catch(reject);
63 | } catch (e) {
64 | console.error("Exception caught in callback", e);
65 | reject();
66 | }
67 | } else {
68 | resolve();
69 | }
70 | return true;
71 | }
72 | return false;
73 | });
74 |
75 | // no such transition
76 | if (!found) {
77 | console.error(`no transition: from ${(me.current).toString()} event ${(event).toString()}`);
78 | reject();
79 | }
80 | }, 0, this);
81 | });
82 | }
83 | }
--------------------------------------------------------------------------------
/src/Core/SceneSaver.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import Node3d from './ObjectTypes/Node3d';
3 |
4 | // Helper to serialize a Node3d object
5 | function serializeNode3d(node: Node3d): any {
6 | // Helper to get vector3 as object
7 | const vec3 = (v: THREE.Vector3) => ({ x: v.x, y: v.y, z: v.z });
8 | // Helper to get Euler as object
9 | const euler = (e: THREE.Euler) => ({ x: e.x, y: e.y, z: e.z });
10 | // Get custom properties if present
11 | const anyNode = node as any;
12 | const obj: any = {
13 | name: node.name || node.type,
14 | modelName: anyNode.modelName || '',
15 | type: node.type,
16 | noCollider: !!anyNode.noCollider,
17 | animation: anyNode.animation || 'Idle',
18 | position: vec3(node.position),
19 | rotation: euler(node.rotation),
20 | scale: vec3(node.scale),
21 | };
22 | // Behaviors (if present)
23 | if (Array.isArray(anyNode.behaviors) && anyNode.behaviors.length > 0) {
24 | obj.behaviors = anyNode.behaviors.map((b: any) => {
25 | const beh: any = { ...b };
26 | // If checkpoints, ensure correct format
27 | if (Array.isArray(beh.checkpoints)) {
28 | beh.checkpoints = beh.checkpoints.map((cp: any) => ({
29 | position: cp.position ? vec3(cp.position) : undefined,
30 | rotation: cp.rotation ? euler(cp.rotation) : undefined,
31 | }));
32 | }
33 | return beh;
34 | });
35 | }
36 | return obj;
37 | }
38 |
39 | // Recursively collect all Node3d children of the root (not root itself)
40 | export function collectNode3dObjects(root: THREE.Object3D): any[] {
41 | const result: any[] = [];
42 | const traverse = (node: THREE.Object3D) => {
43 | for (const child of node.children) {
44 | if (child instanceof Node3d) {
45 | result.push(serializeNode3d(child));
46 | }
47 | traverse(child);
48 | }
49 | };
50 | traverse(root);
51 | return result;
52 | }
53 |
54 | // Save scene to file (browser)
55 | export async function saveSceneToFile(root: THREE.Object3D) {
56 | try {
57 | // @ts-ignore
58 | if (window.showSaveFilePicker) {
59 | // @ts-ignore
60 | const handle = await window.showSaveFilePicker({
61 | suggestedName: 'scene.json',
62 | types: [
63 | {
64 | description: 'JSON file',
65 | accept: { 'application/json': ['.json'] },
66 | },
67 | ],
68 | });
69 | const writable = await handle.createWritable();
70 | const data = JSON.stringify(collectNode3dObjects(root), null, 2);
71 | await writable.write(data);
72 | await writable.close();
73 | } else {
74 | const data = JSON.stringify(collectNode3dObjects(root), null, 2);
75 | const blob = new Blob([data], { type: 'application/json' });
76 | const url = URL.createObjectURL(blob);
77 | const a = document.createElement('a');
78 | a.href = url;
79 | a.download = 'scene.json';
80 | document.body.appendChild(a);
81 | a.click();
82 | setTimeout(() => {
83 | document.body.removeChild(a);
84 | URL.revokeObjectURL(url);
85 | }, 100);
86 | }
87 | } catch (e) {
88 | alert('Failed to save scene: ' + (e instanceof Error ? e.message : e));
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/FileBrowser.module.css:
--------------------------------------------------------------------------------
1 | .fileBrowser {
2 | display: flex;
3 | height: 33vh;
4 | min-height: 0;
5 | min-width: 0;
6 | overflow: hidden;
7 | background: var(--color-main-background);
8 | gap: 1px;
9 | }
10 |
11 | .sidebar {
12 | width: 280px;
13 | min-width: 0;
14 | border-right: 1px solid var(--color-panels-border);
15 | padding: 0;
16 | background: var(--color-panels-background);
17 | display: flex;
18 | flex-direction: column;
19 | color: var(--color-foreground);
20 | }
21 |
22 | .sidebarTree {
23 | flex: 1;
24 | min-height: 0;
25 | min-width: 0;
26 | overflow: auto;
27 | padding: 8px;
28 | background: var(--color-panels-background);
29 | color: var(--color-foreground);
30 | }
31 |
32 | .contentPanel {
33 | flex: 2;
34 | min-width: 0;
35 | min-height: 0;
36 | padding: 0;
37 | display: flex;
38 | background: var(--color-main-background);
39 | color: var(--color-foreground);
40 | overflow: auto;
41 | padding: 16px;
42 | }
43 |
44 | .breadcrumb {
45 | padding: 16px;
46 | border-bottom: 1px solid var(--color-panels-border);
47 | background: var(--color-main-menu-background);
48 | color: var(--color-foreground);
49 | }
50 |
51 | .fileGrid {
52 | flex: 1;
53 | display: flex;
54 | flex-wrap: wrap;
55 | gap: 8px;
56 | align-content: flex-start;
57 | min-height: min-content;
58 | }
59 |
60 |
61 |
62 | .fileItem {
63 | width: 100px;
64 | height: 130px;
65 | display: flex;
66 | flex-direction: column;
67 | align-items: center;
68 | justify-content: flex-start;
69 | background: var(--color-panels-background);
70 | border-radius: 4px;
71 | border: 1px solid var(--color-panels-border);
72 | box-shadow: 0 1px 2px rgba(0,0,0,0.02);
73 | cursor: pointer;
74 | transition: border 0.2s, box-shadow 0.2s;
75 | padding: 4px 0 0 0;
76 | position: relative;
77 | color: var(--color-foreground);
78 | flex-shrink: 0;
79 | }
80 |
81 | .fileItem:hover {
82 | border: 2px solid var(--color-my-link-highlight);
83 | box-shadow: 0 2px 8px var(--color-selected-item);
84 | z-index: 1;
85 | color: var(--color-foreground);
86 | }
87 |
88 | .fileItemSelected {
89 | border: 2px solid var(--color-my-accent);
90 | background: var(--color-selected-item);
91 | z-index: 2;
92 | color: var(--color-foreground);
93 | }
94 |
95 | .fileDetails {
96 | width: 300px;
97 | min-width: 300px;
98 | padding: 16px;
99 | background: var(--color-panels-background);
100 | border-left: 1px solid var(--color-panels-border);
101 | overflow-y: auto;
102 | color: var(--color-foreground);
103 | }
104 |
105 | .fileItemTitle {
106 | font-size: 14px;
107 | font-weight: 500;
108 | display: block;
109 | text-align: center;
110 | margin: 2px 2px 0 2px;
111 | overflow: hidden;
112 | text-overflow: ellipsis;
113 | white-space: nowrap;
114 | width: 92px;
115 | color: var(--color-foreground);
116 | }
117 |
118 | .fileItemDesc {
119 | font-size: 12px;
120 | color: var(--color-foreground);
121 | opacity: 0.5;
122 | text-align: center;
123 | margin: 0 2px 2px 2px;
124 | overflow: hidden;
125 | text-overflow: ellipsis;
126 | white-space: nowrap;
127 | width: 92px;
128 | }
129 |
130 | .assetDesc {
131 | font-size: 12px;
132 | margin-top: 0;
133 | margin-bottom: 4px;
134 | color: var(--color-foreground);
135 | opacity: 0.7;
136 | }
137 |
138 | .fileDetails {
139 | border-top: 1px solid var(--color-panels-border);
140 | padding: 16px;
141 | min-height: 120px;
142 | background: var(--color-inner-panel-background);
143 | color: var(--color-foreground);
144 | opacity: 0.9;
145 | }
146 |
--------------------------------------------------------------------------------
/src/utils/idbHelper.ts:
--------------------------------------------------------------------------------
1 | const DB_NAME = 'Pix3DProjectDB';
2 | const STORE_NAME = 'projectHandles';
3 |
4 | let db: IDBDatabase | null = null;
5 |
6 | /**
7 | * Opens and returns the IndexedDB database instance.
8 | * Initializes the object store if it doesn't exist.
9 | */
10 | async function openDB(): Promise {
11 | if (db) return db;
12 |
13 | return new Promise((resolve, reject) => {
14 | // Open IndexedDB with version 1
15 | const request = indexedDB.open(DB_NAME, 1);
16 |
17 | request.onupgradeneeded = (event) => {
18 | // This event fires if the database is created or a new version is requested
19 | const database = (event.target as IDBOpenDBRequest).result;
20 | // Create an object store to hold our directory handles
21 | // 'id' will be the key to retrieve handles later
22 | database.createObjectStore(STORE_NAME, { keyPath: 'id' });
23 | };
24 |
25 | request.onsuccess = (event) => {
26 | db = (event.target as IDBOpenDBRequest).result;
27 | resolve(db);
28 | };
29 |
30 | request.onerror = (event) => {
31 | console.error('IndexedDB error:', (event.target as IDBRequest).error);
32 | reject('Error opening IndexedDB: ' + (event.target as IDBRequest).error);
33 | };
34 | });
35 | }
36 |
37 | /**
38 | * Saves a FileSystemDirectoryHandle to IndexedDB.
39 | * @param handle The FileSystemDirectoryHandle to save.
40 | * @returns A Promise that resolves with the unique key used to store the handle.
41 | */
42 | export async function saveHandleToIDB(handle: FileSystemDirectoryHandle): Promise {
43 | const database = await openDB();
44 | const transaction = database.transaction(STORE_NAME, 'readwrite');
45 | const store = transaction.objectStore(STORE_NAME);
46 |
47 | // Create a unique ID for this handle entry
48 | const id = handle.name + '-' + new Date().getTime();
49 | // Put the handle into the object store with its ID
50 | const request = store.put({ id: id, handle: handle });
51 |
52 | return new Promise((resolve, reject) => {
53 | request.onsuccess = () => resolve(id);
54 | request.onerror = (event) => reject('Error saving handle to IDB: ' + (event.target as IDBRequest).error);
55 | });
56 | }
57 |
58 | /**
59 | * Retrieves a FileSystemDirectoryHandle from IndexedDB using its key.
60 | * @param id The key of the handle to retrieve.
61 | * @returns A Promise that resolves with the FileSystemDirectoryHandle or null if not found.
62 | */
63 | export async function getHandleFromIDB(id: IDBValidKey): Promise {
64 | const database = await openDB();
65 | const transaction = database.transaction(STORE_NAME, 'readonly');
66 | const store = transaction.objectStore(STORE_NAME);
67 |
68 | const request = store.get(id);
69 |
70 | return new Promise((resolve, reject) => {
71 | request.onsuccess = (event) => {
72 | const result = (event.target as IDBRequest).result;
73 | resolve(result ? result.handle : null);
74 | };
75 | request.onerror = (event) => reject('Error getting handle from IDB: ' + (event.target as IDBRequest).error);
76 | });
77 | }
78 |
79 | /**
80 | * Deletes a FileSystemDirectoryHandle entry from IndexedDB.
81 | * @param id The key of the handle to delete.
82 | * @returns A Promise that resolves when the deletion is complete.
83 | */
84 | export async function deleteHandleFromIDB(id: IDBValidKey): Promise {
85 | const database = await openDB();
86 | const transaction = database.transaction(STORE_NAME, 'readwrite');
87 | const store = transaction.objectStore(STORE_NAME);
88 |
89 | const request = store.delete(id);
90 |
91 | return new Promise((resolve, reject) => {
92 | request.onsuccess = () => resolve();
93 | request.onerror = (event) => reject('Error deleting handle from IDB: ' + (event.target as IDBRequest).error);
94 | });
95 | }
96 |
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/Sine.ts:
--------------------------------------------------------------------------------
1 | import { Behavior } from './Behavior';
2 | import { Object3D, Sprite, SpriteMaterial } from 'three';
3 |
4 | export enum SineType {
5 | HORIZONTAL,
6 | VERTICAL,
7 | SCALE,
8 | OPACITY
9 | }
10 |
11 | export enum WaveType {
12 | SINE,
13 | SAWTOOTH,
14 | TRIANGLE,
15 | SQUARE
16 | }
17 |
18 | export class Sine extends Behavior {
19 | amplitude: number;
20 | period: number;
21 | phase: number;
22 | value: number;
23 | type: SineType;
24 | wave: WaveType;
25 | initialX: number = 0;
26 | initialY: number = 0;
27 | initialScaleX: number = 1;
28 | initialScaleY: number = 1;
29 | initialScaleZ: number = 1;
30 | initialOpacity: number = 1;
31 |
32 | constructor(period: number, amplitude: number, offset: number, type: SineType, wave: WaveType = WaveType.SINE) {
33 | super();
34 |
35 | this.type = type;
36 | this.wave = wave;
37 | this.amplitude = amplitude;
38 | this.period = period;
39 | this.phase = offset;
40 | this.value = 0;
41 | }
42 |
43 | onAttach(): void {
44 | this.resetInitialValues();
45 | }
46 |
47 | public resetInitialValues(): void {
48 | this.initialX = this.object3d.position.x;
49 | this.initialY = this.object3d.position.y;
50 | this.initialScaleX = this.object3d.scale.x;
51 | this.initialScaleY = this.object3d.scale.y;
52 | this.initialScaleZ = this.object3d.scale.z;
53 |
54 | if (this.object3d instanceof Sprite) {
55 | const material = this.object3d.material as SpriteMaterial;
56 | this.initialOpacity = material.opacity;
57 | }
58 | }
59 |
60 | static parseSineType(sineType: string) {
61 | return SineType[sineType as keyof typeof SineType];
62 | }
63 |
64 | static parseWaveType(waveType: string) {
65 | return WaveType[waveType as keyof typeof WaveType];
66 | }
67 |
68 | calculateWaveValue(): number {
69 | const t = (this.phase / this.period) % (2 * Math.PI);
70 | switch (this.wave) {
71 | case WaveType.SINE:
72 | return this.amplitude * Math.sin(t);
73 | case WaveType.SAWTOOTH:
74 | return this.amplitude * (2 * (t / (2 * Math.PI)) - 1);
75 | case WaveType.TRIANGLE:
76 | return this.amplitude * (2 * Math.abs(2 * (t / (2 * Math.PI)) - 1) - 1);
77 | case WaveType.SQUARE:
78 | return this.amplitude * (Math.sign(Math.sin(t)));
79 | default:
80 | return 0;
81 | }
82 | }
83 |
84 | onTick(dt: any): void {
85 | if (!this.isEnabled) return;
86 |
87 | this.phase += dt;
88 | this.value = this.calculateWaveValue();
89 |
90 | switch (this.type) {
91 | case SineType.HORIZONTAL:
92 | this.object3d.position.x = this.value + this.initialX;
93 | break;
94 | case SineType.VERTICAL:
95 | this.object3d.position.y = this.value + this.initialY;
96 | break;
97 | case SineType.SCALE:
98 | const newScaleX = this.initialScaleX + this.value;
99 | const newScaleY = this.initialScaleY + this.value;
100 | const newScaleZ = this.initialScaleZ + this.value;
101 | if (this.object3d instanceof Sprite) {
102 | this.object3d.scale.set(newScaleX, newScaleY, 1);
103 | } else {
104 | this.object3d.scale.set(newScaleX, newScaleY, newScaleZ);
105 | }
106 | break;
107 | case SineType.OPACITY:
108 | if (this.object3d instanceof Sprite) {
109 | const material = this.object3d.material as SpriteMaterial;
110 | const newOpacity = Math.max(0, Math.min(1, this.initialOpacity + this.value));
111 | material.opacity = newOpacity;
112 | }
113 | break;
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/Destructable.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from "./Behavior";
3 |
4 | export interface DestructableConfig {
5 | maxHealth: number;
6 | isDestroyOnDeath?: boolean;
7 | onHitEffect?: () => void;
8 | onDeathEffect?: () => void;
9 | }
10 |
11 | export class Destructable extends Behavior {
12 | private health: number = 100;
13 | private maxHealth: number = 100;
14 | private isDestroyed: boolean;
15 | private isDestroyOnDeath: boolean;
16 | private onHitEffect?: () => void;
17 | private onDeathEffect?: () => void;
18 | private onDeathCallback?: Function;
19 | private deathResolver?: (value: void | PromiseLike) => void;
20 |
21 | constructor(config: DestructableConfig) {
22 | super();
23 | this.maxHealth = config.maxHealth ?? 100;
24 | this.health = this.maxHealth ?? 100;
25 | this.isDestroyed = false;
26 | this.isDestroyOnDeath = config.isDestroyOnDeath ?? false;
27 | this.onHitEffect = config.onHitEffect;
28 | this.onDeathEffect = config.onDeathEffect;
29 | }
30 |
31 | // Register a hit with specific damage
32 | hit(damage: number): void {
33 | if (this.isDestroyed || !this.isEnabled) return;
34 |
35 | this.health = Math.max(0, this.health - damage);
36 |
37 | if (this.onHitEffect) {
38 | this.onHitEffect();
39 | }
40 |
41 | if (this.health <= 0) {
42 | this.die();
43 | }
44 | }
45 |
46 | // Heal the object
47 | heal(amount: number): void {
48 | if (this.isDestroyed || !this.isEnabled) return;
49 | this.health = Math.min(this.maxHealth, this.health + amount);
50 | }
51 |
52 | // Get current health as a percentage (0-1)
53 | getHealthPercent(): number {
54 | return this.health / this.maxHealth;
55 | }
56 |
57 | // Get current health value
58 | getHealth(): number {
59 | return this.health;
60 | }
61 |
62 | // Check if object is destroyed
63 | isObjectDestroyed(): boolean {
64 | return this.isDestroyed;
65 | }
66 |
67 | // Register death event handler
68 | onDeath(callback: Function): void {
69 | this.onDeathCallback = callback;
70 | }
71 |
72 | // Promise-based death handling
73 | waitForDeath(): Promise {
74 | return new Promise(resolve => {
75 | if (this.isDestroyed) {
76 | resolve();
77 | } else {
78 | this.deathResolver = resolve;
79 | }
80 | });
81 | }
82 |
83 | // Handle death
84 | private die(): void {
85 | if (this.isDestroyed) return;
86 |
87 | this.isDestroyed = true;
88 |
89 | if (this.onDeathEffect) {
90 | this.onDeathEffect();
91 | }
92 |
93 | if (this.onDeathCallback) {
94 | this.onDeathCallback();
95 | }
96 |
97 | if (this.deathResolver) {
98 | this.deathResolver();
99 | }
100 |
101 | if (this.isDestroyOnDeath && this.object3d) {
102 | // Remove from parent if it exists
103 | if (this.object3d.parent) {
104 | this.object3d.parent.remove(this.object3d);
105 | }
106 | // Clean up geometry and materials
107 | if (this.object3d instanceof THREE.Mesh) {
108 | if (this.object3d.geometry) {
109 | this.object3d.geometry.dispose();
110 | }
111 | if (this.object3d.material) {
112 | if (Array.isArray(this.object3d.material)) {
113 | this.object3d.material.forEach(material => material.dispose());
114 | } else {
115 | this.object3d.material.dispose();
116 | }
117 | }
118 | }
119 | }
120 | }
121 |
122 | // Reset the object to initial state
123 | reset(): void {
124 | this.health = this.maxHealth;
125 | this.isDestroyed = false;
126 | }
127 |
128 | // Optional: Implement onTick if you need continuous health effects (like regeneration or damage over time)
129 | onTick(dt: number): void {
130 | // Example: Add regeneration or damage over time effects here
131 | }
132 | }
--------------------------------------------------------------------------------
/src/Core/Behaviors/HitEffectBehavior.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from './Behavior';
3 |
4 | export interface HitEffectOptions {
5 | duration?: number; // Duration of the blink in milliseconds
6 | intensity?: number; // How intense the red color should be (0-1)
7 | }
8 |
9 | export class HitEffectBehavior extends Behavior {
10 | private options: HitEffectOptions;
11 | private startTime: number | null = null;
12 | private originalMaterials: THREE.Material[] = [];
13 | private instanceMaterials: THREE.Material[] = [];
14 | private isActive = false;
15 |
16 | constructor(options: HitEffectOptions = {}) {
17 | super();
18 | this.options = {
19 | duration: 200,
20 | intensity: 0.8,
21 | ...options
22 | };
23 | }
24 |
25 | onAttach(): void {
26 | this.createUniqueInstanceMaterials();
27 | }
28 |
29 | onTick(dt: number): void {
30 | if (!this.isEnabled || !this.isActive || !this.startTime) return;
31 |
32 | const elapsed = Date.now() - this.startTime;
33 | const progress = Math.min(elapsed / this.options.duration!, 1);
34 |
35 | // Create a flash effect that peaks in the middle
36 | const intensity = this.options.intensity! * Math.sin(progress * Math.PI);
37 |
38 | this.updateMaterials(intensity);
39 |
40 | if (progress >= 1) {
41 | this.reset();
42 | }
43 | }
44 |
45 | private createUniqueInstanceMaterials(): void {
46 | this.originalMaterials = [];
47 | this.instanceMaterials = [];
48 |
49 | this.object3d.traverse((child: THREE.Object3D) => {
50 | if ('material' in child) {
51 | const materials = Array.isArray(child.material)
52 | ? child.material
53 | : [child.material];
54 |
55 | const newMaterials: THREE.Material[] = [];
56 |
57 | materials.forEach((material) => {
58 | // Store original material reference
59 | this.originalMaterials.push(material);
60 |
61 | // Create a unique instance for this object
62 | const instanceMaterial = material.clone();
63 | this.instanceMaterials.push(instanceMaterial);
64 | newMaterials.push(instanceMaterial);
65 | });
66 |
67 | // Assign unique material instances to this object
68 | if ('material' in child) {
69 | if (Array.isArray(child.material)) {
70 | child.material = newMaterials;
71 | } else {
72 | child.material = newMaterials[0];
73 | }
74 | }
75 | }
76 | });
77 | }
78 |
79 | private updateMaterials(intensity: number): void {
80 | this.instanceMaterials.forEach((material) => {
81 | if (material instanceof THREE.MeshStandardMaterial) {
82 | material.emissive.setRGB(intensity, 0, 0);
83 | }
84 | });
85 | }
86 |
87 | private restoreOriginalMaterials(): void {
88 | this.instanceMaterials.forEach((material, index) => {
89 | if (material instanceof THREE.MeshStandardMaterial) {
90 | const originalMaterial = this.originalMaterials[index];
91 | material.emissive.copy((originalMaterial as THREE.MeshStandardMaterial).emissive);
92 | }
93 | });
94 | }
95 |
96 | blink(): void {
97 | this.startTime = Date.now();
98 | this.isActive = true;
99 | }
100 |
101 | private reset(): void {
102 | this.isActive = false;
103 | this.startTime = null;
104 | this.restoreOriginalMaterials();
105 | }
106 |
107 | disable(): void {
108 | super.disable();
109 | this.reset();
110 |
111 | // Restore original materials when disabled
112 | this.object3d.traverse((child: THREE.Object3D) => {
113 | if ('material' in child) {
114 | if (Array.isArray(child.material)) {
115 | child.material = [...this.originalMaterials];
116 | } else {
117 | child.material = this.originalMaterials[0];
118 | }
119 | }
120 | });
121 | }
122 | }
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/GameObject.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from '../Behaviors/Behavior';
3 | import { Transform } from '../Runtime';
4 | import { GameObjectCollider } from './GameObjectCollider';
5 | import Node3d from './Node3d';
6 |
7 | export default class GameObject extends Node3d {
8 | public type: string = "GameObject";
9 |
10 | animationAction: THREE.AnimationAction | undefined;
11 | mixer: THREE.AnimationMixer | undefined;
12 | model: THREE.Object3D;
13 | collider: GameObjectCollider | undefined;
14 | animations: THREE.AnimationClip[] = [];
15 | currentAnimationName: string | undefined;
16 | modelName: string | undefined;
17 | private onAnimationComplete: (() => void) | undefined;
18 |
19 | constructor(model: THREE.Object3D, transform: Transform, Animations: THREE.AnimationClip[]) {
20 | super();
21 | this.model = model;
22 |
23 | this.add(this.model);
24 | // Ensure all meshes are visible and scale is 1
25 | this.traverse(obj => {
26 | if ((obj as THREE.Mesh).isMesh) {
27 | obj.visible = true;
28 | }
29 | });
30 | this.scale.set(1, 1, 1);
31 |
32 | this.mixer = new THREE.AnimationMixer(model);
33 | this.animations = Animations;
34 |
35 | this.setTransform(transform);
36 | console.log(this);
37 | }
38 |
39 | update(dt: number) {
40 | this.mixer?.update(dt);
41 | }
42 |
43 | playAnimation(animName: string, loop: THREE.AnimationActionLoopStyles = THREE.LoopRepeat, onComplete?: () => void) {
44 | const oldAction = this.animationAction;
45 | if (this.mixer == undefined)
46 | return;
47 |
48 | // Clear any existing animation complete handlers
49 | if (this.onAnimationComplete) {
50 | this.mixer.removeEventListener('finished', this.onAnimationComplete);
51 | this.onAnimationComplete = undefined;
52 | }
53 |
54 | this.mixer.timeScale = 1;
55 |
56 | const anim = this.animations.find(x => x.name.toLocaleLowerCase() == animName.toLocaleLowerCase());
57 | if (anim == undefined)
58 | return;
59 |
60 | oldAction?.fadeOut(0.2);
61 |
62 | const action = this.mixer.clipAction(anim);
63 | action.loop = loop;
64 | action.clampWhenFinished = true;
65 | action.play();
66 |
67 | oldAction?.reset();
68 | if (action !== oldAction)
69 | oldAction?.stop();
70 |
71 | this.animationAction = action;
72 | this.currentAnimationName = animName;
73 |
74 | // If this is a OneShot animation and we have a completion callback
75 | if (loop === THREE.LoopOnce && onComplete) {
76 | this.onAnimationComplete = () => {
77 | onComplete();
78 | // Clear the handler after it's called
79 | if (this.onAnimationComplete) {
80 | this.mixer?.removeEventListener('finished', this.onAnimationComplete);
81 | this.onAnimationComplete = undefined;
82 | }
83 | };
84 |
85 | // Add the event listener to the mixer
86 | this.mixer.addEventListener('finished', this.onAnimationComplete);
87 | }
88 | }
89 |
90 | setCurrentAnimationTime(time: number) {
91 | if (this.animationAction)
92 | this.animationAction.time = time;
93 | }
94 |
95 | getCurrentAnimationTime = () => this.animationAction?.time ?? 0;
96 |
97 | getAnimationDuration(animationName: string) {
98 | const anim = this.animations.find(x => x.name.toLocaleLowerCase() == animationName.toLocaleLowerCase());
99 | return anim?.duration ?? 0;
100 | }
101 |
102 | pauseAnimation() {
103 | if (this.mixer)
104 | this.mixer.timeScale = 0;
105 | }
106 |
107 | setCollider(collider: GameObjectCollider) {
108 | this.collider = collider;
109 | this.add(collider);
110 | collider.gameObject = this;
111 | }
112 | setBehaviors(behaviors: Behavior[]) {
113 | this.behaviors = behaviors;
114 | behaviors.forEach(b => b.object3d = this);
115 | }
116 |
117 | removeCollider() {
118 | if(this.collider)
119 | this.remove(this.collider);
120 | }
121 |
122 | getMeshByName(name: string) {
123 | const rootModel = this.model;
124 | return rootModel.getObjectByName(name);
125 | }
126 | }
127 |
128 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/SceneEditor.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect, useState } from 'react';
2 | import { Layout } from 'antd';
3 | import ResizableDivider from './ResizableDivider';
4 | import * as THREE from 'three';
5 | // import { TransformToolbar } from './TransformToolbar';
6 | import Viewport from './Viewport';
7 | import { SceneTree } from './SceneTree';
8 | import styles from './SceneEditor.module.css';
9 | import { EditMode } from './EditMode';
10 | import SceneManager from '../../../Core/SceneManager';
11 | import { ObjectInspector } from './ObjectInspector';
12 |
13 | const SceneEditor: React.FC = () => {
14 | // const mountRef = useRef(null);
15 | const editModeRef = useRef(null);
16 |
17 | const [selectedObject, setSelectedObject] = useState(SceneManager.instance.selected);
18 | const [scene, setScene] = useState(SceneManager.instance.scene);
19 | const [transformMode, setTransformMode] = useState<'translate' | 'rotate' | 'scale'>('translate');
20 | const [sceneReady, setSceneReady] = useState(false);
21 | const [, forceUpdate] = useState(0); // for transform updates
22 |
23 | useEffect(() => {
24 | // Subscribe to SceneManager updates
25 | const unsubScene = SceneManager.instance.subscribeScene(setScene);
26 | const unsubSel = SceneManager.instance.subscribeSelection(setSelectedObject);
27 | const unsubTransform = SceneManager.instance.subscribeTransform(() => forceUpdate(n => n + 1));
28 |
29 | // Key handler for Delete key
30 | const handleKeyDown = (e: KeyboardEvent) => {
31 | if (e.key === 'Delete' && selectedObject) {
32 | e.preventDefault();
33 | if (window.confirm('Are you sure you want to delete the selected object?')) {
34 | SceneManager.instance.deleteObject(selectedObject);
35 | }
36 | }
37 | };
38 | window.addEventListener('keydown', handleKeyDown);
39 |
40 | return () => {
41 | unsubScene();
42 | unsubSel();
43 | unsubTransform();
44 | window.removeEventListener('keydown', handleKeyDown);
45 | };
46 | }, [selectedObject]);
47 |
48 | // ...existing code...
49 |
50 | // ...existing code...
51 |
52 | const handleSelectObject = (obj: THREE.Object3D | null) => {
53 | editModeRef.current?.selectObject(obj);
54 | SceneManager.instance.setSelected(obj);
55 | }
56 |
57 | // State for resizable columns
58 | const [leftWidth, setLeftWidth] = useState(240);
59 | const [rightWidth, setRightWidth] = useState(320);
60 | const minLeft = 120, maxLeft = 400, minRight = 180, maxRight = 480;
61 |
62 | const handleLeftResize = (dx: number) => {
63 | setLeftWidth(w => Math.max(minLeft, Math.min(maxLeft, w + dx)));
64 | };
65 | const handleRightResize = (dx: number) => {
66 | setRightWidth(w => Math.max(minRight, Math.min(maxRight, w - dx)));
67 | };
68 |
69 | return (
70 |
71 |
75 | {sceneReady && scene && (
76 |
81 | )}
82 |
83 |
84 |
85 |
92 |
93 |
94 |
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | export default SceneEditor;
105 |
--------------------------------------------------------------------------------
/src/Core/ObjectTypes/TextSprite.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from '../Behaviors/Behavior';
3 |
4 | export type TextAlignment = 'left' | 'center' | 'right';
5 |
6 | interface TextSpriteOptions {
7 | fontface?: string;
8 | fontsize?: number;
9 | borderThickness?: number;
10 | borderColor?: { r: number; g: number; b: number; a: number };
11 | backgroundColor?: { r: number; g: number; b: number; a: number };
12 | textColor?: string;
13 | align?: TextAlignment;
14 | lineHeight?: number;
15 | }
16 |
17 | export class TextSprite extends THREE.Sprite {
18 | canvas: HTMLCanvasElement;
19 | context: CanvasRenderingContext2D;
20 | fontsize: number;
21 | borderThickness: number;
22 | spriteMaterial: THREE.SpriteMaterial;
23 | fadeOut: Behavior;
24 | moveUp: Behavior;
25 | align: TextAlignment;
26 | lineHeight: number;
27 | fontface: string;
28 | textColor: string;
29 |
30 | constructor(text: string, options: TextSpriteOptions = {}) {
31 | super();
32 |
33 | this.canvas = document.createElement('canvas');
34 | this.context = this.canvas.getContext('2d')!;
35 |
36 | // Store these for reuse in updateText
37 | this.fontface = options.fontface ?? "Arial";
38 | this.fontsize = (options.fontsize ?? 18) * 1.5;
39 | this.borderThickness = options.borderThickness ?? 4;
40 | this.textColor = options.textColor ?? "#ffffff";
41 | this.align = options.align ?? 'center';
42 | this.lineHeight = options.lineHeight ?? 1.2;
43 |
44 | // Set initial text and create sprite
45 | this.updateText(text);
46 |
47 | // Adjust sprite center based on alignment
48 | switch (this.align) {
49 | case 'left':
50 | this.center.set(0, 1);
51 | break;
52 | case 'right':
53 | this.center.set(1, 1);
54 | break;
55 | case 'center':
56 | default:
57 | this.center.set(0.5, 1);
58 | break;
59 | }
60 | }
61 |
62 | updateText(text: string) {
63 | // Reset font before measuring text
64 | this.context.font = `Bold ${this.fontsize}px ${this.fontface}`;
65 |
66 | // Split text and calculate dimensions
67 | const lines = text.split('\n');
68 | const metrics = lines.map(line => this.context.measureText(line));
69 | const textWidth = Math.max(...metrics.map(m => m.width));
70 | const textHeight = this.fontsize * lines.length * this.lineHeight;
71 |
72 | // Update canvas size with padding
73 | const padding = this.borderThickness * 2;
74 | this.canvas.width = textWidth + padding;
75 | this.canvas.height = textHeight + padding;
76 |
77 | // Reset context properties after canvas resize
78 | this.context.font = `Bold ${this.fontsize}px ${this.fontface}`;
79 | this.context.fillStyle = this.textColor;
80 | this.context.textBaseline = 'middle';
81 | this.context.textAlign = this.align;
82 |
83 | // Calculate base Y position for the first line
84 | const lineHeight = this.fontsize * this.lineHeight;
85 | const totalHeight = lines.length * lineHeight;
86 | let startY = (this.canvas.height / 2) - (totalHeight / 2) + (lineHeight / 2);
87 |
88 | // Draw each line
89 | lines.forEach((line, index) => {
90 | let x: number;
91 | switch (this.align) {
92 | case 'left':
93 | x = padding;
94 | break;
95 | case 'right':
96 | x = this.canvas.width - padding;
97 | break;
98 | case 'center':
99 | default:
100 | x = this.canvas.width / 2;
101 | break;
102 | }
103 |
104 | const y = startY + (index * lineHeight);
105 | this.context.fillText(line, x, y);
106 | });
107 |
108 | // Create or update texture
109 | const texture = new THREE.Texture(this.canvas);
110 | texture.needsUpdate = true;
111 | texture.minFilter = THREE.LinearFilter;
112 | texture.magFilter = THREE.LinearFilter;
113 |
114 | // Update or create material
115 | if (!this.spriteMaterial) {
116 | this.spriteMaterial = new THREE.SpriteMaterial({
117 | map: texture,
118 | transparent: true,
119 | });
120 | this.material = this.spriteMaterial;
121 | } else {
122 | this.spriteMaterial.map = texture;
123 | }
124 |
125 | // Set base scale (without DPI consideration)
126 | const textAspect = this.canvas.width / this.canvas.height;
127 | this.scale.set(
128 | this.fontsize * textAspect,
129 | this.fontsize,
130 | 1
131 | );
132 | }
133 | }
--------------------------------------------------------------------------------
/src/Core/Runtime/AssetsRegistry.ts:
--------------------------------------------------------------------------------
1 | // AssetRegistry.ts
2 | import { GLTF } from 'three/addons/loaders/GLTFLoader.js';
3 | import { Howl } from 'howler';
4 | import * as THREE from 'three'; // Для THREE.Texture
5 |
6 | // Определяем типы для категорий ассетов
7 | interface AssetCategories {
8 | textures: { [key: string]: string | THREE.Texture }; // Изначально URL, после загрузки THREE.Texture
9 | models: { [key: string]: string | GLTF }; // Изначально URL, после загрузки GLTF
10 | sounds: { [key: string]: string | Howl }; // Изначально URL, после загрузки Howl
11 | scenes: { [key: string]: string | any }; // Изначально URL, после загрузки JSON
12 | [key: string]: { [key: string]: string | any }; // Для других динамических категорий
13 | }
14 |
15 | class AssetRegistry {
16 | private static instance: AssetRegistry;
17 |
18 | // Внутренние хранилища для ассетов.
19 | // Обратите внимание: здесь мы будем хранить как URL, так и загруженные объекты.
20 | // AssetLoader будет использовать эти URL, а затем при необходимости обновлять их
21 | // на загруженные объекты, если мы захотим кэшировать их здесь.
22 | // Пока что, для сохранения логики AssetLoader, мы будем хранить только URL.
23 | // Загруженные объекты будут храниться непосредственно в AssetLoader.
24 | public assets: AssetCategories = {
25 | textures: {},
26 | models: {},
27 | sounds: {},
28 | scenes: {},
29 | };
30 |
31 | private constructor() {
32 | // Приватный конструктор для реализации синглтона
33 | }
34 |
35 | public static getInstance(): AssetRegistry {
36 | if (!AssetRegistry.instance) {
37 | AssetRegistry.instance = new AssetRegistry();
38 | }
39 | return AssetRegistry.instance;
40 | }
41 |
42 | /**
43 | * Добавляет или обновляет ресурс в реестре.
44 | * @param category Категория ресурса (e.g., 'textures', 'models', 'sounds', 'scenes').
45 | * @param key Уникальный ключ ресурса (e.g., 'logo_png', 'robot_glb').
46 | * @param url URL ресурса (может быть data URI).
47 | */
48 | public addAsset(category: keyof AssetCategories, key: string, url: string): void {
49 | if (!this.assets[category]) {
50 | this.assets[category] = {};
51 | }
52 | (this.assets[category] as { [key: string]: string })[key] = url;
53 | console.log(`Added asset: Category: ${category}, Key: ${key}, URL: ${url}`);
54 | }
55 |
56 | /**
57 | * Удаляет ресурс из реестра.
58 | * @param category Категория ресурса.
59 | * @param key Ключ ресурса.
60 | */
61 | public removeAsset(category: keyof AssetCategories, key: string): void {
62 | if (this.assets[category] && (this.assets[category] as { [key: string]: string })[key]) {
63 | delete (this.assets[category] as { [key: string]: string })[key];
64 | console.log(`Removed asset: Category: ${category}, Key: ${key}`);
65 | } else {
66 | console.warn(`Asset not found for removal: Category: ${category}, Key: ${key}`);
67 | }
68 | }
69 |
70 | /**
71 | * Очищает всю категорию ассетов.
72 | * @param category Категория для очистки.
73 | */
74 | public clearCategory(category: keyof AssetCategories): void {
75 | if (this.assets[category]) {
76 | this.assets[category] = {};
77 | console.log(`Cleared category: ${category}`);
78 | } else {
79 | console.warn(`Category not found for clearing: ${category}`);
80 | }
81 | }
82 |
83 | /**
84 | * Возвращает текущий реестр ассетов.
85 | * Этот метод будет использоваться AssetLoader.
86 | */
87 | public getRegistry(): AssetCategories {
88 | return this.assets;
89 | }
90 |
91 | /**
92 | * Debug helper to log all registered assets
93 | */
94 | public logRegisteredAssets(): void {
95 | console.group('Asset Registry Contents:');
96 | Object.keys(this.assets).forEach(category => {
97 | const items = Object.keys(this.assets[category]);
98 | console.log(`${category}: [${items.join(', ')}]`);
99 | });
100 | console.groupEnd();
101 | }
102 | }
103 |
104 | // Экспортируем единственный экземпляр реестра ассетов
105 | export const assetRegistry = AssetRegistry.getInstance();
106 |
107 | // Пример начального заполнения реестра (как ваш старый assets.js)
108 | // Это можно удалить или модифицировать в вашем редакторе, если все ассеты загружаются динамически.
109 | /*
110 | assetRegistry.addAsset('textures', 'logo_png', './assets/textures/logo.png?url');
111 | assetRegistry.addAsset('textures', 'play_png', './assets/textures/PLAY.png?url');
112 | assetRegistry.addAsset('models', 'scene_glb', './assets/models/Scene.glb?url');
113 | assetRegistry.addAsset('models', 'robot_glb', './assets/models/Robot.glb.zip?url');
114 | assetRegistry.addAsset('sounds', 'music_sound', './assets/sounds/music.mp3?url');
115 | assetRegistry.addAsset('scenes', 'levelData', './assets/scenes/level.json?url');
116 | assetRegistry.addAsset('scenes', 'uiLayerData', './assets/scenes/ui.json?url');
117 | */
--------------------------------------------------------------------------------
/src/Core/Behaviors/Follow.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Behavior } from "./Behavior";
3 |
4 | export class Follow extends Behavior {
5 | target: THREE.Object3D;
6 | offset: THREE.Vector3;
7 | yRotationOffset: number; // Changed to store only Y rotation
8 |
9 | positionLerp: number;
10 | rotationLerp: number;
11 |
12 | constructor(target: THREE.Object3D, options?: {
13 | offset?: THREE.Vector3,
14 | yRotationOffset?: number,
15 | positionLerp?: number,
16 | rotationLerp?: number
17 | }) {
18 | super();
19 |
20 | this.target = target;
21 | this.offset = options?.offset ?? new THREE.Vector3(0, 0, 0);
22 | this.yRotationOffset = options?.yRotationOffset ?? 0;
23 | this.positionLerp = options?.positionLerp ?? 0.1;
24 | this.rotationLerp = options?.rotationLerp ?? 0.1;
25 | }
26 |
27 | updateOffsetsFromCurrentState() {
28 | if (!this.target || !this.object3d) {
29 | console.warn('Cannot update offsets: target or object3d is not set');
30 | return;
31 | }
32 |
33 | // Get world positions
34 | const targetWorldPos = new THREE.Vector3();
35 | const targetWorldQuat = new THREE.Quaternion();
36 | this.target.getWorldPosition(targetWorldPos);
37 | this.target.getWorldQuaternion(targetWorldQuat);
38 |
39 | const objectWorldPos = new THREE.Vector3();
40 | this.object3d.getWorldPosition(objectWorldPos);
41 |
42 | // Calculate position offset in target's local space
43 | const worldOffset = objectWorldPos.clone().sub(targetWorldPos);
44 | const targetWorldQuatY = new THREE.Quaternion();
45 |
46 | // Extract only Y rotation from target's quaternion
47 | const targetEuler = new THREE.Euler().setFromQuaternion(targetWorldQuat);
48 | targetWorldQuatY.setFromEuler(new THREE.Euler(0, targetEuler.y, 0));
49 |
50 | const targetWorldQuatYInverse = targetWorldQuatY.clone().invert();
51 | this.offset = worldOffset.applyQuaternion(targetWorldQuatYInverse);
52 |
53 | // Calculate Y rotation offset
54 | const targetY = targetEuler.y;
55 | const objectY = new THREE.Euler().setFromQuaternion(
56 | new THREE.Quaternion().setFromEuler(this.object3d.rotation)
57 | ).y;
58 |
59 | // Calculate the difference in Y rotation
60 | this.yRotationOffset = objectY - targetY;
61 |
62 | // Normalize the rotation to -PI to PI range
63 | this.yRotationOffset = ((this.yRotationOffset + Math.PI) % (Math.PI * 2)) - Math.PI;
64 | }
65 |
66 | onTick(dt: number): void {
67 | if (!this.isEnabled || !this.target)
68 | return;
69 |
70 | // Get target's world transform
71 | const targetWorldPos = new THREE.Vector3();
72 | const targetWorldQuat = new THREE.Quaternion();
73 |
74 | this.target.getWorldPosition(targetWorldPos);
75 | this.target.getWorldQuaternion(targetWorldQuat);
76 |
77 | // Extract only Y rotation from target
78 | const targetEuler = new THREE.Euler().setFromQuaternion(targetWorldQuat);
79 | const targetWorldQuatY = new THREE.Quaternion();
80 | targetWorldQuatY.setFromEuler(new THREE.Euler(0, targetEuler.y, 0));
81 |
82 | // Apply offset in target's local space (considering only Y rotation)
83 | const offsetInWorld = this.offset.clone()
84 | .applyQuaternion(targetWorldQuatY);
85 |
86 | const targetPosition = targetWorldPos.clone().add(offsetInWorld);
87 |
88 | // Smoothly interpolate position
89 | this.object3d.position.lerp(targetPosition, this.positionLerp);
90 |
91 | // Calculate target Y rotation
92 | const targetYRotation = targetEuler.y + this.yRotationOffset;
93 |
94 | // Get current Y rotation
95 | const currentYRotation = new THREE.Euler().setFromQuaternion(
96 | new THREE.Quaternion().setFromEuler(this.object3d.rotation)
97 | ).y;
98 |
99 | // Interpolate Y rotation
100 | let newYRotation = currentYRotation;
101 |
102 | // Handle rotation wrapping for smooth interpolation
103 | let rotationDiff = targetYRotation - currentYRotation;
104 | if (rotationDiff > Math.PI) rotationDiff -= Math.PI * 2;
105 | if (rotationDiff < -Math.PI) rotationDiff += Math.PI * 2;
106 |
107 | newYRotation += rotationDiff * this.rotationLerp;
108 |
109 | // Apply only Y rotation, keeping X and Z at 0
110 | this.object3d.rotation.set(0, newYRotation, 0);
111 | }
112 |
113 | setTarget(newTarget: THREE.Object3D) {
114 | this.target = newTarget;
115 | }
116 |
117 | setOffset(newOffset: THREE.Vector3) {
118 | this.offset = newOffset;
119 | }
120 |
121 | setYRotationOffset(newOffset: number) {
122 | this.yRotationOffset = newOffset;
123 | }
124 |
125 | setSmoothingFactors(position: number, rotation: number) {
126 | this.positionLerp = THREE.MathUtils.clamp(position, 0, 1);
127 | this.rotationLerp = THREE.MathUtils.clamp(rotation, 0, 1);
128 | }
129 | }
--------------------------------------------------------------------------------
/src/Core/Runtime/ThirdPersonController.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import GameObject from '../ObjectTypes/GameObject';
3 | import GameScene from '../ObjectTypes/GameScene';
4 | import { GameObjectCollider } from '../ObjectTypes/GameObjectCollider';
5 |
6 | export class ThirdPersonController {
7 | isActive = true;
8 |
9 | dx: number;
10 | dy: number;
11 | oldPointerPos: THREE.Vector2 = new THREE.Vector2();
12 | initialPointer: THREE.Vector2;
13 | targetPosition: THREE.Vector3 = new THREE.Vector3();
14 | raycaster: THREE.Raycaster;
15 |
16 | // Following properties
17 | private readonly followDistance = 1;
18 | private readonly followHeight = 1.6; // Adjusted to be more natural
19 | private readonly targetHeight = 1.6; // Height of player's "eyes"
20 | private readonly followLerp = 0.03;
21 | private readonly rotationLerp = 0.9;
22 |
23 | aimHelper: THREE.AxesHelper;
24 | originHelper: THREE.AxesHelper;
25 | cameraHelper: THREE.CameraHelper;
26 |
27 | // Camera shake properties
28 | private shakeIntensity: number = 0;
29 | private shakeDecay: number = 0.9;
30 | private shakeOffset: THREE.Vector3 = new THREE.Vector3();
31 | private originalCameraPosition: THREE.Vector3 = new THREE.Vector3();
32 |
33 | constructor(
34 | private camera: THREE.Camera,
35 | private player: THREE.Object3D,
36 | private scene: GameScene
37 | ) {
38 | this.raycaster = new THREE.Raycaster();
39 | this.aimHelper = new THREE.AxesHelper(3);
40 | this.originHelper = new THREE.AxesHelper(3);
41 | this.aimHelper.position.set(0, 1.5, 15);
42 | this.originHelper.position.set(-0.35, this.followHeight, -this.followDistance);
43 |
44 | this.aimHelper.visible = false;
45 | this.originHelper.visible = false;
46 |
47 | this.player.add(this.aimHelper);
48 | this.player.add(this.originHelper);
49 |
50 | const pos = this.originHelper.localToWorld(new THREE.Vector3());
51 | const lookAtTarget = this.aimHelper.localToWorld(new THREE.Vector3());
52 | this.camera.position.set(pos.x, pos.y, pos.z);
53 | this.camera.lookAt(lookAtTarget.x, lookAtTarget.y, lookAtTarget.z);
54 |
55 | // this.cameraHelper = new THREE.CameraHelper(this.camera);
56 | // this.scene.add(this.cameraHelper);
57 | }
58 |
59 | update() {
60 | if (!this.isActive) return;
61 |
62 | const pos = this.originHelper.localToWorld(new THREE.Vector3());
63 | const lookAtPos = this.aimHelper.localToWorld(new THREE.Vector3());
64 |
65 | // Store the intended camera position before shake
66 | this.originalCameraPosition.copy(pos);
67 |
68 | // Apply camera shake if active
69 | if (this.shakeIntensity > 0.001) {
70 | this.shakeOffset.set(
71 | (Math.random() - 0.5) * this.shakeIntensity,
72 | (Math.random() - 0.5) * this.shakeIntensity,
73 | (Math.random() - 0.5) * this.shakeIntensity
74 | );
75 |
76 | // Apply shake offset to position
77 | pos.add(this.shakeOffset);
78 |
79 | // Decay the shake intensity
80 | this.shakeIntensity *= this.shakeDecay;
81 | } else {
82 | this.shakeIntensity = 0;
83 | this.shakeOffset.set(0, 0, 0);
84 | }
85 |
86 | // Smooth lerp movement for the camera
87 | this.camera.position.lerp(pos, this.followLerp);
88 |
89 | // Smoothly rotate the camera to look at the target position
90 | const currentLookAt = new THREE.Vector3();
91 | this.camera.getWorldDirection(currentLookAt);
92 | const smoothLookAt = currentLookAt.lerp(lookAtPos.clone().sub(this.originalCameraPosition).normalize(), this.rotationLerp);
93 | const lookAtTarget = this.originalCameraPosition.clone().add(smoothLookAt);
94 |
95 | this.camera.lookAt(lookAtTarget.x, lookAtTarget.y, lookAtTarget.z);
96 | }
97 |
98 | /**
99 | * Trigger a camera shake effect
100 | * @param intensity Initial intensity of the shake (recommended: 0.1 to 1.0)
101 | * @param decay Rate at which the shake fades out (default: 0.9)
102 | */
103 | shake(intensity: number = 0.5, decay: number = 0.9) {
104 | this.shakeIntensity = intensity;
105 | this.shakeDecay = decay;
106 | }
107 |
108 | onPointerPressed(pointer: THREE.Vector2) {
109 | if (!this.isActive) return;
110 |
111 | this.initialPointer = pointer.clone();
112 | this.oldPointerPos = pointer.clone();
113 |
114 | this.dx = 0;
115 | this.dy = 0;
116 | }
117 |
118 | onPointerMoved(pointerPos: THREE.Vector2) {
119 | if (!this.isActive) return;
120 |
121 | this.dx = (pointerPos.x - this.oldPointerPos.x) * 10;
122 | this.dy = (pointerPos.y - this.oldPointerPos.y) * 10;
123 |
124 | const newX = THREE.MathUtils.clamp(this.aimHelper.position.x - this.dx, -4, 4);
125 | const newY = THREE.MathUtils.clamp(this.aimHelper.position.y + this.dy, -1, 4);
126 |
127 | this.aimHelper.position.set(newX, newY, this.aimHelper.position.z);
128 |
129 | this.oldPointerPos = pointerPos.clone();
130 | }
131 |
132 | onPointerReleased() {
133 | if (!this.isActive) return;
134 |
135 | this.dx = 0;
136 | this.dy = 0;
137 | }
138 |
139 | getAimedObject(scene: GameScene, enemies: GameObject[]): GameObject | undefined {
140 | const center = new THREE.Vector2();
141 | this.raycaster.setFromCamera(center, this.camera);
142 |
143 | const intersects = this.raycaster.intersectObject(scene, true);
144 | if (intersects.length > 0) {
145 | const gameObjects = intersects
146 | .filter((i) => i?.object instanceof GameObjectCollider)
147 | .map((i) => (i.object as GameObjectCollider).gameObject);
148 |
149 | for (let i = 0; i < enemies.length; i++) {
150 | const enemy = enemies[i];
151 | if (gameObjects.find((g) => g === enemy))
152 | return enemy;
153 | }
154 | }
155 | return undefined;
156 | }
157 |
158 | getAimPos() {
159 | return this.aimHelper.localToWorld(new THREE.Vector3());
160 | }
161 |
162 | enable() {
163 | this.isActive = true;
164 | this.dx = 0;
165 | this.dy = 0;
166 | }
167 |
168 | disable() {
169 | this.isActive = false;
170 | }
171 | }
--------------------------------------------------------------------------------
/src/stores/projectStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 | import type { ProjectState, ProjectActions, RecentProject } from './types';
4 | import { saveHandleToIDB, getHandleFromIDB, deleteHandleFromIDB } from '../utils/idbHelper';
5 |
6 | import { assetRegistry } from '../Core/Runtime/AssetsRegistry';
7 | import SceneManager from '../Core/SceneManager';
8 |
9 | interface ProjectStore extends ProjectState, ProjectActions {}
10 |
11 | export const useProjectStore = create()(
12 | persist(
13 | (set, get) => ({
14 | // State
15 | hasProjectOpen: false,
16 | currentDirectory: null,
17 | currentPath: '',
18 | recentProjects: [],
19 |
20 | // Actions
21 | openProject: async () => {
22 | try {
23 | const dirHandle = await window.showDirectoryPicker({
24 | mode: 'readwrite',
25 | }) as FileSystemDirectoryHandle;
26 |
27 | set({
28 | currentDirectory: dirHandle,
29 | currentPath: '',
30 | hasProjectOpen: true,
31 | });
32 |
33 | const idbKey = await saveHandleToIDB(dirHandle);
34 | const project: RecentProject = {
35 | name: dirHandle.name,
36 | idbKey: idbKey,
37 | lastOpened: new Date().toISOString(),
38 | };
39 |
40 | const { recentProjects } = get();
41 | const newProjects = [
42 | project,
43 | ...recentProjects.filter(p => p.idbKey !== project.idbKey)
44 | ].slice(0, 5);
45 |
46 | set({ recentProjects: newProjects });
47 |
48 | // Scan and register assets
49 | await scanAndRegisterAssets(dirHandle);
50 | await SceneManager.instance.loadAllAssets();
51 | } catch (error) {
52 | console.error('Error opening directory:', error);
53 | if ((error as DOMException).name !== 'AbortError') {
54 | throw error;
55 | }
56 | }
57 | },
58 |
59 | openRecentProject: async (project: RecentProject) => {
60 | try {
61 | const dirHandle = await getHandleFromIDB(project.idbKey);
62 |
63 | if (dirHandle) {
64 | const permissionStatus = await dirHandle.queryPermission({ mode: 'readwrite' });
65 |
66 | if (permissionStatus === 'granted') {
67 | set({
68 | currentDirectory: dirHandle,
69 | currentPath: '',
70 | hasProjectOpen: true,
71 | });
72 | await scanAndRegisterAssets(dirHandle);
73 | await SceneManager.instance.loadAllAssets();
74 | } else {
75 | // Re-prompt for permission
76 | const newDirHandle = await window.showDirectoryPicker({
77 | mode: 'readwrite',
78 | startIn: dirHandle,
79 | }) as FileSystemDirectoryHandle;
80 |
81 | if (newDirHandle) {
82 | set({
83 | currentDirectory: newDirHandle,
84 | currentPath: '',
85 | hasProjectOpen: true,
86 | });
87 |
88 | await scanAndRegisterAssets(newDirHandle);
89 | await SceneManager.instance.loadAllAssets();
90 |
91 | if (newDirHandle.name !== dirHandle.name) {
92 | const updatedIdbKey = await saveHandleToIDB(newDirHandle);
93 | const { recentProjects } = get();
94 | const updatedProjects = recentProjects.map(p =>
95 | p.idbKey === project.idbKey
96 | ? { ...p, idbKey: updatedIdbKey, name: newDirHandle.name }
97 | : p
98 | );
99 | set({ recentProjects: updatedProjects });
100 | }
101 | }
102 | }
103 | } else {
104 | // Remove broken entry
105 | const { recentProjects } = get();
106 | const updatedProjects = recentProjects.filter(p => p.idbKey !== project.idbKey);
107 | set({ recentProjects: updatedProjects });
108 | await deleteHandleFromIDB(project.idbKey);
109 | }
110 | } catch (error) {
111 | console.error('Error opening recent project:', error);
112 | const { recentProjects } = get();
113 | const updatedProjects = recentProjects.filter(p => p.idbKey !== project.idbKey);
114 | set({ recentProjects: updatedProjects });
115 | await deleteHandleFromIDB(project.idbKey);
116 | }
117 | },
118 |
119 | closeProject: () => {
120 | set({
121 | hasProjectOpen: false,
122 | currentDirectory: null,
123 | currentPath: '',
124 | });
125 | },
126 |
127 | setCurrentPath: (path: string, dirHandle: FileSystemDirectoryHandle) => {
128 | set({
129 | currentPath: path,
130 | currentDirectory: dirHandle,
131 | });
132 | },
133 | }),
134 | {
135 | name: 'project-storage',
136 | partialize: (state) => ({ recentProjects: state.recentProjects }),
137 | }
138 | )
139 | );
140 |
141 | // Utility: Recursively scan directory and register assets
142 | const KNOWN_ASSET_TYPES = [
143 | { ext: ['.png', '.jpg', '.jpeg'], category: 'textures' },
144 | { ext: ['.glb', '.gltf'], category: 'models' },
145 | { ext: ['.mp3', '.wav', '.ogg'], category: 'sounds' },
146 | { ext: ['.json'], category: 'scenes' },
147 | ];
148 |
149 | function getCategoryByExtension(filename: string): string | null {
150 | const lower = filename.toLowerCase();
151 | for (const type of KNOWN_ASSET_TYPES) {
152 | if (type.ext.some(ext => lower.endsWith(ext))) {
153 | return type.category;
154 | }
155 | }
156 | return null;
157 | }
158 |
159 | async function scanAndRegisterAssets(dirHandle: FileSystemDirectoryHandle, pathPrefix = ''): Promise {
160 | for await (const [name, handle] of dirHandle.entries()) {
161 | if (handle.kind === 'file') {
162 | const category = getCategoryByExtension(name);
163 | if (category) {
164 | // Create a key using the relative path
165 | const key = pathPrefix ? `${pathPrefix}/${name}` : name;
166 |
167 | try {
168 | // Get the file and create a blob URL for it
169 | const file = await (handle as FileSystemFileHandle).getFile();
170 | const blobUrl = URL.createObjectURL(file);
171 |
172 | // Register with the blob URL
173 | assetRegistry.addAsset(category as any, key, blobUrl);
174 | console.log(`Registered ${category} asset: ${key} -> ${blobUrl}`);
175 | } catch (error) {
176 | console.error(`Failed to create blob URL for ${key}:`, error);
177 | // Fallback to the relative path (though it won't work for loading)
178 | assetRegistry.addAsset(category as any, key, key);
179 | }
180 | }
181 | } else if (handle.kind === 'directory') {
182 | await scanAndRegisterAssets(handle as FileSystemDirectoryHandle, pathPrefix ? `${pathPrefix}/${name}` : name);
183 | }
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/Core/Runtime/SceneLoader.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import AssetLoader from './AssetLoader';
3 | import GameScene from '../ObjectTypes/GameScene';
4 | import GameObject from '../ObjectTypes/GameObject';
5 | import { Behavior } from '../Behaviors/Behavior';
6 | import { MoveByCheckpoints } from '../Behaviors/MoveByCheckppoints';
7 | import { Destructable } from '../Behaviors/Destructable';
8 | import { GameObjectCollider } from '../ObjectTypes/GameObjectCollider';
9 | import { MeleeAttacker } from '../Behaviors/MeleeAttacker';
10 | import { assetRegistry } from './AssetsRegistry';
11 |
12 | export default class SceneLoader {
13 | assetLoader: AssetLoader;
14 |
15 | constructor(assetLoader: AssetLoader) {
16 | this.assetLoader = assetLoader;
17 | }
18 |
19 | async loadSceneFromJson(levelJson: any) {
20 |
21 | const scene = new GameScene();
22 | const gameObjects: GameObject[] = [];
23 | interface LevelItem {
24 | name: string;
25 | modelName: string;
26 | position: { x: number; y: number; z: number };
27 | rotation: { x: number; y: number; z: number };
28 | scale: { x: number; y: number; z: number };
29 | quaternion?: { x: number; y: number; z: number; w: number };
30 | behaviors?: BehaviorConfig[];
31 | noCollider?: boolean;
32 | animation?: string;
33 | }
34 |
35 | interface BehaviorConfig {
36 | type: string;
37 | [key: string]: any;
38 | }
39 |
40 | // First, collect all unique model names and ensure they are loaded
41 | const modelNames = new Set();
42 | (levelJson as LevelItem[]).forEach((item: LevelItem) => {
43 | if (item.modelName && item.modelName.endsWith('glb')) {
44 | modelNames.add(item.modelName);
45 | }
46 | });
47 |
48 | console.log('Models required by scene:', Array.from(modelNames));
49 |
50 | // Log current asset registry for debugging
51 | assetRegistry.logRegisteredAssets();
52 |
53 | // Load all required models
54 | for (const modelName of modelNames) {
55 | try {
56 | // Check if model is already loaded
57 | if (this.assetLoader.gltfs.find(x => x.key === modelName)) {
58 | console.log(`Model ${modelName} is already loaded`);
59 | continue;
60 | }
61 |
62 | // Check if model is registered in AssetRegistry
63 | if (!assetRegistry.assets.models[modelName]) {
64 | console.error(`Model ${modelName} not found in asset registry.`);
65 | console.log('Available models in registry:', Object.keys(assetRegistry.assets.models));
66 | console.info(`To load this scene properly, please:
67 | 1. Use the file browser to navigate to the model file: ${modelName}
68 | 2. Click "Add to scene" on the model file to register it
69 | 3. Then try loading the scene again`);
70 | continue;
71 | }
72 |
73 | const modelUrl = assetRegistry.assets.models[modelName];
74 | console.log(`Loading model ${modelName} from registry...`);
75 | console.log(`Model URL: ${typeof modelUrl === 'string' ? modelUrl : 'Not a string'}`);
76 |
77 | // Now try to load it
78 | await this.assetLoader.loadModel(modelName);
79 | console.log(`Successfully loaded model ${modelName}`);
80 | } catch (error) {
81 | console.error(`Failed to load model ${modelName}:`, error);
82 | console.warn(`Model ${modelName} is not available. Make sure the model file exists and is accessible.`);
83 | // Continue with other models even if one fails
84 | }
85 | }
86 |
87 | // Now create game objects
88 | (levelJson as LevelItem[]).forEach((item: LevelItem) => {
89 | if (item.modelName && item.modelName.endsWith('glb')) {
90 | try {
91 | // Check if the model was successfully loaded
92 | if (this.assetLoader.gltfs.find(x => x.key === item.modelName)) {
93 | const gameObject = this.loadGameObject(item);
94 | if (gameObject) {
95 | gameObjects.push(gameObject);
96 | }
97 | } else {
98 | console.warn(`Skipping object ${item.name} because model ${item.modelName} is not loaded`);
99 | }
100 | } catch (error) {
101 | console.error(`Failed to create game object ${item.name}:`, error);
102 | // Continue with other objects
103 | }
104 | }
105 | });
106 |
107 | scene.setGameObjects(gameObjects);
108 | return scene;
109 | }
110 |
111 | loadGameObject(item: any): GameObject | null {
112 |
113 | //console.log("loding item", item);
114 |
115 | const transform = {
116 | position: new THREE.Vector3(item.position.x, item.position.y, item.position.z),
117 | rotation: new THREE.Euler(item.rotation.x, item.rotation.y, item.rotation.z),
118 | scale: new THREE.Vector3(item.scale.x, item.scale.y, item.scale.z),
119 | quaternion: new THREE.Quaternion()
120 | };
121 |
122 | const gameObject = this.assetLoader.loadGltfToGameObject(item.name, item.modelName, transform);
123 | if (!gameObject) {
124 | console.error(`Failed to create game object for ${item.name}`);
125 | return null;
126 | }
127 |
128 | const behaviors: Behavior[] = [];
129 |
130 | item.behaviors?.forEach((behaviorItem: any) => {
131 | const behavior = this.loadBehaviors(behaviorItem);
132 | if (behavior) {
133 | behaviors.push(behavior);
134 | }
135 | })
136 |
137 | gameObject.setBehaviors(behaviors);
138 |
139 | if (!item.noCollider)
140 | this.createCollider(gameObject);
141 |
142 | if (item.animation) {
143 | gameObject.playAnimation(item.animation);
144 | }
145 |
146 | //console.log(gameObject);
147 | return gameObject;
148 | }
149 |
150 | loadBehaviors(config?: any): Behavior | undefined {
151 | if (config.type === "MoveByCheckpoints") {
152 | return new MoveByCheckpoints(config);
153 | }
154 |
155 | if (config.type === "Destructable") {
156 | return new Destructable(config);
157 | }
158 |
159 | if (config.type === "MeleeAttacker") {
160 | return new MeleeAttacker(config);
161 | }
162 |
163 | return undefined;
164 | }
165 |
166 |
167 | createCollider(gameObject: GameObject) {
168 | const cubeCollider = new GameObjectCollider();
169 | cubeCollider.position.set(0, 1, 0);
170 | cubeCollider.scale.set(0.8, 1.1, 0.8);
171 |
172 | gameObject.setCollider(cubeCollider);
173 | }
174 |
175 |
176 | }
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/EditMode.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
3 | import { TransformControls } from 'three/addons/controls/TransformControls.js';
4 | import GameScene from '../../../Core/ObjectTypes/GameScene';
5 | import SceneManager from '../../../Core/SceneManager';
6 | import CameraObject from '../../../Core/ObjectTypes/CameraObject';
7 |
8 | export class EditMode {
9 | public camera: THREE.PerspectiveCamera;
10 | public orbitControls: OrbitControls;
11 | public transformControls: TransformControls;
12 |
13 | private renderer: THREE.WebGLRenderer;
14 | private scene: GameScene;
15 | private raycaster = new THREE.Raycaster();
16 | private selectionBox: THREE.Box3Helper | null = null;
17 | private gizmo: THREE.Object3D | null = null;
18 | private cameraHelper: THREE.CameraHelper | null = null;
19 |
20 | private _selectedObject: THREE.Object3D | null = null;
21 |
22 | get selectedObject(): THREE.Object3D | null { return this._selectedObject; }
23 |
24 | private setSelectedObject(obj: THREE.Object3D | null) {
25 | this._selectedObject = obj;
26 | if (this.onSelect) {
27 | this.onSelect(obj);
28 | }
29 | }
30 |
31 | public onSelect: ((obj: THREE.Object3D | null) => void) | null = null;
32 |
33 | constructor(renderer: THREE.WebGLRenderer, scene: GameScene) {
34 | this.renderer = renderer;
35 | this.scene = scene;
36 |
37 | this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
38 | this.camera.name = 'Edit mode camera';
39 | this.camera.position.set(10, 10, 10);
40 |
41 | this.orbitControls = new OrbitControls(this.camera, renderer.domElement);
42 | this.transformControls = new TransformControls(this.camera, renderer.domElement);
43 | this.transformControls.showY = true;
44 | this.transformControls.showX = true;
45 | this.transformControls.showZ = true;
46 |
47 | this.transformControls.addEventListener('dragging-changed', (event) => {
48 | this.orbitControls.enabled = !event.value;
49 | if (!event.value) {
50 | // Dragging ended, notify transform changed for inspector update
51 | SceneManager.instance.emitTransform();
52 | }
53 | });
54 |
55 | this.transformControls.addEventListener('objectChange', () => {
56 | if (this.selectionBox && this.transformControls.object) {
57 | this.selectionBox.box.setFromObject(this.transformControls.object);
58 | }
59 | });
60 |
61 | this.setupEvents();
62 | }
63 |
64 | private setupEvents() {
65 | this.renderer.domElement.addEventListener('click', this.onObjectClick, false);
66 | }
67 |
68 | private onObjectClick = (event: MouseEvent) => {
69 | if (this.transformControls.dragging) return;
70 |
71 | const pointer = new THREE.Vector2();
72 | const rect = this.renderer.domElement.getBoundingClientRect();
73 | pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
74 | pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
75 |
76 | this.raycaster.setFromCamera(pointer, this.camera);
77 | const intersects = this.raycaster.intersectObjects(this.scene.children, true);
78 |
79 | if (intersects.length > 0) {
80 | const firstIntersect = intersects[0].object;
81 | const rootObject = this.findRootObject(firstIntersect);
82 | if (rootObject.userData.isGizmo) {
83 | return; // Ignore clicks on the gizmo
84 | }
85 | this.selectObject(rootObject);
86 | } else {
87 | this.deselectObject();
88 | }
89 | }
90 |
91 | private findRootObject(object: THREE.Object3D): THREE.Object3D {
92 | let current = object;
93 | while (current.parent && current.parent.type !== 'Scene') {
94 | current = current.parent;
95 | }
96 | return current;
97 | }
98 |
99 | public selectObject(object: THREE.Object3D | null) {
100 | if (object === this._selectedObject) return;
101 |
102 |
103 | // Detach from previous object and remove old helpers
104 | this.transformControls.detach();
105 | if (this.selectionBox) {
106 | this.scene.remove(this.selectionBox);
107 | this.selectionBox.dispose();
108 | this.selectionBox = null;
109 | }
110 | if (this.gizmo) {
111 | this.scene.remove(this.gizmo);
112 | this.gizmo = null;
113 | }
114 | if (this.cameraHelper) {
115 | this.scene.remove(this.cameraHelper);
116 | this.cameraHelper = null;
117 | }
118 |
119 | this.setSelectedObject(object);
120 |
121 | // Attach to new object and create helpers
122 | if (object) {
123 | this.transformControls.attach(object);
124 |
125 | // Add Gizmo to scene and tag it
126 | this.gizmo = this.transformControls.getHelper();
127 | this.gizmo.traverse((child) => {
128 | child.userData.isGizmo = true;
129 | });
130 | this.scene.add(this.gizmo);
131 |
132 | // Add Selection Box to scene and tag it
133 | const box = new THREE.Box3().setFromObject(object);
134 | this.selectionBox = new THREE.Box3Helper(box, 0x00ff00);
135 | this.selectionBox.userData.isGizmo = true;
136 | this.scene.add(this.selectionBox);
137 |
138 | // Camera helper logic
139 | // let cameraObj: THREE.PerspectiveCamera | null = null;
140 | // if (object instanceof CameraObject) {
141 | // cameraObj = object.camera;
142 | // } else {
143 | // // Check for direct child of type CameraObject
144 | // const camChild = object.children.find(child => child instanceof CameraObject) as CameraObject | undefined;
145 | // if (camChild) {
146 | // cameraObj = camChild.camera;
147 | // }
148 | // }
149 | // if (cameraObj) {
150 | // this.cameraHelper = new THREE.CameraHelper(cameraObj);
151 | // this.cameraHelper.userData.isGizmo = true;
152 | // this.scene.add(this.cameraHelper);
153 | // }
154 | }
155 | }
156 |
157 | public deselectObject() {
158 | this.selectObject(null);
159 | }
160 |
161 | public setTransformMode(mode: 'translate' | 'rotate' | 'scale') {
162 | this.transformControls.setMode(mode);
163 | }
164 |
165 | public update() {
166 | this.orbitControls.update();
167 | }
168 |
169 | public dispose() {
170 | this.orbitControls.dispose();
171 | this.transformControls.dispose();
172 | this.renderer.domElement.removeEventListener('click', this.onObjectClick, false);
173 | }
174 |
175 | public handleResize(width: number, height: number) {
176 | this.camera.aspect = width / height;
177 | this.camera.updateProjectionMatrix();
178 | }
179 |
180 | public updateSelectionBox() {
181 | if (this.selectionBox && this.transformControls.object) {
182 | this.selectionBox.box.setFromObject(this.transformControls.object);
183 | }
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/SceneTree.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useEffect, useState } from 'react';
2 | import { Popover, Switch, Tree, Dropdown } from 'antd';
3 | import { SettingOutlined, SaveOutlined, PlusOutlined } from '@ant-design/icons';
4 | import { saveSceneToFile } from '../../../Core/SceneSaver';
5 | import * as THREE from 'three';
6 | import Node3d from '../../../Core/ObjectTypes/Node3d';
7 | import CameraObject from '../../../Core/ObjectTypes/CameraObject';
8 | import type { DataNode } from 'antd/es/tree';
9 | import SceneManager from '../../../Core/SceneManager';
10 |
11 | interface SceneTreeProps {
12 | root: THREE.Object3D;
13 | selected: THREE.Object3D | null;
14 | onSelect: (obj: THREE.Object3D | null) => void;
15 | }
16 |
17 | function buildTreeData(node: THREE.Object3D, showAll: boolean, isRoot = false): DataNode | null {
18 | // Always show root node
19 | if (isRoot) {
20 | return {
21 | title: (
22 |
23 | {formatTitle(node)}
24 |
25 | ),
26 | key: node.uuid,
27 | children: node.children
28 | .map(child => buildTreeData(child, showAll, false))
29 | .filter(Boolean) as DataNode[],
30 | };
31 | }
32 | // If not showAll, only include Node3d and its children
33 | const isNode3d = node instanceof Node3d;
34 | if (!showAll && !isNode3d) {
35 | // But if any children are Node3d, include them
36 | const filteredChildren = node.children
37 | .map(child => buildTreeData(child, showAll, false))
38 | .filter(Boolean) as DataNode[];
39 | if (filteredChildren.length === 0) return null;
40 | // If this node is not Node3d but has Node3d children, don't show this node, just its children
41 | return null;
42 | }
43 | return {
44 | title: (
45 |
46 | {formatTitle(node)}
47 |
48 | ),
49 | key: node.uuid,
50 | children: node.children
51 | .map(child => buildTreeData(child, showAll, false))
52 | .filter(Boolean) as DataNode[],
53 | };
54 | // Helper to format the title with object type in brackets
55 | function formatTitle(node: THREE.Object3D): string {
56 | const name = node.name || node.type;
57 | // Use the class name for custom types, otherwise fallback to node.type
58 | let typeName = node.constructor && node.constructor.name ? node.constructor.name : node.type;
59 | // Avoid duplicate type if name is already the type
60 | if (name === typeName) {
61 | return name;
62 | }
63 | return `${name} [${typeName}]`;
64 | }
65 | }
66 |
67 |
68 | export const SceneTree: React.FC = ({ root, selected, onSelect }) => {
69 | // State to force update when object names change
70 | const [version, setVersion] = useState(0);
71 | const [showAll, setShowAll] = useState(false);
72 | const [popoverOpen, setPopoverOpen] = useState(false);
73 |
74 | useEffect(() => {
75 | // Force update when root changes
76 | setVersion(v => v + 1);
77 | }, [root]);
78 |
79 | useEffect(() => {
80 | // Subscribe to selection changes (which are triggered on name change)
81 | const unsub = SceneManager.instance.subscribeSelection(() => {
82 | setVersion(v => v + 1);
83 | });
84 | return () => { unsub(); };
85 | }, []);
86 |
87 | // Recompute treeData when root, version, or showAll changes
88 | const treeData = useMemo(() => {
89 | console.log('Rebuilding scene tree data', root.name, root.uuid, 'showAll:', showAll);
90 | const data = buildTreeData(root, showAll, true);
91 | return data ? [data] : [];
92 | }, [root, version, showAll]);
93 |
94 | const handleSelect = (selectedKeys: React.Key[]) => {
95 | if (selectedKeys.length > 0) {
96 | // Prevent selecting the root scene node
97 | if (selectedKeys[0] === root.uuid) {
98 | onSelect(null);
99 | return;
100 | }
101 | const findObj = (node: THREE.Object3D, uuid: string): THREE.Object3D | null => {
102 | if (node.uuid === uuid) return node;
103 | for (const child of node.children) {
104 | const found = findObj(child, uuid);
105 | if (found) return found;
106 | }
107 | return null;
108 | };
109 | const obj = findObj(root, selectedKeys[0] as string);
110 | onSelect(obj);
111 | } else {
112 | onSelect(null);
113 | }
114 | };
115 |
116 | const popoverContent = (
117 |
118 |
119 |
120 |
121 |
122 |
123 | );
124 |
125 | // Save handler
126 | const handleSave = async () => {
127 | await saveSceneToFile(root);
128 | };
129 |
130 | // Handler to add new object to the scene
131 | const handleAddObject = ({ key }: { key: string }) => {
132 | if (key === 'camera') {
133 | const cam = new CameraObject();
134 | cam.addHelperToScene(root as THREE.Scene);
135 | cam.name = 'New Camera';
136 | root.add(cam);
137 | setVersion(v => v + 1); // Force update
138 | }
139 | // Add more object types here as needed
140 | };
141 |
142 |
143 | const createMenu = {
144 | items: [
145 | { key: 'camera', label: 'Camera' },
146 | // Add more items here
147 | ],
148 | onClick: handleAddObject,
149 | };
150 |
151 | return (
152 |
153 | {/* Save and Create buttons */}
154 |
155 |
163 |
164 |
171 |
172 |
173 | {/* Gear icon button */}
174 |
175 |
181 |
187 |
188 |
189 |
197 |
198 | );
199 | };
200 |
--------------------------------------------------------------------------------
/src/Core/SceneManager.ts:
--------------------------------------------------------------------------------
1 | import GameScene from './ObjectTypes/GameScene';
2 | import GameObject from './ObjectTypes/GameObject';
3 | import * as THREE from 'three';
4 | import { Transform } from './Runtime';
5 | import AssetLoader from './Runtime/AssetLoader';
6 | import SceneLoader from './Runtime/SceneLoader';
7 | import { assetRegistry } from './Runtime/AssetsRegistry';
8 |
9 | type SceneListener = (scene: GameScene | null) => void;
10 | type SelectionListener = (selected: THREE.Object3D | null) => void;
11 |
12 | export class SceneManager {
13 | private static _instance: SceneManager;
14 | private _scene: GameScene | null = null;
15 | private _selected: THREE.Object3D | null = null;
16 | private sceneListeners: SceneListener[] = [];
17 | private selectionListeners: SelectionListener[] = [];
18 | private assetLoader = new AssetLoader();
19 | private sceneLoader = new SceneLoader(this.assetLoader);
20 | /**
21 | * Loads all assets using the internal AssetLoader instance.
22 | */
23 | public async loadAllAssets() {
24 | await this.assetLoader.loadAssets();
25 | }
26 | private transformListeners: (() => void)[] = [];
27 |
28 | private constructor() {}
29 |
30 | public static get instance(): SceneManager {
31 | if (!SceneManager._instance) {
32 | SceneManager._instance = new SceneManager();
33 | }
34 | return SceneManager._instance;
35 | }
36 |
37 | get scene(): GameScene | null {
38 | return this._scene;
39 | }
40 |
41 | get selected(): THREE.Object3D | null {
42 | return this._selected;
43 | }
44 |
45 | /**
46 | * Creates a new scene with a default cube. You can extend this to load from file, etc.
47 | */
48 | createDefaultScene() {
49 | const scene = new GameScene();
50 | scene.init();
51 |
52 | // Add default cube
53 | const geo = new THREE.BoxGeometry();
54 | const mat = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
55 | const cubeMesh = new THREE.Mesh(geo, mat);
56 | const transform: Transform = {
57 | position: new THREE.Vector3(0, 0, 0),
58 | rotation: new THREE.Euler(0, 0, 0),
59 | scale: new THREE.Vector3(1, 1, 1),
60 | quaternion: new THREE.Quaternion()
61 | };
62 | const mesh = new GameObject(cubeMesh, transform, []);
63 | scene.addGameObject(mesh);
64 |
65 | this._scene = scene;
66 | this.emitScene();
67 | return scene;
68 | }
69 |
70 | /**
71 | * Replace the current scene with a new one (e.g., loaded from file)
72 | */
73 | setScene(scene: GameScene) {
74 | this._scene = scene;
75 | // Try to preserve selection by UUID if possible
76 | if (this._selected && this._selected.uuid) {
77 | const prevUUID = this._selected.uuid;
78 | // Recursively search for object with same UUID in new scene
79 | const findByUUID = (node: THREE.Object3D, uuid: string): THREE.Object3D | null => {
80 | if (node.uuid === uuid) return node;
81 | for (const child of node.children) {
82 | const found = findByUUID(child, uuid);
83 | if (found) return found;
84 | }
85 | return null;
86 | };
87 | const newSelected = findByUUID(scene, prevUUID);
88 | this._selected = newSelected || null;
89 | } else {
90 | this._selected = null;
91 | }
92 | this.emitScene();
93 | this.emitSelection();
94 | }
95 |
96 | /**
97 | * Clear the current scene
98 | */
99 | clearScene() {
100 | this._scene = null;
101 | this._selected = null;
102 | this.emitScene();
103 | this.emitSelection();
104 | }
105 |
106 | setSelected(obj: THREE.Object3D | null) {
107 | this._selected = obj;
108 | if (obj) {
109 | // eslint-disable-next-line no-console
110 | console.log('[SceneManager] Selected object:', obj);
111 | } else {
112 | // eslint-disable-next-line no-console
113 | console.log('[SceneManager] Selection cleared');
114 | }
115 | this.emitSelection();
116 | }
117 |
118 | subscribeScene(listener: SceneListener) {
119 | this.sceneListeners.push(listener);
120 | return () => {
121 | this.sceneListeners = this.sceneListeners.filter(l => l !== listener);
122 | };
123 | }
124 |
125 | subscribeSelection(listener: SelectionListener) {
126 | this.selectionListeners.push(listener);
127 | return () => {
128 | this.selectionListeners = this.selectionListeners.filter(l => l !== listener);
129 | };
130 | }
131 |
132 | subscribeTransform(listener: () => void) {
133 | this.transformListeners.push(listener);
134 | return () => {
135 | this.transformListeners = this.transformListeners.filter(l => l !== listener);
136 | };
137 | }
138 |
139 | private emitScene() {
140 | // Emit the live scene object instead of a shallow clone
141 | for (const l of this.sceneListeners) l(this._scene);
142 | }
143 | private emitSelection() {
144 | for (const l of this.selectionListeners) l(this._selected);
145 | }
146 | emitTransform() {
147 | for (const l of this.transformListeners) l();
148 | }
149 |
150 | /**
151 | * Adds a GLB model to the scene. If not loaded, adds to registry and loads it.
152 | * @param key The asset key (e.g. 'robot_glb')
153 | * @param url The asset URL
154 | * @param position Optional position for the new object
155 | */
156 | async AddModelToScene(key: string, url: string, position?: THREE.Vector3) {
157 | // Add to registry if not present
158 | if (!assetRegistry.assets.models[key]) {
159 | assetRegistry.addAsset('models', key, url);
160 | // Wait for the model to be loaded by AssetLoader
161 | await this.assetLoader.loadModel(key);
162 | }
163 | // Prepare a fake item config for SceneLoader
164 | const item = {
165 | name: key,
166 | modelName: key,
167 | position: position || { x: 0, y: 0, z: 0 },
168 | rotation: { x: 0, y: 0, z: 0 },
169 | scale: { x: 1, y: 1, z: 1 },
170 | behaviors: [],
171 | noCollider: false
172 | };
173 | // Use SceneLoader to create a GameObject
174 | const gameObject = this.sceneLoader.loadGameObject(item);
175 | if (gameObject && this._scene) {
176 | this._scene.addGameObject(gameObject);
177 | this.emitScene();
178 | this.setSelected(gameObject); // Select the newly added object
179 | } else if (!gameObject) {
180 | console.error(`Failed to create GameObject for model ${key}`);
181 | }
182 | }
183 |
184 | /**
185 | * Notify listeners that the selection has changed, even if the object reference is the same.
186 | * Useful for forcing UI updates after transform controls.
187 | */
188 | notifySelectionChanged() {
189 | this.emitSelection();
190 | }
191 |
192 | /**
193 | * Deletes the given object from the scene, if present.
194 | * If the object is currently selected, clears the selection.
195 | * @param selectedObject The object to delete
196 | */
197 | deleteObject(selectedObject: THREE.Object3D) {
198 | if (!this._scene || !selectedObject) return;
199 | // Remove from scene graph
200 | if (selectedObject.parent) {
201 | selectedObject.parent.remove(selectedObject);
202 | } else {
203 | // If it's a root GameObject in GameScene
204 | this._scene.removeGameObject?.(selectedObject as GameObject);
205 | }
206 | // Clear selection if deleted object was selected
207 | if (this._selected === selectedObject) {
208 | this.setSelected(null);
209 | } else {
210 | this.emitScene();
211 | }
212 | }
213 |
214 | /**
215 | * Loads a scene from a JSON definition, replacing the current scene.
216 | * @param levelJson The JSON scene definition
217 | */
218 | async loadSceneFromJson(levelJson: any) {
219 | // Clear current scene
220 | this.clearScene();
221 |
222 | await this.assetLoader.loadAssets();
223 | // Use SceneLoader to create a new GameScene from JSON
224 | const newScene = await this.sceneLoader.loadSceneFromJson(levelJson);
225 | // Set and initialize the new scene
226 | if (newScene && typeof newScene.init === 'function') {
227 | newScene.init();
228 | }
229 | this.setScene(newScene);
230 | }
231 | }
232 |
233 | export default SceneManager;
234 |
--------------------------------------------------------------------------------
/src/Modules/SceneEditor/components/Viewport.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect, useState } from 'react';
2 | import * as THREE from 'three';
3 | import { Dropdown } from 'antd';
4 | import { VideoCameraOutlined } from '@ant-design/icons';
5 | import { TransformToolbar } from './TransformToolbar';
6 | import styles from './SceneEditor.module.css';
7 | import SceneManager from '../../../Core/SceneManager';
8 | import { EditMode } from './EditMode';
9 | import CameraObject from '../../../Core/ObjectTypes/CameraObject';
10 |
11 | import type GameScene from '../../../Core/ObjectTypes/GameScene';
12 | interface ViewportProps {
13 | transformMode: 'translate' | 'rotate' | 'scale';
14 | setTransformMode: (mode: 'translate' | 'rotate' | 'scale') => void;
15 | editModeRef: React.MutableRefObject;
16 | setSceneReady: (ready: boolean) => void;
17 | onSceneChange?: (scene: GameScene) => void;
18 | }
19 |
20 | const Viewport: React.FC = ({ transformMode, setTransformMode, editModeRef, setSceneReady, onSceneChange }) => {
21 | const mountRef = useRef(null);
22 | const [currentCamera, setCurrentCamera] = useState<'edit' | string>('edit');
23 | const currentCameraRef = useRef<'edit' | string>('edit');
24 | const [sceneCameras, setSceneCameras] = useState([]);
25 | const rendererRef = useRef(null);
26 | const [scene, setScene] = useState(SceneManager.instance.scene); // Track the current scene
27 |
28 | // Function to find all cameras in the scene
29 | const findCamerasInScene = (scene: GameScene): CameraObject[] => {
30 | const cameras: CameraObject[] = [];
31 | scene.traverse((obj) => {
32 | if (obj instanceof CameraObject) {
33 | cameras.push(obj);
34 | }
35 | });
36 | return cameras;
37 | };
38 |
39 | // Function to update camera list
40 | const updateCameraList = () => {
41 | if (editModeRef.current) {
42 | const cameras = findCamerasInScene(editModeRef.current['scene']);
43 | setSceneCameras(cameras);
44 | }
45 | };
46 |
47 | // Handle camera switch
48 | const handleCameraSwitch = ({ key }: { key: string }) => {
49 | setCurrentCamera(key);
50 | currentCameraRef.current = key;
51 | if (editModeRef.current && rendererRef.current) {
52 | // Enable orbit controls only for edit camera
53 | editModeRef.current.orbitControls.enabled = key === 'edit';
54 | if (key === 'edit') {
55 | // Switch back to edit camera
56 | editModeRef.current.camera.aspect = rendererRef.current.domElement.clientWidth / rendererRef.current.domElement.clientHeight;
57 | editModeRef.current.camera.updateProjectionMatrix();
58 | } else {
59 | // Switch to scene camera
60 | const camera = sceneCameras.find(cam => cam.uuid === key);
61 | if (camera) {
62 | camera.camera.aspect = rendererRef.current.domElement.clientWidth / rendererRef.current.domElement.clientHeight;
63 | camera.camera.updateProjectionMatrix();
64 | }
65 | }
66 | }
67 | };
68 |
69 | // Subscribe to scene changes and update local scene state
70 | useEffect(() => {
71 | const unsub = SceneManager.instance.subscribeScene((newScene) => {
72 | setScene(newScene);
73 | });
74 | return () => { unsub(); };
75 | }, []);
76 |
77 | useEffect(() => {
78 | if (!mountRef.current || !scene) return;
79 | const currentMount = mountRef.current;
80 |
81 | const renderer = new THREE.WebGLRenderer({ antialias: true });
82 | rendererRef.current = renderer;
83 | renderer.setPixelRatio(window.devicePixelRatio);
84 | renderer.setSize(currentMount.clientWidth, currentMount.clientHeight);
85 | currentMount.appendChild(renderer.domElement);
86 |
87 | if (onSceneChange) onSceneChange(scene);
88 |
89 | const editMode = new EditMode(renderer, scene);
90 | editModeRef.current = editMode;
91 |
92 | editMode.onSelect = (obj) => {
93 | SceneManager.instance.setSelected(obj);
94 | // Update camera list when selection changes (in case cameras are added/removed)
95 | const cameras = findCamerasInScene(scene);
96 | setSceneCameras(cameras);
97 | };
98 |
99 | // Initial camera list update
100 | const initialCameras = findCamerasInScene(scene);
101 | setSceneCameras(initialCameras);
102 |
103 | const handleResize = () => {
104 | const width = currentMount.clientWidth;
105 | const height = currentMount.clientHeight;
106 | renderer.setSize(width, height);
107 | editMode.handleResize(width, height);
108 | // Update current camera aspect ratio
109 | if (currentCameraRef.current === 'edit') {
110 | editMode.camera.aspect = width / height;
111 | editMode.camera.updateProjectionMatrix();
112 | } else {
113 | const cameras = findCamerasInScene(scene);
114 | const cameraObj = cameras.find(cam => cam.uuid === currentCameraRef.current);
115 | if (cameraObj) {
116 | cameraObj.camera.aspect = width / height;
117 | cameraObj.camera.updateProjectionMatrix();
118 | }
119 | }
120 | };
121 |
122 | const resizeObserver = new ResizeObserver(handleResize);
123 | resizeObserver.observe(currentMount);
124 |
125 | const clock = new THREE.Clock();
126 | let animationFrameId: number;
127 | const animate = () => {
128 | animationFrameId = requestAnimationFrame(animate);
129 | const dt = clock.getDelta();
130 | editMode.update();
131 | scene.onTick(dt);
132 | // Render with current camera - get fresh camera reference each frame
133 | let renderCamera: THREE.PerspectiveCamera;
134 | if (currentCameraRef.current === 'edit') {
135 | renderCamera = editMode.camera;
136 | } else {
137 | // Get fresh camera list to avoid stale closure
138 | const currentCameras = findCamerasInScene(scene);
139 | const cameraObj = currentCameras.find(cam => cam.uuid === currentCameraRef.current);
140 | renderCamera = cameraObj?.camera || editMode.camera;
141 | }
142 | renderer.render(scene, renderCamera);
143 | };
144 |
145 | animate();
146 | setSceneReady(true);
147 |
148 | return () => {
149 | cancelAnimationFrame(animationFrameId);
150 | resizeObserver.unobserve(currentMount);
151 | currentMount.removeChild(renderer.domElement);
152 | editMode.dispose();
153 | renderer.dispose();
154 | rendererRef.current = null;
155 | };
156 | // Re-run this effect when the scene changes
157 | }, [editModeRef, setSceneReady, scene]);
158 |
159 | useEffect(() => {
160 | editModeRef.current?.setTransformMode(transformMode);
161 | }, [transformMode, editModeRef]);
162 |
163 | // Update camera list when scene cameras change
164 | useEffect(() => {
165 | const interval = setInterval(updateCameraList, 1000); // Check for camera changes every second
166 | return () => clearInterval(interval);
167 | }, [editModeRef]);
168 |
169 | // Create camera dropdown menu
170 | const cameraMenuItems = [
171 | { key: 'edit', label: 'Edit Camera' },
172 | ...sceneCameras.map(cam => ({
173 | key: cam.uuid,
174 | label: cam.name || `Camera ${cam.uuid.slice(0, 8)}`
175 | }))
176 | ];
177 |
178 | const cameraMenu = {
179 | items: cameraMenuItems,
180 | onClick: handleCameraSwitch,
181 | };
182 |
183 | // Also, ensure orbit controls are enabled for edit camera on mount
184 | useEffect(() => {
185 | if (editModeRef.current) {
186 | editModeRef.current.orbitControls.enabled = currentCamera === 'edit';
187 | }
188 | }, [currentCamera, editModeRef]);
189 |
190 | return (
191 |
192 |
193 |
194 | {/* Camera Selector Dropdown */}
195 |
196 |
197 |
216 |
217 |
218 |
219 | );
220 | };
221 |
222 | export default Viewport;
223 |
--------------------------------------------------------------------------------
/src/components/FileBrowser.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from 'react';
2 | import { Typography, Tree } from 'antd';
3 | import { FolderOpenOutlined } from '@ant-design/icons';
4 | import { FileItem } from './FileItem';
5 | import { FileDetailsPanel } from './FileDetailsPanel';
6 | import styles from './FileBrowser.module.css';
7 | import type { FileSystemEntry, DirectoryEntry, FileEntry, FileBrowserProps } from '../types';
8 |
9 | interface TreeNode {
10 | title: string;
11 | key: string;
12 | children?: TreeNode[];
13 | isLeaf?: boolean;
14 | handle: FileSystemDirectoryHandle;
15 | }
16 |
17 | export function FileBrowser({ currentPath = '', currentDirectory, onPathChange }: FileBrowserProps) {
18 | // Store the original root directory handle
19 | const [rootDirectory, setRootDirectory] = useState(null);
20 |
21 | const [files, setFiles] = useState([]);
22 | const [selectedFile, setSelectedFile] = useState(null);
23 | const [isLoading, setIsLoading] = useState(false);
24 | const [treeData, setTreeData] = useState([]);
25 | const [expandedKeys, setExpandedKeys] = useState(['']);
26 | const [selectedKeys, setSelectedKeys] = useState(['']);
27 |
28 | // Recursively build the folder tree from the directory handle
29 | const buildTree = useCallback(async (dirHandle: FileSystemDirectoryHandle, path = ''): Promise => {
30 | const children: TreeNode[] = [];
31 | for await (const handle of (dirHandle as any).values()) {
32 | if (handle.kind === 'directory') {
33 | children.push(await buildTree(handle, path ? `${path}/${handle.name}` : handle.name));
34 | }
35 | }
36 | return {
37 | title: dirHandle.name || 'root',
38 | key: path || '',
39 | children: children.length > 0 ? children : undefined,
40 | isLeaf: children.length === 0,
41 | handle: dirHandle,
42 | };
43 | }, []);
44 |
45 | // Set root directory only once on mount
46 | useEffect(() => {
47 | if (currentDirectory && !rootDirectory) {
48 | setRootDirectory(currentDirectory);
49 | }
50 | }, [currentDirectory, rootDirectory]);
51 |
52 | // Build the tree only from the root directory
53 | useEffect(() => {
54 | if (!rootDirectory) return;
55 | (async () => {
56 | const rootTree = await buildTree(rootDirectory, '');
57 | setTreeData([rootTree]);
58 | setExpandedKeys(['']);
59 | })();
60 | }, [rootDirectory, buildTree]);
61 |
62 | // Keep selectedKeys in sync with currentPath
63 | useEffect(() => {
64 | setSelectedKeys([currentPath || '']);
65 | }, [currentPath]);
66 |
67 |
68 | // Handle tree node selection (do not change root, only change right panel)
69 | const handleTreeSelect = useCallback((keys: React.Key[], info: any) => {
70 | if (info.selected && info.node) {
71 | const path = info.node.key as string;
72 | const handle = info.node.handle as FileSystemDirectoryHandle;
73 | onPathChange(path, handle);
74 | setSelectedFile(null);
75 | setSelectedKeys(keys as string[]);
76 | }
77 | }, [onPathChange]);
78 |
79 | // Handle tree expand
80 | const handleTreeExpand = (keys: React.Key[]) => {
81 | setExpandedKeys(keys as string[]);
82 | };
83 |
84 | // Handle double click in grid (navigate into folder)
85 | const handleFileDoubleClick = useCallback((file: FileSystemEntry) => {
86 | if (file.isDirectory) {
87 | onPathChange(file.path, file.handle as FileSystemDirectoryHandle);
88 | setSelectedKeys([file.path]);
89 | setSelectedFile(null);
90 | }
91 | }, [onPathChange]);
92 |
93 | if (!currentDirectory) {
94 | return No directory selected
;
95 | }
96 |
97 | const readDirectory = useCallback(async (dirHandle: FileSystemDirectoryHandle, path = ''): Promise => {
98 | const entries: FileSystemEntry[] = [];
99 | setIsLoading(true);
100 |
101 | try {
102 | // Use a type-safe approach to iterate over directory entries
103 | for await (const handle of (dirHandle as any).values()) {
104 | const filePath = path ? `${path}/${handle.name}` : handle.name;
105 |
106 | try {
107 | if (handle.kind === 'directory') {
108 | entries.push({
109 | name: handle.name,
110 | path: filePath,
111 | isDirectory: true,
112 | handle: handle as FileSystemDirectoryHandle,
113 | } as DirectoryEntry);
114 | } else {
115 | const fileHandle = handle as FileSystemFileHandle;
116 | const file = await fileHandle.getFile();
117 | entries.push({
118 | name: handle.name,
119 | path: filePath,
120 | isDirectory: false,
121 | file,
122 | size: file.size,
123 | lastModified: file.lastModified,
124 | type: file.type,
125 | handle: fileHandle,
126 | } as FileEntry);
127 | }
128 | } catch (error) {
129 | console.error(`Error processing entry ${handle.name}:`, error);
130 | }
131 | }
132 |
133 | return entries.sort((a, b) => {
134 | // Sort directories first, then by name
135 | if (a.isDirectory && !b.isDirectory) return -1;
136 | if (!a.isDirectory && b.isDirectory) return 1;
137 | return a.name.localeCompare(b.name);
138 | });
139 | } catch (error) {
140 | console.error('Error reading directory:', error);
141 | return [];
142 | } finally {
143 | setIsLoading(false);
144 | }
145 | }, [setIsLoading]);
146 |
147 | // Helper function to navigate to a specific path
148 | const navigateToPath = useCallback(async (path: string) => {
149 | if (!currentDirectory) return;
150 |
151 | try {
152 | // Split the path into parts and navigate to the target directory
153 | const parts = path.split('/').filter(Boolean);
154 | let currentDir = currentDirectory;
155 |
156 | for (const part of parts) {
157 | currentDir = await currentDir.getDirectoryHandle(part);
158 | }
159 |
160 | // Update the path and read the new directory
161 | onPathChange(path, currentDir);
162 | setSelectedFile(null);
163 | } catch (error) {
164 | console.error('Error navigating to path:', error);
165 | }
166 | }, [currentDirectory, onPathChange]);
167 |
168 | // Read directory when currentDirectory changes
169 | useEffect(() => {
170 | if (!currentDirectory || currentPath === undefined) return;
171 |
172 | const loadDirectory = async () => {
173 | const files = await readDirectory(currentDirectory, currentPath);
174 | setFiles(files);
175 | };
176 |
177 | loadDirectory();
178 | }, [currentDirectory, currentPath, readDirectory]);
179 |
180 | // Handle file click
181 | const handleFileClick = useCallback((file: FileSystemEntry) => {
182 | if (file.isDirectory) {
183 | onPathChange(file.path, file.handle as FileSystemDirectoryHandle);
184 | setSelectedFile(null);
185 | } else {
186 | setSelectedFile(file);
187 | // TODO: Handle file preview or other actions
188 | }
189 | }, [onPathChange]);
190 |
191 | // Handle directory up navigation
192 | const handleUpDirectory = useCallback(async () => {
193 | if (!currentDirectory || !currentPath) return;
194 |
195 | const parentPath = currentPath.split('/').slice(0, -1).join('/');
196 | if (parentPath === '') {
197 | onPathChange('', currentDirectory);
198 | setSelectedFile(null);
199 | return;
200 | }
201 |
202 | try {
203 | const parentDir = await currentDirectory.getDirectoryHandle('..');
204 | onPathChange(parentPath, parentDir);
205 | setSelectedFile(null);
206 | } catch (error) {
207 | console.error('Error navigating up directory:', error);
208 | }
209 | }, [currentDirectory, currentPath, onPathChange]);
210 |
211 | // Helper to format file size for display
212 | const formatFileSize = (bytes: number): string => {
213 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
214 | const k = 1024;
215 | const i = Math.floor(Math.log(bytes) / Math.log(k));
216 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
217 | };
218 |
219 | // Helper to format last modified date for display
220 | const formatLastModified = (entry: FileSystemEntry): string => {
221 | if (entry.isDirectory) return '--';
222 | const fileEntry = entry as FileEntry;
223 | return fileEntry.lastModified
224 | ? new Date(fileEntry.lastModified).toLocaleString()
225 | : 'Unknown';
226 | };
227 |
228 | return (
229 |
230 |
231 |
232 | isLeaf ? : }
240 | height={600}
241 | />
242 |
243 |
244 |
245 | {isLoading ? (
246 |
247 | Loading...
248 |
249 | ) : (
250 |
251 | {files.map(file => (
252 |
259 | ))}
260 |
261 | )}
262 |
263 |
264 | {selectedFile ? (
265 |
266 | ) : (
267 |
268 | Select a file to view details
269 |
270 | )}
271 |
272 |
273 | );
274 | }
275 |
--------------------------------------------------------------------------------
/src/Core/Behaviors/TweenBehavior.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { Tween, Easing } from '@tweenjs/tween.js';
3 | import { Behavior } from "./Behavior";
4 |
5 | export class TweenBehavior extends Behavior {
6 | private activeTweens: Map>;
7 |
8 | constructor() {
9 | super();
10 | this.activeTweens = new Map();
11 | }
12 |
13 | onTick(dt: number): void {
14 | if (!this.isEnabled) return;
15 |
16 | this.activeTweens.forEach(tween => {
17 | tween.update();
18 | });
19 |
20 | this.activeTweens.forEach((tween, id) => {
21 | if (tween.isPlaying() === false) {
22 | this.activeTweens.delete(id);
23 | }
24 | });
25 | }
26 |
27 | private generateTweenId(): string {
28 | return Math.random().toString(36).substr(2, 9);
29 | }
30 |
31 | animate(props: {
32 | from?: any,
33 | to: any,
34 | duration?: number,
35 | delay?: number,
36 | easing?: (k: number) => number,
37 | onUpdate?: (obj: any) => void,
38 | onComplete?: () => void
39 | }): Promise {
40 | const tweenId = this.generateTweenId();
41 | const target = props.from || {};
42 |
43 | const tween = new Tween(target)
44 | .to(props.to, props.duration || 1000)
45 | .easing(props.easing || Easing.Elastic.Out)
46 | .delay(props.delay || 0);
47 |
48 | if (props.onUpdate) {
49 | tween.onUpdate(props.onUpdate);
50 | }
51 |
52 | this.activeTweens.set(tweenId, tween);
53 |
54 | return new Promise((resolve) => {
55 | tween.onComplete(() => {
56 | props.onComplete?.();
57 | resolve();
58 | });
59 | tween.start();
60 | });
61 | }
62 |
63 | // Position tweening methods
64 | moveTo(
65 | target: THREE.Vector3 | { x: number, y: number, z: number },
66 | duration: number = 1000,
67 | easing: (k: number) => number = Easing.Quadratic.Out
68 | ): Promise {
69 | const targetPos = target instanceof THREE.Vector3 ? target : new THREE.Vector3(target.x, target.y, target.z);
70 | const currentPos = this.object3d.position.clone();
71 |
72 | return this.animate({
73 | from: { x: currentPos.x, y: currentPos.y, z: currentPos.z },
74 | to: { x: targetPos.x, y: targetPos.y, z: targetPos.z },
75 | duration,
76 | easing,
77 | onUpdate: (obj) => {
78 | this.object3d.position.set(obj.x, obj.y, obj.z);
79 | }
80 | });
81 | }
82 |
83 | moveToX(
84 | targetX: number,
85 | duration: number = 1000,
86 | easing: (k: number) => number = Easing.Quadratic.Out
87 | ): Promise {
88 | return this.animate({
89 | from: { x: this.object3d.position.x },
90 | to: { x: targetX },
91 | duration,
92 | easing,
93 | onUpdate: (obj) => {
94 | this.object3d.position.x = obj.x;
95 | }
96 | });
97 | }
98 |
99 | moveToY(
100 | targetY: number,
101 | duration: number = 1000,
102 | easing: (k: number) => number = Easing.Quadratic.Out
103 | ): Promise {
104 | return this.animate({
105 | from: { y: this.object3d.position.y },
106 | to: { y: targetY },
107 | duration,
108 | easing,
109 | onUpdate: (obj) => {
110 | this.object3d.position.y = obj.y;
111 | }
112 | });
113 | }
114 |
115 | moveToZ(
116 | targetZ: number,
117 | duration: number = 1000,
118 | easing: (k: number) => number = Easing.Quadratic.Out
119 | ): Promise {
120 | return this.animate({
121 | from: { z: this.object3d.position.z },
122 | to: { z: targetZ },
123 | duration,
124 | easing,
125 | onUpdate: (obj) => {
126 | this.object3d.position.z = obj.z;
127 | }
128 | });
129 | }
130 |
131 | // Rotation tweening methods
132 | rotateTo(
133 | target: THREE.Euler | { x: number, y: number, z: number },
134 | duration: number = 1000,
135 | easing: (k: number) => number = Easing.Quadratic.Out
136 | ): Promise {
137 | const targetRot = target instanceof THREE.Euler
138 | ? { x: target.x, y: target.y, z: target.z }
139 | : target;
140 | const currentRot = this.object3d.rotation;
141 |
142 | return this.animate({
143 | from: { x: currentRot.x, y: currentRot.y, z: currentRot.z },
144 | to: { x: targetRot.x, y: targetRot.y, z: targetRot.z },
145 | duration,
146 | easing,
147 | onUpdate: (obj) => {
148 | this.object3d.rotation.set(obj.x, obj.y, obj.z);
149 | }
150 | });
151 | }
152 |
153 | rotateToAngle(
154 | angle: number,
155 | axis: 'x' | 'y' | 'z' = 'z',
156 | duration: number = 1000,
157 | easing: (k: number) => number = Easing.Quadratic.Out
158 | ): Promise {
159 | const from = { angle: this.object3d.rotation[axis] };
160 | const to = { angle };
161 |
162 | return this.animate({
163 | from,
164 | to,
165 | duration,
166 | easing,
167 | onUpdate: (obj) => {
168 | if (axis === 'x') this.object3d.rotation.x = obj.angle;
169 | else if (axis === 'y') this.object3d.rotation.y = obj.angle;
170 | else this.object3d.rotation.z = obj.angle;
171 | }
172 | });
173 | }
174 |
175 | // ... (previous methods remain the same: fadeIn, fadeOut, scale, scaleSprite, colorTo, etc.)
176 |
177 | fadeIn(duration: number = 1000, delay: number = 0): Promise {
178 | if (this.object3d instanceof THREE.Sprite) {
179 | const spriteMaterial = (this.object3d.material as THREE.SpriteMaterial);
180 | spriteMaterial.transparent = true;
181 | spriteMaterial.opacity = 0;
182 |
183 | return this.animate({
184 | from: { opacity: 0 },
185 | to: { opacity: 1 },
186 | duration,
187 | delay,
188 | onUpdate: (obj) => {
189 | spriteMaterial.opacity = obj.opacity;
190 | }
191 | });
192 | } else if (this.object3d instanceof THREE.Object3D) {
193 | const materials = this.getMaterials();
194 | materials.forEach(material => {
195 | material.transparent = true;
196 | material.opacity = 0;
197 | });
198 |
199 | return this.animate({
200 | from: { opacity: 0 },
201 | to: { opacity: 1 },
202 | duration,
203 | delay,
204 | onUpdate: (obj) => {
205 | materials.forEach(material => {
206 | material.opacity = obj.opacity;
207 | });
208 | }
209 | });
210 | }
211 | return Promise.resolve();
212 | }
213 |
214 | fadeOut(duration: number = 1000, delay: number = 0): Promise {
215 | if (this.object3d instanceof THREE.Sprite) {
216 | const spriteMaterial = (this.object3d.material as THREE.SpriteMaterial);
217 | return this.animate({
218 | from: { opacity: 1 },
219 | to: { opacity: 0 },
220 | duration,
221 | delay,
222 | onUpdate: (obj) => {
223 | spriteMaterial.opacity = obj.opacity;
224 | }
225 | });
226 | } else if (this.object3d instanceof THREE.Object3D) {
227 | const materials = this.getMaterials();
228 | return this.animate({
229 | from: { opacity: 1 },
230 | to: { opacity: 0 },
231 | duration,
232 | delay,
233 | onUpdate: (obj) => {
234 | materials.forEach(material => {
235 | material.opacity = obj.opacity;
236 | });
237 | }
238 | });
239 | }
240 | return Promise.resolve();
241 | }
242 |
243 | scale(
244 | targetScale: THREE.Vector3 | number,
245 | duration: number = 1000,
246 | easing: (k: number) => number = Easing.Elastic.Out
247 | ): Promise {
248 | const target = typeof targetScale === 'number'
249 | ? new THREE.Vector3(targetScale, targetScale, targetScale)
250 | : targetScale;
251 |
252 | const initialScale = this.object3d.scale.clone();
253 |
254 | return this.animate({
255 | from: { x: initialScale.x, y: initialScale.y, z: initialScale.z },
256 | to: { x: target.x, y: target.y, z: target.z },
257 | duration,
258 | easing,
259 | onUpdate: (obj) => {
260 | this.object3d.scale.set(obj.x, obj.y, obj.z);
261 | }
262 | });
263 | }
264 |
265 | scaleSprite(
266 | targetScale: number | { x: number, y: number },
267 | duration: number = 1000,
268 | easing: (k: number) => number = Easing.Elastic.Out
269 | ): Promise {
270 | if (!(this.object3d instanceof THREE.Sprite)) {
271 | return Promise.resolve();
272 | }
273 |
274 | const sprite = this.object3d as THREE.Sprite;
275 | const initialScale = { x: sprite.scale.x, y: sprite.scale.y };
276 | const target = typeof targetScale === 'number'
277 | ? { x: targetScale, y: targetScale }
278 | : targetScale;
279 |
280 | return this.animate({
281 | from: initialScale,
282 | to: target,
283 | duration,
284 | easing,
285 | onUpdate: (obj) => {
286 | sprite.scale.set(obj.x, obj.y, 1);
287 | }
288 | });
289 | }
290 |
291 | colorTo(
292 | targetColor: THREE.Color | string | number,
293 | duration: number = 1000,
294 | easing: (k: number) => number = Easing.Linear.None
295 | ): Promise {
296 | if (!(this.object3d instanceof THREE.Sprite)) {
297 | return Promise.resolve();
298 | }
299 |
300 | const spriteMaterial = (this.object3d.material as THREE.SpriteMaterial);
301 | const currentColor = spriteMaterial.color;
302 | const targetColorObj = new THREE.Color(targetColor);
303 |
304 | return this.animate({
305 | from: { r: currentColor.r, g: currentColor.g, b: currentColor.b },
306 | to: { r: targetColorObj.r, g: targetColorObj.g, b: targetColorObj.b },
307 | duration,
308 | easing,
309 | onUpdate: (obj) => {
310 | spriteMaterial.color.setRGB(obj.r, obj.g, obj.b);
311 | }
312 | });
313 | }
314 |
315 | private getMaterials(): THREE.Material[] {
316 | const materials: THREE.Material[] = [];
317 | this.object3d.traverse((child) => {
318 | if (child instanceof THREE.Mesh) {
319 | if (Array.isArray(child.material)) {
320 | materials.push(...child.material);
321 | } else {
322 | materials.push(child.material);
323 | }
324 | }
325 | });
326 | return materials;
327 | }
328 |
329 | stopAll(): void {
330 | this.activeTweens.forEach(tween => {
331 | tween.stop();
332 | });
333 | this.activeTweens.clear();
334 | }
335 |
336 | disable(): void {
337 | super.disable();
338 | this.stopAll();
339 | }
340 | }
--------------------------------------------------------------------------------
/src/Core/Runtime/AssetLoader.ts:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three';
2 | import { decode } from 'base64-arraybuffer'
3 | import { GLTF, GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
4 | import { assetRegistry } from './AssetsRegistry.js';
5 | import GameObject from '../ObjectTypes/GameObject.js';
6 | import { SkeletonUtils } from 'three/examples/jsm/Addons.js';
7 | import { Transform } from './index.js';
8 | import { Howl } from 'howler';
9 | import * as JSZip from 'jszip';
10 |
11 | type ResourceEntry = { key: string, url: string };
12 |
13 | export default class AssetLoader {
14 |
15 | textureLoader: THREE.TextureLoader;
16 | gltfLoader: GLTFLoader;
17 | tutorial_screen: THREE.DataTexture | undefined;
18 | levelJson: any;
19 | uiLayerJson: any;
20 | textures: { key: string, texture: THREE.Texture }[];
21 | gltfs: { key: string, gltf: GLTF }[];
22 | sounds: { key: string; sound: Howl; }[];
23 |
24 | constructor() {
25 | this.textures = [];
26 | this.gltfs = [];
27 | this.sounds = [];
28 |
29 | this.textureLoader = new THREE.TextureLoader();
30 | this.gltfLoader = new GLTFLoader();
31 | }
32 |
33 | async loadAssets() {
34 |
35 | const textureEntries = Object.keys(assetRegistry.getRegistry().textures).map((textureName) => ({
36 | key: textureName, url: assetRegistry.getRegistry().textures[textureName] as string,
37 | }));
38 |
39 | this.textures = await this.loadResoucesAsync(textureEntries, t => this.loadTextureAsync(t));
40 |
41 | const modelEntries = Object.keys(assetRegistry.getRegistry().models).map((modelName) => ({
42 | key: modelName, url: assetRegistry.getRegistry().models[modelName] as string,
43 | }));
44 | this.gltfs = await this.loadResoucesAsync(modelEntries, t => this.loadGltfAsync(t));
45 |
46 |
47 | this.tutorial_screen = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1);
48 | this.tutorial_screen.needsUpdate = true;
49 |
50 | // Load scene JSONs only if present, with error handling
51 | const scenes = assetRegistry.getRegistry().scenes;
52 | if (scenes.levelData) {
53 | try {
54 | this.levelJson = await fetch(scenes.levelData as string).then((response) => response.json());
55 | } catch (e) {
56 | console.warn('Failed to load levelData JSON:', e);
57 | this.levelJson = null;
58 | }
59 | } else {
60 | this.levelJson = null;
61 | }
62 | if (scenes.uiLayerData) {
63 | try {
64 | this.uiLayerJson = await fetch(scenes.uiLayerData as string).then((response) => response.json());
65 | } catch (e) {
66 | console.warn('Failed to load uiLayerData JSON:', e);
67 | this.uiLayerJson = null;
68 | }
69 | } else {
70 | this.uiLayerJson = null;
71 | }
72 |
73 | const soundEntries = Object.keys(assetRegistry.getRegistry().sounds).map((soundName) => ({
74 | key: soundName, url: assetRegistry.getRegistry().sounds[soundName] as string,
75 | }));
76 | this.sounds = await this.loadResoucesAsync(soundEntries, t => this.loadSoundAsync(t));
77 | }
78 |
79 | async loadSoundAsync(entry: ResourceEntry): Promise<{ key: string; sound: Howl; }> {
80 | const sound = new Howl({ src: [entry.url] });
81 | sound.load();
82 | return { key: entry.key, sound };
83 | }
84 | async loadResoucesAsync(entries: ResourceEntry[], loaderFunc?: (entry: ResourceEntry) => Promise) {
85 | const loadingPromises = entries.map((entry) => {
86 | return loaderFunc!(entry);
87 | });
88 |
89 | const resultPromises = await Promise.allSettled(loadingPromises);
90 | const result = resultPromises
91 | .filter(x => x.status === 'fulfilled')
92 | .map((x) => x.value);
93 |
94 | return result;
95 | }
96 |
97 | getGltfByName(name: string) {
98 | const gltfEntry = this.gltfs.find(x => x.key === name);
99 | if (!gltfEntry) {
100 | console.error(`GLTF model not loaded: ${name}`);
101 | console.log('Available models:', this.gltfs.map(x => x.key));
102 | return null;
103 | }
104 | return gltfEntry.gltf as GLTF;
105 | }
106 |
107 | getTextureByName(name: string): THREE.Texture {
108 | const texture = this.textures.find(x => x.key === name)?.texture as THREE.Texture;
109 | return texture;
110 | }
111 |
112 | playSoundByName(name: string) {
113 | const sound = this.getSoundByName(name);
114 | sound.play();
115 | }
116 |
117 | muteSounds() {
118 | Howler.stop();
119 | Howler.mute(true);
120 | }
121 |
122 | unmuteSounds() {
123 | Howler.mute(false);
124 | }
125 |
126 | getSoundByName(name: string): Howl {
127 | const sound = this.sounds.find(x => x.key === name)?.sound as Howl;
128 | return sound;
129 | }
130 |
131 | async loadTextureAsync(entry: ResourceEntry) {
132 | const texture = await this.textureLoader.loadAsync(entry.url);
133 | texture.colorSpace = THREE.SRGBColorSpace;
134 | return { key: entry.key, texture };
135 | }
136 |
137 | async loadGltfAsync(entry: ResourceEntry): Promise<{ key: string, gltf: GLTF }> {
138 | // Handle blob URLs from File System Access API
139 | if (entry.url.startsWith('blob:')) {
140 | return this.loadGltfFromBlobUrl(entry);
141 | }
142 |
143 | // Original file processing (in debug mode)
144 | if (entry.url.startsWith('/')) {
145 | return this.loadGltfFromRegualarUrl(entry);
146 | }
147 |
148 | // Base64 data URLs
149 | if (entry.url.startsWith('data:')) {
150 | return this.loadBase64Gltf(entry);
151 | }
152 |
153 | // For relative paths, try to treat as regular URLs
154 | if (entry.url.includes('.')) {
155 | return this.loadGltfFromRegualarUrl(entry);
156 | }
157 |
158 | throw new Error(`Unsupported URL format for model ${entry.key}: ${entry.url}`);
159 | }
160 |
161 | async loadGltfFromRegualarUrl(entry: ResourceEntry) {
162 | const isZipped = entry.url.includes('.glb.zip');
163 | // Process ZIP archive
164 | if (isZipped) {
165 | const response = await fetch(entry.url);
166 | const zipData = await response.arrayBuffer();
167 | return this.extractAndParseZip(new Uint8Array(zipData), entry);
168 | }
169 |
170 | const gltf = await this.gltfLoader.loadAsync(entry.url);
171 | return { key: entry.key, gltf };
172 | }
173 |
174 | private async extractAndParseZip(zipData: Uint8Array, entry: ResourceEntry): Promise<{ key: string, gltf: GLTF }> {
175 | const zip = await JSZip.loadAsync(zipData);
176 |
177 | // Find first .glb file in archive
178 | const glbFile = Object.values(zip.files).find(
179 | file => file.name.toLowerCase().endsWith('.glb')
180 | );
181 |
182 | if (!glbFile) {
183 | throw new Error('No GLB file found in ZIP archive');
184 | }
185 |
186 | // Extract GLB file as binary data
187 | const glbContent = await glbFile.async('arraybuffer');
188 |
189 | return new Promise((resolve, reject) => {
190 | this.gltfLoader.parse(
191 | glbContent,
192 | "assets/models",
193 | gltf => resolve({ key: entry.key, gltf }),
194 | error => {
195 | console.error('Error loading gltf:', error);
196 | reject(error);
197 | }
198 | );
199 | });
200 | }
201 |
202 | private async loadBase64Gltf(entry: ResourceEntry) : Promise<{ key: string, gltf: GLTF }> {
203 |
204 | if(entry.url.startsWith('data:application/zip;base64,')) {
205 | return this.loadZippedGltf(entry);
206 | }
207 |
208 | // Validate that the URL is actually a base64 data URL
209 | if (!entry.url.startsWith("data:model/gltf-binary;base64,")) {
210 | throw new Error(`Invalid data URL format for model ${entry.key}. Expected base64 GLB data, got: ${entry.url.substring(0, 50)}...`);
211 | }
212 |
213 | const startIndex = "data:model/gltf-binary;base64,".length;
214 | const modelStr = entry.url.substring(startIndex);
215 |
216 | // Validate base64 string
217 | if (!modelStr || modelStr.length < 10) {
218 | throw new Error(`Invalid or too short base64 data for model ${entry.key}. Length: ${modelStr.length}`);
219 | }
220 |
221 | console.log(`Decoding base64 GLB for ${entry.key}, data length: ${modelStr.length}`);
222 |
223 | let mm: ArrayBuffer;
224 | try {
225 | mm = decode(modelStr);
226 | console.log(`Decoded ArrayBuffer size: ${mm.byteLength} bytes`);
227 |
228 | if (mm.byteLength < 20) {
229 | throw new Error(`Decoded GLB data too small: ${mm.byteLength} bytes`);
230 | }
231 | } catch (error) {
232 | console.error(`Failed to decode base64 for model ${entry.key}:`, error);
233 | throw new Error(`Base64 decode failed for model ${entry.key}: ${error instanceof Error ? error.message : String(error)}`);
234 | }
235 |
236 | return new Promise((resolve, reject) => {
237 | this.gltfLoader.parse(
238 | mm,
239 | "assets/models",
240 | gltf => resolve({ key: entry.key, gltf }),
241 | error => {
242 | console.error('Error loading gltf:', error);
243 | reject(error);
244 | }
245 | );
246 | });
247 | }
248 |
249 | private async loadZippedGltf(entry: ResourceEntry): Promise<{ key: string, gltf: GLTF }> {
250 | // For base64 encoded ZIP (published mode)
251 | const startIndex = entry.url.indexOf('base64,') + 7;
252 | const zipBase64 = entry.url.substring(startIndex);
253 | const zipData = Uint8Array.from(atob(zipBase64), c => c.charCodeAt(0));
254 | return this.extractAndParseZip(zipData, entry);
255 | }
256 |
257 | loadGltfToGameObject(name: string, modelName: string, transform: Transform): GameObject | null {
258 | const gltf = this.getGltfByName(modelName);
259 | if (!gltf) {
260 | console.error(`Cannot create GameObject: GLTF model not loaded: ${modelName}`);
261 | return null;
262 | }
263 | const instance = SkeletonUtils.clone(gltf.scene);
264 | const animations = gltf.animations as THREE.AnimationClip[];
265 | const gameObject = new GameObject(instance, transform, animations);
266 | gameObject.name = name;
267 | gameObject.modelName = modelName;
268 | return gameObject;
269 | }
270 |
271 | async loadModel(key: string): Promise {
272 | const modelUrl = assetRegistry.assets.models[key];
273 | if (!modelUrl) throw new Error('Model URL not found in registry: ' + key);
274 | // If already loaded, skip
275 | if (this.gltfs.find(x => x.key === key)) return;
276 | let result;
277 | if ((modelUrl as string).startsWith('blob:')) {
278 | // Handle Blob URL
279 | result = await this.loadGltfFromBlobUrl({ key, url: modelUrl as string });
280 | } else {
281 | result = await this.loadGltfAsync({ key, url: modelUrl as string });
282 | }
283 | this.gltfs.push(result);
284 | }
285 |
286 | private async loadGltfFromBlobUrl(entry: ResourceEntry): Promise<{ key: string, gltf: GLTF }> {
287 | const response = await fetch(entry.url);
288 | const arrayBuffer = await response.arrayBuffer();
289 | return new Promise((resolve, reject) => {
290 | this.gltfLoader.parse(
291 | arrayBuffer,
292 | '',
293 | gltf => resolve({ key: entry.key, gltf }),
294 | error => {
295 | console.error('Error loading gltf from blob:', error);
296 | reject(error);
297 | }
298 | );
299 | });
300 | }
301 | }
--------------------------------------------------------------------------------