/** * 场景物理与环境的配置定义(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; 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; const physics = (raw.physics ?? {}) as Partial; const wind = (physics.wind ?? {}) as Partial; const ground = (physics.ground ?? {}) as Partial; const environment = (raw.environment ?? {}) as Partial; const camera = (raw.camera ?? {}) as Partial; 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; 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), }, }; }