契约先行(三端逐字节一致,已同步 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 场景选择留待下一轮。
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
/**
|
||
* 场景物理与环境的配置定义(T-WEB-015)。
|
||
*
|
||
* 职责划分:**服务端只存参数、不跑物理**;仿真在客户端(Windows 端用 Godot 内置物理:
|
||
* RigidBody3D / Area3D 风场 / PhysicsServer3D)。因此这里只做
|
||
* "结构校验 + 默认值 + 边界收敛",保证下发给客户端的参数一定可解释。
|
||
*/
|
||
|
||
export interface Vec3 {
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
}
|
||
|
||
/** 风:基础风 + 阵风(正弦叠加) */
|
||
export interface WindConfig {
|
||
base: Vec3;
|
||
/** 阵风幅度(米/秒,0 表示无阵风;上限 100) */
|
||
gustAmplitude: number;
|
||
/** 阵风频率(Hz,0.01~5) */
|
||
gustFrequency: number;
|
||
/** 阵风方向(上限 ±1) */
|
||
gustDirection: Vec3;
|
||
/** 世界坐标 or 场景局部坐标 */
|
||
space: 'world' | 'local';
|
||
}
|
||
|
||
export interface GroundConfig {
|
||
/** 摩擦系数 0~1 */
|
||
friction: number;
|
||
/** 弹性 0~1 */
|
||
restitution: number;
|
||
/** 可行走坡度上限(度)0~89 */
|
||
slopeLimit: number;
|
||
}
|
||
|
||
export interface EnvironmentConfig {
|
||
/** 环境光颜色(#RRGGBB) */
|
||
ambientColor: string;
|
||
/** 环境光强度 0~4 */
|
||
ambientIntensity: number;
|
||
/** 雾浓度 0~1(0 = 关闭) */
|
||
fogDensity: number;
|
||
}
|
||
|
||
export interface SpawnPoint {
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
/** 朝向(度,绕 Y 轴) */
|
||
yaw: number;
|
||
}
|
||
|
||
export interface CameraConfig {
|
||
mode: 'follow' | 'fixed' | 'orbit';
|
||
distance: number;
|
||
height: number;
|
||
}
|
||
|
||
export interface SceneConfig {
|
||
physics: {
|
||
/** 重力向量(米/秒^2),默认 (0,-9.8,0) */
|
||
gravity: Vec3;
|
||
wind: WindConfig;
|
||
ground: GroundConfig;
|
||
/** 碰撞体来源:auto(按 GLB 命名约定)| mesh(三角网)| none */
|
||
colliders: 'auto' | 'mesh' | 'none';
|
||
};
|
||
environment: EnvironmentConfig;
|
||
spawnPoints: SpawnPoint[];
|
||
camera: CameraConfig;
|
||
}
|
||
|
||
export const SCENE_CONFIG_DEFAULTS: SceneConfig = {
|
||
physics: {
|
||
gravity: { x: 0, y: -9.8, z: 0 },
|
||
wind: {
|
||
base: { x: 0, y: 0, z: 0 },
|
||
gustAmplitude: 0,
|
||
gustFrequency: 0.2,
|
||
gustDirection: { x: 1, y: 0, z: 0 },
|
||
space: 'world',
|
||
},
|
||
ground: { friction: 0.6, restitution: 0, slopeLimit: 45 },
|
||
colliders: 'auto',
|
||
},
|
||
environment: { ambientColor: '#8fb4ff', ambientIntensity: 1, fogDensity: 0 },
|
||
spawnPoints: [{ x: 0, y: 0, z: 0, yaw: 0 }],
|
||
camera: { mode: 'follow', distance: 4.5, height: 1.6 },
|
||
};
|
||
|
||
function num(value: unknown, fallback: number, min: number, max: number): number {
|
||
const parsed = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||
return Math.min(max, Math.max(min, parsed));
|
||
}
|
||
|
||
function vec3(value: unknown, fallback: Vec3, limit = 1000): Vec3 {
|
||
const raw = (value ?? {}) as Partial<Vec3>;
|
||
return {
|
||
x: num(raw.x, fallback.x, -limit, limit),
|
||
y: num(raw.y, fallback.y, -limit, limit),
|
||
z: num(raw.z, fallback.z, -limit, limit),
|
||
};
|
||
}
|
||
|
||
function hexColor(value: unknown, fallback: string): string {
|
||
return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback;
|
||
}
|
||
|
||
/**
|
||
* 把任意输入收敛成合法 `SceneConfig`:缺项用默认值、越界夹紧、非法枚举回退。
|
||
* **永不抛错** —— 保证客户端拿到的参数一定可解释。
|
||
*/
|
||
export function normalizeSceneConfig(input: unknown): SceneConfig {
|
||
const raw = (input ?? {}) as Partial<SceneConfig>;
|
||
const physics = (raw.physics ?? {}) as Partial<SceneConfig['physics']>;
|
||
const wind = (physics.wind ?? {}) as Partial<WindConfig>;
|
||
const ground = (physics.ground ?? {}) as Partial<GroundConfig>;
|
||
const environment = (raw.environment ?? {}) as Partial<EnvironmentConfig>;
|
||
const camera = (raw.camera ?? {}) as Partial<CameraConfig>;
|
||
const defaults = SCENE_CONFIG_DEFAULTS;
|
||
const spawnPoints = Array.isArray(raw.spawnPoints) && raw.spawnPoints.length > 0
|
||
? raw.spawnPoints
|
||
: defaults.spawnPoints;
|
||
|
||
return {
|
||
physics: {
|
||
gravity: vec3(physics.gravity, defaults.physics.gravity, 200),
|
||
wind: {
|
||
base: vec3(wind.base, defaults.physics.wind.base, 100),
|
||
gustAmplitude: num(wind.gustAmplitude, defaults.physics.wind.gustAmplitude, 0, 100),
|
||
gustFrequency: num(wind.gustFrequency, defaults.physics.wind.gustFrequency, 0.01, 5),
|
||
gustDirection: vec3(wind.gustDirection, defaults.physics.wind.gustDirection, 1),
|
||
space: wind.space === 'local' ? 'local' : 'world',
|
||
},
|
||
ground: {
|
||
friction: num(ground.friction, defaults.physics.ground.friction, 0, 1),
|
||
restitution: num(ground.restitution, defaults.physics.ground.restitution, 0, 1),
|
||
slopeLimit: num(ground.slopeLimit, defaults.physics.ground.slopeLimit, 0, 89),
|
||
},
|
||
colliders: physics.colliders === 'mesh' || physics.colliders === 'none' ? physics.colliders : 'auto',
|
||
},
|
||
environment: {
|
||
ambientColor: hexColor(environment.ambientColor, defaults.environment.ambientColor),
|
||
ambientIntensity: num(environment.ambientIntensity, defaults.environment.ambientIntensity, 0, 4),
|
||
fogDensity: num(environment.fogDensity, defaults.environment.fogDensity, 0, 1),
|
||
},
|
||
spawnPoints: spawnPoints.slice(0, 16).map((point) => {
|
||
const item = (point ?? {}) as Partial<SpawnPoint>;
|
||
return {
|
||
x: num(item.x, 0, -1000, 1000),
|
||
y: num(item.y, 0, -1000, 1000),
|
||
z: num(item.z, 0, -1000, 1000),
|
||
yaw: num(item.yaw, 0, -360, 360),
|
||
};
|
||
}),
|
||
camera: {
|
||
mode: camera.mode === 'fixed' || camera.mode === 'orbit' ? camera.mode : defaults.camera.mode,
|
||
distance: num(camera.distance, defaults.camera.distance, 0.5, 50),
|
||
height: num(camera.height, defaults.camera.height, 0, 20),
|
||
},
|
||
};
|
||
}
|