139 lines
5.6 KiB
TypeScript
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);
|
|
}
|