muse-engine-32 / engine /io /setup-schema.js
axonis22's picture
Muse Engine 32: your FBX/GLB scene as the level, skinned characters, Model check, save/load setup
6c78aa4 verified
Raw History Blame Contribute Delete
16.7 kB
// engine/io/setup-schema.js - written by Muse Glimmer (Phase 32, part B, 27 Sep 2026; raw output in game/muse-authored/phase32/raw/),
// reviewed by Grok Bot (coach) with game/muse-authored/phase32/harness/. Coach fixes are marked [coach fix P32-n], see FIXES-P32.md.
(function () {
'use strict';
var KIND = 'muse-engine-setup';
var VERSION = 1;
var UNITS = ['auto', 'm', 'cm', 'mm', 'in', 'ft'];
var UP = ['auto', 'y', 'z'];
var FACING = ['+z', '-z', '+x', '-x'];
var ROLES = ['idle', 'walk', 'run', 'shoot', 'death'];
var SOURCES = ['url', 'file'];
var MODEL_EXTS = { glb: true, gltf: true, fbx: true };
var EXTRA_EXTS = { bin: true, png: true, jpg: true, jpeg: true, webp: true, tga: true, ktx2: true };
// [coach change P32-9] extension point for optional readers (none in the standard build): registerModelExt(ext, { extras }) makes ext a
// model file for planDrop (and no longer an unsupported one); extras: true lets it take dropped .bin / texture files like .gltf / .fbx.
var EXTRA_MAIN_EXTS = { gltf: true, fbx: true };
var UNSUPPORTED_EXTS = {
obj: true,
blend: true,
max: true,
mb: true,
ma: true,
'3ds': true,
dae: true,
usd: true,
usdz: true,
stl: true,
c4d: true,
skp: true,
};
function isPlainObject(o) {
return o !== null && typeof o === 'object' && !Array.isArray(o);
}
function lower(s) {
return String(s).toLowerCase();
}
function extOf(name) {
var i = name.lastIndexOf('.');
return i >= 0 ? lower(name.slice(i + 1)) : '';
}
function inArr(v, a) {
return a.indexOf(v) !== -1;
}
function trim(s) {
return String(s).trim();
}
function normalizeName(n) {
var t = String(n); // [coach fix P32-7] spec: String(s.name || ''); a number name became ''
if (t.length > 260) {
t = t.slice(0, 260);
}
return t;
}
function urlAllowed(u) {
if (typeof u !== 'string') return false;
var s = trim(u);
if (s.length > 2048) return false; // [coach fix P32-5] an empty / blank url is caught by 'url is required', not the URL rule
if (s.startsWith('https://') || s.startsWith('http://')) return true;
var colon = s.indexOf(':');
var slash = s.indexOf('/');
if (colon !== -1 && (slash === -1 || colon < slash)) return false;
return true;
}
function slotNormalize(s, slotName, warnings) {
if (s == null) return null;
if (!isPlainObject(s)) return null;
var name = normalizeName(s.name || '');
if (typeof s.name === 'string' && s.name.length > 260) {
warnings.push(slotName + '.name truncated');
}
var source = s.source === 'file' ? 'file' : 'url';
var url = null;
if (source === 'url') {
url = typeof s.url === 'string' ? s.url : null;
}
var units = 'auto';
if (s.units && inArr(s.units, UNITS)) units = s.units;
else if (s.units) {
warnings.push(slotName + '.units "' + s.units + '" invalid, using auto');
}
var up = 'auto';
if (s.up && inArr(s.up, UP)) up = s.up;
else if (s.up) {
warnings.push(slotName + '.up "' + s.up + '" invalid, using auto');
}
var facing = '+z';
if (slotName === 'character') {
if (s.facing && inArr(s.facing, FACING)) facing = s.facing;
else if (s.facing) {
warnings.push(slotName + '.facing "' + s.facing + '" invalid, using +z');
}
}
var clipPicks = {};
if (slotName === 'character' && isPlainObject(s.clipPicks)) {
for (var i = 0; i < ROLES.length; i++) {
var k = ROLES[i];
var v = s.clipPicks[k];
if (v === null || typeof v === 'string') {
clipPicks[k] = v;
} else if (v !== undefined) {
warnings.push(slotName + '.clipPicks.' + k + ' ignored (not a clip name)');
}
}
for (var kk in s.clipPicks) {
if (s.clipPicks.hasOwnProperty(kk) && inArr(kk, ROLES) === false) {
warnings.push(slotName + '.clipPicks.' + kk + ' ignored');
}
}
}
if (source === 'file') {
warnings.push(slotName + ' "' + name + '" was loaded from a file: drop that file on the game view to load it');
}
var out = { name: name, source: source, url: url, asLevel: true, units: units, up: up };
if (slotName === 'scene') {
out.asLevel = s.asLevel !== false;
}
if (slotName === 'character') {
out.facing = facing;
out.clipPicks = clipPicks;
}
return out;
}
function make(state) {
state = state || {}; // [coach fix P32-7] make() / make(null) threw
// [coach fix P32-7] spec: String(state.x || default); non-string engine / savedAt / look were replaced by the default
var engine = String(state.engine || '');
var savedAt = String(state.savedAt || '');
var look = String(state.look || 'A');
var config = {};
if (isPlainObject(state.config)) {
var keys = Object.keys(state.config)
.filter(function (k) {
return typeof state.config[k] === 'boolean';
})
.sort();
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
config[k] = state.config[k];
}
}
function slot(s, slotName) {
if (s == null) return null;
var name = String(s.name || ''); // [coach fix P32-7] make() keeps the name as given (validate() truncates with a warning)
var source = s.source === 'file' ? 'file' : 'url';
var url = source === 'url' ? String(s.url || '') : null;
var obj = { name: name, source: source, url: url };
if (slotName === 'scene') {
obj.asLevel = s.asLevel !== false;
obj.units = inArr(s.units, UNITS) ? s.units : 'auto';
obj.up = inArr(s.up, UP) ? s.up : 'auto';
}
if (slotName === 'character') {
obj.units = inArr(s.units, UNITS) ? s.units : 'auto';
obj.up = inArr(s.up, UP) ? s.up : 'auto';
obj.facing = inArr(s.facing, FACING) ? s.facing : '+z';
obj.clipPicks = {};
if (isPlainObject(s.clipPicks)) {
for (var i = 0; i < ROLES.length; i++) {
var k = ROLES[i];
var v = s.clipPicks[k];
if (v === null || typeof v === 'string') obj.clipPicks[k] = v;
}
}
}
return obj;
}
var out = {
kind: KIND,
version: VERSION,
engine: engine,
savedAt: savedAt,
look: look,
config: config,
scene: slot(state.scene, 'scene'),
character: slot(state.character, 'character'),
};
return out;
}
function validate(obj, opts) {
var errors = [];
var warnings = [];
var value = null;
opts = opts || {};
if (!isPlainObject(obj)) {
return { ok: false, errors: ['not a JSON object'], warnings: [], value: null };
}
for (var k in obj) {
if (obj.hasOwnProperty(k) && !['kind', 'version', 'engine', 'savedAt', 'look', 'config', 'scene', 'character'].includes(k)) {
warnings.push('unknown key "' + k + '" ignored');
}
}
if (obj.kind !== KIND) {
errors.push('kind must be "muse-engine-setup"');
}
if (typeof obj.version !== 'number' || !Number.isInteger(obj.version) || obj.version < 1) {
errors.push('version must be a positive integer');
} else if (obj.version > VERSION) {
errors.push('version ' + obj.version + ' is newer than this engine (' + VERSION + ')');
}
var configObj = {};
if (isPlainObject(obj.config)) {
var cfg = obj.config;
for (var ck in cfg) {
if (cfg.hasOwnProperty(ck)) {
if (typeof cfg[ck] !== 'boolean') {
warnings.push('config.' + ck + ' ignored (not true/false)');
continue;
}
if (opts.configKeys && Array.isArray(opts.configKeys) && !inArr(ck, opts.configKeys)) {
warnings.push('config.' + ck + ' ignored (unknown toggle)');
continue;
}
configObj[ck] = cfg[ck];
}
}
} else if (obj.config !== undefined) {
warnings.push('config ignored (not an object)');
}
// [coach fix P32-6] look checked after config (spec warning order); [coach fix P32-4] spec: look kept as String(look) unless opts.looks rejects it; missing look -> 'A' silently.
// Was: any non-string look became 'A' (no warning even when opts.looks was given), and a missing look was warned about.
var look = obj.look === undefined ? 'A' : String(obj.look);
if (obj.look !== undefined && Array.isArray(opts.looks) && !inArr(look, opts.looks)) {
warnings.push('look "' + look + '" unknown, using ' + opts.looks[0]);
look = opts.looks[0];
}
function checkSlot(slotObj, slotName) {
if (slotObj == null) return null;
if (!isPlainObject(slotObj)) {
errors.push(slotName + ' must be an object or null');
return null;
}
// [coach fix P32-6] errors in the spec's order: source, url required, url rule, name (name was checked first)
var source = slotObj.source;
if (source !== undefined && !inArr(source, SOURCES)) {
errors.push(slotName + '.source must be url or file');
}
var url = slotObj.url;
// [coach fix P32-5] url is required only for source 'url' or a missing source (an invalid source like 'ftp' also got this error),
// and the URL rule applies to any string url, also with source 'file' (it was skipped there, and skipped when the url was blank)
if ((source === 'url' || source === undefined) && !(typeof url === 'string' && url.length > 0)) {
errors.push(slotName + '.url is required for source url');
}
if (typeof url === 'string' && !urlAllowed(url)) {
errors.push(slotName + '.url is not allowed: ' + url);
}
var name = slotObj.name;
if (name !== undefined && typeof name !== 'string') {
errors.push(slotName + '.name must be a string');
}
return slotNormalize(slotObj, slotName, warnings);
}
var sceneNorm = null,
charNorm = null;
if (obj.scene !== undefined) {
sceneNorm = checkSlot(obj.scene, 'scene');
}
if (obj.character !== undefined) {
charNorm = checkSlot(obj.character, 'character');
}
if (errors.length > 0) {
return { ok: false, errors: errors, warnings: warnings, value: null };
}
var normState = {
engine: typeof obj.engine === 'string' ? obj.engine : '',
savedAt: typeof obj.savedAt === 'string' ? obj.savedAt : '',
look: look,
config: configObj,
scene: sceneNorm,
character: charNorm,
};
value = make(normState);
return { ok: true, errors: [], warnings: warnings, value: value };
}
function parse(text, opts) {
var errors = [];
var warnings = [];
var value = null;
if (typeof text !== 'string') {
return { ok: false, errors: ['not valid JSON: input not a string'], warnings: [], value: null };
}
if (text.length > 262144) {
return { ok: false, errors: ['file too large (max 256 KB)'], warnings: [], value: null };
}
var obj;
try {
obj = JSON.parse(text);
} catch (e) {
return { ok: false, errors: ['not valid JSON: ' + e.message], warnings: [], value: null };
}
return validate(obj, opts);
}
function planDrop(files, setup, target) {
var setupIdx = -1;
var sceneMain = -1;
var charMain = -1;
var sceneExtras = [];
var charExtras = [];
var ignored = [];
var errors = [];
var modelIndices = [];
var extraIndices = [];
var otherIndices = [];
var jsonIndices = [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
var e = extOf(f.name);
if (e === 'json') {
jsonIndices.push(i);
continue;
}
if (MODEL_EXTS[e]) {
modelIndices.push(i);
continue;
}
if (EXTRA_EXTS[e]) {
extraIndices.push(i);
continue;
}
if (UNSUPPORTED_EXTS[e]) {
otherIndices.push({ i: i, name: f.name, ext: e, reason: 'unsupported' });
continue;
}
otherIndices.push({ i: i, name: f.name, ext: e, reason: 'unknown' });
}
if (jsonIndices.length > 0) {
setupIdx = jsonIndices[0];
for (var j = 1; j < jsonIndices.length; j++) {
ignored.push(jsonIndices[j]);
}
}
var setupSceneName = null,
setupCharName = null;
if (setup && setup.scene && setup.scene.name) {
setupSceneName = setup.scene.name.toLowerCase();
}
if (setup && setup.character && setup.character.name) {
setupCharName = setup.character.name.toLowerCase();
}
var assignedModel = set();
function set() {
var s = {};
return {
has: function (k) {
return s.hasOwnProperty(k);
},
add: function (k) {
s[k] = true;
},
get: function () {
return s;
},
};
}
// [coach fix P32-8] two passes as specified: first every model whose name matches the setup's scene / character, then the
// remaining models by target. The single pass let an earlier unrelated model take a slot before the matching file was seen
// (e.g. yard.fbx took the scene although swat.glb was the setup's scene). Errors: file-type errors in file order, then no-slot errors.
var rest = [];
for (var m = 0; m < modelIndices.length; m++) {
var idx = modelIndices[m];
var lname = files[idx].name.toLowerCase();
if (setupSceneName && lname === setupSceneName && sceneMain === -1) sceneMain = idx;
else if (setupCharName && lname === setupCharName && charMain === -1) charMain = idx;
else rest.push(idx);
}
for (var o = 0; o < otherIndices.length; o++) {
var it = otherIndices[o];
ignored.push(it.i);
if (it.reason === 'unsupported') {
errors.push(it.name + ': .' + it.ext + ' is not supported, export FBX or glTF (.glb) instead');
} else {
errors.push(it.name + ': unknown file type');
}
}
for (var q = 0; q < rest.length; q++) {
var ri = rest[q];
var matched = false;
if (target === 'scene' && sceneMain === -1) {
sceneMain = ri;
matched = true;
} else if (target === 'character' && charMain === -1) {
charMain = ri;
matched = true;
} else if (setupIdx === -1 && target == null) {
if (sceneMain === -1) {
sceneMain = ri;
matched = true;
} else if (charMain === -1) {
charMain = ri;
matched = true;
}
}
if (!matched) {
ignored.push(ri);
errors.push(files[ri].name + ': dropped with no free slot (drop one model on each half of the game view)');
}
}
function mainExt(idx) {
if (idx === -1) return null;
return extOf(files[idx].name);
}
var sceneMainExt = mainExt(sceneMain);
var charMainExt = mainExt(charMain);
var sceneCanExtra = !!sceneMainExt && EXTRA_MAIN_EXTS[sceneMainExt] === true; // [coach change P32-9] was: 'gltf' || 'fbx'
var charCanExtra = !!charMainExt && EXTRA_MAIN_EXTS[charMainExt] === true;
for (var eIdx = 0; eIdx < extraIndices.length; eIdx++) {
var idx = extraIndices[eIdx];
var assigned = false;
if (sceneCanExtra && charCanExtra) {
sceneExtras.push(idx);
charExtras.push(idx);
assigned = true;
} else if (sceneCanExtra) {
sceneExtras.push(idx);
assigned = true;
} else if (charCanExtra) {
charExtras.push(idx);
assigned = true;
}
if (!assigned) {
ignored.push(idx);
}
}
sceneExtras.sort(function (a, b) {
return a - b;
});
charExtras.sort(function (a, b) {
return a - b;
});
ignored.sort(function (a, b) {
return a - b;
});
return {
setup: setupIdx,
scene: { main: sceneMain, extras: sceneExtras },
character: { main: charMain, extras: charExtras },
ignored: ignored,
errors: errors,
};
}
function registerModelExt(ext, opts) { // [coach change P32-9]
ext = String(ext == null ? '' : ext).toLowerCase().replace(/^\./, '');
if (!/^[a-z0-9]+$/.test(ext) || ext === 'json' || EXTRA_EXTS[ext] === true || MODEL_EXTS[ext] === true) return false;
MODEL_EXTS[ext] = true;
delete UNSUPPORTED_EXTS[ext];
if (opts && opts.extras) EXTRA_MAIN_EXTS[ext] = true;
return true;
}
var MuseSetup = { KIND: KIND, VERSION: VERSION, make: make, validate: validate, parse: parse, planDrop: planDrop, registerModelExt: registerModelExt };
if (typeof window !== 'undefined') {
window.MuseSetup = MuseSetup;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = MuseSetup;
}
})();