契约先行(三端逐字节一致,已同步 windows/android):
- rest-api -> 0.5.0:新增 GET/POST /api/scenes、GET/PATCH/DELETE /api/scenes/{id}
- data-models -> 0.7.0:新增 §7 Scene 与 7.1 SceneConfig(物理/环境/出生点/相机)
- database -> 0.4.0:新增 scenes 表(config/stats 用 MEDIUMTEXT,迁移可逆)
后端:
- entities/scene.entity.ts + 迁移 AddScenes1789650000000(scenes 表)
- scenes/scene-config.ts:SceneConfig 定义 + 默认值 + **边界收敛**(越界夹紧、
非法枚举回退),保证客户端拿到的参数一定可解释;服务端只存参数不跑物理
- scenes/scenes.service.ts:上传(.glb/.gltf 直接导入;.zip/.pmx/.fbx 转 GLB,
场景不做 humanoid 重绑定、不转 VRM)、列表/详情/更新/软删除、缩略图复用模型
光栅化器、分析复用模型检查器、config 同时接受 JSON 字符串与平铺字段
- scenes/scenes.controller.ts + scenes.module.ts(app.module 注册)
- 审核复用 /api/reviews/:type/:id:TargetType.Scene 接入待审列表与状态流转
- catalog/export 增 scenes[],目录指纹纳入场景变更
- 枚举:TargetType.Scene、AssetKind.Scene / SceneThumbnail
测试:新增 test/scene-config.spec.ts 6 项(默认值/夹紧/枚举回退/出生点上限/颜色/
序列化);规模 19 文件 / 243 项;check-routes 35 条一致。版本 0.6.0。
注:管理端场景页(GLB 预览 + 物理参数可视化)与 Windows 场景选择留待下一轮。
108 lines
4.5 KiB
TypeScript
108 lines
4.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import { normalizeSceneConfig, SCENE_CONFIG_DEFAULTS } from '../src/scenes/scene-config';
|
||
import { toSceneDto } from '../src/scenes/scene.serializer';
|
||
import { PublishStatus, SourceType } from '../src/common/enums';
|
||
import type { Scene } from '../src/entities/scene.entity';
|
||
|
||
/**
|
||
* 场景物理参数(T-WEB-015)。
|
||
*
|
||
* 服务端**只存参数、不跑物理**,所以这里锁两件事:
|
||
* 1. 越界/非法输入一律被收敛成可解释的值(客户端拿到就一定能用);
|
||
* 2. 缺省即 `SCENE_CONFIG_DEFAULTS`(重力 -9.8 等),且序列化结构稳定。
|
||
*/
|
||
describe('SceneConfig 归一化', () => {
|
||
it('空输入 -> 默认值(重力 -9.8、无风、跟随相机)', () => {
|
||
const config = normalizeSceneConfig(undefined);
|
||
expect(config).toEqual(SCENE_CONFIG_DEFAULTS);
|
||
expect(config.physics.gravity).toEqual({ x: 0, y: -9.8, z: 0 });
|
||
expect(config.physics.wind.gustAmplitude).toBe(0);
|
||
expect(config.camera.mode).toBe('follow');
|
||
});
|
||
|
||
it('越界数值被夹紧(重力 ±200、摩擦 0~1、频率 0.01~5、雾 0~1)', () => {
|
||
const config = normalizeSceneConfig({
|
||
physics: {
|
||
gravity: { x: 9999, y: -9999, z: 0 },
|
||
wind: { base: { x: 500, y: 0, z: 0 }, gustAmplitude: 999, gustFrequency: 99 },
|
||
ground: { friction: 5, restitution: -1, slopeLimit: 120 },
|
||
},
|
||
environment: { ambientIntensity: 99, fogDensity: 9 },
|
||
camera: { distance: 999, height: -5 },
|
||
});
|
||
expect(config.physics.gravity.x).toBe(200);
|
||
expect(config.physics.gravity.y).toBe(-200);
|
||
expect(config.physics.wind.base.x).toBe(100);
|
||
expect(config.physics.wind.gustAmplitude).toBe(100);
|
||
expect(config.physics.wind.gustFrequency).toBe(5);
|
||
expect(config.physics.ground.friction).toBe(1);
|
||
expect(config.physics.ground.restitution).toBe(0);
|
||
expect(config.physics.ground.slopeLimit).toBe(89);
|
||
expect(config.environment.ambientIntensity).toBe(4);
|
||
expect(config.environment.fogDensity).toBe(1);
|
||
expect(config.camera.distance).toBe(50);
|
||
expect(config.camera.height).toBe(0);
|
||
});
|
||
|
||
it('非法枚举回退默认;风坐标系只认 local/world', () => {
|
||
const config = normalizeSceneConfig({
|
||
physics: { colliders: 'banana', wind: { space: 'space' } },
|
||
camera: { mode: 'fly' },
|
||
environment: { ambientColor: 'red' },
|
||
});
|
||
expect(config.physics.colliders).toBe('auto');
|
||
expect(config.physics.wind.space).toBe('world');
|
||
expect(config.camera.mode).toBe('follow');
|
||
expect(config.environment.ambientColor).toBe(SCENE_CONFIG_DEFAULTS.environment.ambientColor);
|
||
|
||
const local = normalizeSceneConfig({ physics: { colliders: 'mesh', wind: { space: 'local' } } });
|
||
expect(local.physics.colliders).toBe('mesh');
|
||
expect(local.physics.wind.space).toBe('local');
|
||
});
|
||
|
||
it('出生点最多 16 个,坐标与朝向被夹紧', () => {
|
||
const many = Array.from({ length: 40 }, () => ({ x: 1e9, y: 0, z: 0, yaw: 1e9 }));
|
||
const config = normalizeSceneConfig({ spawnPoints: many });
|
||
expect(config.spawnPoints.length).toBe(16);
|
||
expect(config.spawnPoints[0].x).toBe(1000);
|
||
expect(config.spawnPoints[0].yaw).toBe(360);
|
||
});
|
||
|
||
it('合法 #RRGGBB 颜色被接受并小写化', () => {
|
||
const config = normalizeSceneConfig({ environment: { ambientColor: '#AABBCC' } });
|
||
expect(config.environment.ambientColor).toBe('#aabbcc');
|
||
});
|
||
});
|
||
|
||
describe('SceneDto 序列化', () => {
|
||
it('带出 config 与来源五元组;URL 由调用方注入', () => {
|
||
const scene = {
|
||
id: 3,
|
||
name: '黄昏广场',
|
||
glbAssetId: 11,
|
||
thumbnailAssetId: 12,
|
||
config: normalizeSceneConfig({ physics: { gravity: { x: 0, y: -3.5, z: 0 } } }),
|
||
stats: { triangles: 1234 },
|
||
fileSize: 456,
|
||
fileSha256: 'abc',
|
||
conversionNote: '直接导入(GLB,未做重编码)',
|
||
sourceType: SourceType.Other,
|
||
sourceName: 'x',
|
||
sourceUrl: 'y',
|
||
license: 'z',
|
||
author: 'a',
|
||
status: PublishStatus.Pending,
|
||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||
updatedAt: new Date('2026-01-02T00:00:00Z'),
|
||
} as unknown as Scene;
|
||
const dto = toSceneDto(scene, { glbUrl: '/static/scene/x.glb', thumbnailUrl: '/static/scene/x.png' });
|
||
expect(dto.id).toBe(3);
|
||
expect(dto.glbUrl).toBe('/static/scene/x.glb');
|
||
expect(dto.config?.physics.gravity.y).toBe(-3.5);
|
||
expect(dto.stats).toEqual({ triangles: 1234 });
|
||
expect(dto.status).toBe(PublishStatus.Pending);
|
||
expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
||
});
|
||
});
|