@cmpose/core 0.3.0 → 0.5.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 +1 -1
- package/package.json +1 -1
- package/src/backend/contract.js +39 -0
- package/src/backend/describe.js +73 -0
- package/src/backend/generate.js +39 -0
- package/src/backend/identity.js +109 -0
- package/src/backend/index.js +24 -0
- package/src/backend/options.js +159 -0
- package/src/backend/record.js +31 -0
- package/src/backend/validate.js +64 -0
- package/src/backend/verifier.js +69 -0
- package/src/backendOption.js +1 -64
- package/src/game/applyTheme.js +129 -0
- package/src/game/contract.js +4 -1
- package/src/game/describe.js +15 -3
- package/src/game/generate.js +19 -4
- package/src/game/index.js +8 -0
- package/src/game/record.js +4 -2
- package/src/game/themeColors.js +173 -0
- package/src/game/themes.js +112 -0
- package/src/game/validate.js +10 -1
- package/src/index.js +2 -0
- package/src/markerBlocks.js +93 -0
- package/src/pins.js +3 -1
package/src/backendOption.js
CHANGED
|
@@ -3,17 +3,11 @@ const fs = require('fs-extra');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { removeSwiftPackage, parseObjects, isa } = require('./xcodePackages');
|
|
5
5
|
const { PRIVACY_MANIFEST } = require('./privacyManifest');
|
|
6
|
+
const { removeMarkedBlocks, applySwapMarkers } = require('./markerBlocks');
|
|
6
7
|
|
|
7
8
|
// featuresConfig.backend: the template marks everything the backend adds (MAINTAINING.md → Backend markers).
|
|
8
9
|
// Off strips it, on keeps it and drops only the markers. Runs on the template package, before the rename.
|
|
9
10
|
|
|
10
|
-
const MARKERS = {
|
|
11
|
-
slash: { open: '// cmpose:backend', close: '// /cmpose:backend' },
|
|
12
|
-
hash: { open: '# cmpose:backend', close: '# /cmpose:backend' },
|
|
13
|
-
html: { open: '<!-- cmpose:backend -->', close: '<!-- /cmpose:backend -->' }
|
|
14
|
-
};
|
|
15
|
-
const SWAP = '// cmpose:backend-off: ';
|
|
16
|
-
|
|
17
11
|
const KIND_BY_EXTENSION = {
|
|
18
12
|
'.kt': 'slash', '.kts': 'slash', '.swift': 'slash', '.xcconfig': 'slash',
|
|
19
13
|
'.toml': 'hash', '.gitignore': 'hash', '.properties': 'hash',
|
|
@@ -75,63 +69,6 @@ const BACKEND_PIN_IDENTITIES = new Set([
|
|
|
75
69
|
'gtm-session-fetcher', 'gtmappauth', 'interop-ios-for-google-sdks', 'leveldb', 'nanopb', 'promises', 'swift-protobuf'
|
|
76
70
|
]);
|
|
77
71
|
|
|
78
|
-
// Off: drops each marked block with its marker lines; on: drops only the marker lines. Either way a blank
|
|
79
|
-
// line on both sides of a dropped run becomes one (blank runs elsewhere stay).
|
|
80
|
-
function removeMarkedBlocks(content, kind, keep) {
|
|
81
|
-
const { open, close } = MARKERS[kind];
|
|
82
|
-
const out = [];
|
|
83
|
-
const joins = [];
|
|
84
|
-
let inside = false;
|
|
85
|
-
let dropped = false;
|
|
86
|
-
for (const line of content.split('\n')) {
|
|
87
|
-
const t = line.trim();
|
|
88
|
-
if (t === open) {
|
|
89
|
-
if (inside) throw new Error(`nested ${open}`);
|
|
90
|
-
inside = true;
|
|
91
|
-
dropped = true;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (t === close) {
|
|
95
|
-
if (!inside) throw new Error(`stray ${close}`);
|
|
96
|
-
inside = false;
|
|
97
|
-
dropped = true;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (inside && !keep) continue;
|
|
101
|
-
if (dropped) joins.push(out.length);
|
|
102
|
-
dropped = false;
|
|
103
|
-
out.push(line);
|
|
104
|
-
}
|
|
105
|
-
if (inside) throw new Error('unclosed cmpose:backend block');
|
|
106
|
-
if (dropped) joins.push(out.length);
|
|
107
|
-
return collapseBlankRuns(out, joins).join('\n');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// At each join (the index of the first line after a dropped run), blank lines that follow a blank line go
|
|
111
|
-
function collapseBlankRuns(lines, joins) {
|
|
112
|
-
const drop = new Set();
|
|
113
|
-
for (const join of joins) {
|
|
114
|
-
if (join === 0 || lines[join - 1] !== '') continue;
|
|
115
|
-
for (let i = join; i < lines.length && lines[i] === ''; i++) drop.add(i);
|
|
116
|
-
}
|
|
117
|
-
return lines.filter((_, i) => !drop.has(i));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Off: the line after a swap marker becomes the marker's text at that line's indentation; on: the marker goes
|
|
121
|
-
function applySwapMarkers(content, keep) {
|
|
122
|
-
const lines = content.split('\n');
|
|
123
|
-
const out = [];
|
|
124
|
-
for (let i = 0; i < lines.length; i++) {
|
|
125
|
-
const t = lines[i].trim();
|
|
126
|
-
if (!t.startsWith(SWAP)) { out.push(lines[i]); continue; }
|
|
127
|
-
const next = lines[i + 1];
|
|
128
|
-
if (next === undefined) throw new Error('cmpose:backend-off marker on the last line');
|
|
129
|
-
if (keep) { out.push(next); } else { out.push(next.match(/^\s*/)[0] + t.slice(SWAP.length)); }
|
|
130
|
-
i++;
|
|
131
|
-
}
|
|
132
|
-
return out.join('\n');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
72
|
// Off: the Firebase and GoogleSignIn packages, the build phase copying GoogleService-Info.plist, the
|
|
136
73
|
// membership exceptions of the entitlements and GoogleService-Info.plist, and the entitlements and
|
|
137
74
|
// Info.plist preprocessing (LOCAL_SERVER) build settings. Text edits only.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { setScalar, setList } = require('./unityYaml');
|
|
5
|
+
const { readMetaGuid } = require('./guid');
|
|
6
|
+
const { hexToRgb, derivePalette } = require('./themeColors');
|
|
7
|
+
|
|
8
|
+
// cmpose game: writes a style family (themes.js) into the project as ThemeFamilies.Apply does in the editor: the light
|
|
9
|
+
// palette, the dark one while the uiTheme flag's folder is there, and the style asset (fonts, shapes, motion, post,
|
|
10
|
+
// world colours). Values are written the way Unity serializes them, so applying the template's own family changes nothing.
|
|
11
|
+
const LIGHT_PATH = 'Assets/_Project/Core/Presentation/Resources/Theme/Light.asset';
|
|
12
|
+
const DARK_PATH = 'Assets/_Project/Core/Theme/Resources/Theme/Dark.asset';
|
|
13
|
+
const STYLE_PATH = 'Assets/_Project/Core/Presentation/Resources/Theme/Style.asset';
|
|
14
|
+
const ASSET_DOC = { classId: 114, fileId: 11400000 };
|
|
15
|
+
|
|
16
|
+
// ThemeStyle's enums (SurfaceStyle, MotionProfile, BackgroundStyle): Unity stores the index
|
|
17
|
+
const ENUMS = {
|
|
18
|
+
surface: ['Flat', 'Soft', 'Raised'],
|
|
19
|
+
motion: ['Calm', 'Snappy', 'Bouncy'],
|
|
20
|
+
background: ['Solid', 'Gradient', 'Pattern']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// A float as Unity writes it: the shortest text that reads back as the same 32-bit float
|
|
24
|
+
function unityFloat(x) {
|
|
25
|
+
const f = Math.fround(x);
|
|
26
|
+
for (let p = 1; p <= 9; p++) {
|
|
27
|
+
const s = f.toPrecision(p);
|
|
28
|
+
if (Math.fround(Number(s)) === f) return String(Number(s));
|
|
29
|
+
}
|
|
30
|
+
return String(f);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function unityColor(hex) {
|
|
34
|
+
const { r, g, b, a } = hexToRgb(hex);
|
|
35
|
+
return `{r: ${unityFloat(r / 255)}, g: ${unityFloat(g / 255)}, b: ${unityFloat(b / 255)}, a: ${unityFloat(a / 255)}}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Palette's serialized field of a ThemeRole: Background → background, OnPrimary → onPrimary
|
|
39
|
+
const fieldOf = (role) => role.charAt(0).toLowerCase() + role.slice(1);
|
|
40
|
+
|
|
41
|
+
// themes.json names assets by project path; only paths inside Assets are followed
|
|
42
|
+
function assetPath(projectDir, rel, what) {
|
|
43
|
+
if (typeof rel !== 'string' || !rel.startsWith('Assets/') || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
|
44
|
+
throw new Error(`theme: ${what} must be a path inside Assets`);
|
|
45
|
+
}
|
|
46
|
+
return path.join(projectDir, rel);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function assetRef(projectDir, rel, what) {
|
|
50
|
+
const file = assetPath(projectDir, rel, what);
|
|
51
|
+
if (!(await fs.pathExists(file)) || !(await fs.pathExists(`${file}.meta`))) throw new Error(`theme: ${what} ${rel} is missing`);
|
|
52
|
+
return `{fileID: 11400000, guid: ${readMetaGuid(await fs.readFile(`${file}.meta`, 'utf8'))}, type: 2}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function enumIndex(name, value) {
|
|
56
|
+
const i = ENUMS[name].indexOf(value);
|
|
57
|
+
if (i < 0) throw new Error(`theme: style.${name} must be one of ${ENUMS[name].join(', ')}`);
|
|
58
|
+
return i;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writePalette(text, roles) {
|
|
62
|
+
let out = text;
|
|
63
|
+
for (const [role, hex] of Object.entries(roles)) out = setScalar(out, fieldOf(role), unityColor(hex), { doc: ASSET_DOC });
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readAsset(projectDir, rel) {
|
|
68
|
+
const file = path.join(projectDir, rel);
|
|
69
|
+
if (!(await fs.pathExists(file))) throw new Error(`theme: ${rel} is missing`);
|
|
70
|
+
return fs.readFile(file, 'utf8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Writes themeValue (validateTheme().value: family, fontPair, primary?, accent?) of themes (readThemes) into
|
|
74
|
+
* projectDir. uiTheme: whether the flag is on (its Dark.asset is written, never created). Every asset is read and every
|
|
75
|
+
* reference resolved before anything is written. Returns the warnings of colours that fell back and the theme as
|
|
76
|
+
* applied (primary/accent only where they were kept). */
|
|
77
|
+
async function applyTheme(projectDir, themeValue, themes, { uiTheme = false } = {}) {
|
|
78
|
+
const family = themes.families.find((f) => f.id === themeValue.family);
|
|
79
|
+
if (!family) throw new Error('theme: unknown family');
|
|
80
|
+
const pair = family.fontPairs.find((p) => p.id === themeValue.fontPair);
|
|
81
|
+
if (!pair) throw new Error('theme: unknown font pair');
|
|
82
|
+
const style = family.style;
|
|
83
|
+
|
|
84
|
+
const requested = { primary: themeValue.primary, accent: themeValue.accent };
|
|
85
|
+
const light = derivePalette(family, requested, 'light');
|
|
86
|
+
const dark = uiTheme ? derivePalette(family, requested, 'dark') : null;
|
|
87
|
+
|
|
88
|
+
const display = await assetRef(projectDir, pair.display, 'display font');
|
|
89
|
+
const body = await assetRef(projectDir, pair.body, 'body font');
|
|
90
|
+
const post = await assetRef(projectDir, style.post, 'post profile');
|
|
91
|
+
const worldColors = Array.isArray(style.worldColors) ? style.worldColors : [];
|
|
92
|
+
if (worldColors.length < 3 || worldColors.length > 4) throw new Error('theme: style.worldColors needs 3 or 4 colours');
|
|
93
|
+
const numbers = {};
|
|
94
|
+
for (const [key, min, max] of [['cornerRadius', 0, 48], ['shadowStrength', 0, 1]]) {
|
|
95
|
+
const v = style[key];
|
|
96
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new Error(`theme: style.${key} must be from ${min} to ${max}`);
|
|
97
|
+
numbers[key] = unityFloat(v);
|
|
98
|
+
}
|
|
99
|
+
const enums = Object.fromEntries(Object.keys(ENUMS).map((k) => [k, enumIndex(k, style[k])]));
|
|
100
|
+
|
|
101
|
+
const lightText = writePalette(await readAsset(projectDir, LIGHT_PATH), light.roles);
|
|
102
|
+
const darkText = dark ? writePalette(await readAsset(projectDir, DARK_PATH), dark.roles) : null;
|
|
103
|
+
let styleText = await readAsset(projectDir, STYLE_PATH);
|
|
104
|
+
const scalars = [
|
|
105
|
+
['familyId', family.id], ['displayFont', display], ['bodyFont', body],
|
|
106
|
+
['cornerRadius', numbers.cornerRadius], ['surface', enums.surface], ['shadowStrength', numbers.shadowStrength],
|
|
107
|
+
['motion', enums.motion], ['post', post], ['background', enums.background]
|
|
108
|
+
];
|
|
109
|
+
for (const [key, v] of scalars) styleText = setScalar(styleText, key, v, { doc: ASSET_DOC });
|
|
110
|
+
styleText = setList(styleText, 'worldColors', worldColors.map(unityColor), { doc: ASSET_DOC });
|
|
111
|
+
|
|
112
|
+
await fs.writeFile(path.join(projectDir, LIGHT_PATH), lightText);
|
|
113
|
+
if (darkText !== null) await fs.writeFile(path.join(projectDir, DARK_PATH), darkText);
|
|
114
|
+
await fs.writeFile(path.join(projectDir, STYLE_PATH), styleText);
|
|
115
|
+
|
|
116
|
+
const warnings = [...light.warnings, ...(dark ? dark.warnings : [])];
|
|
117
|
+
const fellBack = (role) => warnings.some((w) => w.startsWith(`theme: ${role} `));
|
|
118
|
+
return {
|
|
119
|
+
warnings,
|
|
120
|
+
theme: {
|
|
121
|
+
family: family.id,
|
|
122
|
+
...(themeValue.primary && !fellBack('Primary') ? { primary: themeValue.primary } : {}),
|
|
123
|
+
...(themeValue.accent && !fellBack('Accent') ? { accent: themeValue.accent } : {}),
|
|
124
|
+
fontPair: pair.id
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { LIGHT_PATH, DARK_PATH, STYLE_PATH, unityFloat, unityColor, applyTheme };
|
package/src/game/contract.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { hashContract } = require('../contract');
|
|
3
3
|
const limits = require('../limits');
|
|
4
|
+
const { THEME_FIELDS } = require('./themes');
|
|
4
5
|
|
|
5
6
|
// The Unity game generator's contract: what the game_* tools accept. Published facts feed contractVersion.
|
|
6
7
|
const GAME_SCHEMA = Object.freeze({
|
|
@@ -26,7 +27,9 @@ const GAME_SCHEMA = Object.freeze({
|
|
|
26
27
|
'button', 'lifetime', 'object', 'preserve', 'resources', 'serializable', 'transform',
|
|
27
28
|
'about', 'ads', 'boot', 'common', 'guide', 'inspector', 'leaderboards', 'menu', 'network', 'pause',
|
|
28
29
|
'purchases', 'runner2d', 'settings', 'theme', 'track3d', 'update'],
|
|
29
|
-
reservedPackagePrefix: 'com.kturker'
|
|
30
|
+
reservedPackagePrefix: 'com.kturker',
|
|
31
|
+
// The theme request's fields; the families themselves come from the template (game_describe themes)
|
|
32
|
+
theme: { fields: THEME_FIELDS.slice(), colorFormat: '#RRGGBB' }
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
function gameContractFacts() {
|
package/src/game/describe.js
CHANGED
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
const pins = require('../pins');
|
|
3
3
|
const { GAME_SCHEMA, gameContractFacts, gameContractVersion } = require('./contract');
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
function
|
|
5
|
+
// What a planner needs to pick a family: palettes, style and font paths stay in the template
|
|
6
|
+
function summarizeThemes(themes) {
|
|
7
|
+
const families = themes && Array.isArray(themes.families) ? themes.families : [];
|
|
8
|
+
return {
|
|
9
|
+
default: families.length ? themes.default : null,
|
|
10
|
+
families: families.map((f) => ({ id: f.id, title: f.title, suits: f.suits, fontPairs: f.fontPairs.map((p) => p.id) }))
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What the Unity game generator offers: engine, pinned template, defaults, the template's theme families
|
|
15
|
+
* (readThemes of the pinned template; none when not given) and the contract facts */
|
|
16
|
+
function describeGame({ themes = null } = {}) {
|
|
7
17
|
return {
|
|
8
18
|
engine: GAME_SCHEMA.engine,
|
|
9
19
|
unityVersion: GAME_SCHEMA.unityVersion,
|
|
@@ -17,8 +27,10 @@ function describeGame() {
|
|
|
17
27
|
minSdk: GAME_SCHEMA.limits.minSdkMin,
|
|
18
28
|
iosVersion: GAME_SCHEMA.limits.iosVersionMin
|
|
19
29
|
},
|
|
30
|
+
// Outside `contract`: the families belong to the template, contractVersion hashes the contract only
|
|
31
|
+
themes: summarizeThemes(themes),
|
|
20
32
|
contract: gameContractFacts()
|
|
21
33
|
};
|
|
22
34
|
}
|
|
23
35
|
|
|
24
|
-
module.exports = { describeGame };
|
|
36
|
+
module.exports = { describeGame, summarizeThemes };
|
package/src/game/generate.js
CHANGED
|
@@ -12,6 +12,8 @@ const { writeGameIds } = require('./ids');
|
|
|
12
12
|
const { applyGameReadme } = require('./readme');
|
|
13
13
|
const { buildGameRecord, writeGameRecord } = require('./record');
|
|
14
14
|
const { verifyGameProject } = require('./verifier');
|
|
15
|
+
const { readThemes, validateTheme } = require('./themes');
|
|
16
|
+
const { applyTheme } = require('./applyTheme');
|
|
15
17
|
|
|
16
18
|
// The template cache's own records and Unity's local state never reach the project; nor does the template's
|
|
17
19
|
// LICENSE (the generated project is the user's, to license as they choose), nor an older template's GameCI
|
|
@@ -42,6 +44,11 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
|
|
|
42
44
|
commit: await readMarker(COMMIT_FILE_NAME)
|
|
43
45
|
};
|
|
44
46
|
|
|
47
|
+
// The family and font pair are checked against the template's themes.json before anything changes
|
|
48
|
+
const themes = await readThemes(projectDir);
|
|
49
|
+
const theme = validateTheme(value.theme, themes);
|
|
50
|
+
if (!theme.ok) throw new Error(theme.error);
|
|
51
|
+
|
|
45
52
|
// 1. leftovers
|
|
46
53
|
for (const leftover of LEFTOVERS) await fs.remove(path.join(projectDir, leftover));
|
|
47
54
|
log.info('✅ Cleanup completed');
|
|
@@ -63,13 +70,20 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
|
|
|
63
70
|
await applyGameReadme(projectDir, value);
|
|
64
71
|
log.info(`✅ Renamed to ${value.companyName}.${value.projectName} (${value.packageName})`);
|
|
65
72
|
|
|
66
|
-
// 7.
|
|
73
|
+
// 7. the style family (the template's default when none was asked; nothing for a template without families)
|
|
74
|
+
let applied = { warnings: [], theme: null };
|
|
75
|
+
if (theme.value) {
|
|
76
|
+
applied = await applyTheme(projectDir, theme.value, themes, { uiTheme: value.flags.includes('uiTheme') });
|
|
77
|
+
log.info(`✅ Applied theme ${applied.theme.family} (${applied.theme.fontPair})`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 8. a broken project is never returned
|
|
67
81
|
const problems = await verifyGameProject(projectDir);
|
|
68
82
|
if (problems.length) throw new Error('generated project failed verification:\n- ' + problems.join('\n- '));
|
|
69
83
|
|
|
70
|
-
//
|
|
84
|
+
// 9. the template's package guid allowlist goes; the record stays
|
|
71
85
|
await fs.remove(path.join(projectDir, PACKAGE_GUIDS));
|
|
72
|
-
const record = buildGameRecord({ template: source, coreVersion, generatorVersion, value, now });
|
|
86
|
+
const record = buildGameRecord({ template: source, coreVersion, generatorVersion, value, theme: applied.theme, now });
|
|
73
87
|
await writeGameRecord(projectDir, record);
|
|
74
88
|
|
|
75
89
|
return {
|
|
@@ -78,7 +92,8 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
|
|
|
78
92
|
flags: value.flags.slice(),
|
|
79
93
|
features: value.features.slice(),
|
|
80
94
|
ids: record.ids,
|
|
81
|
-
|
|
95
|
+
theme: applied.theme,
|
|
96
|
+
warnings: [...applied.warnings, ...gameWarnings(value)]
|
|
82
97
|
};
|
|
83
98
|
}
|
|
84
99
|
|
package/src/game/index.js
CHANGED
|
@@ -9,6 +9,9 @@ const { writeGameIds } = require('./ids');
|
|
|
9
9
|
const { applyGameReadme } = require('./readme');
|
|
10
10
|
const { buildGameRecord, writeGameRecord } = require('./record');
|
|
11
11
|
const { verifyGameProject } = require('./verifier');
|
|
12
|
+
const { readThemes, validateTheme, ThemesFileError } = require('./themes');
|
|
13
|
+
const { applyTheme } = require('./applyTheme');
|
|
14
|
+
const { derivePalette } = require('./themeColors');
|
|
12
15
|
|
|
13
16
|
module.exports = {
|
|
14
17
|
validateGame,
|
|
@@ -24,6 +27,11 @@ module.exports = {
|
|
|
24
27
|
buildGameRecord,
|
|
25
28
|
writeGameRecord,
|
|
26
29
|
verifyGameProject,
|
|
30
|
+
readThemes,
|
|
31
|
+
ThemesFileError,
|
|
32
|
+
validateTheme,
|
|
33
|
+
applyTheme,
|
|
34
|
+
derivePalette,
|
|
27
35
|
flags: require('./flags'),
|
|
28
36
|
guid: require('./guid'),
|
|
29
37
|
guidIntegrity: require('./guidIntegrity'),
|
package/src/game/record.js
CHANGED
|
@@ -20,8 +20,9 @@ function idStatuses(flags, ids) {
|
|
|
20
20
|
const strings = (list) => (Array.isArray(list) ? list.filter((x) => typeof x === 'string') : []);
|
|
21
21
|
|
|
22
22
|
/** The .cmpose/generation.json content of a generated Unity game (no personal data, no ID values);
|
|
23
|
-
* generatorVersion names the caller (e.g. mcp@0.2.0), null when not given
|
|
24
|
-
|
|
23
|
+
* generatorVersion names the caller (e.g. mcp@0.2.0), null when not given; theme the style family as applied
|
|
24
|
+
* (applyTheme), null for a template without families */
|
|
25
|
+
function buildGameRecord({ template, coreVersion, generatorVersion = null, value, theme = null, now = new Date() }) {
|
|
25
26
|
const flags = strings(value.flags);
|
|
26
27
|
return {
|
|
27
28
|
generator: '@cmpose/core',
|
|
@@ -36,6 +37,7 @@ function buildGameRecord({ template, coreVersion, generatorVersion = null, value
|
|
|
36
37
|
minSdk: value.minSdk,
|
|
37
38
|
iosVersion: value.iosVersion,
|
|
38
39
|
ids: idStatuses(flags, value.ids),
|
|
40
|
+
theme: theme ? { ...theme } : null,
|
|
39
41
|
template: { repo: template.repo, ref: template.ref, commit: template.commit },
|
|
40
42
|
generatedAt: now.toISOString()
|
|
41
43
|
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// cmpose game: a family's palette with the requested Primary/Accent. The colours keep the requested hue and saturation;
|
|
4
|
+
// only the lightness moves, until every contrast floor the template's own tests ask for holds (ThemeFamilyTests'
|
|
5
|
+
// AssertReads, Contrast.AssertBoxes and AssertAccentAndStatus). A colour that cannot reach them stays the family's.
|
|
6
|
+
|
|
7
|
+
// Contrast floors of Contrast (Assets/_Project/Tests/EditMode/ThemeTests.cs)
|
|
8
|
+
const BODY_TEXT = 4.5;
|
|
9
|
+
const NON_TEXT = 3;
|
|
10
|
+
const SMALL_TEXT = 3;
|
|
11
|
+
const BOX_ON_PANEL = 1.25;
|
|
12
|
+
const SELECTED_ON_ROW = 1.1;
|
|
13
|
+
// The floors that differ by palette: Primary text on the rows, Control on Surface
|
|
14
|
+
const MODE_FLOORS = {
|
|
15
|
+
light: { accentOnRow: 3, controlOnSurface: 1.4 },
|
|
16
|
+
dark: { accentOnRow: 2.3, controlOnSurface: 3 }
|
|
17
|
+
};
|
|
18
|
+
// A requested hue stays visible: never shifted to black or white
|
|
19
|
+
const MIN_LIGHTNESS = 0.1;
|
|
20
|
+
const MAX_LIGHTNESS = 0.9;
|
|
21
|
+
const STEP = 0.01;
|
|
22
|
+
|
|
23
|
+
function hexToRgb(hex) {
|
|
24
|
+
const m = /^#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$/.exec(hex);
|
|
25
|
+
if (!m) throw new Error(`'${hex}' is not a #RRGGBB colour`);
|
|
26
|
+
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: m[4] === undefined ? 255 : parseInt(m[4], 16) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const byte = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase();
|
|
30
|
+
const rgbToHex = ({ r, g, b }) => `#${byte(r)}${byte(g)}${byte(b)}`;
|
|
31
|
+
|
|
32
|
+
// WCAG 2 relative luminance, as Contrast.Luminance computes it
|
|
33
|
+
function luminance(hex) {
|
|
34
|
+
const { r, g, b } = hexToRgb(hex);
|
|
35
|
+
const channel = (c) => {
|
|
36
|
+
const v = c / 255;
|
|
37
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
38
|
+
};
|
|
39
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function contrast(a, b) {
|
|
43
|
+
const la = luminance(a);
|
|
44
|
+
const lb = luminance(b);
|
|
45
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hexToHsl(hex) {
|
|
49
|
+
const { r, g, b } = hexToRgb(hex);
|
|
50
|
+
const [R, G, B] = [r / 255, g / 255, b / 255];
|
|
51
|
+
const max = Math.max(R, G, B);
|
|
52
|
+
const min = Math.min(R, G, B);
|
|
53
|
+
const l = (max + min) / 2;
|
|
54
|
+
if (max === min) return { h: 0, s: 0, l };
|
|
55
|
+
const d = max - min;
|
|
56
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
57
|
+
let h;
|
|
58
|
+
if (max === R) h = (G - B) / d + (G < B ? 6 : 0);
|
|
59
|
+
else if (max === G) h = (B - R) / d + 2;
|
|
60
|
+
else h = (R - G) / d + 4;
|
|
61
|
+
return { h: h / 6, s, l };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hslToHex({ h, s, l }) {
|
|
65
|
+
if (s === 0) return rgbToHex({ r: l * 255, g: l * 255, b: l * 255 });
|
|
66
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
67
|
+
const p = 2 * l - q;
|
|
68
|
+
const hue = (t) => {
|
|
69
|
+
if (t < 0) t += 1;
|
|
70
|
+
if (t > 1) t -= 1;
|
|
71
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
72
|
+
if (t < 1 / 2) return q;
|
|
73
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
74
|
+
return p;
|
|
75
|
+
};
|
|
76
|
+
return rgbToHex({ r: hue(h + 1 / 3) * 255, g: hue(h) * 255, b: hue(h - 1 / 3) * 255 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Every floor a palette must meet: [a, b, floor] contrast pairs, then the colours that must differ
|
|
80
|
+
function contrastPairs(mode) {
|
|
81
|
+
const { accentOnRow, controlOnSurface } = MODE_FLOORS[mode];
|
|
82
|
+
return [
|
|
83
|
+
['Text', 'Background', BODY_TEXT],
|
|
84
|
+
['Text', 'Surface', BODY_TEXT],
|
|
85
|
+
['TextMuted', 'Surface', BODY_TEXT],
|
|
86
|
+
['OnPrimary', 'Primary', BODY_TEXT],
|
|
87
|
+
['OnAccent', 'Accent', BODY_TEXT],
|
|
88
|
+
['Danger', 'Surface', NON_TEXT],
|
|
89
|
+
['Success', 'Surface', NON_TEXT],
|
|
90
|
+
['Primary', 'Surface', NON_TEXT],
|
|
91
|
+
['Primary', 'Control', NON_TEXT],
|
|
92
|
+
['Control', 'Surface', controlOnSurface],
|
|
93
|
+
['Track', 'Surface', BOX_ON_PANEL],
|
|
94
|
+
['SurfaceVariant', 'Surface', BOX_ON_PANEL],
|
|
95
|
+
['SurfaceVariant', 'Background', BOX_ON_PANEL],
|
|
96
|
+
['SurfaceVariant', 'Track', SELECTED_ON_ROW],
|
|
97
|
+
['TextMuted', 'Track', SMALL_TEXT],
|
|
98
|
+
['TextMuted', 'SurfaceVariant', SMALL_TEXT],
|
|
99
|
+
['Primary', 'Track', accentOnRow],
|
|
100
|
+
['Primary', 'SurfaceVariant', accentOnRow],
|
|
101
|
+
['Primary', 'Track', NON_TEXT]
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
const DISTINCT = [['Accent', 'Primary'], ['Success', 'Danger']];
|
|
105
|
+
|
|
106
|
+
const sameColour = (a, b) => a.toUpperCase() === b.toUpperCase();
|
|
107
|
+
|
|
108
|
+
/** The floors roles (role → #RRGGBB) misses in mode ('light' | 'dark'), as 'A on B' / 'A is B'; only the pairs
|
|
109
|
+
* that involve one of `only` when given */
|
|
110
|
+
function paletteProblems(roles, mode, only = null) {
|
|
111
|
+
if (!MODE_FLOORS[mode]) throw new Error(`unknown palette mode '${mode}'`);
|
|
112
|
+
const concerns = (a, b) => !only || only.includes(a) || only.includes(b);
|
|
113
|
+
const problems = [];
|
|
114
|
+
for (const [a, b, floor] of contrastPairs(mode)) {
|
|
115
|
+
if (concerns(a, b) && contrast(roles[a], roles[b]) < floor) problems.push(`${a} on ${b}`);
|
|
116
|
+
}
|
|
117
|
+
for (const [a, b] of DISTINCT) if (concerns(a, b) && sameColour(roles[a], roles[b])) problems.push(`${a} is ${b}`);
|
|
118
|
+
return problems;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Lightness values from the requested one outwards, inside the visible range
|
|
122
|
+
function lightnessSteps(from) {
|
|
123
|
+
const start = Math.min(MAX_LIGHTNESS, Math.max(MIN_LIGHTNESS, from));
|
|
124
|
+
const steps = [start];
|
|
125
|
+
for (let d = STEP; d <= 1; d += STEP) {
|
|
126
|
+
if (start - d >= MIN_LIGHTNESS - 1e-9) steps.push(start - d);
|
|
127
|
+
if (start + d <= MAX_LIGHTNESS + 1e-9) steps.push(start + d);
|
|
128
|
+
}
|
|
129
|
+
return steps;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const unique = (list) => list.filter((c, i) => list.findIndex((x) => sameColour(x, c)) === i);
|
|
133
|
+
|
|
134
|
+
// The requested colour itself when it passes, else its hue at the nearest lightness that does; the text on it is the
|
|
135
|
+
// family's On colour, its Text or its Surface (whichever reads); Control moves only when nothing else works
|
|
136
|
+
function shiftRole(roles, mode, role, requested) {
|
|
137
|
+
const on = `On${role}`;
|
|
138
|
+
const changes = role === 'Primary' ? [role, on, 'Control'] : [role, on];
|
|
139
|
+
const ons = unique([roles[on], roles.Text, roles.Surface]);
|
|
140
|
+
const hsl = hexToHsl(requested);
|
|
141
|
+
const candidates = unique([requested.toUpperCase(), ...lightnessSteps(hsl.l).map((l) => hslToHex({ ...hsl, l }))]);
|
|
142
|
+
const controls = [roles.Control];
|
|
143
|
+
if (role === 'Primary') {
|
|
144
|
+
const c = hexToHsl(roles.Control);
|
|
145
|
+
controls.push(...unique(lightnessSteps(c.l).map((l) => hslToHex({ ...c, l }))).filter((x) => !sameColour(x, roles.Control)));
|
|
146
|
+
}
|
|
147
|
+
for (const control of controls) {
|
|
148
|
+
for (const colour of candidates) {
|
|
149
|
+
for (const text of ons) {
|
|
150
|
+
const next = { ...roles, [role]: colour, [on]: text, ...(role === 'Primary' ? { Control: control } : {}) };
|
|
151
|
+
if (!paletteProblems(next, mode, changes).length) return next;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** family's palette for mode ('light' | 'dark') with the requested primary/accent (#RRGGBB) shifted to pass every
|
|
159
|
+
* contrast floor. roles: role → #RRGGBB(AA); warnings: one line per colour that fell back to the family's */
|
|
160
|
+
function derivePalette(family, { primary, accent } = {}, mode = 'light') {
|
|
161
|
+
if (!family || !family[mode]) throw new Error(`family has no ${mode} palette`);
|
|
162
|
+
let roles = { ...family[mode] };
|
|
163
|
+
const warnings = [];
|
|
164
|
+
for (const [role, requested] of [['Primary', primary], ['Accent', accent]]) {
|
|
165
|
+
if (!requested) continue;
|
|
166
|
+
const next = shiftRole(roles, mode, role, requested);
|
|
167
|
+
if (next) roles = next;
|
|
168
|
+
else warnings.push(`theme: ${role} fell back to the family default in the ${mode} palette`);
|
|
169
|
+
}
|
|
170
|
+
return { roles, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { MODE_FLOORS, hexToRgb, rgbToHex, luminance, contrast, hexToHsl, hslToHex, paletteProblems, derivePalette };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
|
|
5
|
+
// The template owns its style families (palettes, style, font pairs); older pins have no such file
|
|
6
|
+
const THEMES_PATH = path.join('.cmpose', 'themes.json');
|
|
7
|
+
const THEME_FIELDS = ['family', 'primary', 'accent', 'fontPair'];
|
|
8
|
+
const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
|
|
9
|
+
// Palette entries: #RRGGBB or #RRGGBBAA (Scrim carries alpha)
|
|
10
|
+
const PALETTE_HEX_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
|
|
11
|
+
|
|
12
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const isText = (v) => typeof v === 'string' && v.length > 0;
|
|
14
|
+
|
|
15
|
+
// A template's themes.json that is there but cannot be used
|
|
16
|
+
class ThemesFileError extends Error { }
|
|
17
|
+
|
|
18
|
+
function broken(why) {
|
|
19
|
+
return new ThemesFileError(`${THEMES_PATH.split(path.sep).join('/')} is not valid: ${why}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkPalette(id, name, palette) {
|
|
23
|
+
if (!isObject(palette)) throw broken(`family "${id}" has no ${name} palette`);
|
|
24
|
+
for (const [key, value] of Object.entries(palette)) {
|
|
25
|
+
if (typeof value !== 'string' || !PALETTE_HEX_RE.test(value)) throw broken(`family "${id}" ${name}.${key} is not a #RRGGBB colour`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkFamily(f) {
|
|
30
|
+
if (!isObject(f) || !isText(f.id)) throw broken('a family has no id');
|
|
31
|
+
checkPalette(f.id, 'light', f.light);
|
|
32
|
+
checkPalette(f.id, 'dark', f.dark);
|
|
33
|
+
if (!isObject(f.style)) throw broken(`family "${f.id}" has no style`);
|
|
34
|
+
if (!Array.isArray(f.fontPairs) || !f.fontPairs.length) throw broken(`family "${f.id}" has no font pairs`);
|
|
35
|
+
for (const p of f.fontPairs) {
|
|
36
|
+
if (!isObject(p) || !isText(p.id) || !isText(p.display) || !isText(p.body)) throw broken(`family "${f.id}" has a font pair without id, display or body`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
id: f.id, title: f.title, suits: f.suits,
|
|
40
|
+
light: { ...f.light }, dark: { ...f.dark }, style: { ...f.style },
|
|
41
|
+
fontPairs: f.fontPairs.map((p) => ({ id: p.id, display: p.display, body: p.body }))
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The template's style families from .cmpose/themes.json; no families when the file is missing.
|
|
46
|
+
* A file that is there but broken throws: the generator must not guess a theme. */
|
|
47
|
+
async function readThemes(templateDir) {
|
|
48
|
+
const file = path.join(templateDir, THEMES_PATH);
|
|
49
|
+
if (!(await fs.pathExists(file))) return { version: null, default: null, families: [] };
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
throw broken('not JSON');
|
|
55
|
+
}
|
|
56
|
+
if (!isObject(data) || !Array.isArray(data.families)) throw broken('no families list');
|
|
57
|
+
const families = data.families.map(checkFamily);
|
|
58
|
+
const ids = families.map((f) => f.id);
|
|
59
|
+
if (new Set(ids).size !== ids.length) throw broken('duplicate family ids');
|
|
60
|
+
if (!ids.includes(data.default)) throw broken(`default "${data.default}" is not a family`);
|
|
61
|
+
return { version: data.version, default: data.default, families };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** theme's shape alone (validateGame has no template): an object of known string fields, colours as #RRGGBB.
|
|
65
|
+
* value keeps the given fields, colours uppercased. */
|
|
66
|
+
function checkThemeShape(theme) {
|
|
67
|
+
if (!isObject(theme)) return { error: 'theme must be an object' };
|
|
68
|
+
const extra = Object.keys(theme).filter((k) => !THEME_FIELDS.includes(k));
|
|
69
|
+
if (extra.length) return { error: `theme has unknown fields: ${extra.join(', ')} (known: ${THEME_FIELDS.join(', ')})` };
|
|
70
|
+
const value = {};
|
|
71
|
+
for (const k of THEME_FIELDS) {
|
|
72
|
+
if (!Object.hasOwn(theme, k)) continue;
|
|
73
|
+
const v = theme[k];
|
|
74
|
+
if (typeof v !== 'string') return { error: `theme.${k} must be a string` };
|
|
75
|
+
if ((k === 'primary' || k === 'accent') && !HEX_RE.test(v)) return { error: `theme.${k} must look like #RRGGBB` };
|
|
76
|
+
value[k] = k === 'primary' || k === 'accent' ? v.toUpperCase() : v;
|
|
77
|
+
}
|
|
78
|
+
return { value };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Errors list the valid ids but never echo the request's value back
|
|
82
|
+
/** A theme request against the template's families (readThemes). value: { family, primary?, accent?, fontPair },
|
|
83
|
+
* the default family and each family's first font pair filling gaps; null when the template has no families and
|
|
84
|
+
* none was asked for. */
|
|
85
|
+
function validateTheme(theme, themes) {
|
|
86
|
+
const families = themes && Array.isArray(themes.families) ? themes.families : [];
|
|
87
|
+
const asked = theme !== undefined && theme !== null;
|
|
88
|
+
if (!families.length) {
|
|
89
|
+
return asked ? { ok: false, error: 'theme cannot be used: this template has no theme families' } : { ok: true, value: null };
|
|
90
|
+
}
|
|
91
|
+
const shape = asked ? checkThemeShape(theme) : { value: {} };
|
|
92
|
+
if (shape.error) return { ok: false, error: shape.error };
|
|
93
|
+
const t = shape.value;
|
|
94
|
+
const ids = families.map((f) => f.id);
|
|
95
|
+
const familyId = t.family === undefined ? themes.default : t.family;
|
|
96
|
+
const family = families.find((f) => f.id === familyId);
|
|
97
|
+
if (!family) return { ok: false, error: `theme.family must be one of this template's families: ${ids.join(', ')}` };
|
|
98
|
+
const pairs = family.fontPairs.map((p) => p.id);
|
|
99
|
+
const fontPair = t.fontPair === undefined ? pairs[0] : t.fontPair;
|
|
100
|
+
if (!pairs.includes(fontPair)) return { ok: false, error: `theme.fontPair must be one of ${family.id}'s font pairs: ${pairs.join(', ')}` };
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
value: {
|
|
104
|
+
family: family.id,
|
|
105
|
+
...(t.primary ? { primary: t.primary } : {}),
|
|
106
|
+
...(t.accent ? { accent: t.accent } : {}),
|
|
107
|
+
fontPair
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { THEMES_PATH, THEME_FIELDS, ThemesFileError, readThemes, validateTheme, checkThemeShape };
|