@homie-rocks/studio 0.21.0 → 0.23.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/CHANGELOG.md +68 -1
- package/README.md +92 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +81 -2
- package/chat/CHAT.md +101 -0
- package/chat/OWNERS.md +78 -0
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +32 -2
- package/lib/chat-cli.mjs +89 -0
- package/lib/cloudflare.mjs +5 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +39 -2
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +76 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/netplay/NETPLAY.md +191 -3
- package/netplay/netplay.ts +150 -3
- package/package.json +15 -2
- package/port/index.ts +3 -2
- package/port/view.ts +238 -0
- package/site/SITE.md +11 -1
- package/starters/ember-vale/src/main.ts +22 -2
- package/starters/gem-rush/src/main.ts +21 -1
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2107 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/agents.mjs +3 -3
- package/worker/chat-page.mjs +556 -0
- package/worker/chat-store.mjs +161 -0
- package/worker/chat-words.mjs +46 -0
- package/worker/chat.mjs +419 -0
- package/worker/index.mjs +153 -8
- package/worker/office-page.mjs +115 -0
- package/worker/office.mjs +137 -6
- package/worker/pages.mjs +32 -9
- package/worker/qr.mjs +11 -0
- package/worker/room.mjs +288 -10
- package/worker/servers.mjs +5 -1
- package/worker/site.mjs +9 -4
- package/worker/stats.mjs +4 -1
- package/worker/version.mjs +1 -1
package/lib/site.mjs
CHANGED
|
@@ -417,7 +417,12 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
417
417
|
|
|
418
418
|
const words = L.players && typeof L.players === 'object' ? { one: str(L.players.one, 24), many: str(L.players.many, 24) } : null;
|
|
419
419
|
// The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
|
|
420
|
-
|
|
420
|
+
// Else the game's own palette (games/<id>/style.json, its art-direction decisions): one look from the game to its page.
|
|
421
|
+
const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
|
|
422
|
+
// Its accents only: the page keeps the studio's light or dark scheme, so its text always reads.
|
|
423
|
+
const fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
|
|
424
|
+
const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
|
|
425
|
+
const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
|
|
421
426
|
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
422
427
|
// landing.scheme: "light" for a white or cream game (its hero is tinted light and its words dark), "dark" for a
|
|
423
428
|
// dark game in a light studio. Anything else follows the studio's look.
|
package/lib/studio.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* current directory, and its games.
|
|
4
4
|
*/
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
9
9
|
|
|
@@ -115,17 +115,24 @@ function renameTakeSaves(dir, from, id) {
|
|
|
115
115
|
if (next !== text) writeFileSync(file, next);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/**
|
|
119
|
+
* `homie-studio game new <id> --from <starter> --name "<Name>"`: a starter copied in as this studio's own game. A starter
|
|
120
|
+
* whose game.json says `"assets": "library"` gets its models from the starter library here (fetchStarterModels).
|
|
121
|
+
*/
|
|
122
|
+
export async function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
120
123
|
if (!GAME_ID.test(String(id ?? '')) || RESERVED_IDS.has(id)) throw new Error(`a game id is lowercase letters, digits and hyphens, up to 40, and not one of ${[...RESERVED_IDS].join(', ')} (got ${JSON.stringify(id)})`);
|
|
121
124
|
const src = join(PACKAGE_ROOT, 'starters', from);
|
|
122
125
|
if (!existsSync(join(src, 'game.json'))) throw new Error(`no starter "${from}"; starters: ${starters().map((s) => s.id).join(', ')}`);
|
|
123
126
|
const dest = join(root, 'games', id);
|
|
124
127
|
if (existsSync(join(dest, 'game.json'))) throw new Error(`games/${id} already exists; pick another id or change that game`);
|
|
125
|
-
// A planned game (its folder holds the Game Codex, maybe art, and no game.json yet): the
|
|
126
|
-
// what is there, and nothing of the plan is overwritten.
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
// A planned game (its folder holds the Game Codex, maybe its art direction and models, and no game.json yet): the
|
|
129
|
+
// starter goes in around what is there, and nothing of the plan is overwritten. Its style.json (from its own
|
|
130
|
+
// decisions) and credits.json stay; the starter's models join its assets/manifest.json beside the game's own.
|
|
131
|
+
const ADOPT = ['style.json', 'credits.json', 'assets'];
|
|
132
|
+
const planned = existsSync(dest) ? readdirSync(dest).filter((f) => existsSync(join(src, f))) : [];
|
|
133
|
+
const clash = planned.filter((f) => !ADOPT.includes(f));
|
|
134
|
+
if (clash.length) throw new Error(`games/${id} holds files of its own that a starter would overwrite (${clash.join(', ')}); pick another id`);
|
|
135
|
+
cpSync(src, dest, { recursive: true, filter: (from) => !planned.some((f) => from === join(src, f) || from.startsWith(join(src, f) + sep)) });
|
|
129
136
|
const meta = JSON.parse(readFileSync(join(dest, 'game.json'), 'utf8'));
|
|
130
137
|
meta.id = id;
|
|
131
138
|
if (name) meta.name = String(name).slice(0, 60);
|
|
@@ -136,7 +143,113 @@ export function newGame(root, id, { from = 'gem-rush', name } = {}) {
|
|
|
136
143
|
renameTakeSaves(dest, from, id);
|
|
137
144
|
const html = join(dest, 'index.html');
|
|
138
145
|
if (name && existsSync(html)) writeFileSync(html, readFileSync(html, 'utf8').replace(/<title>[^<]*<\/title>/, `<title>${String(name).replace(/[<&]/g, '')}</title>`));
|
|
139
|
-
|
|
146
|
+
const merged = planned.includes('assets') && existsSync(join(src, 'assets', 'manifest.json')) ? await adoptStarterAssets(root, id, src) : null;
|
|
147
|
+
const needs = addNeeds(root, meta.needs);
|
|
148
|
+
const models = meta.assets === 'library' ? await fetchStarterModels(dest, merged) : null;
|
|
149
|
+
// A starter with models carries their rights: assets/RIGHTS.md and the credits, written again for this game.
|
|
150
|
+
if (existsSync(join(dest, 'assets', 'manifest.json'))) {
|
|
151
|
+
const { readManifest, syncCredits, writeRights } = await import('./asset-manifest.mjs');
|
|
152
|
+
const manifest = readManifest(root, id);
|
|
153
|
+
writeRights(root, id, manifest);
|
|
154
|
+
syncCredits(root, id, manifest);
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, command: 'game new', id, from, dir: dest, files: readdirSync(dest, { recursive: true }).map(String), ...needs, ...(models ? { models } : {}) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A planned game that already has models: the starter's entries join its assets/manifest.json (an id the game
|
|
161
|
+
* already uses keeps the game's own). The game's code draws them under the game's current decisions (it reads
|
|
162
|
+
* style.json), so each adopted entry is recorded as made under those revisions, not the starter's. Returns the
|
|
163
|
+
* adopted ids (the only ones fetched).
|
|
164
|
+
*/
|
|
165
|
+
async function adoptStarterAssets(root, id, src) {
|
|
166
|
+
const { readManifest, writeManifest } = await import('./asset-manifest.mjs');
|
|
167
|
+
const { readDecisions, revisionsFor, setDecision } = await import('./decisions.mjs');
|
|
168
|
+
const mine = readManifest(root, id);
|
|
169
|
+
const theirs = JSON.parse(readFileSync(join(src, 'assets', 'manifest.json'), 'utf8'));
|
|
170
|
+
// The starter is dressed from one library family: an automatic pick follows it (one family a game); one the person
|
|
171
|
+
// chose stays, and the lineup flags the starter's models until they are swapped.
|
|
172
|
+
const family = (theirs.assets ?? []).map((a) => String(a.from?.pack ?? '').split('-')[0]).find(Boolean);
|
|
173
|
+
const before = readDecisions(root, id)?.decisions?.['cast.family'];
|
|
174
|
+
if (family && before && before.value !== family && before.state === 'auto') {
|
|
175
|
+
setDecision(root, id, 'cast.family', family, { by: 'ai', why: `the starter's models are ${family}; one family a game, so every piece fits` });
|
|
176
|
+
}
|
|
177
|
+
const doc = readDecisions(root, id);
|
|
178
|
+
const adopted = [];
|
|
179
|
+
for (const a of theirs.assets ?? []) {
|
|
180
|
+
if (mine.assets.some((x) => x.id === a.id)) continue;
|
|
181
|
+
if (doc && a.made?.under) a.made.under = revisionsFor(doc, Object.keys(a.made.under));
|
|
182
|
+
mine.assets.push(a);
|
|
183
|
+
adopted.push(a.id);
|
|
184
|
+
}
|
|
185
|
+
writeManifest(root, id, mine);
|
|
186
|
+
return adopted;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A STARTER'S MODELS COME FROM THE STARTER LIBRARY: the repository keeps no binaries, so such a starter ships only its
|
|
191
|
+
* assets/manifest.json, each model a library item (`from.item`) and the file it goes to. Each is fetched from the
|
|
192
|
+
* library (HOMIE_LIBRARY: a folder or an address; homie.rocks by default), checked by the index's SHA-256 and the GLB
|
|
193
|
+
* safety rules (lib/library.mjs fetchItemFile), and written there; the manifest keeps the SHA-256 of what was written.
|
|
194
|
+
* When the library cannot be reached the models stay out, `why` says so, and the game draws its stand-ins until they
|
|
195
|
+
* come (`homie-studio assets add <game> <item> --as <asset>`). Returns { fetched, missing: [{ asset, item, why }], from }.
|
|
196
|
+
*/
|
|
197
|
+
async function fetchStarterModels(dest, only = null) {
|
|
198
|
+
const file = join(dest, 'assets', 'manifest.json');
|
|
199
|
+
let manifest;
|
|
200
|
+
try { manifest = JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
|
|
201
|
+
const wanted = (manifest.assets ?? []).filter((a) => (!only || only.includes(a.id)) && a.route === 'library' && a.from?.item && (a.files ?? []).some((f) => f.role === 'model' && /^public\/models\/[a-z0-9][a-z0-9-]{0,47}\.glb$/.test(String(f.path))));
|
|
202
|
+
if (!wanted.length) return null;
|
|
203
|
+
const { fetchItemFile, libraryBase, loadIndex } = await import('./library.mjs');
|
|
204
|
+
const lib = libraryBase();
|
|
205
|
+
let index;
|
|
206
|
+
try { index = (await loadIndex({ lib })).index; } catch (error) {
|
|
207
|
+
return { fetched: 0, missing: wanted.map((a) => ({ asset: a.id, item: a.from.item, why: 'no library' })), why: error.message, from: lib.base };
|
|
208
|
+
}
|
|
209
|
+
const missing = [];
|
|
210
|
+
let fetched = 0;
|
|
211
|
+
for (const a of wanted) {
|
|
212
|
+
const item = index.items.find((x) => x.id === a.from.item);
|
|
213
|
+
const f = a.files.find((x) => x.role === 'model');
|
|
214
|
+
if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
|
|
215
|
+
try {
|
|
216
|
+
const { bytes, sha256 } = await fetchItemFile(lib, item);
|
|
217
|
+
mkdirSync(dirname(join(dest, f.path)), { recursive: true });
|
|
218
|
+
writeFileSync(join(dest, f.path), bytes);
|
|
219
|
+
Object.assign(f, { bytes: bytes.byteLength, sha256 });
|
|
220
|
+
a.from.sha256 = sha256;
|
|
221
|
+
fetched += 1;
|
|
222
|
+
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
225
|
+
return { fetched, missing, from: lib.base };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* WHAT A STARTER NEEDS FROM NPM. A starter's game.json may say `"needs": { "three": "0.185.1" }`: libraries its code
|
|
230
|
+
* imports that a studio does not have yet. Each one the studio's package.json lacks (in dependencies or
|
|
231
|
+
* devDependencies) goes into devDependencies at that exact version, so the game bundles one copy of it (a second
|
|
232
|
+
* three.js beside the one @homie-rocks/studio/assets imports would be two engines). A version the studio already
|
|
233
|
+
* pins is left as it is and named in `needsHeld`. `needs` stays in the game's game.json: it is what the game was
|
|
234
|
+
* written against. Returns { needs, needsAdded, needsHeld, installNeeded }.
|
|
235
|
+
*/
|
|
236
|
+
export function addNeeds(root, needs) {
|
|
237
|
+
const want = needs && typeof needs === 'object' && !Array.isArray(needs) ? Object.entries(needs).filter(([n, v]) => /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]{0,213}$/.test(n) && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(v))) : [];
|
|
238
|
+
if (!want.length) return {};
|
|
239
|
+
const file = join(root, 'package.json');
|
|
240
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
241
|
+
const added = []; const held = [];
|
|
242
|
+
for (const [n, v] of want) {
|
|
243
|
+
const have = pkg.dependencies?.[n] ?? pkg.devDependencies?.[n];
|
|
244
|
+
if (have) { if (have !== v) held.push({ name: n, want: v, have }); continue; }
|
|
245
|
+
pkg.devDependencies = { ...(pkg.devDependencies ?? {}), [n]: String(v) };
|
|
246
|
+
added.push({ name: n, version: String(v) });
|
|
247
|
+
}
|
|
248
|
+
if (added.length) {
|
|
249
|
+
pkg.devDependencies = Object.fromEntries(Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
250
|
+
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
251
|
+
}
|
|
252
|
+
return { needs: Object.fromEntries(want), needsAdded: added, needsHeld: held, installNeeded: added.length > 0 };
|
|
140
253
|
}
|
|
141
254
|
|
|
142
255
|
/**
|
|
@@ -188,5 +301,19 @@ export async function remixGame(root, source, id, { name } = {}) {
|
|
|
188
301
|
const main = join(dest, meta.entry ?? 'src/main.ts');
|
|
189
302
|
if (existsSync(main) && from) writeFileSync(main, readFileSync(main, 'utf8').replace(new RegExp(`game: '${from}'`, 'g'), `game: '${id}'`));
|
|
190
303
|
if (from) renameTakeSaves(dest, from, id);
|
|
191
|
-
|
|
304
|
+
// Its models and pictures (0.22.0): fetched from the original site and checked by SHA-256 where their licence lets a
|
|
305
|
+
// remix carry them; a grey placeholder of the same size where it does not (lib/remix-assets.mjs).
|
|
306
|
+
let assets = null;
|
|
307
|
+
if (existsSync(join(dest, 'assets', 'manifest.json'))) {
|
|
308
|
+
const { fetchRemixAssets } = await import('./remix-assets.mjs');
|
|
309
|
+
assets = await fetchRemixAssets(root, id, url, { credit: { studio: credit.studio ?? null, game: credit.game ?? meta.name ?? from } });
|
|
310
|
+
}
|
|
311
|
+
// What its code imports beyond the toolkit (a 3D game: three.js). A remix source is someone else's text, so only the
|
|
312
|
+
// libraries the toolkit's own starters use are added; anything else is named for the person to decide.
|
|
313
|
+
const asked = meta.needs && typeof meta.needs === 'object' && !Array.isArray(meta.needs) ? meta.needs : {};
|
|
314
|
+
const refusedNeeds = Object.keys(asked).filter((n) => !REMIX_NEEDS.has(n));
|
|
315
|
+
const needs = addNeeds(root, Object.fromEntries(Object.entries(asked).filter(([n]) => REMIX_NEEDS.has(n))));
|
|
316
|
+
return { ok: true, command: 'game remix', id, from: String(url), credit: meta.remixOf.credit, page: meta.remixOf.page, license, files: wrote, ...(assets ? { assets } : {}), ...needs, ...(refusedNeeds.length ? { needsNotAdded: refusedNeeds } : {}) };
|
|
192
317
|
}
|
|
318
|
+
/** The libraries a remix may add to a studio's package.json by itself (the ones the toolkit's starters use). */
|
|
319
|
+
const REMIX_NEEDS = new Set(['three']);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STYLE BOARD AND THE LINEUP: pictures the engine draws for free (lib/render3d.mjs).
|
|
3
|
+
*
|
|
4
|
+
* styleBoard(root, id) three coherent directions (lib/decisions.mjs directionsFor), each drawn as the game would
|
|
5
|
+
* draw it: its palette, light, camera, material model, fonts and proportions, with the
|
|
6
|
+
* starter library family's own pieces re-tinted into its palette. Saved as
|
|
7
|
+
* games/<id>/codex/board/<a|b|c>-swatch.jpg (private, under 300 KB each), the board in
|
|
8
|
+
* decisions.json. A painted mood image per direction is optional and paid: the art skill
|
|
9
|
+
* makes it on the creator's own fal account and `style mood` records it, labelled a target.
|
|
10
|
+
* assetsLineup(root, id) every made asset at true scale on a 1 m grid under the game's light (front and
|
|
11
|
+
* three-quarter), silhouettes black on white at 64 px tall, and flags: over budget, palette
|
|
12
|
+
* drift (CIEDE2000 from the locked palette), an unreadable silhouette, another library family,
|
|
13
|
+
* stale. .studio/art/<id>/lineup-*.jpg and lineup.json.
|
|
14
|
+
*/
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { extname, join, relative } from 'node:path';
|
|
17
|
+
import { readManifest } from './asset-manifest.mjs';
|
|
18
|
+
import { assetsCheck } from './asset-check.mjs';
|
|
19
|
+
import { mix, nearest } from './colour.mjs';
|
|
20
|
+
import { directionsFor, initDecisions, labelOf, lightColours, readDecisions, staleAssets, styleTokens, writeDecisions, addLatestLine } from './decisions.mjs';
|
|
21
|
+
import { PALETTE_KEYS } from './style-presets.mjs';
|
|
22
|
+
import { loadIndex, searchLibrary, fetchItemFile, libraryBase } from './library.mjs';
|
|
23
|
+
import { modelIn, saveDataUrl, withRenderer, dataUrlBytes } from './render3d.mjs';
|
|
24
|
+
|
|
25
|
+
const BOARD_DIR = join('codex', 'board');
|
|
26
|
+
const PICTURE_MAX = 300 * 1024;
|
|
27
|
+
|
|
28
|
+
/** A direction's (or a game's) style values as the render page's tokens. */
|
|
29
|
+
export function tokensOf(values) {
|
|
30
|
+
const ui = values['style.ui'] ?? {};
|
|
31
|
+
return {
|
|
32
|
+
palette: values['style.palette'], light: values['style.light'], camera: values['style.camera'], render: values['style.render'],
|
|
33
|
+
materials: values['style.materials'], fonts: { display: ui.display, body: ui.body }, shape: values['style.shape'], proportions: values['style.proportions'],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Up to `n` library pieces of a family that suit the game's words, as model inputs (empty when no library is reachable). */
|
|
38
|
+
async function libraryPieces(family, query, n, log) {
|
|
39
|
+
try {
|
|
40
|
+
const { index, lib } = await loadIndex({ lib: libraryBase() });
|
|
41
|
+
const picks = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const q of [query, 'tree', 'rock', 'bush plant', 'crate barrel']) {
|
|
44
|
+
for (const hit of searchLibrary(index, q, { family, limit: 6 })) {
|
|
45
|
+
if (picks.length >= n) break;
|
|
46
|
+
const it = hit.item;
|
|
47
|
+
if (seen.has(it.id) || it.kind === 'texture' || it.kind === 'sky' || it.rigged) continue;
|
|
48
|
+
seen.add(it.id);
|
|
49
|
+
try { const { bytes } = await fetchItemFile(lib, it); picks.push(modelIn(it.id, bytes, { label: it.name })); } catch (error) { log(`library ${it.id}: ${error.message}`); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return picks;
|
|
53
|
+
} catch (error) { log(`no library pieces: ${error.message}`); return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Save a data: URL picture under `max` bytes (re-encoding JPEG quality down if it is over). */
|
|
57
|
+
async function savePicture(url, file, max = PICTURE_MAX) {
|
|
58
|
+
let bytes = saveDataUrl(url, file);
|
|
59
|
+
if (bytes <= max) return bytes;
|
|
60
|
+
try {
|
|
61
|
+
const sharp = (await import('sharp')).default;
|
|
62
|
+
for (const q of [78, 68, 58]) {
|
|
63
|
+
const out = await sharp(dataUrlBytes(url).bytes).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
64
|
+
writeFileSync(file, out);
|
|
65
|
+
bytes = out.byteLength;
|
|
66
|
+
if (bytes <= max) break;
|
|
67
|
+
}
|
|
68
|
+
} catch { /* kept as it is */ }
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function styleBoard(root, id, { prompt = '', log = () => {}, library = true } = {}) {
|
|
73
|
+
let doc = readDecisions(root, id);
|
|
74
|
+
if (!doc) { initDecisions(root, id, { prompt, path: 'hands-on' }); doc = readDecisions(root, id); }
|
|
75
|
+
const { directions } = directionsFor(root, id, { prompt: prompt || doc.prompt });
|
|
76
|
+
let game = {};
|
|
77
|
+
try { game = JSON.parse(readFileSync(join(root, 'games', id, 'game.json'), 'utf8')); } catch { game = {}; }
|
|
78
|
+
const title = game.name ?? id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
79
|
+
const words = [prompt, doc.prompt, (doc.decisions['cast.list']?.value ?? []).map((c) => c.id).join(' ')].join(' ');
|
|
80
|
+
const dir = join(root, 'games', id, BOARD_DIR);
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
const pieces = {};
|
|
83
|
+
if (library) for (const d of directions) if (!pieces[d.family]) pieces[d.family] = await libraryPieces(d.family, words, 4, log);
|
|
84
|
+
const out = [];
|
|
85
|
+
const renderer = await withRenderer(async (r) => {
|
|
86
|
+
for (const d of directions) {
|
|
87
|
+
const url = await r.swatch(tokensOf(d.values), pieces[d.family] ?? [], { title });
|
|
88
|
+
const file = join(dir, `${d.id}-swatch.jpg`);
|
|
89
|
+
const bytes = await savePicture(url, file);
|
|
90
|
+
out.push({ ...d, swatch: relative(join(root, 'games', id), file), bytes, library: (pieces[d.family] ?? []).map((m) => m.id) });
|
|
91
|
+
}
|
|
92
|
+
return r.renderer;
|
|
93
|
+
}, { log });
|
|
94
|
+
const prev = doc.board?.directions ?? [];
|
|
95
|
+
doc = readDecisions(root, id);
|
|
96
|
+
// A pick survives a redraw only while its letter still names the same direction.
|
|
97
|
+
const was = prev.find((p) => p.id === doc.board?.chosen);
|
|
98
|
+
const still = was && out.some((d) => d.id === was.id && d.label === was.label);
|
|
99
|
+
doc.board = {
|
|
100
|
+
at: new Date().toISOString(), renderer, chosen: still ? was.id : null,
|
|
101
|
+
directions: out.map((d) => ({ id: d.id, label: d.label, family: d.family, values: d.values, swatch: d.swatch.split('\\').join('/'), library: d.library, mood: prev.find((p) => p.id === d.id && p.label === d.label)?.mood ?? null })),
|
|
102
|
+
};
|
|
103
|
+
writeDecisions(root, id, doc);
|
|
104
|
+
return { ok: true, command: 'style board', id, title, renderer, directions: boardView(root, id, doc) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The board as cards and the CLI show it: per direction its rows (decision, label), swatch path and mood. */
|
|
108
|
+
export function boardView(root, id, doc = readDecisions(root, id)) {
|
|
109
|
+
return (doc?.board?.directions ?? []).map((d) => ({
|
|
110
|
+
id: d.id, label: d.label, family: d.family, swatch: d.swatch, mood: d.mood,
|
|
111
|
+
chosen: doc.board.chosen === d.id,
|
|
112
|
+
colours: PALETTE_KEYS.map((k) => d.values['style.palette']?.[k]).filter(Boolean),
|
|
113
|
+
fonts: { display: d.values['style.ui']?.display, body: d.values['style.ui']?.body },
|
|
114
|
+
rows: Object.entries(d.values).map(([k, v]) => ({ decision: k, label: labelOf(k, v), same: JSON.stringify(doc.decisions[k]?.value) === JSON.stringify(v), state: doc.decisions[k]?.state ?? null })),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `style mood <id> <dir> --image <file>`: a painted mood image for one direction (made and paid for by the art skill). */
|
|
119
|
+
export async function recordMood(root, id, dirId, image, { usd = null, receipt = null, model = null } = {}) {
|
|
120
|
+
const doc = readDecisions(root, id);
|
|
121
|
+
const d = doc?.board?.directions?.find((x) => x.id === dirId);
|
|
122
|
+
if (!d) throw new Error(`no direction "${dirId}" on the style board (style board ${id} draws it first)`);
|
|
123
|
+
if (!existsSync(image)) throw new Error(`no picture at ${image}`);
|
|
124
|
+
const file = join(root, 'games', id, BOARD_DIR, `${dirId}-mood.jpg`);
|
|
125
|
+
const sharp = (await import('sharp')).default;
|
|
126
|
+
let q = 82;
|
|
127
|
+
let out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer();
|
|
128
|
+
while (out.byteLength > PICTURE_MAX && q > 50) { q -= 10; out = await sharp(readFileSync(image)).resize(960, 540, { fit: 'cover' }).jpeg({ quality: q, mozjpeg: true }).toBuffer(); }
|
|
129
|
+
writeFileSync(file, out);
|
|
130
|
+
d.mood = { path: relative(join(root, 'games', id), file).split('\\').join('/'), usd: usd === null ? null : Number(usd), receipt, model, label: 'target (painted, not what the game draws)', at: new Date().toISOString() };
|
|
131
|
+
writeDecisions(root, id, doc);
|
|
132
|
+
return { ok: true, command: 'style mood', id, direction: dirId, mood: d.mood };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `style golden <id> add <image>`: one of two to six golden images (references for every concept call). */
|
|
136
|
+
export async function addGolden(root, id, image, { from = null } = {}) {
|
|
137
|
+
const doc = readDecisions(root, id);
|
|
138
|
+
if (!doc) throw new Error(`games/${id} has no decisions yet`);
|
|
139
|
+
if (!existsSync(image)) throw new Error(`no picture at ${image}`);
|
|
140
|
+
doc.golden = doc.golden ?? [];
|
|
141
|
+
if (doc.golden.length >= 6) throw new Error('six golden images already: remove one first (style golden <id> remove <n>)');
|
|
142
|
+
const n = doc.golden.reduce((m, g) => Math.max(m, Number(/(\d+)\.jpg$/.exec(g.path)?.[1] ?? 0)), 0) + 1;
|
|
143
|
+
const dir = join(root, 'games', id, BOARD_DIR, 'golden');
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const file = join(dir, `${n}.jpg`);
|
|
146
|
+
try {
|
|
147
|
+
const sharp = (await import('sharp')).default;
|
|
148
|
+
writeFileSync(file, await sharp(readFileSync(image)).resize(1024, 1024, { fit: 'inside' }).jpeg({ quality: 84, mozjpeg: true }).toBuffer());
|
|
149
|
+
} catch { if (/\.jpe?g$/i.test(extname(image))) copyFileSync(image, file); else throw new Error('sharp could not read that picture'); }
|
|
150
|
+
doc.golden.push({ path: relative(join(root, 'games', id), file).split('\\').join('/'), from: from ?? relative(root, image), approvedBy: 'person', at: new Date().toISOString() });
|
|
151
|
+
writeDecisions(root, id, doc);
|
|
152
|
+
addLatestLine(root, id, `Added golden image ${n} (from ${from ?? relative(root, image)}): every concept is made with it as a reference.`);
|
|
153
|
+
return { ok: true, command: 'style golden', id, golden: doc.golden };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ the lineup */
|
|
157
|
+
|
|
158
|
+
const DRIFT = 18; // mean CIEDE2000 (area-weighted) from the palette above which an asset is flagged
|
|
159
|
+
|
|
160
|
+
export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
161
|
+
const manifest = readManifest(root, id);
|
|
162
|
+
const doc = readDecisions(root, id);
|
|
163
|
+
const tokens = styleTokens(doc) ?? { palette: { bg: '#20242c', ink: '#f2f2f2', accent: '#ffcf5a', accent2: '#7dffb0', danger: '#ff5d5d', good: '#5fdc8b', gold: '#f2c14e', ramp: [] } };
|
|
164
|
+
const models = [];
|
|
165
|
+
for (const a of manifest.assets) {
|
|
166
|
+
const f = (a.files ?? []).find((x) => x.role === 'model');
|
|
167
|
+
if (!f) continue;
|
|
168
|
+
const abs = join(root, 'games', id, f.path);
|
|
169
|
+
if (!existsSync(abs)) continue;
|
|
170
|
+
// `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
|
|
171
|
+
// lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
|
|
172
|
+
const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
|
|
173
|
+
models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
|
|
174
|
+
}
|
|
175
|
+
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
176
|
+
const dir = join(root, '.studio', 'art', id);
|
|
177
|
+
mkdirSync(dir, { recursive: true });
|
|
178
|
+
const shot = await withRenderer(async (r) => ({ renderer: r.renderer, ...(await r.lineup(models.map((m) => m.input), tokens)) }), { log });
|
|
179
|
+
const images = { front: join(dir, 'lineup-front.jpg'), quarter: join(dir, 'lineup-quarter.jpg'), silhouettes: join(dir, 'lineup-silhouettes.png') };
|
|
180
|
+
saveDataUrl(shot.front, images.front); saveDataUrl(shot.quarter, images.quarter); saveDataUrl(shot.silhouettes, images.silhouettes);
|
|
181
|
+
const palette = [...PALETTE_KEYS.map((k) => tokens.palette[k]), ...(tokens.palette.ramp ?? [])].filter(Boolean);
|
|
182
|
+
const family = doc?.decisions?.['cast.family']?.value ?? null;
|
|
183
|
+
const check = await assetsCheck(root, id, { validate: false, write: true }).catch(() => null);
|
|
184
|
+
const stale = doc ? staleAssets(doc, manifest) : [];
|
|
185
|
+
const { albedoOf } = await import('./optimise.mjs');
|
|
186
|
+
const rows = [];
|
|
187
|
+
for (const m of models) {
|
|
188
|
+
const flags = [];
|
|
189
|
+
let drift = null;
|
|
190
|
+
try {
|
|
191
|
+
const colours = await albedoOf(m.abs);
|
|
192
|
+
// A model the game pulls toward the palette (inGame.pull) is scored on the colours it is drawn in.
|
|
193
|
+
const pull = Math.max(0, Math.min(1, Number(m.a.inGame?.pull) || 0));
|
|
194
|
+
const drawn = colours.map((c) => (pull ? { ...c, hex: mix(c.hex, nearest(c.hex, palette).hex, pull) } : c));
|
|
195
|
+
const scored = drawn.map((c) => { const n = nearest(c.hex, palette); return { hex: c.hex, share: c.share, palette: n.hex, delta: +n.delta.toFixed(1) }; });
|
|
196
|
+
const mean = scored.reduce((n, c) => n + c.delta * c.share, 0) / Math.max(0.0001, scored.reduce((n, c) => n + c.share, 0));
|
|
197
|
+
drift = { mean: +mean.toFixed(1), colours: scored };
|
|
198
|
+
if (m.a.inGame?.repaint) drift.repaint = m.a.inGame.repaint;
|
|
199
|
+
else if (mean > DRIFT) flags.push(`palette drift: its colours sit ${drift.mean} from the palette (over ${DRIFT}); a re-tint (free) or a remake`);
|
|
200
|
+
} catch (error) { log(`${m.a.id}: colours not read (${error.message})`); }
|
|
201
|
+
const row = shot.rows.find((x) => x.id === m.a.id);
|
|
202
|
+
if (row && ['character', 'creature'].includes(m.a.kind) && (row.silhouette.widthPx < 6 || row.silhouette.fill > 0.92)) flags.push(`silhouette: ${row.silhouette.widthPx < 6 ? 'too thin to read at 64 px' : 'a blob at 64 px (no limbs, ears or tail break the outline)'}`);
|
|
203
|
+
const c = check?.rows?.find((x) => x.id === m.a.id);
|
|
204
|
+
if (c && !c.ok) flags.push(`over budget: ${c.problems[0]}`);
|
|
205
|
+
if (family && m.a.route === 'library' && m.a.from?.pack && !String(m.a.from.pack).startsWith(family)) flags.push(`another library family (${m.a.from.pack}; the game's is ${family}): mixing families shows`);
|
|
206
|
+
if (stale.some((s) => s.id === m.a.id)) flags.push('stale: made under an older decision');
|
|
207
|
+
rows.push({ id: m.a.id, label: m.input.label, kind: m.a.kind, route: m.a.route, size: row?.size ?? null, silhouette: row?.silhouette ?? null, drift, flags });
|
|
208
|
+
}
|
|
209
|
+
const result = { ok: true, command: 'assets lineup', id, renderer: shot.renderer, images: Object.fromEntries(Object.entries(images).map(([k, v]) => [k, relative(root, v)])), rows, palette: palette.slice(0, 7), family, at: new Date().toISOString(), flagged: rows.filter((r) => r.flags.length).length };
|
|
210
|
+
writeFileSync(join(dir, 'lineup.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `assets review <id> --score n --outliers a,b`: a fresh reviewer's "one game?" score, and the assets it named. */
|
|
215
|
+
export function recordReview(root, id, { score, outliers = [], note = null, by = 'reviewer' } = {}) {
|
|
216
|
+
const s = Number(score);
|
|
217
|
+
if (!(s >= 1 && s <= 10)) throw new Error('--score 1 to 10 ("does it look like one game?")');
|
|
218
|
+
const dir = join(root, '.studio', 'art', id);
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
const manifest = readManifest(root, id);
|
|
221
|
+
const needs = new Set(outliers.filter(Boolean));
|
|
222
|
+
if (s < 7) for (const a of manifest.assets) if (a.route !== 'procedural') needs.add(a.id);
|
|
223
|
+
const review = { score: s, outliers: [...needs], note, by, at: new Date().toISOString(), verdict: s >= 7 && !outliers.length ? 'one game' : 'needs review' };
|
|
224
|
+
writeFileSync(join(dir, 'review.json'), `${JSON.stringify(review, null, 2)}\n`);
|
|
225
|
+
return { ok: true, command: 'assets review', id, review };
|
|
226
|
+
}
|
|
227
|
+
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STYLE VOCABULARY the decisions pick from (lib/decisions.mjs): render styles, named palettes, light and camera
|
|
3
|
+
* set-ups, fonts and the starter library's families, each with the words in a prompt that point at it. Taste, kept
|
|
4
|
+
* as data: the automatic path reads it, the style board mixes it, and a person steers past it with their own values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** How the world is drawn. `family`: the starter library family that fits; `materials`: the material model. */
|
|
8
|
+
export const RENDERS = Object.freeze({
|
|
9
|
+
'lowpoly-flat': { label: 'Flat low-poly', words: /\b(low[- ]?poly|cozy|cosy|cute|toy|minimal|clean|simple|wholesome|chill)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Fredoka', body: 'Nunito' }, vfx: 'soft', note: 'faceted shapes in flat palette colours: the cheapest look on a phone, the most coherent, and the richest free library' },
|
|
10
|
+
toon: { label: 'Toon / cel', words: /\b(toon|cel|cartoon|anime|comic|neon|cyber|synth|arcade)\b/i, materials: 'toon', outline: true, family: 'kaykit', fonts: { display: 'Lilita One', body: 'Nunito' }, vfx: 'toon', note: 'banded light and ink outlines: reads at any size, loud on a TV' },
|
|
11
|
+
painted: { label: 'Stylised hand-painted', words: /\b(painterly|painted|storybook|watercolou?r|ghibli-like|fairy ?tale|whimsical|hand[- ]painted)\b/i, materials: 'hand-painted', outline: false, family: 'kaykit', fonts: { display: 'Fraunces', body: 'Lora' }, vfx: 'soft', note: 'soft painted albedo with the light baked gently in: warm and storybook' },
|
|
12
|
+
'stylised-pbr': { label: 'Stylised PBR', words: /\b(realistic|real|photo|gritty|grounded|military|pbr)\b/i, materials: 'pbr', outline: false, family: 'polyhaven', fonts: { display: 'Rajdhani', body: 'Inter' }, vfx: 'soft', note: 'metal and roughness under real light: the most expensive look on a phone (textures and memory)' },
|
|
13
|
+
'pixel-hd2d': { label: 'Pixel / HD-2D', words: /\b(pixel|8[- ]?bit|16[- ]?bit|retro|hd[- ]?2d|sprite)\b/i, materials: 'pixel', outline: false, family: 'kenney', fonts: { display: 'Press Start 2P', body: 'Silkscreen' }, vfx: 'pixel', note: 'pixel sprites in a low-poly world with tilt-shift and bloom (the sprite branch is phase 3; the board shows the world)' },
|
|
14
|
+
voxel: { label: 'Voxel', words: /\b(voxel|blocky|blocks|cube|minecraft-like)\b/i, materials: 'flat', outline: false, family: 'kenney', fonts: { display: 'Silkscreen', body: 'Nunito' }, vfx: 'pixel', note: 'everything built of cubes: crisp, cheap, playful' },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** The codex keys every palette fills (frontmatter `palette`), plus `ramp`: 12 to 16 colours for textures. */
|
|
18
|
+
export const PALETTE_KEYS = Object.freeze(['bg', 'ink', 'accent', 'accent2', 'danger', 'good', 'gold']);
|
|
19
|
+
|
|
20
|
+
export const PALETTES = Object.freeze({
|
|
21
|
+
'autumn-grove': { label: 'Autumn grove: rust, moss and cream', words: /\b(forest|woods?|grove|autumn|fall|fox(es)?|berr(y|ies)|mushroom|acorn|leaf|leaves)\b/i,
|
|
22
|
+
bg: '#1f2a24', ink: '#f6ecd9', accent: '#e9893a', accent2: '#8fbf5a', danger: '#e2553f', good: '#7fcf6a', gold: '#f2c14e',
|
|
23
|
+
ramp: ['#2c3b31', '#3f5640', '#5d7a4a', '#8fbf5a', '#c9d98a', '#6b4a2f', '#9a6a3d', '#c98a4b', '#e9893a', '#b8442e', '#f6ecd9', '#d9c7a3', '#f2c14e', '#7a5a8c'] },
|
|
24
|
+
'meadow-morning': { label: 'Meadow morning: sky blue, grass and butter', words: /\b(meadow|farm|garden|spring|sunny|bright|happy|picnic|village)\b/i,
|
|
25
|
+
bg: '#cfe8f5', ink: '#1d2b3a', accent: '#ff8a5b', accent2: '#5cbf8a', danger: '#e2553f', good: '#45b36b', gold: '#ffd25a',
|
|
26
|
+
ramp: ['#9fd3ef', '#cfe8f5', '#f8f3e6', '#8fd06a', '#5cbf8a', '#3f8f5a', '#ffd25a', '#ffb347', '#ff8a5b', '#e86a6a', '#b07ad9', '#8a6a4a', '#5a4a3a', '#ffffff'] },
|
|
27
|
+
'embers-night': { label: 'Embers at night: warm golds on soot', words: /\b(fire|ember|lava|volcano|forge|torch|lantern|burning|ash(en)?)\b/i,
|
|
28
|
+
bg: '#120c0a', ink: '#f6e7d3', accent: '#f4b23c', accent2: '#7ad38a', danger: '#ff5a4e', good: '#5fdc8b', gold: '#f2c14e',
|
|
29
|
+
ramp: ['#120c0a', '#2b1d16', '#4a2e20', '#6e3b22', '#a4512a', '#e0702f', '#f4b23c', '#ffd98a', '#f6e7d3', '#7ad38a', '#3f6b4a', '#5a4a6e'] },
|
|
30
|
+
'neon-dusk': { label: 'Neon dusk: magenta and cyan on ink', words: /\b(neon|cyber|synth(wave)?|city|night ?club|future|retro[- ]future|laser)\b/i,
|
|
31
|
+
bg: '#0b0a1a', ink: '#eef0ff', accent: '#ff3fa4', accent2: '#2ee6f2', danger: '#ff4d4d', good: '#4dff9a', gold: '#ffd84a',
|
|
32
|
+
ramp: ['#0b0a1a', '#1a1438', '#2c1f5e', '#4b2a8c', '#7a35c4', '#ff3fa4', '#ff7ac8', '#2ee6f2', '#7af3ff', '#ffd84a', '#eef0ff', '#3a3a5e'] },
|
|
33
|
+
'candy-pop': { label: 'Candy pop: bubblegum and mint', words: /\b(candy|sweet|sugar|pastel|toy|kawaii|party|bubble|jelly)\b/i,
|
|
34
|
+
bg: '#fff2f7', ink: '#2a1f3d', accent: '#ff5fa2', accent2: '#4fd1c5', danger: '#ff4f64', good: '#40c07a', gold: '#ffc94d',
|
|
35
|
+
ramp: ['#fff2f7', '#ffd6e8', '#ff9ec7', '#ff5fa2', '#c45fd1', '#8a7cff', '#4fd1c5', '#a6f0d9', '#ffc94d', '#ff9a5a', '#2a1f3d', '#ffffff'] },
|
|
36
|
+
'frost-blue': { label: 'Frost: ice blue and snow', words: /\b(snow|ice|icy|winter|frost|frozen|arctic|polar|penguin)\b/i,
|
|
37
|
+
bg: '#eaf3fb', ink: '#14243a', accent: '#3d8bff', accent2: '#8fe3ff', danger: '#ff5a6a', good: '#41c08a', gold: '#ffcc4d',
|
|
38
|
+
ramp: ['#ffffff', '#eaf3fb', '#cfe2f5', '#a9cbee', '#7fb0e6', '#3d8bff', '#2a5fb0', '#14243a', '#8fe3ff', '#c7f3ff', '#ffcc4d', '#b4a3d9'] },
|
|
39
|
+
'desert-noon': { label: 'Desert noon: sand, terracotta and turquoise', words: /\b(desert|sand|dune|canyon|western|cowboy|egypt|pyramid|savanna)\b/i,
|
|
40
|
+
bg: '#f5e2c0', ink: '#3a2412', accent: '#d9653b', accent2: '#2bb3a8', danger: '#c8322a', good: '#5aa84a', gold: '#f2b632',
|
|
41
|
+
ramp: ['#fff3dc', '#f5e2c0', '#e8c48f', '#d9a35f', '#c47a3f', '#d9653b', '#a8452a', '#6e2e1a', '#3a2412', '#2bb3a8', '#7ad3c8', '#f2b632'] },
|
|
42
|
+
'deep-sea': { label: 'Deep sea: teal dark with coral', words: /\b(ocean|sea|under ?water|reef|coral|fish|submarine|diver?|pirate|island|beach)\b/i,
|
|
43
|
+
bg: '#06222e', ink: '#e6fbff', accent: '#ff7a59', accent2: '#3fe0c5', danger: '#ff5a6a', good: '#5fe08a', gold: '#ffd166',
|
|
44
|
+
ramp: ['#03141b', '#06222e', '#0b3a4a', '#11566a', '#1a7a8c', '#3fe0c5', '#a6f5e6', '#ff7a59', '#ffb08a', '#ffd166', '#e6fbff', '#5a4a8c'] },
|
|
45
|
+
'moonlit-grove': { label: 'Moonlit grove: indigo, silver and firefly', words: /\b(night|moon(lit)?|dark|spooky|haunted|ghost|witch|owl|halloween|grave)\b/i,
|
|
46
|
+
bg: '#0e1430', ink: '#e8ecff', accent: '#c9a8ff', accent2: '#b8f26a', danger: '#ff5e7a', good: '#6fe0a0', gold: '#ffe08a',
|
|
47
|
+
ramp: ['#070a1c', '#0e1430', '#1b2350', '#2c3770', '#47509a', '#7c84c9', '#c9a8ff', '#e8ecff', '#b8f26a', '#ffe08a', '#3a5a4a', '#5e3a5a'] },
|
|
48
|
+
'heroic-dawn': { label: 'Heroic dawn: steel blue and gold', words: /\b(hero(ic)?|epic|fantasy|kingdom|castle|knight|quest|rpg|dungeon|sword|dragon|adventure)\b/i,
|
|
49
|
+
bg: '#1a2238', ink: '#f3efe6', accent: '#e8b84a', accent2: '#6aa8e0', danger: '#e04a3a', good: '#5ac07a', gold: '#f2c14e',
|
|
50
|
+
ramp: ['#0f1526', '#1a2238', '#2c3a5c', '#4a5f8c', '#6aa8e0', '#b8d4f0', '#f3efe6', '#d9c49a', '#e8b84a', '#a8742a', '#8c3a2a', '#4a6a3a'] },
|
|
51
|
+
'space-ink': { label: 'Space: ink black with signal orange', words: /\b(space|galaxy|planet|star(ship)?|rocket|alien|asteroid|sci[- ]?fi|moon ?base|orbit)\b/i,
|
|
52
|
+
bg: '#07080f', ink: '#eef2ff', accent: '#ff8a3d', accent2: '#5ad1ff', danger: '#ff4d5e', good: '#59e08a', gold: '#ffd24d',
|
|
53
|
+
ramp: ['#07080f', '#11142a', '#1e2447', '#2f3a6e', '#4a5aa0', '#5ad1ff', '#b8ecff', '#eef2ff', '#ff8a3d', '#ffb87a', '#ffd24d', '#8c5ad9'] },
|
|
54
|
+
'ash-dawn': { label: 'Ash dawn: muted greys with rose', words: /\b(ruin(s|ed)?|post[- ]?apocalyp\w*|grim|bleak|ash|ashen|wasteland|mist(y)?|fog(gy)?|quiet|melanchol\w*)\b/i,
|
|
55
|
+
bg: '#2a2a2e', ink: '#ece6e0', accent: '#e8927c', accent2: '#9cb8b0', danger: '#d9584a', good: '#86b886', gold: '#d9b86a',
|
|
56
|
+
ramp: ['#1a1a1e', '#2a2a2e', '#3e3d42', '#57555c', '#7a7680', '#a49ea6', '#ece6e0', '#e8927c', '#c26a5a', '#9cb8b0', '#6a8a84', '#d9b86a'] },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Light set-ups (the style.light decision). `key`: the sun's direction (from the light toward the scene). */
|
|
60
|
+
export const LIGHTS = Object.freeze({
|
|
61
|
+
morning: { label: 'Soft morning', words: /\b(morning|dawn|spring|fresh)\b/i, key: [-0.5, -1, -0.35], intensity: 2.2, hardness: 0.35, sky: 'mix:bg>#ffffff:0.35', ground: 'mix:accent2>bg:0.25', fog: 0.35, shadows: 'blob', toonSteps: 3, bloom: 0.1, tiltShift: false },
|
|
62
|
+
noon: { label: 'Bright noon', words: /\b(noon|midday|sunny|bright|desert|beach)\b/i, key: [-0.2, -1, -0.15], intensity: 2.8, hardness: 0.75, sky: 'mix:bg>#ffffff:0.5', ground: 'mix:gold>accent2:0.45', fog: 0.2, shadows: 'blob', toonSteps: 2, bloom: 0.05, tiltShift: false },
|
|
63
|
+
golden: { label: 'Golden hour', words: /\b(golden|sunset|dusk|evening|warm|autumn|fall)\b/i, key: [-0.85, -0.55, -0.25], intensity: 2.4, hardness: 0.55, sky: 'mix:accent>#ffffff:0.45', ground: 'mix:accent2>accent:0.3', fog: 0.45, shadows: 'blob', toonSteps: 3, bloom: 0.25, tiltShift: false },
|
|
64
|
+
night: { label: 'Moonlight and lamps', words: /\b(night|moon(lit)?|dark|spooky|neon|stars?|lantern|ember|fire)\b/i, key: [0.4, -1, 0.3], intensity: 1.1, hardness: 0.4, sky: 'bg', ground: 'mix:accent2>bg:0.6', fog: 0.6, shadows: 'blob', toonSteps: 3, bloom: 0.5, tiltShift: false },
|
|
65
|
+
overcast: { label: 'Soft overcast', words: /\b(overcast|cloudy|grey|gray|misty|fog(gy)?|ash|quiet)\b/i, key: [-0.3, -1, -0.2], intensity: 1.6, hardness: 0.15, sky: 'mix:bg>#ffffff:0.25', ground: 'mix:accent2>bg:0.5', fog: 0.7, shadows: 'blob', toonSteps: 2, bloom: 0.05, tiltShift: false },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** Camera set-ups (the style.camera decision): distance in metres, field of view in degrees, the angle above the ground. */
|
|
69
|
+
export const CAMERAS = Object.freeze({
|
|
70
|
+
'high-3/4': { label: 'High three-quarter', projection: 'perspective', pitch: 52, distance: 22, fov: 38, note: 'the whole arena and every player readable on a phone' },
|
|
71
|
+
'low-3/4': { label: 'Low three-quarter', projection: 'perspective', pitch: 34, distance: 20, fov: 42, note: 'down among the players, the woods beyond the clearing in view: a place, not a board' },
|
|
72
|
+
'close-3/4': { label: 'Close three-quarter', projection: 'perspective', pitch: 40, distance: 11, fov: 42, note: 'nearer the characters: more detail on screen, less of the world' },
|
|
73
|
+
iso: { label: 'Isometric', projection: 'orthographic', pitch: 35.264, distance: 20, fov: 30, note: 'a diorama: tidy and readable, the HD-2D and tactics look' },
|
|
74
|
+
'top-down': { label: 'Top-down', projection: 'perspective', pitch: 80, distance: 24, fov: 35, note: 'the map is the screen: best for a crowd and for aiming' },
|
|
75
|
+
chase: { label: 'Chase', projection: 'perspective', pitch: 18, distance: 7, fov: 60, note: 'behind the player: speed and scale, one body at a time' },
|
|
76
|
+
side: { label: 'Side', projection: 'perspective', pitch: 6, distance: 14, fov: 35, note: 'a platformer\'s view: jumps read exactly' },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Genres (from the prompt and the codex), with the defaults they bring. */
|
|
80
|
+
export const GENRES = Object.freeze({
|
|
81
|
+
gather: { words: /\b(gather|collect|harvest|forag\w*|berr(y|ies)|gems?|coins?|pick ?up|treasure|loot|farm)\b/i, camera: 'low-3/4', heads: 2.5, heightM: 0.9, clips: ['idle', 'walk', 'run', 'pick-up', 'emote'] },
|
|
82
|
+
brawl: { words: /\b(brawl|fight|arena|knock|bump|smash|wrestl\w*|sumo|battle royale)\b/i, camera: 'high-3/4', heads: 2.5, heightM: 1.0, clips: ['idle', 'run', 'jump', 'attack', 'hit', 'die'] },
|
|
83
|
+
race: { words: /\b(race|racing|kart|car|drive|driving|drift|speed)\b/i, camera: 'chase', heads: 3, heightM: 1.0, clips: ['idle', 'drive', 'boost', 'crash'] },
|
|
84
|
+
rpg: { words: /\b(rpg|quest|dungeon|adventure|mmo|wow|zones?|class(es)?|loot|level up)\b/i, camera: 'iso', heads: 4.5, heightM: 1.7, clips: ['idle', 'walk', 'run', 'attack', 'cast', 'hit', 'die', 'interact'] },
|
|
85
|
+
platformer: { words: /\b(platform(er)?|jump(ing)?|parkour|climb)\b/i, camera: 'side', heads: 3, heightM: 1.1, clips: ['idle', 'run', 'jump', 'fall', 'land'] },
|
|
86
|
+
shooter: { words: /\b(shoot(er|ing)?|blaster|gun|laser|tank|twin[- ]stick)\b/i, camera: 'top-down', heads: 5, heightM: 1.8, clips: ['idle', 'run', 'aim', 'shoot', 'hit', 'die'] },
|
|
87
|
+
party: { words: /\b(party|mini[- ]?games?|silly|chaos|couch)\b/i, camera: 'high-3/4', heads: 2.5, heightM: 1.0, clips: ['idle', 'run', 'jump', 'emote', 'win'] },
|
|
88
|
+
puzzle: { words: /\b(puzzle|match|tiles?|board game|cards?|chess)\b/i, camera: 'top-down', heads: 3, heightM: 1.0, clips: ['idle', 'emote', 'win'] },
|
|
89
|
+
sports: { words: /\b(soccer|football|golf|basketball|hockey|ball|sports?)\b/i, camera: 'high-3/4', heads: 3, heightM: 1.2, clips: ['idle', 'run', 'kick', 'jump', 'win'] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Starter library families: which look each belongs to. */
|
|
93
|
+
export const FAMILIES = Object.freeze({
|
|
94
|
+
kenney: { label: 'Kenney (CC0)', renders: ['lowpoly-flat', 'voxel', 'pixel-hd2d'], note: 'clean flat-colour low-poly kits: nature, food, pets, dungeons, cars' },
|
|
95
|
+
kaykit: { label: 'KayKit (CC0)', renders: ['toon', 'painted'], note: 'stylised gradient-atlas low-poly: adventurers, dungeons, props' },
|
|
96
|
+
polyhaven: { label: 'Poly Haven (CC0)', renders: ['stylised-pbr'], note: 'photo-scanned props, materials and skies' },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** Shape language words. */
|
|
100
|
+
export const SHAPES = Object.freeze({
|
|
101
|
+
round: { label: 'Round and soft', words: /\b(cozy|cosy|cute|soft|friendly|gentle|bubbly|kids?|round)\b/i, bevel: 0.6, note: 'round silhouettes and big soft bevels: friendly at a glance' },
|
|
102
|
+
angular: { label: 'Angular and sharp', words: /\b(tense|dark|grim|aggressive|sharp|edgy|horror|cyber|military)\b/i, bevel: 0.1, note: 'pointed silhouettes and crisp edges: tension and speed' },
|
|
103
|
+
blocky: { label: 'Blocky', words: /\b(blocky|voxel|cube|lego|toy|robot)\b/i, bevel: 0.25, note: 'boxes with small bevels: toy-like and readable' },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Google Fonts (OFL) by mood, for style.ui. */
|
|
107
|
+
export const FONT_SETS = Object.freeze({
|
|
108
|
+
friendly: { display: 'Fredoka', body: 'Nunito' },
|
|
109
|
+
bold: { display: 'Lilita One', body: 'Nunito' },
|
|
110
|
+
storybook: { display: 'Fraunces', body: 'Lora' },
|
|
111
|
+
clean: { display: 'Rajdhani', body: 'Inter' },
|
|
112
|
+
pixel: { display: 'Press Start 2P', body: 'Silkscreen' },
|
|
113
|
+
future: { display: 'Orbitron', body: 'Inter' },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Which words mean which mood (palette, light and shape read them). */
|
|
117
|
+
export const MOODS = Object.freeze({
|
|
118
|
+
cozy: /\b(cozy|cosy|cute|warm|gentle|calm|chill|relax\w*|wholesome|soft|friendly|sweet)\b/i,
|
|
119
|
+
tense: /\b(tense|dark|grim|horror|scary|creepy|spooky|survival|hardcore)\b/i,
|
|
120
|
+
loud: /\b(neon|loud|wild|chaos|crazy|party|arcade|electric)\b/i,
|
|
121
|
+
});
|