├── 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 | 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 | {entry.name} 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 | } --------------------------------------------------------------------------------