Add Blender export tooling and refactor asset structure
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:
Michael Mainguy 2025-11-06 12:25:34 -06:00
parent f11005fdb6
commit 146ffccd3d
36 changed files with 1395 additions and 246 deletions

229
docs/BLENDER_EXPORT.md Normal file
View 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
View File

@ -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",

View File

@ -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"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

211
scripts/exportBlend.ts Normal file
View 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();

View File

@ -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() {
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;
}
}

View File

@ -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');
}
}

View File

@ -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);

View File

@ -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 = [

View File

@ -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],

View File

@ -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();

View File

@ -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<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);

View File

@ -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);
}
}
}

View File

@ -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;
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);
// 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;
}
function clearParent (meshes: Map<string, AbstractMesh>, position?: Vector3) {
meshes.forEach((mesh) => {
mesh.setParent(null);
if (position) {
mesh.position = position;
}
DefaultScene.MainScene.addMesh(mesh);
})
}

View 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
View 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};
}

Binary file not shown.

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

Binary file not shown.

BIN
themes/default/ship.blend Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB