feat(web-platform): release V0.7 地图模块
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user