Add Blender export tooling and refactor asset structure
All checks were successful
Build / build (push) Successful in 1m18s
All checks were successful
Build / build (push) Successful in 1m18s
## Blender Export Utilities - Add blenderExporter utility with ESM support (tsx) - Create CLI script for exporting .blend files to GLB - Add npm scripts: export-blend, export-blend:watch, export-blend:batch - Support watch mode, batch export, and Draco compression - Complete documentation in docs/BLENDER_EXPORT.md - Add loadAsset utility helper ## Asset Structure Reorganization - Move models to themeable structure: public/assets/themes/default/models/ - Add themes/ directory with source .blend files - Remove old model files from public/ root - Consolidate to asteroid.glb, base.glb, ship.glb ## Level Configuration Improvements - Make startBase optional in LevelConfig interface - Update LevelGenerator to not generate startBase data by default - Update LevelDeserializer to handle optional startBase - Update Level1 to handle null startBase - Fix levelEditor to remove startBase generation references - Update validation to treat startBase as optional ## Dependencies - Add tsx for ESM TypeScript execution - Add @types/node for Node.js types - Update package-lock.json This enables modding support with themeable assets and simplifies level generation by making base stations optional. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f11005fdb6
commit
146ffccd3d
229
docs/BLENDER_EXPORT.md
Normal file
229
docs/BLENDER_EXPORT.md
Normal file
@ -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/
|
||||
```
|
||||
526
package-lock.json
generated
526
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
BIN
public/arrow.stl
BIN
public/arrow.stl
Binary file not shown.
BIN
public/assets/themes/default/models/asteroid.glb
Normal file
BIN
public/assets/themes/default/models/asteroid.glb
Normal file
Binary file not shown.
BIN
public/assets/themes/default/models/base.glb
Normal file
BIN
public/assets/themes/default/models/base.glb
Normal file
Binary file not shown.
BIN
public/assets/themes/default/models/ship.glb
Normal file
BIN
public/assets/themes/default/models/ship.glb
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/base.glb
BIN
public/base.glb
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/ship1.glb
BIN
public/ship1.glb
Binary file not shown.
BIN
public/ship2.glb
BIN
public/ship2.glb
Binary file not shown.
211
scripts/exportBlend.ts
Normal file
211
scripts/exportBlend.ts
Normal file
@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* CLI script to export Blender files to GLB format
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/exportBlend.ts <input.blend> <output.glb>
|
||||
* npm run export-blend -- <input.blend> <output.glb>
|
||||
*
|
||||
* 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 -- <input.blend> <output.glb> [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();
|
||||
@ -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<Level> = new Observable<Level>();
|
||||
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<string> = new Set<string>();
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ScoreEvent>): 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<AbstractMesh> {
|
||||
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);
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
|
||||
@ -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 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<ScoreEvent>): Promise<Rock> {
|
||||
linearVelocitry: Vector3, angularVelocity: Vector3, score: Observable<ScoreEvent>): Promise<Rock> {
|
||||
|
||||
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);
|
||||
|
||||
12
src/ship.ts
12
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);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<AbstractMesh> {
|
||||
const scene = DefaultScene.MainScene;
|
||||
|
||||
// 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;
|
||||
export default class StarBase {
|
||||
public static async buildStarBase(position: Vector3): Promise<AbstractMesh> {
|
||||
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);
|
||||
|
||||
|
||||
if (config.physicsEnabled) {
|
||||
const agg = new PhysicsAggregate(starBase, PhysicsShapeType.MESH, {
|
||||
const agg2 = new PhysicsAggregate(baseMesh, PhysicsShapeType.MESH, {
|
||||
mass: 10000
|
||||
}, scene);
|
||||
agg.body.setMotionType(PhysicsMotionType.ANIMATED);
|
||||
agg.body.setCollisionCallbackEnabled(true);
|
||||
agg.body.getCollisionObservable().add((collidedBody) => {
|
||||
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 starBase;
|
||||
//importMesh.rootNodes[0].dispose();
|
||||
return baseMesh;
|
||||
}
|
||||
}
|
||||
function clearParent (meshes: Map<string, AbstractMesh>, position?: Vector3) {
|
||||
meshes.forEach((mesh) => {
|
||||
mesh.setParent(null);
|
||||
if (position) {
|
||||
mesh.position = position;
|
||||
}
|
||||
|
||||
DefaultScene.MainScene.addMesh(mesh);
|
||||
})
|
||||
}
|
||||
301
src/utils/blenderExporter.ts
Normal file
301
src/utils/blenderExporter.ts
Normal file
@ -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<BlenderExportResult> {
|
||||
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<BlenderExportResult[]> {
|
||||
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}`);
|
||||
};
|
||||
}
|
||||
15
src/utils/loadAsset.ts
Normal file
15
src/utils/loadAsset.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {DefaultScene} from "../defaultScene";
|
||||
import {AbstractMesh, AssetContainer, LoadAssetContainerAsync} from "@babylonjs/core";
|
||||
|
||||
export type LoadedAsset = {
|
||||
container: AssetContainer,
|
||||
meshes: Map<string, AbstractMesh>,
|
||||
}
|
||||
export default async function loadAsset(file: string, theme: string = "default"): Promise<LoadedAsset> {
|
||||
const container = await LoadAssetContainerAsync(`assets/themes/${theme}/models/${file}`, DefaultScene.MainScene);
|
||||
const map: Map<string, AbstractMesh> = new Map();
|
||||
for (const mesh of container.rootNodes[0].getChildMeshes(false)) {
|
||||
map.set(mesh.id, mesh);
|
||||
}
|
||||
return {container: container, meshes: map};
|
||||
}
|
||||
BIN
themes/default/asteroid.blend
Normal file
BIN
themes/default/asteroid.blend
Normal file
Binary file not shown.
BIN
themes/default/asteroid.jpg
Normal file
BIN
themes/default/asteroid.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
themes/default/base.blend
Normal file
BIN
themes/default/base.blend
Normal file
Binary file not shown.
BIN
themes/default/ship.blend
Normal file
BIN
themes/default/ship.blend
Normal file
Binary file not shown.
BIN
themes/default/shiplights.jpg
Normal file
BIN
themes/default/shiplights.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
Loading…
Reference in New Issue
Block a user