dewctl 0.5.6 → 0.6.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/examples/AGENTS.md +132 -0
- package/examples/starter/package.json +8 -0
- package/examples/starter/{main.js → src/main.js} +1 -5
- package/package.json +1 -1
- package/src/command.js +3 -2
- package/src/main.js +18 -13
- package/src/project.js +79 -31
- package/src/tar.js +25 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Working on this game
|
|
2
|
+
|
|
3
|
+
This is a three.js game for Dewdrop. Dewdrop draws each frame from the scene, in the style the game describes.
|
|
4
|
+
The docs are at https://alpha.dewdrop.world/docs, and each rule below links the page with the details.
|
|
5
|
+
|
|
6
|
+
## 1. Agree the look with the user first
|
|
7
|
+
|
|
8
|
+
Before you build or restyle anything, ask the user for a specific look:
|
|
9
|
+
- the style or medium, such as clay, paper, ink sketch, watercolor or voxel;
|
|
10
|
+
- the setting and the time of day;
|
|
11
|
+
- the palette and the mood;
|
|
12
|
+
- the camera: first or third person, and how close.
|
|
13
|
+
|
|
14
|
+
If the answer is vague ("make it look nice"), offer three or four of these looks from the
|
|
15
|
+
[style recipes](https://alpha.dewdrop.world/docs/recipes), the nearest to what the user wants, and let them choose:
|
|
16
|
+
- **Clay:** matte plasticine with thumbprints, chunky rounded shapes, saturated colors, a tabletop set.
|
|
17
|
+
- **Knitted wool:** chunky knitted yarn and soft felt, visible stitches, plump rounded forms, a cozy craft diorama.
|
|
18
|
+
- **Paper:** folded and cut colored paper and card, sharp creases, white cut edges.
|
|
19
|
+
- **Voxel:** everything built from small cubes, in flat saturated colors.
|
|
20
|
+
- **Cartoon / anime:** cel-shaded with clean dark outlines, a bright summer afternoon.
|
|
21
|
+
- **Ink sketch:** thin black ink outlines on white paper, no shading, no color.
|
|
22
|
+
- **Watercolor:** soft washes fading into bare white paper, no outlines.
|
|
23
|
+
- **Picture book:** crisp ink outlines and loose watercolor washes on warm cream paper.
|
|
24
|
+
- **Colored pencil:** pencil hatching on textured cream paper, warm earthy colors with saturated accents.
|
|
25
|
+
- **Stained glass:** jewel-colored glass panes in dark lead, glowing like a lit window at night.
|
|
26
|
+
- **Neon cyberpunk:** a city at night in the rain, magenta and cyan tubes, wet black ground.
|
|
27
|
+
- **Noir:** black and white, hard light, razor-sharp black shadows, a wet street at night.
|
|
28
|
+
- **Horror:** a candlelit house at night, pitch-black beyond the candlelight, cold blue moonlight.
|
|
29
|
+
- **Winter:** deep midwinter, thick fresh snow, bare trees, warm lamplight in the windows.
|
|
30
|
+
- **Golden hour:** gouache countryside under a low warm sun, gold highlights, cool blue-green shade.
|
|
31
|
+
- **Underwater:** murky teal water, god rays from the surface, caustic light on the sea floor.
|
|
32
|
+
|
|
33
|
+
So you never stall:
|
|
34
|
+
- If `renderer.prompt` already starts with `style:`, the look is chosen. Keep it and don't ask.
|
|
35
|
+
- If nobody can answer, pick the nearest recipe, say which in your first message, and keep the look in one place.
|
|
36
|
+
|
|
37
|
+
Write the agreed look where the prompt lives: `src/look.js` in a starter game, `renderer.prompt` in `src/main.js`
|
|
38
|
+
in the Blank game. Ask again before you change the style.
|
|
39
|
+
|
|
40
|
+
## 2. How Dewdrop makes a frame
|
|
41
|
+
|
|
42
|
+
Dewdrop renders the game from what is in the three.js scene ([renderer](https://alpha.dewdrop.world/docs/renderer)):
|
|
43
|
+
- `renderer.prompt`: the style of the whole game. It starts with `style:` and the style, then describes the scene:
|
|
44
|
+
`'style: claymation. A handmade plasticine miniature on a dark blue studio backdrop.'`
|
|
45
|
+
- `scene.background`, a `THREE.Color`, and `renderer.backgroundDescription`, the sky in words.
|
|
46
|
+
- Each mesh's shape, its base color (its material's `color`) and its `userData.description`.
|
|
47
|
+
|
|
48
|
+
Textures, vertex colors, emissive colors, lights, shadows, fog, custom shaders, points, lines and sprites don't
|
|
49
|
+
show on Dewdrop. The prompt is at most 4 KiB, the background description and each description at most 1 KiB. A
|
|
50
|
+
scene draws at most 65,536 objects: each mesh counts once, and so does each instance.
|
|
51
|
+
The view is 16:9 ([limits](https://alpha.dewdrop.world/docs/limits#the-look)).
|
|
52
|
+
|
|
53
|
+
## 3. The rules that matter most
|
|
54
|
+
|
|
55
|
+
The controls, strongest first: colors, descriptions, shapes, light and shadow, then the prompt and the background.
|
|
56
|
+
A new look usually means changing the first three together.
|
|
57
|
+
|
|
58
|
+
- **Color carries the most.** A mesh comes out at its base color, whatever the words say. Very dark colors for
|
|
59
|
+
night, close to white for a drawing on paper. Set a color on every mesh: colors don't pass down the scene graph.
|
|
60
|
+
- **Dark means very dark.** Dewdrop lifts dark colors. For black, stay under about `#12` in each channel and say
|
|
61
|
+
"black". For deep darkness, use `#02` to `#06` and words such as "in deep shadow"
|
|
62
|
+
([colors](https://alpha.dewdrop.world/docs/renderer#colors)).
|
|
63
|
+
- **Describe what the camera sees, in the style's words:** "the back of an owl's head" behind a character,
|
|
64
|
+
"a plasticine tree". Use one description for each kind of object.
|
|
65
|
+
- **Keep color words in step with the colors.** "Terracotta" on a pale roof came out red, and "pale cream wall"
|
|
66
|
+
came out white on a dark wall. Name the material without its lightness
|
|
67
|
+
([descriptions](https://alpha.dewdrop.world/docs/renderer#descriptions)).
|
|
68
|
+
- A group's description reaches every mesh under it that has none of its own; `null` gives none, and a mesh's
|
|
69
|
+
`name` is never used ([scene graph](https://alpha.dewdrop.world/docs/descriptions)).
|
|
70
|
+
- **Don't name objects in the prompt.** Describe them on their meshes. The prompt can't override the colors.
|
|
71
|
+
- **Dewdrop keeps your shapes.** A style made of shapes needs them: cubes for voxel, folded flat faces for paper,
|
|
72
|
+
rounded lumpy forms for clay. A realistic look needs realistic shapes. A face needs eye and nose meshes. Build the
|
|
73
|
+
letters you want read ([shapes](https://alpha.dewdrop.world/docs/renderer#shapes)).
|
|
74
|
+
- **A light is a mesh.** Three.js lights don't show on Dewdrop. Give a lamp or candle a bright mesh described as
|
|
75
|
+
glowing. Night is every surface at about a fifth of its daylight color, with the lights in view. Words don't light
|
|
76
|
+
a surface; warmth comes from color. A shadow is a thin dark mesh on the ground
|
|
77
|
+
([light and shadow](https://alpha.dewdrop.world/docs/renderer#light-and-shadow)).
|
|
78
|
+
- **The sky is the background.** Describe it in `renderer.backgroundDescription`, not the prompt, with a matching
|
|
79
|
+
`scene.background` color. A gradient and the sun come from the words; a sun disc mesh shows as a flat coin. Big
|
|
80
|
+
clouds are stacked white spheres about 1 km or more away. Flat bands or panels in the sky become a wall
|
|
81
|
+
([background](https://alpha.dewdrop.world/docs/renderer#style-prompt-and-background)).
|
|
82
|
+
- Mist, rain and god rays come from the words. `scene.fog` doesn't show; to fade distance, or for pools of light
|
|
83
|
+
and sunlight, bake them into colors ([baked light](https://alpha.dewdrop.world/docs/baked-light)).
|
|
84
|
+
- **Keep the camera clear.** A mesh very close to the camera becomes a big smooth blob. Keep scenery and moving
|
|
85
|
+
parts a few meters from anywhere the camera goes. In first person, hold items 0.6 to 0.7 m from the camera, as
|
|
86
|
+
its children ([camera](https://alpha.dewdrop.world/docs/camera)).
|
|
87
|
+
- **Small moving things smear.** Make what matters large in frame. Fish, flocks and sparks: muted, about 0.6 m long
|
|
88
|
+
or more, 8 m or more from the camera. Smoke and steam can't be built. Turn flat things to face the camera
|
|
89
|
+
([what to watch for](https://alpha.dewdrop.world/docs/renderer#what-to-watch-for)).
|
|
90
|
+
- To find which mesh became a blob, give the suspects a loud color such as magenta for a moment, and check again.
|
|
91
|
+
|
|
92
|
+
The code ([limits](https://alpha.dewdrop.world/docs/limits)):
|
|
93
|
+
- The frame rate on Dewdrop is often far lower than a browser's own three.js, and varies. Move by the real time
|
|
94
|
+
that passed, don't cap a frame's time below about 0.25 s, and step in small fixed steps.
|
|
95
|
+
- `index.html`'s own scripts don't run. Leave `html` and `body` without a background, and don't set
|
|
96
|
+
`color-scheme: dark`. Put what the page shows under `public/ui/`, or import it.
|
|
97
|
+
- `fetch` reads only the game's own files. WebSocket, WebRTC and workers aren't available. Sound plays through
|
|
98
|
+
Web Audio; `<audio>` and `<video>` make no sound.
|
|
99
|
+
- Keep `three` at 0.185.1, which Dewdrop provides ([project](https://alpha.dewdrop.world/docs/project)).
|
|
100
|
+
|
|
101
|
+
## 4. The working loop
|
|
102
|
+
|
|
103
|
+
Follow the [development flow](https://alpha.dewdrop.world/docs/development-flow). The game runs on Dewdrop alone,
|
|
104
|
+
in its dev version; there is no local preview.
|
|
105
|
+
1. **Shapes:** build the scene and the game from simple shapes, and play it until it works.
|
|
106
|
+
Check that the player and the roads show. Seed any random layout.
|
|
107
|
+
2. **Colors:** give every mesh its base color.
|
|
108
|
+
3. **Descriptions:** say what each thing is and what it's made of, in the style's words.
|
|
109
|
+
4. **Style prompt:** set the prompt for the whole game, and the sky.
|
|
110
|
+
|
|
111
|
+
After each step, upload. While the user runs `npm run dev`, every save uploads by itself and the page reloads;
|
|
112
|
+
otherwise `npx dewctl dev` builds the game, uploads it and prints the link to play it on Dewdrop. Don't run both at
|
|
113
|
+
once: they build into the same folder. Judge the look there, in motion, not from a still frame. The first second is
|
|
114
|
+
plain and grey.
|
|
115
|
+
|
|
116
|
+
You can't see Dewdrop's frames. Ask the user specific questions ("Does the lantern glow?", "What does the sky look
|
|
117
|
+
like?"), or ask for a screenshot. In the dev version, `dewdrop.evaluate` runs code in the game from the browser
|
|
118
|
+
console or a script driving the browser, to read the game's state or change it
|
|
119
|
+
([run code](https://alpha.dewdrop.world/docs/development-flow#run-code-in-your-dev-version)). Change one thing
|
|
120
|
+
at a time, so you know what each change did.
|
|
121
|
+
|
|
122
|
+
## 5. The project
|
|
123
|
+
|
|
124
|
+
- `dewdrop.json` connects the folder to the game: its name, the build command, the folder the build writes, and
|
|
125
|
+
the thumbnail ([dewdrop.json](https://alpha.dewdrop.world/docs/dewdrop-json)).
|
|
126
|
+
- `npm run dev` plays the dev version on Dewdrop and uploads it again on every save; `npx dewctl dev` uploads it
|
|
127
|
+
once. `npx dewctl status` shows both versions. Run `npx dewctl publish` only when the user asks: it puts the game
|
|
128
|
+
in front of players ([commands](https://alpha.dewdrop.world/docs/cli)).
|
|
129
|
+
- In a starter game, README.md says how the game works and why its look works. Read it first.
|
|
130
|
+
- `thumbnail.jpg` is the image players see for the game. A starter's shows the starter, so once the look has
|
|
131
|
+
changed, ask the user for a screenshot of the game on Dewdrop to replace it before publishing (a PNG, JPEG or WebP
|
|
132
|
+
of at most 1 MB, at 16:9).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Your game. Save a change while `
|
|
1
|
+
// Your game. Save a change while `npm run dev` runs and the game on Dewdrop updates.
|
|
2
2
|
import * as THREE from 'three';
|
|
3
3
|
|
|
4
4
|
const scene=new THREE.Scene();
|
|
@@ -14,10 +14,6 @@ const cube=new THREE.Mesh(new THREE.BoxGeometry(1.5,1.5,1.5),new THREE.MeshStand
|
|
|
14
14
|
// …and what this mesh is.
|
|
15
15
|
cube.userData.description='A polished turquoise cube';
|
|
16
16
|
scene.add(cube);
|
|
17
|
-
scene.add(new THREE.AmbientLight(0xffffff,1));
|
|
18
|
-
const light=new THREE.DirectionalLight(0xffffff,2);
|
|
19
|
-
light.position.set(3,4,5);
|
|
20
|
-
scene.add(light);
|
|
21
17
|
const heading=document.createElement('h1');
|
|
22
18
|
heading.textContent='Hello from your Dewdrop game';
|
|
23
19
|
heading.style.cssText='position:absolute;top:20px;left:24px;color:white;font:24px sans-serif;';
|
package/package.json
CHANGED
package/src/command.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
// How the developer runs this CLI, for the commands its messages suggest: through npx when npx
|
|
2
|
-
|
|
1
|
+
// How the developer runs this CLI, for the commands its messages suggest: through npx when npx or an npm script
|
|
2
|
+
// (`npm run dev`, which runs `dewctl dev --watch`) started it, else by name.
|
|
3
|
+
export const RUN=['exec','run','run-script'].includes(process.env.npm_command)?'npx dewctl':'dewctl';
|
package/src/main.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Dewdrop's developer commands: sign in, connect a directory to a game, then run, publish, share and inspect it.
|
|
3
3
|
import {spawn} from 'node:child_process';
|
|
4
4
|
import {readFileSync} from 'node:fs';
|
|
5
|
-
import {readProject,platformOrigin,initProject,DEWDROP} from './project.js';
|
|
5
|
+
import {readProject,platformOrigin,initProject,chooseTemplate,DEWDROP} from './project.js';
|
|
6
6
|
import {Credentials} from './credentials.js';
|
|
7
7
|
import {Client} from './client.js';
|
|
8
8
|
import {deploy,watch,size} from './watch.js';
|
|
@@ -13,8 +13,11 @@ import {syncThumbnail} from './thumbnail.js';
|
|
|
13
13
|
|
|
14
14
|
const HELP=`${RUN} — make three.js games on Dewdrop
|
|
15
15
|
|
|
16
|
-
init [--game NAME]
|
|
17
|
-
|
|
16
|
+
init [--game NAME] [--template NAME] Connect this folder to a game. An empty folder gets a new game from
|
|
17
|
+
a template: blank, golden-glide, ember-wyrm, woolly-farm,
|
|
18
|
+
turtle-tide or hollow-manor. Without --template, init asks which at a terminal
|
|
19
|
+
(Enter for Blank), and starts Blank in a script. The game is named
|
|
20
|
+
after the folder unless --game names it
|
|
18
21
|
login Sign in: prints a link to open in your browser and approve
|
|
19
22
|
logout Sign this computer out
|
|
20
23
|
dev [--watch] [--release ID] Build, upload and play the dev version; --watch repeats this on every
|
|
@@ -40,8 +43,8 @@ const HELP=`${RUN} — make three.js games on Dewdrop
|
|
|
40
43
|
`;
|
|
41
44
|
// Each command's flags; share and unshare also take one address, delete one release and delete-game the game's name.
|
|
42
45
|
// Anything else on the line is refused.
|
|
43
|
-
const COMMANDS={init:['--game'],login:[],logout:[],dev:['--watch','--release'],stop:[],publish:['--release'],unpublish:[],share:[],unshare:[],status:[],releases:[],delete:[],'delete-game':[]};
|
|
44
|
-
const VALUED=['--game','--release'];
|
|
46
|
+
const COMMANDS={init:['--game','--template'],login:[],logout:[],dev:['--watch','--release'],stop:[],publish:['--release'],unpublish:[],share:[],unshare:[],status:[],releases:[],delete:[],'delete-game':[]};
|
|
47
|
+
const VALUED=['--game','--template','--release'];
|
|
45
48
|
function options(command,args) {
|
|
46
49
|
const out={};
|
|
47
50
|
for(let i=0;i<args.length;i++) {
|
|
@@ -92,23 +95,25 @@ async function main() {
|
|
|
92
95
|
if(!Object.hasOwn(COMMANDS,command))throw new Error(`There's no ${command} command. Run ${RUN} --help to see them.`);
|
|
93
96
|
const flags=options(command,args);
|
|
94
97
|
if(command==='init') {
|
|
95
|
-
const config=initProject(process.cwd(),flags.game);
|
|
98
|
+
const config=await initProject(process.cwd(),flags.game,flags.template,process.stdin.isTTY&&process.stdout.isTTY?chooseTemplate:undefined);
|
|
96
99
|
const run=RUN;
|
|
97
|
-
if(config.starter)console.log(`Set up ${config.game} here. Next:\n npm install\n ${run} login\n
|
|
98
|
-
else console.log(`Created dewdrop.json for ${config.game}. Next: ${run} login, then ${run} dev --watch.`);
|
|
99
|
-
if(config.buildScript
|
|
100
|
-
if(!config.starter&&!config.buildScript)console.log('No build
|
|
100
|
+
if(config.starter)console.log(`Set up ${config.game} here. Next:\n npm install\n ${run} login\n npm run dev\nnpm run dev builds the game, uploads it to Dewdrop and prints a link to play it, then uploads again on every save; the game is created on Dewdrop the first time.${config.template==='blank'?'':'\nRead README.md: how the game works and why its look works.'}\nAGENTS.md tells coding agents how to work on the game, starting with agreeing its look with you.`);
|
|
101
|
+
else console.log(`Created dewdrop.json for ${config.game}. Next: ${run} login, then ${run} dev --watch. To make npm run dev play it on Dewdrop, set package.json's dev script to dewctl dev --watch.`);
|
|
102
|
+
if(!config.starter&&config.buildScript)console.log(`Building for Dewdrop with npm run ${config.buildScript}, which must run vite build with the Dewdrop plugin in vite.config.js.`);
|
|
103
|
+
if(!config.starter&&!config.buildScript)console.log('No build script is in package.json, so dewdrop.json builds with npm run build. Add one that runs vite build with the Dewdrop plugin in vite.config.js, or set "build" in dewdrop.json to your own command.');
|
|
101
104
|
return;
|
|
102
105
|
}
|
|
103
106
|
// Every command but init reads the game from the project's dewdrop.json.
|
|
104
107
|
const project=readProject(process.cwd());
|
|
105
108
|
const origin=platformOrigin();
|
|
106
109
|
const credentials=new Credentials(origin),client=new Client(origin,credentials);
|
|
107
|
-
// The commands that wait on Dewdrop stop waiting at Ctrl-C; the rest end the ordinary way.
|
|
110
|
+
// The commands that wait on Dewdrop stop waiting at Ctrl-C; the rest end the ordinary way. Every signal is taken,
|
|
111
|
+
// not the first alone: under `npm run dev`, npm passes the terminal's Ctrl-C on to the CLI, which has had it already,
|
|
112
|
+
// and a second signal must not end the CLI before it has said what it stopped.
|
|
108
113
|
const controller=new AbortController(),{signal}=controller;
|
|
109
114
|
if(['login','stop','unpublish','publish','dev'].includes(command)) {
|
|
110
|
-
process.
|
|
111
|
-
process.
|
|
115
|
+
process.on('SIGINT',()=>controller.abort(new Error('Stopped.')));
|
|
116
|
+
process.on('SIGTERM',()=>controller.abort(new Error('Stopped.')));
|
|
112
117
|
}
|
|
113
118
|
// login makes a sign-in token and prints the link that approves it; the token is kept once Dewdrop says whose it is.
|
|
114
119
|
if(command==='login') {
|
package/src/project.js
CHANGED
|
@@ -3,7 +3,10 @@ import {readFileSync, writeFileSync, existsSync, lstatSync, readdirSync, openSyn
|
|
|
3
3
|
import {resolve, relative, join, isAbsolute, basename, dirname} from 'node:path';
|
|
4
4
|
import {createHash,randomBytes} from 'node:crypto';
|
|
5
5
|
import {spawn} from 'node:child_process';
|
|
6
|
-
import {
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import {gunzipSync} from 'node:zlib';
|
|
8
|
+
import {createInterface} from 'node:readline';
|
|
9
|
+
import {packTar,readTar} from './tar.js';
|
|
7
10
|
import {RUN} from './command.js';
|
|
8
11
|
|
|
9
12
|
const MAX_BYTES = 1024 * 1024 * 1024;
|
|
@@ -37,7 +40,7 @@ export function readProject(root = process.cwd()) {
|
|
|
37
40
|
if (!GAME_NAME.test(config.game ?? '')) throw new Error("Set game in dewdrop.json to the game's name (for example my-game).");
|
|
38
41
|
projectPath(root,config.bundle);
|
|
39
42
|
if (config.thumbnail !== undefined) projectPath(root,config.thumbnail);
|
|
40
|
-
if (config.build !== undefined && (!Array.isArray(config.build) || !config.build.length || config.build.some(v=>typeof v !== 'string' || !v || v.includes('\0')))) throw new Error('build must be an argument array, such as ["npm","run","build
|
|
43
|
+
if (config.build !== undefined && (!Array.isArray(config.build) || !config.build.length || config.build.some(v=>typeof v !== 'string' || !v || v.includes('\0')))) throw new Error('build must be an argument array, such as ["npm","run","build"].');
|
|
41
44
|
return {...config,root};
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -101,7 +104,7 @@ export function fingerprint(project) {
|
|
|
101
104
|
export function snapshot(project) {
|
|
102
105
|
const directory=projectPath(project.root,project.bundle);
|
|
103
106
|
if (!existsSync(directory)) throw new Error(project.build
|
|
104
|
-
? `The build wrote no ${project.bundle} folder. Set "bundle" in dewdrop.json to the folder your
|
|
107
|
+
? `The build wrote no ${project.bundle} folder. Set "bundle" in dewdrop.json to the folder your build (vite build) writes.`
|
|
105
108
|
: `There's no ${project.bundle} folder to upload. Build the game first, or set "build" in dewdrop.json so ${RUN} dev builds it.`);
|
|
106
109
|
const root=realpathSync(directory);
|
|
107
110
|
if (root!==directory) throw new Error(`The ${project.bundle} folder can't be a symlink.`);
|
|
@@ -112,7 +115,7 @@ export function snapshot(project) {
|
|
|
112
115
|
const nested=[...files.keys()].filter(path=>path.endsWith('/main.js')).sort((a,b)=>a.split('/').length-b.split('/').length)[0];
|
|
113
116
|
throw new Error(nested
|
|
114
117
|
? `There's no main.js at the top of ${project.bundle}, but there is ${project.bundle}/${nested}. Set "bundle": "${project.bundle}/${dirname(nested)}" in dewdrop.json.`
|
|
115
|
-
: `There's no main.js in ${project.bundle}. Build with the Dewdrop plugin (vite build
|
|
118
|
+
: `There's no main.js in ${project.bundle}. Build with the Dewdrop plugin in vite.config.js (vite build), and set "bundle" in dewdrop.json to the folder that build writes.`);
|
|
116
119
|
}
|
|
117
120
|
let bytes=0;
|
|
118
121
|
const entries=[...files].map(([path,{file,size}])=>{
|
|
@@ -152,56 +155,101 @@ export async function build(project, signal) {
|
|
|
152
155
|
if(signal?.aborted)cancel();
|
|
153
156
|
});
|
|
154
157
|
}
|
|
155
|
-
// The one project shape: a Vite project with the SDK's plugin, which
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// dewdrop.json alone, so nothing of it is touched and no
|
|
158
|
+
// The one project shape: a Vite project with the SDK's plugin, which builds for Dewdrop with `npm run build`
|
|
159
|
+
// and plays there with `npm run dev`, which runs `dewctl dev --watch`. A directory holding none of the starter's
|
|
160
|
+
// files gets a new game, depending on the SDK and this CLI from npm; one holding any of them has a game of its own
|
|
161
|
+
// and gets dewdrop.json alone, so nothing of it is touched and no new game is left half written.
|
|
159
162
|
const STARTER_FILES=['package.json','src','index.html','vite.config.js'];
|
|
160
163
|
// The SDK a new game depends on — the range moves with the SDK's minor, since a caret on 0.x stays within one —
|
|
161
164
|
// and this CLI, at its own version.
|
|
162
|
-
const SDK_RANGE='^0.
|
|
165
|
+
const SDK_RANGE='^0.6.0';
|
|
163
166
|
const CLI_RANGE=`^${JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version}`;
|
|
167
|
+
// The games a new one can start from: Blank, which ships with this CLI, and the starter games in the public
|
|
168
|
+
// starter-templates repository, a folder each, at the commit this release pins.
|
|
169
|
+
const STARTERS={repo:'DewdropLabs/starter-templates',commit:'4247a60d29f5477b94b498fcd31a4c3c0e873cc0'};
|
|
170
|
+
const TEMPLATES={
|
|
171
|
+
blank:['Blank','a cube and a style prompt, the smallest game'],
|
|
172
|
+
'golden-glide':['Golden Glide','flight over golden-hour countryside, in gouache'],
|
|
173
|
+
'ember-wyrm':['Ember Wyrm','a third-person dragon fight, in colored pencil'],
|
|
174
|
+
'woolly-farm':['Woolly Farm','cozy farming, third person, in knitted wool'],
|
|
175
|
+
'turtle-tide':['Turtle Tide','a sea turtle swimming through kelp, in stained glass'],
|
|
176
|
+
'hollow-manor':['Hollow Manor','first-person horror by candlelight'],
|
|
177
|
+
};
|
|
178
|
+
const EXAMPLES=fileURLToPath(new URL('../examples/',import.meta.url)),BLANK=join(EXAMPLES,'starter');
|
|
179
|
+
// Asks at a terminal which game a new one starts from. Enter is Blank, and any answer but a listed number asks again.
|
|
180
|
+
export async function chooseTemplate(input=process.stdin, output=process.stdout) {
|
|
181
|
+
const ids=Object.keys(TEMPLATES),ask=`Choose 1–${ids.length} [1]: `;
|
|
182
|
+
output.write(`Start from:\n${ids.map((id,i)=>` ${i+1} ${TEMPLATES[id][0].padEnd(14)}${TEMPLATES[id][1]}`).join('\n')}\n${ask}`);
|
|
183
|
+
for await (const line of createInterface({input})) {
|
|
184
|
+
const answer=line.trim()||'1',id=/^\d$/.test(answer)&&ids[answer-1];
|
|
185
|
+
if(id)return id;
|
|
186
|
+
output.write(ask);
|
|
187
|
+
}
|
|
188
|
+
throw new Error('No game was chosen to start from.');
|
|
189
|
+
}
|
|
190
|
+
// A starter game's files, from the repository's tarball at the pinned commit (or the one DEWDROP_STARTERS names, for
|
|
191
|
+
// tests): the regular files under the game's folder, by their paths in it, leaving out names starting with a dot.
|
|
192
|
+
async function download(template) {
|
|
193
|
+
const name=TEMPLATES[template][0];
|
|
194
|
+
let response,files;
|
|
195
|
+
try { response=await fetch(process.env.DEWDROP_STARTERS??`https://codeload.github.com/${STARTERS.repo}/tar.gz/${STARTERS.commit}`,{signal:AbortSignal.timeout(120000)}); }
|
|
196
|
+
catch(error) { throw new Error(`Couldn't download the ${name} game (${error.cause?.message??error.message}). Nothing was written; check your connection and run init again.`); }
|
|
197
|
+
try {
|
|
198
|
+
if(!response.ok)throw new Error(`the download answered ${response.status}`);
|
|
199
|
+
files=readTar(gunzipSync(await response.bytes()));
|
|
200
|
+
} catch(error) { throw new Error(`Couldn't read the ${name} game (${error.message}). Nothing was written.`); }
|
|
201
|
+
files=files.filter(({path})=>path.startsWith(`${template}/`)&&!path.split('/').some(part=>part.startsWith('.'))).map(({path,data})=>({path:path.slice(template.length+1),data}));
|
|
202
|
+
if(!files.length)throw new Error(`The ${name} game is missing from the download. Nothing was written.`);
|
|
203
|
+
return files;
|
|
204
|
+
}
|
|
164
205
|
// A game name from the project directory, or a random one when the directory name yields nothing usable.
|
|
165
206
|
function gameNameFrom(root) {
|
|
166
207
|
const slug=basename(resolve(root)).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^[^a-z]+/,'').replace(/-+$/,'').slice(0,48).replace(/-+$/,'');
|
|
167
208
|
return GAME_NAME.test(slug)?slug:`game-${randomBytes(4).toString('hex')}`;
|
|
168
209
|
}
|
|
169
|
-
// The build command for a project of its own: its build:dew
|
|
170
|
-
//
|
|
210
|
+
// The build command for a project of its own: its build:dew npm script when it has one — a project set up before
|
|
211
|
+
// dewdrop-sdk 0.6, whose SDK init leaves as it is, builds for Dewdrop through that script alone — else its build
|
|
212
|
+
// script, named so init can say which it chose, else the default with no script, so init can tell the developer to
|
|
213
|
+
// add one.
|
|
171
214
|
function buildCommandFor(root) {
|
|
172
215
|
try {
|
|
173
216
|
const scripts=JSON.parse(readFileSync(join(root,'package.json'),'utf8')).scripts;
|
|
174
217
|
for(const name of ['build:dew','build'])if(scripts&&typeof scripts[name]==='string')return {build:['npm','run',name],script:name};
|
|
175
218
|
} catch {}
|
|
176
|
-
return {build:['npm','run','build
|
|
219
|
+
return {build:['npm','run','build'],script:null};
|
|
177
220
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
221
|
+
// Connects `root` to `game`. A new game starts from `template`, else from the one `choose` answers, else Blank, and
|
|
222
|
+
// gets its name, this CLI's ranges and the agent instructions; a project of its own gets dewdrop.json alone.
|
|
223
|
+
export async function initProject(root, game, template, choose) {
|
|
224
|
+
if(existsSync(join(root,'dewdrop.json')))throw new Error('This folder already has a dewdrop.json.');
|
|
181
225
|
const starter=!STARTER_FILES.some(file=>existsSync(join(root,file)));
|
|
182
226
|
game??=gameNameFrom(root);
|
|
183
227
|
if(!GAME_NAME.test(game))throw new Error('A game name is 1–48 lowercase letters, digits or dashes, starting with a letter.');
|
|
184
|
-
|
|
185
|
-
|
|
228
|
+
if(template!==undefined&&!Object.hasOwn(TEMPLATES,template))throw new Error(`--template takes ${Object.keys(TEMPLATES).join(', ')}.`);
|
|
229
|
+
if(template&&!starter)throw new Error('This folder has a project of its own, so init only connects it to a game. Start from a template in an empty folder.');
|
|
230
|
+
if(starter)template??=choose?await choose():'blank';
|
|
231
|
+
const files=!starter?[]:template==='blank'?readdirSync(BLANK,{recursive:true}).filter(path=>lstatSync(join(BLANK,path)).isFile()).map(path=>({path,data:readFileSync(join(BLANK,path))})):await download(template);
|
|
232
|
+
const {build,script}=starter?{build:['npm','run','build'],script:'build'}:buildCommandFor(root);
|
|
233
|
+
const config={version:1,game,bundle:'dist',build,...files.some(({path})=>path==='thumbnail.jpg')&&{thumbnail:'thumbnail.jpg'}};
|
|
234
|
+
// The game's package and page take its name, and the package this CLI's ranges of the SDK and itself.
|
|
235
|
+
const edits=new Map([
|
|
236
|
+
['package.json',text=>{const pkg=JSON.parse(text);pkg.name=game;pkg.devDependencies={...pkg.devDependencies,'dewdrop-sdk':SDK_RANGE,dewctl:CLI_RANGE};return JSON.stringify(pkg,null,2)+'\n';}],
|
|
237
|
+
['index.html',text=>text.replace(/<title>[^<]*<\/title>/,`<title>${game}</title>`)],
|
|
238
|
+
]);
|
|
186
239
|
// Everything init writes, so a failure part-way removes it again and a retry starts from the same empty folder.
|
|
240
|
+
const taken=files.find(({path})=>existsSync(join(root,path)));
|
|
241
|
+
if(taken)throw new Error(`${taken.path} is already in this folder, and the game has one of its own. Move it out, or start in an empty folder.`);
|
|
187
242
|
const made=[];
|
|
188
|
-
const write=(
|
|
189
|
-
const folder=dir=>{if(!existsSync(dir)){mkdirSync(dir,{recursive:true});made.push(dir);}};
|
|
243
|
+
const write=(path,data)=>{const file=join(root,path),dir=mkdirSync(dirname(file),{recursive:true});if(dir)made.push(dir);writeFileSync(file,data,{flag:'wx'});made.push(file);};
|
|
190
244
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
write(join(root,'vite.config.js'),template('vite.config.js'));
|
|
197
|
-
const devDependencies={'dewdrop-sdk':SDK_RANGE,dewctl:CLI_RANGE,vite:'^8'};
|
|
198
|
-
write(join(root,'package.json'),JSON.stringify({name:game,private:true,type:'module',scripts:{dev:'vite','build:dew':'vite build --mode dew'},dependencies:{three:'0.185.1'},devDependencies},null,2)+'\n');
|
|
199
|
-
if(!existsSync(join(root,'.gitignore')))write(join(root,'.gitignore'),'dist/\nnode_modules/\n.env*\n');
|
|
200
|
-
}
|
|
201
|
-
write(path,JSON.stringify(config,null,2)+'\n');
|
|
245
|
+
for(const {path,data} of files)write(path,edits.get(path)?.(data.toString())??data);
|
|
246
|
+
// The files every new game gets beside its own, each left as it is when the folder has one already: AGENTS.md
|
|
247
|
+
// guides coding agents, which read it by that name.
|
|
248
|
+
if(starter)for(const [name,data] of Object.entries({'.gitignore':'dist/\nnode_modules/\n','AGENTS.md':readFileSync(join(EXAMPLES,'AGENTS.md'))}))if(!existsSync(join(root,name)))write(name,data);
|
|
249
|
+
write('dewdrop.json',JSON.stringify(config,null,2)+'\n');
|
|
202
250
|
} catch(error) {
|
|
203
251
|
for(const file of made.reverse())rmSync(file,{recursive:true,force:true});
|
|
204
252
|
throw error;
|
|
205
253
|
}
|
|
206
|
-
return {...config,starter,buildScript:script};
|
|
254
|
+
return {...config,starter,template,buildScript:script};
|
|
207
255
|
}
|
package/src/tar.js
CHANGED
|
@@ -46,3 +46,28 @@ export function packTar(entries) {
|
|
|
46
46
|
}
|
|
47
47
|
return out;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
// The regular files of a ustar archive, such as a GitHub tarball, as `{path, data}` with the archive's one top-level
|
|
51
|
+
// folder taken off each path. Folders and pax headers are passed over, but for the path a pax header gives the file
|
|
52
|
+
// after it; any other kind of entry, a path that could leave the folder, or an archive cut short is refused.
|
|
53
|
+
export function readTar(archive) {
|
|
54
|
+
const files=[];
|
|
55
|
+
let top,long;
|
|
56
|
+
for(let offset=0;offset+BLOCK<=archive.length;) {
|
|
57
|
+
const block=archive.subarray(offset,offset+BLOCK);
|
|
58
|
+
if(!block.some(byte=>byte))return files;
|
|
59
|
+
const text=(start,length)=>{const raw=block.subarray(start,start+length),end=raw.indexOf(0);return raw.subarray(0,end<0?length:end).toString();};
|
|
60
|
+
const size=text(124,12).trim(),type=text(156,1)||'0',start=offset+BLOCK,end=start+parseInt(size,8);
|
|
61
|
+
if(text(257,5)!=='ustar'||!/^[0-7]+$/.test(size)||end>archive.length)throw new Error(end>archive.length?'the archive is cut short':'the archive is damaged');
|
|
62
|
+
const data=archive.subarray(start,end);
|
|
63
|
+
offset=start+padded(end-start);
|
|
64
|
+
if(type==='x'){long=/(?:^|\n)\d+ path=([^\n]*)/.exec(data.toString())?.[1];continue;}
|
|
65
|
+
const path=long??[text(345,155),text(0,100)].filter(Boolean).join('/'),parts=path.split('/');
|
|
66
|
+
long=undefined;
|
|
67
|
+
if(type==='5'||type==='g')continue;
|
|
68
|
+
if(type!=='0')throw new Error(`the archive holds ${path}, which isn't a regular file`);
|
|
69
|
+
if(parts.length<2||parts.some(part=>!part||part==='.'||part==='..')||parts[0]!==(top??=parts[0]))throw new Error(`the archive holds a file outside its folder: ${path}`);
|
|
70
|
+
files.push({path:parts.slice(1).join('/'),data});
|
|
71
|
+
}
|
|
72
|
+
throw new Error('the archive is cut short');
|
|
73
|
+
}
|