diff --git a/docs/BLENDER_EXPORT.md b/docs/BLENDER_EXPORT.md new file mode 100644 index 0000000..6fed62e --- /dev/null +++ b/docs/BLENDER_EXPORT.md @@ -0,0 +1,229 @@ +# Blender Export Utility + +Automated export of Blender `.blend` files to GLB format using Blender's command-line interface. + +## Installation + +First, install the required dependencies: + +```bash +npm install +``` + +This will install `tsx` and `@types/node` which are needed to run the export scripts. + +## Requirements + +- **Blender**: Must be installed on your system + - macOS: Install from [blender.org](https://www.blender.org/download/) (will be at `/Applications/Blender.app`) + - Windows: Install to default location (`C:\Program Files\Blender Foundation\Blender\`) + - Linux: Install via package manager (`sudo apt install blender`) + +## Usage + +### Basic Export + +Export a single `.blend` file to `.glb`: + +```bash +npm run export-blend -- public/ship1.blend public/ship1.glb +``` + +### Watch Mode + +Automatically re-export when the `.blend` file changes: + +```bash +npm run export-blend -- public/ship1.blend public/ship1.glb --watch +``` + +This is useful during development - save your Blender file and it will auto-export! + +### Batch Export + +Export all `.blend` files in a directory: + +```bash +npm run export-blend -- public/ public/ --batch +``` + +This will: +- Find all `.blend` files in `public/` +- Export each to `.glb` with the same name +- Skip `.blend1` backup files + +### Compression + +Enable Draco mesh compression for smaller file sizes: + +```bash +npm run export-blend -- public/ship1.blend public/ship1.glb --compress +``` + +### Advanced Options + +```bash +# Don't apply modifiers during export +npm run export-blend -- input.blend output.glb --no-modifiers + +# Combine options +npm run export-blend -- input.blend output.glb --watch --compress +``` + +## Programmatic Usage + +You can also use the export functions directly in your TypeScript code: + +```typescript +import { exportBlendToGLB, batchExportBlendToGLB, watchAndExport } from './src/utils/blenderExporter'; + +// Single export +async function exportMyModel() { + const result = await exportBlendToGLB( + './models/ship.blend', + './public/ship.glb', + { + exportParams: { + export_draco_mesh_compression_enable: true, + export_apply_modifiers: true, + export_animations: true + } + } + ); + + console.log(`Exported in ${result.duration}ms`); +} + +// Batch export +async function exportAllModels() { + const results = await batchExportBlendToGLB([ + ['./models/ship1.blend', './public/ship1.glb'], + ['./models/ship2.blend', './public/ship2.glb'], + ['./models/asteroid.blend', './public/asteroid.glb'] + ], { + exportParams: { + export_draco_mesh_compression_enable: true + } + }); + + console.log(`Exported ${results.length} files`); +} + +// Watch for changes +function watchMyModel() { + const stopWatching = watchAndExport( + './models/ship.blend', + './public/ship.glb', + { + exportParams: { + export_apply_modifiers: true + } + } + ); + + // Call stopWatching() when you want to stop + setTimeout(() => { + stopWatching(); + }, 60000); // Stop after 1 minute +} +``` + +## Export Parameters + +The following glTF export parameters are supported: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `export_format` | `'GLB'` \| `'GLTF_SEPARATE'` \| `'GLTF_EMBEDDED'` | `'GLB'` | Output format | +| `export_draco_mesh_compression_enable` | `boolean` | `false` | Enable Draco compression | +| `export_apply_modifiers` | `boolean` | `true` | Apply modifiers before export | +| `export_yup` | `boolean` | `true` | Use Y-up coordinate system | +| `export_animations` | `boolean` | `true` | Export animations | +| `export_materials` | `'EXPORT'` \| `'PLACEHOLDER'` \| `'NONE'` | `'EXPORT'` | Material export mode | + +See [Blender's glTF export documentation](https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf) for all available parameters. + +## Troubleshooting + +### "Blender executable not found" + +**Solution**: Set a custom Blender path: + +```typescript +await exportBlendToGLB('input.blend', 'output.glb', { + blenderPath: '/custom/path/to/blender' +}); +``` + +Or for the CLI, edit the `blenderExporter.ts` file to update `getDefaultBlenderPath()`. + +### "Export timed out" + +**Solution**: Increase the timeout (default is 60 seconds): + +```typescript +await exportBlendToGLB('large-model.blend', 'output.glb', { + timeout: 120000 // 2 minutes +}); +``` + +### Output file not created + +Check that: +1. The `.blend` file opens in Blender without errors +2. The output directory exists +3. You have write permissions to the output directory +4. Check the console for Blender error messages + +## Example Workflow + +Here's a typical development workflow: + +```bash +# Terminal 1: Run the dev server +npm run build + +# Terminal 2: Watch your ship model +npm run export-blend -- public/ship2.blend public/ship2.glb --watch --compress + +# Now edit ship2.blend in Blender +# Every time you save, it will auto-export to ship2.glb +# Refresh your browser to see changes +``` + +## Integration with Build Process + +You can add the batch export to your build process: + +```json +{ + "scripts": { + "prebuild": "npm run export-blend:batch -- public/ public/", + "build": "tsc && vite build" + } +} +``` + +Now all `.blend` files will be exported before every build! + +## Performance Tips + +1. **Use compression for production**: Add `--compress` flag to reduce file sizes by ~50-70% +2. **Batch exports are sequential**: Large batches may take time +3. **Watch mode debounces**: Changes are detected with 1-second delay to avoid excessive exports +4. **Optimize your models**: Lower poly counts export faster + +## Current Project Models + +Here are the `.blend` files currently in the project: + +```bash +# Export all current models +npm run export-blend -- public/ship1.blend public/ship1.glb +npm run export-blend -- public/ship2.blend public/ship2.glb +npm run export-blend -- public/asteroid4.blend public/asteroid4.glb +npm run export-blend -- public/base.blend public/base.glb + +# Or batch export all at once +npm run export-blend:batch -- public/ public/ +``` diff --git a/package-lock.json b/package-lock.json index 156a2cc..12d4240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "openai": "4.52.3" }, "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.1", "typescript": "^5.4.5", "vite": "^5.2.13" } @@ -392,6 +394,22 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -408,6 +426,22 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -424,6 +458,22 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -749,11 +799,11 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", - "integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -939,6 +989,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -1044,6 +1106,19 @@ "openai": "bin/cli" } }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -1078,6 +1153,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", @@ -1127,6 +1211,434 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/typescript": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", @@ -1141,9 +1653,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "node_modules/vite": { "version": "5.3.3", diff --git a/package.json b/package.json index 334cf8c..75b3c6b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "build": "tsc && vite build", "preview": "vite preview", "havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps", - "speech": "tsc && node ./dist/server/voices.js" + "speech": "tsc && node ./dist/server/voices.js", + "export-blend": "tsx scripts/exportBlend.ts", + "export-blend:watch": "tsx scripts/exportBlend.ts --watch", + "export-blend:batch": "tsx scripts/exportBlend.ts --batch" }, "dependencies": { "@babylonjs/core": "8.32.0", @@ -23,6 +26,8 @@ "openai": "4.52.3" }, "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.1", "typescript": "^5.4.5", "vite": "^5.2.13" } diff --git a/public/arrow.stl b/public/arrow.stl deleted file mode 100644 index 1e1b242..0000000 Binary files a/public/arrow.stl and /dev/null differ diff --git a/public/assets/themes/default/models/asteroid.glb b/public/assets/themes/default/models/asteroid.glb new file mode 100644 index 0000000..6211a43 Binary files /dev/null and b/public/assets/themes/default/models/asteroid.glb differ diff --git a/public/assets/themes/default/models/base.glb b/public/assets/themes/default/models/base.glb new file mode 100644 index 0000000..ecdea59 Binary files /dev/null and b/public/assets/themes/default/models/base.glb differ diff --git a/public/assets/themes/default/models/ship.glb b/public/assets/themes/default/models/ship.glb new file mode 100644 index 0000000..2d0ae63 Binary files /dev/null and b/public/assets/themes/default/models/ship.glb differ diff --git a/public/asteroid.glb b/public/asteroid.glb deleted file mode 100644 index 1c81260..0000000 Binary files a/public/asteroid.glb and /dev/null differ diff --git a/public/asteroid2.glb b/public/asteroid2.glb deleted file mode 100644 index ddcdf9d..0000000 Binary files a/public/asteroid2.glb and /dev/null differ diff --git a/public/asteroid3.blend b/public/asteroid3.blend deleted file mode 100644 index 85e1aaf..0000000 Binary files a/public/asteroid3.blend and /dev/null differ diff --git a/public/asteroid3.glb b/public/asteroid3.glb deleted file mode 100644 index 8f67993..0000000 Binary files a/public/asteroid3.glb and /dev/null differ diff --git a/public/base.glb b/public/base.glb deleted file mode 100644 index c332c49..0000000 Binary files a/public/base.glb and /dev/null differ diff --git a/public/cockpit.glb b/public/cockpit.glb deleted file mode 100644 index 105591b..0000000 Binary files a/public/cockpit.glb and /dev/null differ diff --git a/public/cockpit2.glb b/public/cockpit2.glb deleted file mode 100644 index faa21e3..0000000 Binary files a/public/cockpit2.glb and /dev/null differ diff --git a/public/cockpit3.glb b/public/cockpit3.glb deleted file mode 100644 index 9083efb..0000000 Binary files a/public/cockpit3.glb and /dev/null differ diff --git a/public/cockpit4.glb b/public/cockpit4.glb deleted file mode 100644 index a74822e..0000000 Binary files a/public/cockpit4.glb and /dev/null differ diff --git a/public/ship1.blend b/public/ship1.blend deleted file mode 100644 index 6e69725..0000000 Binary files a/public/ship1.blend and /dev/null differ diff --git a/public/ship1.glb b/public/ship1.glb deleted file mode 100644 index a95912d..0000000 Binary files a/public/ship1.glb and /dev/null differ diff --git a/public/ship2.glb b/public/ship2.glb deleted file mode 100644 index d13b7f1..0000000 Binary files a/public/ship2.glb and /dev/null differ diff --git a/scripts/exportBlend.ts b/scripts/exportBlend.ts new file mode 100644 index 0000000..e799bb9 --- /dev/null +++ b/scripts/exportBlend.ts @@ -0,0 +1,211 @@ +#!/usr/bin/env tsx + +/** + * CLI script to export Blender files to GLB format + * + * Usage: + * tsx scripts/exportBlend.ts + * npm run export-blend -- + * + * Examples: + * npm run export-blend -- public/ship1.blend public/ship1.glb + * npm run export-blend -- public/asteroid4.blend public/asteroid4.glb + * + * Options: + * --watch Watch the input file and auto-export on changes + * --compress Enable Draco mesh compression + * --no-modifiers Don't apply modifiers + * --batch Export all .blend files in a directory + */ + +import { exportBlendToGLB, watchAndExport, batchExportBlendToGLB } from '../src/utils/blenderExporter.js'; +import { readdirSync, statSync } from 'fs'; +import path from 'path'; + +interface CLIArgs { + input?: string; + output?: string; + watch: boolean; + compress: boolean; + noModifiers: boolean; + batch: boolean; +} + +function parseArgs(): CLIArgs { + const args: CLIArgs = { + watch: false, + compress: false, + noModifiers: false, + batch: false + }; + + const rawArgs = process.argv.slice(2); + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]; + + if (arg === '--watch') { + args.watch = true; + } else if (arg === '--compress') { + args.compress = true; + } else if (arg === '--no-modifiers') { + args.noModifiers = true; + } else if (arg === '--batch') { + args.batch = true; + } else if (!args.input) { + args.input = arg; + } else if (!args.output) { + args.output = arg; + } + } + + return args; +} + +function printUsage() { + console.log(` +Usage: npm run export-blend -- [options] + +Options: + --watch Watch the input file and auto-export on changes + --compress Enable Draco mesh compression + --no-modifiers Don't apply modifiers during export + --batch Export all .blend files in input directory + +Examples: + npm run export-blend -- public/ship1.blend public/ship1.glb + npm run export-blend -- public/ship1.blend public/ship1.glb --compress + npm run export-blend -- public/ship1.blend public/ship1.glb --watch + npm run export-blend -- public/ public/ --batch + `); +} + +async function main() { + const args = parseArgs(); + + if (!args.input) { + console.error('Error: Input file or directory required\n'); + printUsage(); + process.exit(1); + } + + // Build export options + const options = { + exportParams: { + export_format: 'GLB' as const, + export_draco_mesh_compression_enable: args.compress, + export_apply_modifiers: !args.noModifiers, + export_yup: true + } + }; + + try { + if (args.batch) { + // Batch export mode + await batchExportMode(args.input, args.output || args.input, options); + } else if (args.watch) { + // Watch mode + if (!args.output) { + console.error('Error: Output file required for watch mode\n'); + printUsage(); + process.exit(1); + } + await watchMode(args.input, args.output, options); + } else { + // Single export mode + if (!args.output) { + console.error('Error: Output file required\n'); + printUsage(); + process.exit(1); + } + await singleExportMode(args.input, args.output, options); + } + } catch (error) { + console.error('Export failed:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +async function singleExportMode(input: string, output: string, options: any) { + console.log(`Exporting ${input} to ${output}...`); + const result = await exportBlendToGLB(input, output, options); + + if (result.success) { + console.log(`āœ… Successfully exported in ${result.duration}ms`); + console.log(` Output: ${result.outputPath}`); + } +} + +async function watchMode(input: string, output: string, options: any) { + console.log(`šŸ‘€ Watching ${input} for changes...`); + console.log(` Will export to ${output}`); + console.log(` Press Ctrl+C to stop\n`); + + // Do initial export + try { + await exportBlendToGLB(input, output, options); + console.log('āœ… Initial export complete\n'); + } catch (error) { + console.error('āŒ Initial export failed:', error); + } + + // Start watching + const stopWatching = watchAndExport(input, output, options); + + // Handle Ctrl+C + process.on('SIGINT', () => { + console.log('\n\nStopping watch mode...'); + stopWatching(); + process.exit(0); + }); + + // Keep process alive + await new Promise(() => {}); +} + +async function batchExportMode(inputDir: string, outputDir: string, options: any) { + console.log(`šŸ“¦ Batch exporting .blend files from ${inputDir}...`); + + // Find all .blend files in input directory + const files = readdirSync(inputDir) + .filter(f => f.endsWith('.blend') && !f.endsWith('.blend1')) + .filter(f => { + const fullPath = path.join(inputDir, f); + return statSync(fullPath).isFile(); + }); + + if (files.length === 0) { + console.log('No .blend files found in directory'); + return; + } + + console.log(`Found ${files.length} .blend file(s):`); + files.forEach(f => console.log(` - ${f}`)); + console.log(''); + + const exports: Array<[string, string]> = files.map(f => { + const inputPath = path.join(inputDir, f); + const outputPath = path.join(outputDir, f.replace('.blend', '.glb')); + return [inputPath, outputPath]; + }); + + const results = await batchExportBlendToGLB(exports, options, true); // Sequential + + // Print summary + console.log('\nšŸ“Š Export Summary:'); + const successful = results.filter(r => r.success).length; + console.log(`āœ… Successful: ${successful}/${results.length}`); + + results.forEach((result, i) => { + const [input] = exports[i]; + const filename = path.basename(input); + if (result.success) { + console.log(` āœ“ ${filename} (${result.duration}ms)`); + } else { + console.log(` āœ— ${filename} - FAILED`); + } + }); +} + +// Run the script +main(); diff --git a/src/level1.ts b/src/level1.ts index 6abfb66..4753e2f 100644 --- a/src/level1.ts +++ b/src/level1.ts @@ -2,19 +2,11 @@ import {DefaultScene} from "./defaultScene"; import type {AudioEngineV2} from "@babylonjs/core"; import { AbstractMesh, - Color3, - DistanceConstraint, - MeshBuilder, Observable, - PhysicsAggregate, - PhysicsMotionType, - PhysicsShapeType, - StandardMaterial, Vector3 } from "@babylonjs/core"; import {Ship} from "./ship"; import Level from "./level"; -import {Scoreboard} from "./scoreboard"; import setLoadingMessage from "./setLoadingMessage"; import {LevelConfig} from "./levelConfig"; import {LevelDeserializer} from "./levelDeserializer"; @@ -25,7 +17,7 @@ export class Level1 implements Level { private _ship: Ship; private _onReadyObservable: Observable = new Observable(); private _initialized: boolean = false; - private _startBase: AbstractMesh; + private _startBase: AbstractMesh | null; private _endBase: AbstractMesh; private _levelConfig: LevelConfig; private _audioEngine: AudioEngineV2; @@ -46,6 +38,7 @@ export class Level1 implements Level { xr.baseExperience.onInitialXRPoseSetObservable.add(() => { xr.baseExperience.camera.parent = this._ship.transformNode; + const currPose = xr.baseExperience.camera.globalPosition.y; xr.baseExperience.camera.position = new Vector3(0, 0, 0); const observer = xr.input.onControllerAddedObservable.add((controller) => { debugLog('šŸŽ® onControllerAddedObservable FIRED for:', controller.inputSource.handedness); @@ -59,7 +52,6 @@ export class Level1 implements Level { return this._onReadyObservable; } - private scored: Set = new Set(); public async play() { // Create background music using AudioEngineV2 const background = await this._audioEngine.createSoundAsync("background", "/song1.mp3", { @@ -88,7 +80,9 @@ export class Level1 implements Level { } public dispose() { - this._startBase.dispose(); + if (this._startBase) { + this._startBase.dispose(); + } this._endBase.dispose(); if (this._backgroundStars) { this._backgroundStars.dispose(); @@ -113,25 +107,7 @@ export class Level1 implements Level { this._ship.scoreboard.setRemainingCount(entities.asteroids.length); debugLog(`Initialized scoreboard with ${entities.asteroids.length} asteroids`); - // Position ship from config - const shipConfig = this._deserializer.getShipConfig(); - //this._ship.position = new Vector3(shipConfig.position[0], shipConfig.position[1], shipConfig.position[2]); - // Add distance constraints to asteroids (if physics enabled) - setLoadingMessage("Configuring physics constraints..."); - const asteroidMeshes = entities.asteroids; - if (this._startBase.physicsBody) { - for (let i = 0; i < asteroidMeshes.length; i++) { - const asteroidMesh = asteroidMeshes[i]; - if (asteroidMesh.physicsBody) { - // Calculate distance from start base - const dist = Vector3.Distance(asteroidMesh.position, this._startBase.position); - const constraint = new DistanceConstraint(dist, DefaultScene.MainScene); - // constraint.isCollisionsEnabled = true; - this._startBase.physicsBody.addConstraint(asteroidMesh.physicsBody, constraint); - } - } - } // Create background starfield setLoadingMessage("Creating starfield..."); @@ -156,33 +132,4 @@ export class Level1 implements Level { // Notify that initialization is complete this._onReadyObservable.notifyObservers(this); } - - - private createTarget(i: number) { - const target = MeshBuilder.CreateTorus("target" + i, {diameter: 10, tessellation: 72}, DefaultScene.MainScene); - const targetLOD = MeshBuilder.CreateTorus("target" + i, { - diameter: 50, - tessellation: 10 - }, DefaultScene.MainScene); - targetLOD.parent = target; - target.addLODLevel(300, targetLOD); - - const material = new StandardMaterial("material", DefaultScene.MainScene); - material.diffuseColor = new Color3(1, 0, 0); - material.alpha = .9; - target.material = material; - target.position = Vector3.Random(-1000, 1000); - target.rotation = Vector3.Random(0, Math.PI * 2); - const disc = MeshBuilder.CreateDisc("disc-" + i, {radius: 2, tessellation: 72}, DefaultScene.MainScene); - const discMaterial = new StandardMaterial("material", DefaultScene.MainScene); - discMaterial.ambientColor = new Color3(.1, 1, .1); - discMaterial.alpha = .2; - target.addLODLevel(200, null); - disc.material = discMaterial; - disc.parent = target; - disc.rotation.x = -Math.PI / 2; - const agg = new PhysicsAggregate(disc, PhysicsShapeType.MESH, {mass: 0}, DefaultScene.MainScene); - agg.body.setMotionType(PhysicsMotionType.STATIC); - agg.shape.isTrigger = true; - } } \ No newline at end of file diff --git a/src/levelConfig.ts b/src/levelConfig.ts index 86e5a0f..fe3100b 100644 --- a/src/levelConfig.ts +++ b/src/levelConfig.ts @@ -19,11 +19,12 @@ export interface ShipConfig { /** * Start base configuration (yellow cylinder where asteroids are constrained to) + * All fields optional to allow levels without start bases */ export interface StartBaseConfig { - position: Vector3Array; - diameter: number; - height: number; + position?: Vector3Array; + diameter?: number; + height?: number; color?: Vector3Array; // RGB values 0-1 } @@ -85,7 +86,7 @@ export interface LevelConfig { }; ship: ShipConfig; - startBase: StartBaseConfig; + startBase?: StartBaseConfig; sun: SunConfig; planets: PlanetConfig[]; asteroids: AsteroidConfig[]; @@ -127,17 +128,15 @@ export function validateLevelConfig(config: any): ValidationResult { } } - // Check startBase - if (!config.startBase) { - errors.push('Missing startBase configuration'); - } else { - if (!Array.isArray(config.startBase.position) || config.startBase.position.length !== 3) { + // Check startBase (optional) + if (config.startBase) { + if (config.startBase.position && (!Array.isArray(config.startBase.position) || config.startBase.position.length !== 3)) { errors.push('Invalid startBase.position - must be [x, y, z] array'); } - if (typeof config.startBase.diameter !== 'number') { + if (config.startBase.diameter !== undefined && typeof config.startBase.diameter !== 'number') { errors.push('Invalid startBase.diameter - must be a number'); } - if (typeof config.startBase.height !== 'number') { + if (config.startBase.height !== undefined && typeof config.startBase.height !== 'number') { errors.push('Invalid startBase.height - must be a number'); } } diff --git a/src/levelDeserializer.ts b/src/levelDeserializer.ts index 172c601..8fc0c1c 100644 --- a/src/levelDeserializer.ts +++ b/src/levelDeserializer.ts @@ -1,15 +1,7 @@ import { AbstractMesh, - Color3, DirectionalLight, - GlowLayer, MeshBuilder, Observable, - PhysicsAggregate, - PhysicsMotionType, - PhysicsShapeType, - PointLight, - StandardMaterial, - Texture, Vector3 } from "@babylonjs/core"; import { DefaultScene } from "./defaultScene"; @@ -18,19 +10,13 @@ import { ScoreEvent } from "./scoreboard"; import { LevelConfig, ShipConfig, - StartBaseConfig, - SunConfig, - PlanetConfig, - AsteroidConfig, Vector3Array, validateLevelConfig } from "./levelConfig"; -import { FireProceduralTexture } from "@babylonjs/procedural-textures"; -import {createSphereLightmap} from "./sphereLightmap"; import { GameConfig } from "./gameConfig"; -import buildStarBase from "./starBase"; import { MaterialFactory } from "./materialFactory"; import debugLog from './debug'; +import StarBase from "./starBase"; /** * Deserializes a LevelConfig JSON object and creates all entities in the scene @@ -53,7 +39,7 @@ export class LevelDeserializer { * Create all entities from the configuration */ public async deserialize(scoreObservable: Observable): Promise<{ - startBase: AbstractMesh; + startBase: AbstractMesh | null; sun: AbstractMesh; planets: AbstractMesh[]; asteroids: AbstractMesh[]; @@ -66,10 +52,13 @@ export class LevelDeserializer { const planets = this.createPlanets(); const asteroids = await this.createAsteroids(scoreObservable); + /* const dir = new Vector3(-1,-2,-1) + const light = new DirectionalLight("dirLight", dir, DefaultScene.MainScene); const light2 = new DirectionalLight("dirLight2", dir.negate(), DefaultScene.MainScene); light2.intensity = .5; + */ return { startBase, sun, @@ -82,13 +71,8 @@ export class LevelDeserializer { * Create the start base from config */ private async createStartBase(): Promise { - const config = this.config.startBase; - const position = this.arrayToVector3(config.position); - - // Call the buildStarBase function to load and configure the base - const baseMesh = await buildStarBase(position); - - return baseMesh; + const position = this?.config?.startBase?.position?this.arrayToVector3(this?.config?.startBase?.position):null; + return await StarBase.buildStarBase(position); } /** @@ -96,19 +80,11 @@ export class LevelDeserializer { */ private createSun(): AbstractMesh { const config = this.config.sun; - - // Create point light - //const light = new PointLight("light", this.arrayToVector3(config.position), this.scene); - //light.intensity = config.intensity || 1000000; - - // Create sun sphere const sun = MeshBuilder.CreateSphere("sun", { diameter: config.diameter, segments: 32 }, this.scene); - sun.position = this.arrayToVector3(config.position); - // Create material using GameConfig texture level const gameConfig = GameConfig.getInstance(); const material = MaterialFactory.createSunMaterial( @@ -181,21 +157,11 @@ export class LevelDeserializer { i, this.arrayToVector3(asteroidConfig.position), this.arrayToVector3(asteroidConfig.scaling), + this.arrayToVector3(asteroidConfig.linearVelocity), + this.arrayToVector3(asteroidConfig.angularVelocity), scoreObservable ); - // Set velocities from config - if (rock.physicsBody) { - rock.physicsBody.setLinearVelocity(this.arrayToVector3(asteroidConfig.linearVelocity)); - - if (asteroidConfig.angularVelocity) { - rock.physicsBody.setAngularVelocity(this.arrayToVector3(asteroidConfig.angularVelocity)); - } - - // Note: We don't set mass here as RockFactory already sets it to 10000 - // If needed, could add: rock.physicsBody.setMassProperties({ mass: asteroidConfig.mass || 10000 }); - } - // Get the actual mesh from the Rock object // The Rock class wraps the mesh, need to access it via position getter const mesh = this.scene.getMeshByName(asteroidConfig.id); diff --git a/src/levelEditor.ts b/src/levelEditor.ts index c5d37f3..2c6162a 100644 --- a/src/levelEditor.ts +++ b/src/levelEditor.ts @@ -1,5 +1,5 @@ import { LevelGenerator } from "./levelGenerator"; -import { LevelConfig, DifficultyConfig, Vector3Array, validateLevelConfig } from "./levelConfig"; +import { LevelConfig, DifficultyConfig, validateLevelConfig } from "./levelConfig"; import debugLog from './debug'; const STORAGE_KEY = 'space-game-levels'; @@ -373,14 +373,7 @@ class LevelEditor { parseFloat((document.getElementById('shipZ') as HTMLInputElement).value) ]; - // Override start base - generator.startBasePosition = [ - parseFloat((document.getElementById('baseX') as HTMLInputElement).value), - parseFloat((document.getElementById('baseY') as HTMLInputElement).value), - parseFloat((document.getElementById('baseZ') as HTMLInputElement).value) - ]; - generator.startBaseDiameter = parseFloat((document.getElementById('baseDiameter') as HTMLInputElement).value); - generator.startBaseHeight = parseFloat((document.getElementById('baseHeight') as HTMLInputElement).value); + // Note: startBase is no longer generated by default // Override sun generator.sunPosition = [ diff --git a/src/levelGenerator.ts b/src/levelGenerator.ts index 8138dc3..c81518a 100644 --- a/src/levelGenerator.ts +++ b/src/levelGenerator.ts @@ -19,10 +19,6 @@ export class LevelGenerator { // Configurable properties (can be overridden by subclasses or set before generate()) public shipPosition: Vector3Array = [0, 1, 0]; - public startBasePosition: Vector3Array = [0, 0, 0]; - public startBaseDiameter = 10; - public startBaseHeight = 1; - public startBaseColor: Vector3Array = [1, 1, 0]; // Yellow public sunPosition: Vector3Array = [0, 0, 400]; public sunDiameter = 50; @@ -52,7 +48,6 @@ export class LevelGenerator { */ public generate(): LevelConfig { const ship = this.generateShip(); - const startBase = this.generateStartBase(); const sun = this.generateSun(); const planets = this.generatePlanets(); const asteroids = this.generateAsteroids(); @@ -66,7 +61,7 @@ export class LevelGenerator { description: `Procedurally generated ${this._difficulty} level` }, ship, - startBase, + // startBase is now optional and not generated sun, planets, asteroids, @@ -83,15 +78,6 @@ export class LevelGenerator { }; } - private generateStartBase(): StartBaseConfig { - return { - position: [...this.startBasePosition], - diameter: this.startBaseDiameter, - height: this.startBaseHeight, - color: [...this.startBaseColor] - }; - } - private generateSun(): SunConfig { return { position: [...this.sunPosition], diff --git a/src/main.ts b/src/main.ts index 4c228a3..bcbdbd0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,16 +2,14 @@ import { AudioEngineV2, Color3, CreateAudioEngineAsync, - DirectionalLight, Engine, - HavokPlugin, HemisphericLight, + HavokPlugin, ParticleHelper, Scene, - ScenePerformancePriority, Vector3, WebGPUEngine, WebXRDefaultExperience, - WebXRFeatureName, WebXRFeaturesManager + WebXRFeaturesManager } from "@babylonjs/core"; import '@babylonjs/loaders'; import HavokPhysics from "@babylonjs/havok"; @@ -216,7 +214,7 @@ export class Main { DefaultScene.DemoScene = new Scene(this._engine); DefaultScene.MainScene = new Scene(this._engine); - DefaultScene.MainScene.ambientColor = new Color3(0,0,0); + DefaultScene.MainScene.ambientColor = new Color3(.2,.2,.2); DefaultScene.MainScene.clearColor = new Color3(0, 0, 0).toColor4(); diff --git a/src/rockFactory.ts b/src/rockFactory.ts index 10d2eea..f2f97f2 100644 --- a/src/rockFactory.ts +++ b/src/rockFactory.ts @@ -1,21 +1,21 @@ import { AbstractMesh, + DistanceConstraint, InstancedMesh, Mesh, Observable, - PBRMaterial, - PhysicsAggregate, PhysicsBody, + PhysicsAggregate, + PhysicsBody, PhysicsMotionType, - PhysicsShapeType, PhysicsViewer, - SceneLoader, + PhysicsShapeType, TransformNode, Vector3 } from "@babylonjs/core"; import {DefaultScene} from "./defaultScene"; import {ScoreEvent} from "./scoreboard"; -import { GameConfig } from "./gameConfig"; -import { MaterialFactory } from "./materialFactory"; -import { ExplosionManager } from "./explosionManager"; +import {GameConfig} from "./gameConfig"; +import {ExplosionManager} from "./explosionManager"; import debugLog from './debug'; +import loadAsset from "./utils/loadAsset"; export class Rock { private _rockMesh: AbstractMesh; @@ -31,60 +31,39 @@ export class Rock { } export class RockFactory { - private static _rockMesh: AbstractMesh; - private static _rockMaterial: PBRMaterial; - private static _originalMaterial: PBRMaterial = null; + private static _asteroidMesh: AbstractMesh; private static _explosionManager: ExplosionManager; - private static _viewer: PhysicsViewer = null; + private static _orbitCenter: PhysicsAggregate; public static async init() { // Initialize explosion manager + const node = new TransformNode('orbitCenter', DefaultScene.MainScene); + node.position = Vector3.Zero(); + this._orbitCenter = new PhysicsAggregate(node, PhysicsShapeType.SPHERE, {radius: .1, mass: 1000}, DefaultScene.MainScene ); + this._explosionManager = new ExplosionManager(DefaultScene.MainScene, { - duration: 500, - explosionForce: 15.0, + duration: 800, + explosionForce: 20.0, frameRate: 60 }); await this._explosionManager.initialize(); - if (!this._rockMesh) { + if (!this._asteroidMesh) { await this.loadMesh(); } } private static async loadMesh() { debugLog('loading mesh'); - const importMesh = await SceneLoader.ImportMeshAsync(null, "./", "asteroid4.glb", DefaultScene.MainScene); - this._rockMesh = importMesh.meshes[1].clone("asteroid", null, false); - this._rockMesh.setParent(null); - this._rockMesh.setEnabled(false); - - //importMesh.meshes[1].dispose(); - debugLog(importMesh.meshes); - if (!this._rockMaterial) { - // Clone the original material from GLB to preserve all textures - this._originalMaterial = this._rockMesh.material.clone("asteroid-original") as PBRMaterial; - this._rockMaterial = this._rockMesh.material.clone("asteroid-original") as PBRMaterial; - debugLog('Cloned original material from GLB:', this._originalMaterial); - - // Create material using GameConfig texture level - /*const config = GameConfig.getInstance(); - this._rockMaterial = MaterialFactory.createAsteroidMaterial( - 'asteroid-material', - config.asteroidTextureLevel, - DefaultScene.MainScene, - this._originalMaterial - ) as PBRMaterial;*/ - this._rockMaterial.freeze(); - - this._rockMesh.material = this._rockMaterial; - importMesh.meshes[1].dispose(false, true); - importMesh.meshes[0].dispose(); - } + this._asteroidMesh = (await loadAsset("asteroid.glb")).meshes.get('Asteroid'); + this._asteroidMesh.setParent(null); + this._asteroidMesh.setEnabled(false); + debugLog(this._asteroidMesh); } public static async createRock(i: number, position: Vector3, size: Vector3, - score: Observable): Promise { + linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable): Promise { - const rock = new InstancedMesh("asteroid-" +i, this._rockMesh as Mesh); + const rock = new InstancedMesh("asteroid-" +i, this._asteroidMesh as Mesh); debugLog(rock.id); rock.scaling = size; rock.position = position; @@ -105,23 +84,16 @@ export class RockFactory { // Don't pass radius - let Babylon compute from scaled mesh bounds }, DefaultScene.MainScene); const body = agg.body; + const constraint = new DistanceConstraint(Vector3.Distance(position, this._orbitCenter.body.transformNode.position), DefaultScene.MainScene); + body.addConstraint(this._orbitCenter.body, constraint); - if (!this._viewer) { - // this._viewer = new PhysicsViewer(DefaultScene.MainScene); - } - - // this._viewer.showBody(body); body.setLinearDamping(0) body.setMotionType(PhysicsMotionType.DYNAMIC); body.setCollisionCallbackEnabled(true); - + body.setLinearVelocity(linearVelocitry); + body.setAngularVelocity(angularVelocity); body.getCollisionObservable().add((eventData) => { if (eventData.type == 'COLLISION_STARTED') { - /*debugLog('[RockFactory] Collision detected:', { - collidedWith: eventData.collidedAgainst.transformNode.id, - asteroidName: eventData.collider.transformNode.name - });*/ - if ( eventData.collidedAgainst.transformNode.id == 'ammo') { debugLog('[RockFactory] ASTEROID HIT! Triggering explosion...'); score.notifyObservers({score: 1, remaining: -1, message: "Asteroid Destroyed"}); @@ -149,17 +121,8 @@ export class RockFactory { eventData.collidedAgainst.dispose(); debugLog('[RockFactory] Disposal complete'); } - } else { - /*debugLog('[RockFactory] Collision ended between:', { - collider: eventData.collider.transformNode.id, - collidedWith: eventData.collidedAgainst.transformNode.id - });*/ } - }); - - //body.setAngularVelocity(new Vector3(Math.random(), Math.random(), Math.random())); - // body.setLinearVelocity(Vector3.Random(-10, 10)); } return new Rock(rock); diff --git a/src/ship.ts b/src/ship.ts index 70358e8..7090253 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -7,7 +7,7 @@ import { Observable, PhysicsAggregate, PhysicsMotionType, - PhysicsShapeType, PointLight, + PhysicsShapeType, SceneLoader, StandardMaterial, TransformNode, @@ -374,6 +374,16 @@ export class Ship { this._shooting = false; } } + if (controllerEvent.component.type == 'button') { + if (controllerEvent.component.id == 'a-button') { + DefaultScene.XR.baseExperience.camera.position.y = DefaultScene.XR.baseExperience.camera.position.y - .1; + } + if (controllerEvent.component.id == 'b-button') { + DefaultScene.XR.baseExperience.camera.position.y = DefaultScene.XR.baseExperience.camera.position.y + .1; + } + console.log(controllerEvent); + + } } } diff --git a/src/starBase.ts b/src/starBase.ts index 5c5172e..0e884fc 100644 --- a/src/starBase.ts +++ b/src/starBase.ts @@ -1,50 +1,64 @@ import { - AbstractMesh, LoadAssetContainerAsync, Mesh, + AbstractMesh, + HavokPlugin, PhysicsAggregate, PhysicsMotionType, - PhysicsShapeType, Scene, + PhysicsShapeType, Vector3 } from "@babylonjs/core"; import {DefaultScene} from "./defaultScene"; import {GameConfig} from "./gameConfig"; import debugLog from "./debug"; +import loadAsset from "./utils/loadAsset"; /** * Create and load the star base mesh * @param position - Position for the star base * @returns Promise resolving to the loaded star base mesh */ -export default async function buildStarBase(position: Vector3): Promise { - const scene = DefaultScene.MainScene; +export default class StarBase { + public static async buildStarBase(position: Vector3): Promise { + const config = GameConfig.getInstance(); + const scene = DefaultScene.MainScene; + const importMeshes = await loadAsset('base.glb'); + const baseMesh = importMeshes.meshes.get('Base'); + const landingMesh = importMeshes.meshes.get('BaseLandingZone'); + clearParent(importMeshes.meshes, position); - // Load the base model - const importMesh = await LoadAssetContainerAsync('/base.glb', DefaultScene.MainScene, - { - pluginOptions: { - gltf: { - enabled: true, - } - } - }); - importMesh.addAllToScene(); - const starBase = Mesh.MergeMeshes(importMesh.rootNodes[0].getChildMeshes(false), true, false, null, false, true); - starBase.id = 'starBase'; - starBase.name = 'starBase'; - DefaultScene.MainScene.addMesh(starBase); - debugLog('imported base mesh', importMesh.meshes[0]); - starBase.position = position; - const config = GameConfig.getInstance(); - if (config.physicsEnabled) { - const agg = new PhysicsAggregate(starBase, PhysicsShapeType.MESH, { - mass: 10000 - }, scene); - agg.body.setMotionType(PhysicsMotionType.ANIMATED); - agg.body.setCollisionCallbackEnabled(true); - agg.body.getCollisionObservable().add((collidedBody) => { - debugLog('collidedBody', collidedBody); - }) + if (config.physicsEnabled) { + const agg2 = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, { + mass: 10000 + }, scene); + agg2.body.setMotionType(PhysicsMotionType.ANIMATED); + + agg2.body.getCollisionObservable().add((collidedBody) => { + debugLog('collidedBody', collidedBody); + }) + + const landingAgg = new PhysicsAggregate(landingMesh, PhysicsShapeType.MESH); + landingAgg.body.setMotionType(PhysicsMotionType.ANIMATED); + landingAgg.body.getCollisionObservable().add((collidedCollidedBody) => { + console.log(collidedCollidedBody); + }); + landingAgg.shape.isTrigger = true; + (DefaultScene.MainScene.getPhysicsEngine().getPhysicsPlugin() as HavokPlugin).onTriggerCollisionObservable.add((eventdata, eventState) => { + console.log(eventState); + console.log(eventdata); + }) + landingAgg.body.setCollisionCallbackEnabled(true); + } + //importMesh.rootNodes[0].dispose(); + return baseMesh; } - importMesh.rootNodes[0].dispose(); - return starBase; -} \ No newline at end of file +} +function clearParent (meshes: Map, position?: Vector3) { + meshes.forEach((mesh) => { + mesh.setParent(null); + if (position) { + mesh.position = position; + } + + DefaultScene.MainScene.addMesh(mesh); + }) +} diff --git a/src/utils/blenderExporter.ts b/src/utils/blenderExporter.ts new file mode 100644 index 0000000..3a3c821 --- /dev/null +++ b/src/utils/blenderExporter.ts @@ -0,0 +1,301 @@ +import { spawn } from 'child_process'; +import { platform } from 'os'; +import { existsSync, watch } from 'fs'; +import path from 'path'; + +/** + * Configuration options for Blender export + */ +export interface BlenderExportOptions { + /** + * Custom path to Blender executable (optional) + * If not provided, will use default paths for the current platform + */ + blenderPath?: string; + + /** + * Additional glTF export parameters + * See: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf + */ + exportParams?: { + export_format?: 'GLB' | 'GLTF_SEPARATE' | 'GLTF_EMBEDDED'; + export_draco_mesh_compression_enable?: boolean; + export_texture_dir?: string; + export_apply_modifiers?: boolean; + export_yup?: boolean; + export_animations?: boolean; + export_materials?: 'EXPORT' | 'PLACEHOLDER' | 'NONE'; + [key: string]: any; + }; + + /** + * Timeout in milliseconds (default: 60000 = 1 minute) + */ + timeout?: number; +} + +/** + * Result of a Blender export operation + */ +export interface BlenderExportResult { + success: boolean; + outputPath: string; + stdout: string; + stderr: string; + duration: number; // milliseconds +} + +/** + * Get the default Blender executable path for the current platform + */ +function getDefaultBlenderPath(): string { + const os = platform(); + + switch (os) { + case 'darwin': // macOS + return '/Applications/Blender.app/Contents/MacOS/Blender'; + case 'win32': // Windows + // Try common installation paths + const windowsPaths = [ + 'C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender 4.1\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender 4.0\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender 3.6\\blender.exe', + 'C:\\Program Files\\Blender Foundation\\Blender\\blender.exe', + ]; + for (const p of windowsPaths) { + if (existsSync(p)) return p; + } + return 'blender'; // Fall back to PATH + case 'linux': + return 'blender'; // Assume it's in PATH + default: + return 'blender'; + } +} + +/** + * Build the Python expression for glTF export + */ +function buildPythonExpr(outputPath: string, options?: BlenderExportOptions['exportParams']): string { + const params: string[] = [`filepath='${outputPath.replace(/\\/g, '/')}'`]; + + if (options) { + for (const [key, value] of Object.entries(options)) { + if (typeof value === 'boolean') { + params.push(`${key}=${value ? 'True' : 'False'}`); + } else if (typeof value === 'string') { + params.push(`${key}='${value}'`); + } else if (typeof value === 'number') { + params.push(`${key}=${value}`); + } + } + } + + return `import bpy; bpy.ops.export_scene.gltf(${params.join(', ')})`; +} + +/** + * Export a Blender file to GLB format using Blender's command-line interface + * + * @param blendFilePath - Path to the input .blend file + * @param outputPath - Path for the output .glb file + * @param options - Optional configuration for the export + * @returns Promise that resolves with export result + * + * @example + * ```typescript + * // Basic usage + * await exportBlendToGLB('./models/ship.blend', './public/ship.glb'); + * + * // With options + * await exportBlendToGLB('./models/asteroid.blend', './public/asteroid.glb', { + * exportParams: { + * export_draco_mesh_compression_enable: true, + * export_apply_modifiers: true + * } + * }); + * + * // With custom Blender path + * await exportBlendToGLB('./model.blend', './output.glb', { + * blenderPath: '/custom/path/to/blender' + * }); + * ``` + */ +export async function exportBlendToGLB( + blendFilePath: string, + outputPath: string, + options?: BlenderExportOptions +): Promise { + const startTime = Date.now(); + + // Validate input file exists + if (!existsSync(blendFilePath)) { + throw new Error(`Input blend file not found: ${blendFilePath}`); + } + + // Ensure output directory exists + const outputDir = path.dirname(outputPath); + if (!existsSync(outputDir)) { + throw new Error(`Output directory does not exist: ${outputDir}`); + } + + // Get Blender executable path + const blenderPath = options?.blenderPath || getDefaultBlenderPath(); + + // Verify Blender exists + if (blenderPath !== 'blender' && !existsSync(blenderPath)) { + throw new Error(`Blender executable not found at: ${blenderPath}`); + } + + // Build Python expression + const pythonExpr = buildPythonExpr(outputPath, options?.exportParams); + + // Build command arguments + const args = [ + '-b', // Background mode (no UI) + blendFilePath, // Input file + '--python-expr', // Execute Python expression + pythonExpr // The export command + ]; + + console.log(`[BlenderExporter] Running: ${blenderPath} ${args.join(' ')}`); + + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const process = spawn(blenderPath, args, { + shell: false, + windowsHide: true + }); + + // Set timeout + const timeout = options?.timeout || 60000; + const timeoutId = setTimeout(() => { + process.kill(); + reject(new Error(`Blender export timed out after ${timeout}ms`)); + }, timeout); + + process.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + process.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + process.on('error', (error) => { + clearTimeout(timeoutId); + reject(new Error(`Failed to spawn Blender process: ${error.message}`)); + }); + + process.on('close', (code) => { + clearTimeout(timeoutId); + const duration = Date.now() - startTime; + + if (code === 0) { + // Check if output file was created + if (existsSync(outputPath)) { + console.log(`[BlenderExporter] Successfully exported to ${outputPath} in ${duration}ms`); + resolve({ + success: true, + outputPath, + stdout, + stderr, + duration + }); + } else { + reject(new Error(`Blender exited successfully but output file was not created: ${outputPath}`)); + } + } else { + const error = new Error( + `Blender export failed with exit code ${code}\n` + + `STDERR: ${stderr}\n` + + `STDOUT: ${stdout}` + ); + reject(error); + } + }); + }); +} + +/** + * Batch export multiple Blender files to GLB + * + * @param exports - Array of [inputPath, outputPath] tuples + * @param options - Optional configuration for all exports + * @param sequential - If true, run exports one at a time (default: false for parallel) + * @returns Promise that resolves with array of results + * + * @example + * ```typescript + * const results = await batchExportBlendToGLB([ + * ['./ship1.blend', './public/ship1.glb'], + * ['./ship2.blend', './public/ship2.glb'], + * ['./asteroid.blend', './public/asteroid.glb'] + * ], { + * exportParams: { export_draco_mesh_compression_enable: true } + * }); + * ``` + */ +export async function batchExportBlendToGLB( + exports: Array<[string, string]>, + options?: BlenderExportOptions, + sequential: boolean = false +): Promise { + if (sequential) { + const results: BlenderExportResult[] = []; + for (const [input, output] of exports) { + const result = await exportBlendToGLB(input, output, options); + results.push(result); + } + return results; + } else { + return Promise.all( + exports.map(([input, output]) => exportBlendToGLB(input, output, options)) + ); + } +} + +/** + * Watch a Blender file and auto-export on changes + * (Requires fs.watch - Node.js only, not for browser) + * + * @param blendFilePath - Path to watch + * @param outputPath - Output GLB path + * @param options - Export options + * @returns Function to stop watching + */ +export function watchAndExport( + blendFilePath: string, + outputPath: string, + options?: BlenderExportOptions +): () => void { + console.log(`[BlenderExporter] Watching ${blendFilePath} for changes...`); + + let debounceTimer: NodeJS.Timeout | null = null; + + const watcher = watch(blendFilePath, (eventType: string) => { + if (eventType === 'change') { + // Debounce: wait 1 second after last change + if (debounceTimer) clearTimeout(debounceTimer); + + debounceTimer = setTimeout(async () => { + console.log(`[BlenderExporter] Detected change in ${blendFilePath}, exporting...`); + try { + await exportBlendToGLB(blendFilePath, outputPath, options); + } catch (error) { + console.error(`[BlenderExporter] Export failed:`, error); + } + }, 1000); + } + }); + + // Return cleanup function + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + console.log(`[BlenderExporter] Stopped watching ${blendFilePath}`); + }; +} diff --git a/src/utils/loadAsset.ts b/src/utils/loadAsset.ts new file mode 100644 index 0000000..83ae0c1 --- /dev/null +++ b/src/utils/loadAsset.ts @@ -0,0 +1,15 @@ +import {DefaultScene} from "../defaultScene"; +import {AbstractMesh, AssetContainer, LoadAssetContainerAsync} from "@babylonjs/core"; + +export type LoadedAsset = { + container: AssetContainer, + meshes: Map, +} +export default async function loadAsset(file: string, theme: string = "default"): Promise { + const container = await LoadAssetContainerAsync(`assets/themes/${theme}/models/${file}`, DefaultScene.MainScene); + const map: Map = new Map(); + for (const mesh of container.rootNodes[0].getChildMeshes(false)) { + map.set(mesh.id, mesh); + } + return {container: container, meshes: map}; +} \ No newline at end of file diff --git a/themes/default/asteroid.blend b/themes/default/asteroid.blend new file mode 100644 index 0000000..b3b6922 Binary files /dev/null and b/themes/default/asteroid.blend differ diff --git a/themes/default/asteroid.jpg b/themes/default/asteroid.jpg new file mode 100644 index 0000000..879a581 Binary files /dev/null and b/themes/default/asteroid.jpg differ diff --git a/themes/default/base.blend b/themes/default/base.blend new file mode 100644 index 0000000..2e7c090 Binary files /dev/null and b/themes/default/base.blend differ diff --git a/themes/default/ship.blend b/themes/default/ship.blend new file mode 100644 index 0000000..31d1d95 Binary files /dev/null and b/themes/default/ship.blend differ diff --git a/themes/default/shiplights.jpg b/themes/default/shiplights.jpg new file mode 100644 index 0000000..c50b5c0 Binary files /dev/null and b/themes/default/shiplights.jpg differ