create-threejs-game 1.0.6 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -3
- package/bin/cli.js +158 -0
- package/package.json +4 -1
- package/template/scripts/combine-previews.js +211 -0
package/README.md
CHANGED
|
@@ -7,15 +7,28 @@ Scaffold a Three.js game project with AI-assisted design documents.
|
|
|
7
7
|
Before running the CLI, have these ready:
|
|
8
8
|
|
|
9
9
|
### 1. 3D Assets (Required)
|
|
10
|
-
Download
|
|
10
|
+
Download GLTF asset packs from:
|
|
11
11
|
- [itch.io](https://itch.io/game-assets/tag-3d)
|
|
12
12
|
- [Kenney.nl](https://kenney.nl/assets)
|
|
13
13
|
- [Quaternius](https://quaternius.com/)
|
|
14
14
|
|
|
15
15
|
The CLI will ask for the path to your downloaded assets folder and validate it contains `.gltf` or `.glb` files.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
**Multi-Pack Support:** You can combine multiple asset packs by organizing them in subdirectories (nested to any depth):
|
|
18
|
+
```
|
|
19
|
+
my-assets/
|
|
20
|
+
├── characters/
|
|
21
|
+
│ ├── humans/ # Has Preview.jpg
|
|
22
|
+
│ └── monsters/ # Has Preview.jpg
|
|
23
|
+
├── buildings/ # Has Preview.jpg
|
|
24
|
+
└── environment/
|
|
25
|
+
├── trees/ # Has Preview.jpg
|
|
26
|
+
└── rocks/ # Has Preview.jpg
|
|
27
|
+
```
|
|
28
|
+
The CLI recursively finds all directories with previews and combines them into a single grid.
|
|
29
|
+
|
|
30
|
+
### 2. Preview Image (Auto-generated for multi-packs)
|
|
31
|
+
Most asset packs include a `Preview.jpg`. For multi-pack directories, the CLI automatically combines all subdirectory previews into one image. For single packs without a preview, take a screenshot of your assets.
|
|
19
32
|
|
|
20
33
|
### 3. API Keys (Required for automation)
|
|
21
34
|
Set these as environment variables for the smoothest experience:
|
package/bin/cli.js
CHANGED
|
@@ -16,6 +16,14 @@ const path = require('path');
|
|
|
16
16
|
const readline = require('readline');
|
|
17
17
|
const { execSync, spawn } = require('child_process');
|
|
18
18
|
|
|
19
|
+
// Sharp for image processing (optional, for combining previews)
|
|
20
|
+
let sharp;
|
|
21
|
+
try {
|
|
22
|
+
sharp = require('sharp');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
// Sharp not available - preview combining will be skipped
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
// Colors for terminal output
|
|
20
28
|
const colors = {
|
|
21
29
|
reset: '\x1b[0m',
|
|
@@ -91,6 +99,131 @@ function copyDir(src, dest, exclude = []) {
|
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
// Find preview image in a directory
|
|
103
|
+
function findPreview(dir) {
|
|
104
|
+
const previewNames = ['Preview.jpg', 'Preview.png', 'preview.jpg', 'preview.png',
|
|
105
|
+
'Preview.jpeg', 'preview.jpeg'];
|
|
106
|
+
for (const name of previewNames) {
|
|
107
|
+
const previewPath = path.join(dir, name);
|
|
108
|
+
if (fs.existsSync(previewPath)) {
|
|
109
|
+
return previewPath;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Recursively find all directories with preview images
|
|
116
|
+
function findPacksWithPreviews(dir, basePath = dir) {
|
|
117
|
+
const packs = [];
|
|
118
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
119
|
+
|
|
120
|
+
// Check if this directory has a preview
|
|
121
|
+
const preview = findPreview(dir);
|
|
122
|
+
if (preview && dir !== basePath) {
|
|
123
|
+
// Use relative path from base as the name
|
|
124
|
+
const relativePath = path.relative(basePath, dir);
|
|
125
|
+
packs.push({
|
|
126
|
+
name: relativePath,
|
|
127
|
+
path: dir,
|
|
128
|
+
preview: preview
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Recursively check subdirectories
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (!entry.isDirectory()) continue;
|
|
135
|
+
if (entry.name.startsWith('.')) continue; // Skip hidden dirs
|
|
136
|
+
|
|
137
|
+
const subdirPath = path.join(dir, entry.name);
|
|
138
|
+
const subPacks = findPacksWithPreviews(subdirPath, basePath);
|
|
139
|
+
packs.push(...subPacks);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return packs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Detect if directory is a multi-pack (has subdirs with previews)
|
|
146
|
+
function detectMultiPack(sourceDir) {
|
|
147
|
+
const packsWithPreviews = findPacksWithPreviews(sourceDir, sourceDir);
|
|
148
|
+
|
|
149
|
+
// Consider it a multi-pack if at least 2 directories have previews
|
|
150
|
+
if (packsWithPreviews.length >= 2) {
|
|
151
|
+
return packsWithPreviews;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Combine preview images into a grid
|
|
158
|
+
async function combinePreviews(packs, outputPath) {
|
|
159
|
+
if (!sharp) {
|
|
160
|
+
console.log(c('yellow', ' ⚠ sharp not available - skipping preview combination'));
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const cellWidth = 512;
|
|
165
|
+
const cellHeight = 512;
|
|
166
|
+
const padding = 10;
|
|
167
|
+
const backgroundColor = { r: 30, g: 30, b: 30, alpha: 1 };
|
|
168
|
+
|
|
169
|
+
const cols = Math.ceil(Math.sqrt(packs.length));
|
|
170
|
+
const rows = Math.ceil(packs.length / cols);
|
|
171
|
+
const totalWidth = cols * cellWidth + (cols + 1) * padding;
|
|
172
|
+
const totalHeight = rows * cellHeight + (rows + 1) * padding;
|
|
173
|
+
|
|
174
|
+
console.log(c('dim', ` Creating ${cols}x${rows} combined preview...`));
|
|
175
|
+
|
|
176
|
+
const composites = [];
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < packs.length; i++) {
|
|
179
|
+
const pack = packs[i];
|
|
180
|
+
const col = i % cols;
|
|
181
|
+
const row = Math.floor(i / cols);
|
|
182
|
+
|
|
183
|
+
const x = padding + col * (cellWidth + padding);
|
|
184
|
+
const y = padding + row * (cellHeight + padding);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const resized = await sharp(pack.preview)
|
|
188
|
+
.resize(cellWidth, cellHeight, {
|
|
189
|
+
fit: 'contain',
|
|
190
|
+
background: backgroundColor
|
|
191
|
+
})
|
|
192
|
+
.toBuffer();
|
|
193
|
+
|
|
194
|
+
composites.push({
|
|
195
|
+
input: resized,
|
|
196
|
+
left: x,
|
|
197
|
+
top: y
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.log(c('yellow', ` ⚠ Failed to process ${pack.name}: ${err.message}`));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (composites.length === 0) return null;
|
|
205
|
+
|
|
206
|
+
// Ensure output directory exists
|
|
207
|
+
const outputDir = path.dirname(outputPath);
|
|
208
|
+
if (!fs.existsSync(outputDir)) {
|
|
209
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await sharp({
|
|
213
|
+
create: {
|
|
214
|
+
width: totalWidth,
|
|
215
|
+
height: totalHeight,
|
|
216
|
+
channels: 4,
|
|
217
|
+
background: backgroundColor
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
.composite(composites)
|
|
221
|
+
.jpeg({ quality: 90 })
|
|
222
|
+
.toFile(outputPath);
|
|
223
|
+
|
|
224
|
+
return outputPath;
|
|
225
|
+
}
|
|
226
|
+
|
|
94
227
|
// Run a script
|
|
95
228
|
function runScript(scriptPath, args = [], cwd) {
|
|
96
229
|
return new Promise((resolve, reject) => {
|
|
@@ -307,7 +440,21 @@ async function main() {
|
|
|
307
440
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
308
441
|
|
|
309
442
|
// Copy assets if path was provided
|
|
443
|
+
let isMultiPack = false;
|
|
444
|
+
let multiPackInfo = null;
|
|
445
|
+
|
|
310
446
|
if (assetsSourcePath) {
|
|
447
|
+
// Check if this is a multi-pack directory
|
|
448
|
+
multiPackInfo = detectMultiPack(assetsSourcePath);
|
|
449
|
+
isMultiPack = multiPackInfo !== null;
|
|
450
|
+
|
|
451
|
+
if (isMultiPack) {
|
|
452
|
+
console.log(c('cyan', ` Detected multi-pack with ${multiPackInfo.length} asset packs:`));
|
|
453
|
+
for (const pack of multiPackInfo) {
|
|
454
|
+
console.log(c('dim', ` - ${pack.name}`));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
311
458
|
console.log(c('dim', ' Copying assets...'));
|
|
312
459
|
copyDir(assetsSourcePath, assetsDir, ['node_modules', '.git', '.DS_Store']);
|
|
313
460
|
|
|
@@ -326,6 +473,17 @@ async function main() {
|
|
|
326
473
|
};
|
|
327
474
|
const fileCount = countFiles(assetsDir);
|
|
328
475
|
console.log(c('green', ' ✓ ') + `public/assets/${gameName}/ (${fileCount} files copied)`);
|
|
476
|
+
|
|
477
|
+
// Combine previews if multi-pack
|
|
478
|
+
if (isMultiPack && sharp) {
|
|
479
|
+
try {
|
|
480
|
+
const combinedPreviewPath = path.join(assetsDir, 'Preview.jpg');
|
|
481
|
+
await combinePreviews(multiPackInfo, combinedPreviewPath);
|
|
482
|
+
console.log(c('green', ' ✓ ') + 'Combined preview image generated');
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.log(c('yellow', ` ⚠ Could not combine previews: ${err.message}`));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
329
487
|
} else {
|
|
330
488
|
fs.writeFileSync(path.join(assetsDir, '.gitkeep'), '');
|
|
331
489
|
console.log(c('green', ' ✓ ') + `public/assets/${gameName}/`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-threejs-game",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Scaffold a Three.js game project with AI-assisted design documents",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-threejs-game": "./bin/cli.js"
|
|
@@ -25,5 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"sharp": "^0.33.0"
|
|
28
31
|
}
|
|
29
32
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combine Preview Images
|
|
5
|
+
*
|
|
6
|
+
* Scans a directory for subdirectories containing Preview.jpg/png files
|
|
7
|
+
* and combines them into a single grid image.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node combine-previews.js <source-dir> <output-path>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// We'll use sharp for image processing - it's fast and handles this well
|
|
16
|
+
let sharp;
|
|
17
|
+
try {
|
|
18
|
+
sharp = require('sharp');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
console.error('Error: sharp module not found.');
|
|
21
|
+
console.error('Install it with: npm install sharp');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find preview image in a directory
|
|
27
|
+
*/
|
|
28
|
+
function findPreview(dir) {
|
|
29
|
+
const previewNames = ['Preview.jpg', 'Preview.png', 'preview.jpg', 'preview.png',
|
|
30
|
+
'Preview.jpeg', 'preview.jpeg'];
|
|
31
|
+
for (const name of previewNames) {
|
|
32
|
+
const previewPath = path.join(dir, name);
|
|
33
|
+
if (fs.existsSync(previewPath)) {
|
|
34
|
+
return previewPath;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Recursively find all preview images in subdirectories (any depth)
|
|
42
|
+
*/
|
|
43
|
+
function findPreviews(sourceDir, basePath = sourceDir) {
|
|
44
|
+
const previews = [];
|
|
45
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
// Check if this directory has a preview (skip the root)
|
|
48
|
+
if (sourceDir !== basePath) {
|
|
49
|
+
const preview = findPreview(sourceDir);
|
|
50
|
+
if (preview) {
|
|
51
|
+
const relativePath = path.relative(basePath, sourceDir);
|
|
52
|
+
previews.push({
|
|
53
|
+
path: preview,
|
|
54
|
+
name: relativePath
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Recursively check subdirectories
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry.isDirectory()) continue;
|
|
62
|
+
if (entry.name.startsWith('.')) continue; // Skip hidden dirs
|
|
63
|
+
|
|
64
|
+
const subdirPath = path.join(sourceDir, entry.name);
|
|
65
|
+
const subPreviews = findPreviews(subdirPath, basePath);
|
|
66
|
+
previews.push(...subPreviews);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return previews;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calculate grid dimensions for N images
|
|
74
|
+
*/
|
|
75
|
+
function calculateGrid(count) {
|
|
76
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
77
|
+
const rows = Math.ceil(count / cols);
|
|
78
|
+
return { cols, rows };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Combine images into a grid
|
|
83
|
+
*/
|
|
84
|
+
async function combineImages(previews, outputPath, options = {}) {
|
|
85
|
+
const {
|
|
86
|
+
cellWidth = 512,
|
|
87
|
+
cellHeight = 512,
|
|
88
|
+
padding = 10,
|
|
89
|
+
backgroundColor = { r: 30, g: 30, b: 30, alpha: 1 }
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
if (previews.length === 0) {
|
|
93
|
+
console.log('No preview images found.');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (previews.length === 1) {
|
|
98
|
+
// Just copy the single preview
|
|
99
|
+
fs.copyFileSync(previews[0].path, outputPath);
|
|
100
|
+
console.log(`Copied single preview: ${previews[0].name}`);
|
|
101
|
+
return outputPath;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { cols, rows } = calculateGrid(previews.length);
|
|
105
|
+
const totalWidth = cols * cellWidth + (cols + 1) * padding;
|
|
106
|
+
const totalHeight = rows * cellHeight + (rows + 1) * padding;
|
|
107
|
+
|
|
108
|
+
console.log(`Creating ${cols}x${rows} grid (${totalWidth}x${totalHeight}px) for ${previews.length} previews...`);
|
|
109
|
+
|
|
110
|
+
// Prepare each image
|
|
111
|
+
const composites = [];
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < previews.length; i++) {
|
|
114
|
+
const preview = previews[i];
|
|
115
|
+
const col = i % cols;
|
|
116
|
+
const row = Math.floor(i / cols);
|
|
117
|
+
|
|
118
|
+
const x = padding + col * (cellWidth + padding);
|
|
119
|
+
const y = padding + row * (cellHeight + padding);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Resize image to fit cell while maintaining aspect ratio
|
|
123
|
+
const resized = await sharp(preview.path)
|
|
124
|
+
.resize(cellWidth, cellHeight, {
|
|
125
|
+
fit: 'contain',
|
|
126
|
+
background: backgroundColor
|
|
127
|
+
})
|
|
128
|
+
.toBuffer();
|
|
129
|
+
|
|
130
|
+
composites.push({
|
|
131
|
+
input: resized,
|
|
132
|
+
left: x,
|
|
133
|
+
top: y
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log(` [${i + 1}/${previews.length}] ${preview.name}`);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(` Failed to process ${preview.name}: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Create the combined image
|
|
143
|
+
const combined = sharp({
|
|
144
|
+
create: {
|
|
145
|
+
width: totalWidth,
|
|
146
|
+
height: totalHeight,
|
|
147
|
+
channels: 4,
|
|
148
|
+
background: backgroundColor
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await combined
|
|
153
|
+
.composite(composites)
|
|
154
|
+
.jpeg({ quality: 90 })
|
|
155
|
+
.toFile(outputPath);
|
|
156
|
+
|
|
157
|
+
console.log(`\nCombined preview saved to: ${outputPath}`);
|
|
158
|
+
return outputPath;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Main function
|
|
163
|
+
*/
|
|
164
|
+
async function main() {
|
|
165
|
+
const args = process.argv.slice(2);
|
|
166
|
+
|
|
167
|
+
if (args.length < 2) {
|
|
168
|
+
console.log('Usage: node combine-previews.js <source-dir> <output-path>');
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('Scans source-dir for subdirectories with Preview.jpg/png');
|
|
171
|
+
console.log('and combines them into a single grid image.');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sourceDir = args[0];
|
|
176
|
+
const outputPath = args[1];
|
|
177
|
+
|
|
178
|
+
if (!fs.existsSync(sourceDir)) {
|
|
179
|
+
console.error(`Error: Source directory not found: ${sourceDir}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const previews = findPreviews(sourceDir);
|
|
184
|
+
|
|
185
|
+
if (previews.length === 0) {
|
|
186
|
+
console.log('No subdirectories with preview images found.');
|
|
187
|
+
console.log('Looking for: Preview.jpg, Preview.png, preview.jpg, preview.png');
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(`Found ${previews.length} preview images:\n`);
|
|
192
|
+
|
|
193
|
+
// Ensure output directory exists
|
|
194
|
+
const outputDir = path.dirname(outputPath);
|
|
195
|
+
if (!fs.existsSync(outputDir)) {
|
|
196
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await combineImages(previews, outputPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Export for use as module
|
|
203
|
+
module.exports = { findPreview, findPreviews, combineImages, calculateGrid };
|
|
204
|
+
|
|
205
|
+
// Run if called directly
|
|
206
|
+
if (require.main === module) {
|
|
207
|
+
main().catch(err => {
|
|
208
|
+
console.error('Error:', err.message);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
});
|
|
211
|
+
}
|