//! Serializable project settings loaded from `assets/project.ron`. mod audio; mod plugin; mod rendering; pub use audio::*; pub use plugin::{ save_project_settings, sync_sim_tuning, ProjectSettingsChanged, ProjectSettingsIo, ProjectSettingsPlugin, SimTuning, }; pub use rendering::{ effective_auto_exposure, resolve_effective_render_stack, resolve_gi_path, write_effective_render_stack, ActiveCameraRenderProfile, ActiveVolumeContribution, EffectiveRenderStack, ExposureMode, GiMode, GiPath, RenderFallbackReason, RenderingCapabilities, VolumeContribution, }; use bevy::prelude::*; use serde::{Deserialize, Serialize}; pub const DEFAULT_PROJECT_PATH: &str = "assets/project.ron"; pub const SETTINGS_VERSION: u32 = 1; pub const PROJECT_TEMPLATE_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Reflect, PartialEq, Eq)] #[reflect(Clone)] pub enum ProjectKind { #[default] Game, Sandbox, } /// Marker for cameras that receive the shared project rendering stack. #[derive(Component, Debug, Clone, Copy, Default)] pub struct ProjectRenderCamera; /// Root project manifest and tunable sections. #[derive(Resource, Debug, Clone, Serialize, Deserialize, Reflect)] #[reflect(Resource, Clone)] pub struct ProjectSettings { pub version: u32, #[serde(default)] pub project_id: String, pub name: String, #[serde(default)] pub project_kind: ProjectKind, #[serde(default = "default_project_template_version")] pub template_version: u32, #[serde(default)] pub capabilities: Vec, pub default_level: String, pub asset_roots: Vec, pub rendering: RenderingSettings, #[serde(default)] pub audio: AudioSettings, pub physics: PhysicsSettings, pub input: InputSettings, } impl Default for ProjectSettings { fn default() -> Self { Self { version: SETTINGS_VERSION, project_id: "blacksite-default".into(), name: "Bevy FPS Foundation".into(), project_kind: ProjectKind::Game, template_version: PROJECT_TEMPLATE_VERSION, capabilities: vec![ "editor".into(), "gameplay".into(), "rendering".into(), "audio".into(), ], default_level: "assets/levels/editor_scene.scn.ron".into(), asset_roots: vec![ "assets/models".into(), "assets/textures".into(), "assets/materials".into(), "assets/levels".into(), "assets/audio".into(), ], rendering: RenderingSettings::default(), audio: AudioSettings::default(), physics: PhysicsSettings::default(), input: InputSettings::default(), } } } const fn default_project_template_version() -> u32 { PROJECT_TEMPLATE_VERSION } /// Camera post-processing and world lighting defaults. #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] #[reflect(Clone)] pub struct RenderingSettings { #[serde(default)] pub gi_mode: GiMode, pub hdr: bool, #[serde(default)] pub exposure_mode: ExposureMode, pub exposure_ev100: f32, #[serde(default = "default_auto_exposure_speed_brighten")] pub auto_exposure_speed_brighten: f32, #[serde(default = "default_auto_exposure_speed_darken")] pub auto_exposure_speed_darken: f32, pub tonemapping_aces: bool, pub bloom: bool, pub taa: bool, pub ssao: bool, pub atmosphere: bool, pub fog_color: [f32; 3], pub fog_density: f32, pub ambient_color: [f32; 3], pub ambient_brightness: f32, pub sun_illuminance: f32, pub shadow_cascades: u32, pub shadow_first_cascade: f32, pub shadow_max_distance: f32, } fn default_auto_exposure_speed_brighten() -> f32 { 3.0 } fn default_auto_exposure_speed_darken() -> f32 { 1.0 } impl Default for RenderingSettings { fn default() -> Self { Self { gi_mode: GiMode::default(), hdr: true, exposure_mode: ExposureMode::default(), exposure_ev100: 13.0, auto_exposure_speed_brighten: default_auto_exposure_speed_brighten(), auto_exposure_speed_darken: default_auto_exposure_speed_darken(), tonemapping_aces: true, bloom: true, taa: true, ssao: true, atmosphere: true, fog_color: [0.66, 0.75, 0.86], fog_density: 0.0012, ambient_color: [0.6, 0.7, 0.85], ambient_brightness: 60.0, sun_illuminance: 100_000.0, shadow_cascades: 4, shadow_first_cascade: 6.0, shadow_max_distance: 220.0, } } } /// Gameplay movement and capsule constants (mirrors former `sim::tuning`). #[derive(Debug, Clone, Copy, Serialize, Deserialize, Reflect)] #[reflect(Clone)] pub struct PhysicsSettings { pub player_radius: f32, pub player_length: f32, pub eye_offset_stand: f32, pub eye_offset_crouch: f32, pub gravity: f32, pub walk_speed: f32, pub run_speed: f32, pub crouch_speed: f32, pub jump_speed: f32, pub ground_accel: f32, pub air_accel: f32, pub ground_check_dist: f32, pub skin: f32, pub max_slide_iter: u32, pub ground_normal_y: f32, pub coyote_time: f32, pub jump_buffer: f32, } impl Default for PhysicsSettings { fn default() -> Self { Self { player_radius: 0.4, player_length: 1.0, eye_offset_stand: 0.6, eye_offset_crouch: 0.1, gravity: 22.0, walk_speed: 6.0, run_speed: 9.5, crouch_speed: 3.0, jump_speed: 7.5, ground_accel: 70.0, air_accel: 16.0, ground_check_dist: 0.2, skin: 0.02, max_slide_iter: 4, ground_normal_y: 0.5, coyote_time: 0.12, jump_buffer: 0.12, } } } /// Input sensitivities and editor fly camera speeds. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Reflect)] #[reflect(Clone)] pub struct InputSettings { pub yaw_sensitivity: f32, pub pitch_sensitivity: f32, pub editor_fly_speed: f32, pub editor_look_sensitivity: f32, pub editor_pan_speed: f32, } impl Default for InputSettings { fn default() -> Self { Self { yaw_sensitivity: 0.003, pitch_sensitivity: 0.0022, editor_fly_speed: 14.0, editor_look_sensitivity: 0.003, editor_pan_speed: 0.015, } } } /// Loads settings from disk, falling back to defaults. pub fn load_project_settings_from_path(path: &str) -> ProjectSettings { load_project_settings_with_source(path).0 } /// Loads settings plus the exact source text used when the file was readable. pub fn load_project_settings_with_source(path: &str) -> (ProjectSettings, Option) { match std::fs::read_to_string(path) { Ok(contents) => match ron::from_str(&contents) { Ok(settings) => (settings, Some(contents)), Err(error) => { warn!("Failed to parse {path}: {error}; using defaults"); (ProjectSettings::default(), Some(contents)) } }, Err(error) => { warn!("Could not read {path}: {error}; using defaults"); (ProjectSettings::default(), None) } } } /// Serializes settings to RON. pub fn save_project_settings_to_string(settings: &ProjectSettings) -> Result { ron::ser::to_string_pretty(settings, ron::ser::PrettyConfig::default()) } #[cfg(test)] mod tests { use super::*; #[test] fn settings_round_trip() { let settings = ProjectSettings::default(); let text = save_project_settings_to_string(&settings).expect("serialize"); let parsed: ProjectSettings = ron::from_str(&text).expect("deserialize"); assert_eq!(parsed.name, settings.name); assert_eq!(parsed.project_id, settings.project_id); assert_eq!(parsed.project_kind, settings.project_kind); assert_eq!(parsed.template_version, settings.template_version); assert_eq!(parsed.capabilities, settings.capabilities); assert_eq!(parsed.audio, settings.audio); assert_eq!(parsed.physics.walk_speed, settings.physics.walk_speed); assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode); } #[test] fn settings_load_returns_the_exact_source_for_revision_guards() { let root = std::env::temp_dir().join(format!( "blacksite-settings-source-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let path = root.join("project.ron"); std::fs::create_dir_all(&root).unwrap(); let source = save_project_settings_to_string(&ProjectSettings::default()).unwrap(); std::fs::write(&path, &source).unwrap(); let (settings, loaded_source) = load_project_settings_with_source(path.to_str().unwrap()); assert_eq!(settings.name, ProjectSettings::default().name); assert_eq!(loaded_source.as_deref(), Some(source.as_str())); std::fs::remove_dir_all(root).unwrap(); } #[test] fn legacy_manifest_defaults_project_identity_fields() { let legacy = r#"( version: 1, name: "Legacy", default_level: "assets/levels/main.scn.ron", asset_roots: [], rendering: ( gi_mode: Auto, hdr: true, exposure_mode: Manual, exposure_ev100: 13.0, auto_exposure_speed_brighten: 3.0, auto_exposure_speed_darken: 1.0, tonemapping_aces: true, bloom: true, taa: true, ssao: true, atmosphere: true, fog_color: (0.0, 0.0, 0.0), fog_density: 0.0, ambient_color: (0.0, 0.0, 0.0), ambient_brightness: 0.0, sun_illuminance: 100000.0, shadow_cascades: 4, shadow_first_cascade: 6.0, shadow_max_distance: 220.0, ), physics: ( player_radius: 0.4, player_length: 1.0, eye_offset_stand: 0.6, eye_offset_crouch: 0.1, gravity: 22.0, walk_speed: 6.0, run_speed: 9.5, crouch_speed: 3.0, jump_speed: 7.5, ground_accel: 70.0, air_accel: 16.0, ground_check_dist: 0.2, skin: 0.02, max_slide_iter: 4, ground_normal_y: 0.5, coyote_time: 0.12, jump_buffer: 0.12, ), input: ( yaw_sensitivity: 0.003, pitch_sensitivity: 0.0022, editor_fly_speed: 14.0, editor_look_sensitivity: 0.003, editor_pan_speed: 0.015, ), )"#; let parsed: ProjectSettings = ron::from_str(legacy).unwrap(); assert!(parsed.project_id.is_empty()); assert_eq!(parsed.project_kind, ProjectKind::Game); assert_eq!(parsed.template_version, PROJECT_TEMPLATE_VERSION); assert!(parsed.capabilities.is_empty()); assert_eq!(parsed.audio, AudioSettings::default()); assert!(parsed.audio.validate().is_ok()); } #[test] fn project_defaults_include_audio_capability_and_asset_root() { let settings = ProjectSettings::default(); assert!(settings.capabilities.iter().any(|item| item == "audio")); assert!(settings .asset_roots .iter() .any(|item| item == "assets/audio")); assert!(settings.audio.validate().is_ok()); } #[test] fn gi_mode_defaults_auto() { assert_eq!(RenderingSettings::default().gi_mode, GiMode::Auto); } #[test] fn exposure_mode_auto_round_trip() { let mut settings = ProjectSettings::default(); settings.rendering.exposure_mode = ExposureMode::Auto; settings.rendering.auto_exposure_speed_brighten = 4.0; settings.rendering.auto_exposure_speed_darken = 0.5; let text = save_project_settings_to_string(&settings).expect("serialize"); let parsed: ProjectSettings = ron::from_str(&text).expect("deserialize"); assert_eq!(parsed.rendering.exposure_mode, ExposureMode::Auto); assert_eq!(parsed.rendering.auto_exposure_speed_brighten, 4.0); assert_eq!(parsed.rendering.auto_exposure_speed_darken, 0.5); } #[test] fn effective_auto_exposure_requires_hdr_and_compute() { let mut profile = ActiveCameraRenderProfile { exposure_mode: ExposureMode::Auto, hdr: true, ..Default::default() }; let caps = RenderingCapabilities { auto_exposure_supported: true, ..Default::default() }; assert!(effective_auto_exposure(&profile, &caps)); profile.hdr = false; assert!(!effective_auto_exposure(&profile, &caps)); profile.hdr = true; let caps = RenderingCapabilities::default(); assert!(!effective_auto_exposure(&profile, &caps)); } }