Files
Mujoco_WASM/web_platform/src/map/mapSchema.ts
T
chenlin 3b4ced6437
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(web-platform): release V0.7 地图模块
2026-09-01 14:23:49 +08:00

139 lines
5.6 KiB
TypeScript

import type { MapDefinition, SpawnPoint } from './types';
export class MapValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapValidationError';
}
}
function object(value: unknown, field: string): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new MapValidationError(`${field} 必须是对象`);
return value as Record<string, unknown>;
}
function text(value: unknown, field: string): string {
if (typeof value !== 'string' || !value.trim())
throw new MapValidationError(`${field} 必须是非空字符串`);
return value.trim();
}
function identifier(value: unknown, field: string): string {
const result = text(value, field);
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(result))
throw new MapValidationError(`${field} 只能包含英文字母、数字、下划线和连字符`);
return result;
}
function optionalBoolean(value: unknown, field: string, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== 'boolean') throw new MapValidationError(`${field} 必须是布尔值`);
return value;
}
function finite(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value))
throw new MapValidationError(`${field} 必须是有限数字`);
return value;
}
function vector3(value: unknown, field: string): [number, number, number] {
if (!Array.isArray(value) || value.length !== 3)
throw new MapValidationError(`${field} 必须包含 3 个数字`);
return [
finite(value[0], `${field}[0]`),
finite(value[1], `${field}[1]`),
finite(value[2], `${field}[2]`),
];
}
function spawnPoint(value: unknown, index: number): SpawnPoint {
const source = object(value, `spawnPoints[${index}]`);
return {
id: identifier(source.id, `spawnPoints[${index}].id`),
name: text(source.name ?? source.id, `spawnPoints[${index}].name`),
position: vector3(source.position, `spawnPoints[${index}].position`),
yawDeg: finite(source.yawDeg ?? 0, `spawnPoints[${index}].yawDeg`),
};
}
export function parseMapDefinition(value: unknown): MapDefinition {
const source = object(value, 'map.json');
if (source.schemaVersion !== 1 && source.schemaVersion !== 2)
throw new MapValidationError('仅支持 schemaVersion: 1 或 2');
const coordinates = object(source.coordinateSystem, 'coordinateSystem');
if (coordinates.units !== 'm' || coordinates.up !== 'Z' || coordinates.forward !== '+X')
throw new MapValidationError('coordinateSystem 必须为 units=m、up=Z、forward=+X');
const physicsSource = source.physics
? text(object(source.physics, 'physics').source, 'physics.source')
: undefined;
const visualObject = source.visual ? object(source.visual, 'visual') : undefined;
const visualSource = visualObject ? text(visualObject.source, 'visual.source') : undefined;
const authoringSource = source.authoring
? text(object(source.authoring, 'authoring').source, 'authoring.source')
: undefined;
if (authoringSource && source.schemaVersion !== 2)
throw new MapValidationError('authoring 仅支持 schemaVersion: 2');
if (authoringSource && !physicsSource)
throw new MapValidationError('可编辑地图必须同时声明 physics.source');
if (!physicsSource && !visualSource)
throw new MapValidationError('physics.source 和 visual.source 至少需要一个');
if (physicsSource && !/\.xml$/i.test(physicsSource))
throw new MapValidationError('physics.source 必须是 XML 文件');
if (visualSource && !/\.glb$/i.test(visualSource))
throw new MapValidationError('visual.source 必须是自包含 GLB 文件');
if (authoringSource && !/\.scene\.json$/i.test(authoringSource))
throw new MapValidationError('authoring.source 必须是 .scene.json 文件');
const spawnValues = source.spawnPoints ?? [];
if (!Array.isArray(spawnValues)) throw new MapValidationError('spawnPoints 必须是数组');
const spawnPoints = spawnValues.map(spawnPoint);
const spawnIds = new Set<string>();
for (const spawn of spawnPoints) {
if (spawnIds.has(spawn.id)) throw new MapValidationError(`出生点 id 重复:${spawn.id}`);
spawnIds.add(spawn.id);
}
let bounds: MapDefinition['bounds'];
if (source.bounds !== undefined) {
const value = object(source.bounds, 'bounds');
const minimum = vector3(value.min, 'bounds.min');
const maximum = vector3(value.max, 'bounds.max');
if (minimum.some((component, index) => component >= maximum[index]))
throw new MapValidationError('bounds.min 必须在每个轴上小于 bounds.max');
bounds = { min: minimum, max: maximum };
}
return {
schemaVersion: source.schemaVersion,
id: identifier(source.id, 'id'),
name: text(source.name, 'name'),
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: physicsSource ? { source: physicsSource } : undefined,
visual: visualSource
? {
source: visualSource,
castShadow: optionalBoolean(visualObject?.castShadow, 'visual.castShadow', true),
receiveShadow: optionalBoolean(visualObject?.receiveShadow, 'visual.receiveShadow', true),
}
: undefined,
authoring: authoringSource ? { source: authoringSource } : undefined,
spawnPoints,
bounds,
};
}
export function decodeMapDefinition(data: Uint8Array): MapDefinition {
let value: unknown;
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(data));
} catch (error) {
throw new MapValidationError(
`map.json 无法解析:${error instanceof Error ? error.message : String(error)}`,
);
}
return parseMapDefinition(value);
}