Files
opendance-web/web-server/src/scenes/scene-config.ts
T
opendance 584b7eaca9 feat(scenes): 场景管理后端 + 契约 0.5.0/0.7.0/0.4.0(T-WEB-015)
契约先行(三端逐字节一致,已同步 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 场景选择留待下一轮。
2026-09-17 00:27:08 +08:00

164 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 场景物理与环境的配置定义(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),
},
};
}