@cmpose/core 0.4.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/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/pins.js +1 -1
package/README.md
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). See the README at the root of the cmpose repository for usage and development.
|
|
4
4
|
|
|
5
|
-
It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame
|
|
5
|
+
It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame` (`describeGame({ themes: await game.readThemes(templateDir) })` adds the template's theme families).
|
package/package.json
CHANGED
|
@@ -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 };
|
package/src/game/validate.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const PV = require('../ProjectValidator');
|
|
3
3
|
const limits = require('../limits');
|
|
4
4
|
const { GAME_SCHEMA: S } = require('./contract');
|
|
5
|
+
const { checkThemeShape } = require('./themes');
|
|
5
6
|
|
|
6
7
|
const CSHARP_KEYWORDS = ('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while').split(' ');
|
|
7
8
|
const KOTLIN_KEYWORDS = PV.KOTLIN_HARD_KEYWORDS;
|
|
@@ -179,9 +180,17 @@ function validateGame(input) {
|
|
|
179
180
|
ids = r.value;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// The family and font pair are checked against the template's themes.json later (validateTheme)
|
|
184
|
+
let theme = null;
|
|
185
|
+
if (input.theme !== undefined) {
|
|
186
|
+
const r = checkThemeShape(input.theme);
|
|
187
|
+
if (r.error) return fail(r.error);
|
|
188
|
+
theme = r.value;
|
|
189
|
+
}
|
|
190
|
+
|
|
182
191
|
return {
|
|
183
192
|
ok: true,
|
|
184
|
-
value: { projectName, appName, packageName, companyName, templateType, render, flags, features, minSdk, iosVersion, ids }
|
|
193
|
+
value: { projectName, appName, packageName, companyName, templateType, render, flags, features, minSdk, iosVersion, ids, theme }
|
|
185
194
|
};
|
|
186
195
|
}
|
|
187
196
|
|
package/src/pins.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module.exports = Object.freeze({
|
|
4
4
|
cmp: Object.freeze({ repo: 'whinstudio/cmpose-template', ref: 'template-2026.10.17' }),
|
|
5
5
|
// The release step replaces the placeholder with the game template's tag
|
|
6
|
-
game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.
|
|
6
|
+
game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.17' }),
|
|
7
7
|
// The release step replaces the placeholder with the backend template's tag
|
|
8
8
|
backend: Object.freeze({ repo: 'whinstudio/cmpose-backend-template', ref: 'backend-template-2026.10.17' })
|
|
9
9
|
});
|