create-threejs-game 1.0.5 → 1.1.0
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 +14 -3
- package/bin/cli.js +145 -0
- package/package.json +4 -1
- package/template/scripts/combine-previews.js +193 -0
package/README.md
CHANGED
|
@@ -7,15 +7,24 @@ 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:
|
|
18
|
+
```
|
|
19
|
+
my-assets/
|
|
20
|
+
├── characters/ # Asset pack 1 (with Preview.jpg)
|
|
21
|
+
├── buildings/ # Asset pack 2 (with Preview.jpg)
|
|
22
|
+
└── environment/ # Asset pack 3 (with Preview.jpg)
|
|
23
|
+
```
|
|
24
|
+
The CLI auto-detects this structure and combines all preview images into a single grid.
|
|
25
|
+
|
|
26
|
+
### 2. Preview Image (Auto-generated for multi-packs)
|
|
27
|
+
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
28
|
|
|
20
29
|
### 3. API Keys (Required for automation)
|
|
21
30
|
Set these as environment variables for the smoothest experience:
|
|
@@ -74,6 +83,8 @@ my-game/
|
|
|
74
83
|
├── plans/ # Generated implementation plans
|
|
75
84
|
├── prompts/ # Prompt templates (fallback/reference)
|
|
76
85
|
├── public/
|
|
86
|
+
│ ├── {game}/
|
|
87
|
+
│ │ └── concept.jpg # Generated mockup
|
|
77
88
|
│ └── assets/{game}/ # Your 3D assets (copied by CLI)
|
|
78
89
|
├── scripts/
|
|
79
90
|
│ ├── config.json # API keys and game config
|
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,118 @@ 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
|
+
// Detect if directory is a multi-pack (has subdirs with previews)
|
|
116
|
+
function detectMultiPack(sourceDir) {
|
|
117
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
118
|
+
const subdirs = entries.filter(e => e.isDirectory());
|
|
119
|
+
|
|
120
|
+
if (subdirs.length === 0) return null;
|
|
121
|
+
|
|
122
|
+
const packsWithPreviews = [];
|
|
123
|
+
|
|
124
|
+
for (const subdir of subdirs) {
|
|
125
|
+
const subdirPath = path.join(sourceDir, subdir.name);
|
|
126
|
+
const preview = findPreview(subdirPath);
|
|
127
|
+
if (preview) {
|
|
128
|
+
packsWithPreviews.push({
|
|
129
|
+
name: subdir.name,
|
|
130
|
+
path: subdirPath,
|
|
131
|
+
preview: preview
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Consider it a multi-pack if at least 2 subdirs have previews
|
|
137
|
+
if (packsWithPreviews.length >= 2) {
|
|
138
|
+
return packsWithPreviews;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Combine preview images into a grid
|
|
145
|
+
async function combinePreviews(packs, outputPath) {
|
|
146
|
+
if (!sharp) {
|
|
147
|
+
console.log(c('yellow', ' ⚠ sharp not available - skipping preview combination'));
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const cellWidth = 512;
|
|
152
|
+
const cellHeight = 512;
|
|
153
|
+
const padding = 10;
|
|
154
|
+
const backgroundColor = { r: 30, g: 30, b: 30, alpha: 1 };
|
|
155
|
+
|
|
156
|
+
const cols = Math.ceil(Math.sqrt(packs.length));
|
|
157
|
+
const rows = Math.ceil(packs.length / cols);
|
|
158
|
+
const totalWidth = cols * cellWidth + (cols + 1) * padding;
|
|
159
|
+
const totalHeight = rows * cellHeight + (rows + 1) * padding;
|
|
160
|
+
|
|
161
|
+
console.log(c('dim', ` Creating ${cols}x${rows} combined preview...`));
|
|
162
|
+
|
|
163
|
+
const composites = [];
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < packs.length; i++) {
|
|
166
|
+
const pack = packs[i];
|
|
167
|
+
const col = i % cols;
|
|
168
|
+
const row = Math.floor(i / cols);
|
|
169
|
+
|
|
170
|
+
const x = padding + col * (cellWidth + padding);
|
|
171
|
+
const y = padding + row * (cellHeight + padding);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const resized = await sharp(pack.preview)
|
|
175
|
+
.resize(cellWidth, cellHeight, {
|
|
176
|
+
fit: 'contain',
|
|
177
|
+
background: backgroundColor
|
|
178
|
+
})
|
|
179
|
+
.toBuffer();
|
|
180
|
+
|
|
181
|
+
composites.push({
|
|
182
|
+
input: resized,
|
|
183
|
+
left: x,
|
|
184
|
+
top: y
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.log(c('yellow', ` ⚠ Failed to process ${pack.name}: ${err.message}`));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (composites.length === 0) return null;
|
|
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 sharp({
|
|
200
|
+
create: {
|
|
201
|
+
width: totalWidth,
|
|
202
|
+
height: totalHeight,
|
|
203
|
+
channels: 4,
|
|
204
|
+
background: backgroundColor
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.composite(composites)
|
|
208
|
+
.jpeg({ quality: 90 })
|
|
209
|
+
.toFile(outputPath);
|
|
210
|
+
|
|
211
|
+
return outputPath;
|
|
212
|
+
}
|
|
213
|
+
|
|
94
214
|
// Run a script
|
|
95
215
|
function runScript(scriptPath, args = [], cwd) {
|
|
96
216
|
return new Promise((resolve, reject) => {
|
|
@@ -307,7 +427,21 @@ async function main() {
|
|
|
307
427
|
fs.mkdirSync(assetsDir, { recursive: true });
|
|
308
428
|
|
|
309
429
|
// Copy assets if path was provided
|
|
430
|
+
let isMultiPack = false;
|
|
431
|
+
let multiPackInfo = null;
|
|
432
|
+
|
|
310
433
|
if (assetsSourcePath) {
|
|
434
|
+
// Check if this is a multi-pack directory
|
|
435
|
+
multiPackInfo = detectMultiPack(assetsSourcePath);
|
|
436
|
+
isMultiPack = multiPackInfo !== null;
|
|
437
|
+
|
|
438
|
+
if (isMultiPack) {
|
|
439
|
+
console.log(c('cyan', ` Detected multi-pack with ${multiPackInfo.length} asset packs:`));
|
|
440
|
+
for (const pack of multiPackInfo) {
|
|
441
|
+
console.log(c('dim', ` - ${pack.name}`));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
311
445
|
console.log(c('dim', ' Copying assets...'));
|
|
312
446
|
copyDir(assetsSourcePath, assetsDir, ['node_modules', '.git', '.DS_Store']);
|
|
313
447
|
|
|
@@ -326,6 +460,17 @@ async function main() {
|
|
|
326
460
|
};
|
|
327
461
|
const fileCount = countFiles(assetsDir);
|
|
328
462
|
console.log(c('green', ' ✓ ') + `public/assets/${gameName}/ (${fileCount} files copied)`);
|
|
463
|
+
|
|
464
|
+
// Combine previews if multi-pack
|
|
465
|
+
if (isMultiPack && sharp) {
|
|
466
|
+
try {
|
|
467
|
+
const combinedPreviewPath = path.join(assetsDir, 'Preview.jpg');
|
|
468
|
+
await combinePreviews(multiPackInfo, combinedPreviewPath);
|
|
469
|
+
console.log(c('green', ' ✓ ') + 'Combined preview image generated');
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.log(c('yellow', ` ⚠ Could not combine previews: ${err.message}`));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
329
474
|
} else {
|
|
330
475
|
fs.writeFileSync(path.join(assetsDir, '.gitkeep'), '');
|
|
331
476
|
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.0
|
|
3
|
+
"version": "1.1.0",
|
|
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,193 @@
|
|
|
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 all preview images in subdirectories
|
|
27
|
+
*/
|
|
28
|
+
function findPreviews(sourceDir) {
|
|
29
|
+
const previews = [];
|
|
30
|
+
const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
31
|
+
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
|
|
35
|
+
const subdir = path.join(sourceDir, entry.name);
|
|
36
|
+
const previewNames = ['Preview.jpg', 'Preview.png', 'preview.jpg', 'preview.png',
|
|
37
|
+
'Preview.jpeg', 'preview.jpeg'];
|
|
38
|
+
|
|
39
|
+
for (const name of previewNames) {
|
|
40
|
+
const previewPath = path.join(subdir, name);
|
|
41
|
+
if (fs.existsSync(previewPath)) {
|
|
42
|
+
previews.push({
|
|
43
|
+
path: previewPath,
|
|
44
|
+
name: entry.name
|
|
45
|
+
});
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return previews;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Calculate grid dimensions for N images
|
|
56
|
+
*/
|
|
57
|
+
function calculateGrid(count) {
|
|
58
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
59
|
+
const rows = Math.ceil(count / cols);
|
|
60
|
+
return { cols, rows };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Combine images into a grid
|
|
65
|
+
*/
|
|
66
|
+
async function combineImages(previews, outputPath, options = {}) {
|
|
67
|
+
const {
|
|
68
|
+
cellWidth = 512,
|
|
69
|
+
cellHeight = 512,
|
|
70
|
+
padding = 10,
|
|
71
|
+
backgroundColor = { r: 30, g: 30, b: 30, alpha: 1 }
|
|
72
|
+
} = options;
|
|
73
|
+
|
|
74
|
+
if (previews.length === 0) {
|
|
75
|
+
console.log('No preview images found.');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (previews.length === 1) {
|
|
80
|
+
// Just copy the single preview
|
|
81
|
+
fs.copyFileSync(previews[0].path, outputPath);
|
|
82
|
+
console.log(`Copied single preview: ${previews[0].name}`);
|
|
83
|
+
return outputPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { cols, rows } = calculateGrid(previews.length);
|
|
87
|
+
const totalWidth = cols * cellWidth + (cols + 1) * padding;
|
|
88
|
+
const totalHeight = rows * cellHeight + (rows + 1) * padding;
|
|
89
|
+
|
|
90
|
+
console.log(`Creating ${cols}x${rows} grid (${totalWidth}x${totalHeight}px) for ${previews.length} previews...`);
|
|
91
|
+
|
|
92
|
+
// Prepare each image
|
|
93
|
+
const composites = [];
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < previews.length; i++) {
|
|
96
|
+
const preview = previews[i];
|
|
97
|
+
const col = i % cols;
|
|
98
|
+
const row = Math.floor(i / cols);
|
|
99
|
+
|
|
100
|
+
const x = padding + col * (cellWidth + padding);
|
|
101
|
+
const y = padding + row * (cellHeight + padding);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
// Resize image to fit cell while maintaining aspect ratio
|
|
105
|
+
const resized = await sharp(preview.path)
|
|
106
|
+
.resize(cellWidth, cellHeight, {
|
|
107
|
+
fit: 'contain',
|
|
108
|
+
background: backgroundColor
|
|
109
|
+
})
|
|
110
|
+
.toBuffer();
|
|
111
|
+
|
|
112
|
+
composites.push({
|
|
113
|
+
input: resized,
|
|
114
|
+
left: x,
|
|
115
|
+
top: y
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
console.log(` [${i + 1}/${previews.length}] ${preview.name}`);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(` Failed to process ${preview.name}: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Create the combined image
|
|
125
|
+
const combined = sharp({
|
|
126
|
+
create: {
|
|
127
|
+
width: totalWidth,
|
|
128
|
+
height: totalHeight,
|
|
129
|
+
channels: 4,
|
|
130
|
+
background: backgroundColor
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await combined
|
|
135
|
+
.composite(composites)
|
|
136
|
+
.jpeg({ quality: 90 })
|
|
137
|
+
.toFile(outputPath);
|
|
138
|
+
|
|
139
|
+
console.log(`\nCombined preview saved to: ${outputPath}`);
|
|
140
|
+
return outputPath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Main function
|
|
145
|
+
*/
|
|
146
|
+
async function main() {
|
|
147
|
+
const args = process.argv.slice(2);
|
|
148
|
+
|
|
149
|
+
if (args.length < 2) {
|
|
150
|
+
console.log('Usage: node combine-previews.js <source-dir> <output-path>');
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log('Scans source-dir for subdirectories with Preview.jpg/png');
|
|
153
|
+
console.log('and combines them into a single grid image.');
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sourceDir = args[0];
|
|
158
|
+
const outputPath = args[1];
|
|
159
|
+
|
|
160
|
+
if (!fs.existsSync(sourceDir)) {
|
|
161
|
+
console.error(`Error: Source directory not found: ${sourceDir}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const previews = findPreviews(sourceDir);
|
|
166
|
+
|
|
167
|
+
if (previews.length === 0) {
|
|
168
|
+
console.log('No subdirectories with preview images found.');
|
|
169
|
+
console.log('Looking for: Preview.jpg, Preview.png, preview.jpg, preview.png');
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`Found ${previews.length} preview images:\n`);
|
|
174
|
+
|
|
175
|
+
// Ensure output directory exists
|
|
176
|
+
const outputDir = path.dirname(outputPath);
|
|
177
|
+
if (!fs.existsSync(outputDir)) {
|
|
178
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await combineImages(previews, outputPath);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Export for use as module
|
|
185
|
+
module.exports = { findPreviews, combineImages, calculateGrid };
|
|
186
|
+
|
|
187
|
+
// Run if called directly
|
|
188
|
+
if (require.main === module) {
|
|
189
|
+
main().catch(err => {
|
|
190
|
+
console.error('Error:', err.message);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
});
|
|
193
|
+
}
|