1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EngineConfig {
18 pub window_title: String,
19 pub window_width: u32,
20 pub window_height: u32,
21 pub target_fps: u32,
22 pub vsync: bool,
23 pub audio: AudioConfig,
24 pub render: RenderConfig,
25 pub physics: PhysicsConfig,
26 pub input: InputConfig,
27 pub debug: DebugConfig,
28 pub gameplay: GameplayConfig,
29 pub accessibility: AccessibilityConfig,
30}
31
32impl Default for EngineConfig {
33 fn default() -> Self {
34 Self {
35 window_title: "Proof Engine".to_string(),
36 window_width: 1280,
37 window_height: 800,
38 target_fps: 60,
39 vsync: true,
40 audio: AudioConfig::default(),
41 render: RenderConfig::default(),
42 physics: PhysicsConfig::default(),
43 input: InputConfig::default(),
44 debug: DebugConfig::default(),
45 gameplay: GameplayConfig::default(),
46 accessibility: AccessibilityConfig::default(),
47 }
48 }
49}
50
51impl EngineConfig {
52 pub fn load(path: &str) -> Self {
56 let mut cfg: Self = std::fs::read_to_string(path)
57 .ok()
58 .and_then(|s| toml::from_str(&s).ok())
59 .unwrap_or_default();
60 cfg.validate();
61 cfg
62 }
63
64 pub fn save(&self, path: &str) -> bool {
66 toml::to_string_pretty(self)
67 .ok()
68 .and_then(|s| std::fs::write(path, s).ok())
69 .is_some()
70 }
71
72 pub fn profile_low_end() -> Self {
76 Self {
77 window_width: 960,
78 window_height: 540,
79 target_fps: 30,
80 vsync: true,
81 render: RenderConfig {
82 bloom_enabled: false,
83 motion_blur_enabled: false,
84 chromatic_aberration: 0.0,
85 film_grain: 0.0,
86 scanlines_enabled: false,
87 particle_multiplier: 0.5,
88 shadow_quality: ShadowQuality::Off,
89 ..RenderConfig::default()
90 },
91 physics: PhysicsConfig {
92 fluid_grid_size: 16,
93 soft_body_iters: 2,
94 ..PhysicsConfig::default()
95 },
96 ..Self::default()
97 }
98 }
99
100 pub fn profile_steam_deck() -> Self {
102 Self {
103 window_width: 1280,
104 window_height: 800,
105 target_fps: 60,
106 vsync: true,
107 render: RenderConfig {
108 bloom_intensity: 0.6,
109 chromatic_aberration: 0.001,
110 particle_multiplier: 0.75,
111 shadow_quality: ShadowQuality::Low,
112 ..RenderConfig::default()
113 },
114 ..Self::default()
115 }
116 }
117
118 pub fn profile_ultra() -> Self {
120 Self {
121 window_width: 2560,
122 window_height: 1440,
123 target_fps: 144,
124 vsync: false,
125 render: RenderConfig {
126 bloom_intensity: 1.5,
127 chromatic_aberration: 0.003,
128 film_grain: 0.03,
129 particle_multiplier: 2.0,
130 shadow_quality: ShadowQuality::Ultra,
131 ..RenderConfig::default()
132 },
133 physics: PhysicsConfig {
134 fluid_grid_size: 128,
135 soft_body_iters: 8,
136 ..PhysicsConfig::default()
137 },
138 ..Self::default()
139 }
140 }
141
142 pub fn profile_debug() -> Self {
144 Self {
145 vsync: false,
146 target_fps: 0, debug: DebugConfig {
148 show_fps: true,
149 show_frame_graph: true,
150 show_physics: true,
151 show_spawn_zones: true,
152 show_entity_ids: true,
153 log_level: LogLevel::Debug,
154 ..DebugConfig::default()
155 },
156 ..Self::default()
157 }
158 }
159
160 pub fn apply_args(&mut self, args: &[String]) {
166 let mut i = 0;
167 while i < args.len() {
168 match args[i].as_str() {
169 "--width" => {
170 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
171 self.window_width = v;
172 i += 1;
173 }
174 }
175 "--height" => {
176 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
177 self.window_height = v;
178 i += 1;
179 }
180 }
181 "--fps" => {
182 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
183 self.target_fps = v;
184 i += 1;
185 }
186 }
187 "--no-audio" => self.audio.enabled = false,
188 "--no-vsync" => self.vsync = false,
189 "--no-bloom" => self.render.bloom_enabled = false,
190 "--no-postfx" => {
191 self.render.bloom_enabled = false;
192 self.render.distortion_enabled = false;
193 self.render.motion_blur_enabled = false;
194 self.render.chromatic_aberration = 0.0;
195 self.render.film_grain = 0.0;
196 }
197 "--fullscreen" => self.render.fullscreen = true,
198 "--windowed" => self.render.fullscreen = false,
199 "--low-end" => *self = Self::profile_low_end(),
200 "--ultra" => *self = Self::profile_ultra(),
201 "--debug" => *self = Self::profile_debug(),
202 _ => {}
203 }
204 i += 1;
205 }
206 self.validate();
207 }
208
209 pub fn validate(&mut self) {
213 self.window_width = self.window_width.clamp(320, 7680);
214 self.window_height = self.window_height.clamp(240, 4320);
215 self.target_fps = if self.target_fps == 0 { 0 } else { self.target_fps.clamp(15, 360) };
216 self.audio.validate();
217 self.render.validate();
218 self.physics.validate();
219 }
220
221 pub fn aspect_ratio(&self) -> f32 {
224 self.window_width as f32 / self.window_height as f32
225 }
226
227 pub fn is_widescreen(&self) -> bool {
229 let ar = self.aspect_ratio();
230 (ar - 16.0 / 9.0).abs() < 0.05
231 }
232
233 pub fn diff(&self, other: &Self) -> ConfigDiff {
235 ConfigDiff {
236 window_changed: self.window_width != other.window_width
237 || self.window_height != other.window_height,
238 audio_changed: self.audio.enabled != other.audio.enabled
239 || (self.audio.master_volume - other.audio.master_volume).abs() > 0.01,
240 render_changed: self.render.bloom_enabled != other.render.bloom_enabled
241 || self.render.bloom_intensity != other.render.bloom_intensity,
242 physics_changed: self.physics.fluid_grid_size != other.physics.fluid_grid_size,
243 }
244 }
245}
246
247#[derive(Debug, Default)]
249pub struct ConfigDiff {
250 pub window_changed: bool,
251 pub audio_changed: bool,
252 pub render_changed: bool,
253 pub physics_changed: bool,
254}
255
256impl ConfigDiff {
257 pub fn any_changed(&self) -> bool {
258 self.window_changed || self.audio_changed || self.render_changed || self.physics_changed
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct AudioConfig {
266 pub enabled: bool,
267 pub master_volume: f32,
268 pub music_volume: f32,
269 pub sfx_volume: f32,
270 pub sample_rate: u32,
272 pub buffer_size: u32,
274 pub spatial_audio: bool,
276 pub reverb_room: f32,
278 pub audio_backend: AudioBackend,
279}
280
281impl Default for AudioConfig {
282 fn default() -> Self {
283 Self {
284 enabled: true,
285 master_volume: 1.0,
286 music_volume: 0.6,
287 sfx_volume: 0.8,
288 sample_rate: 0,
289 buffer_size: 0,
290 spatial_audio: true,
291 reverb_room: 0.2,
292 audio_backend: AudioBackend::Default,
293 }
294 }
295}
296
297impl AudioConfig {
298 pub fn validate(&mut self) {
299 self.master_volume = self.master_volume.clamp(0.0, 1.0);
300 self.music_volume = self.music_volume.clamp(0.0, 1.0);
301 self.sfx_volume = self.sfx_volume.clamp(0.0, 1.0);
302 self.reverb_room = self.reverb_room.clamp(0.0, 1.0);
303 }
304}
305
306#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
307pub enum AudioBackend { Default, Wasapi, Asio, PulseAudio, Alsa, CoreAudio }
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct RenderConfig {
313 pub bloom_enabled: bool,
314 pub bloom_intensity: f32,
315 pub bloom_radius: f32,
317 pub distortion_enabled: bool,
318 pub motion_blur_enabled: bool,
319 pub motion_blur_samples: u32,
321 pub chromatic_aberration: f32,
322 pub film_grain: f32,
323 pub scanlines_enabled: bool,
324 pub scanline_intensity: f32,
325
326 pub exposure: f32,
334 pub tonemap: f32,
339 pub halation: f32,
341 pub saturation: f32,
342 pub contrast: f32,
343 pub brightness: f32,
344 pub vignette: f32,
346 pub vignette_softness: f32,
348 pub tint: [f32; 3],
350 pub lift: [f32; 3],
352 pub gain: [f32; 3],
354 pub bloom_threshold: f32,
356 pub bloom_knee: f32,
358 pub anamorphic: f32,
360 pub sharpen: f32,
362 pub dither: f32,
368 pub barrel: f32,
370 pub font_size: u32,
371 pub fullscreen: bool,
372 pub render_scale: f32,
374 pub particle_multiplier: f32,
376 pub shadow_quality: ShadowQuality,
377 pub antialiasing: bool,
379 pub color_depth: u8,
381 pub global_illumination: bool,
388 pub volumetric_fog: bool,
391
392 pub indirect_light: f32,
399 pub light_shafts: f32,
402 pub lens_flare: f32,
405 pub shake_pixels: f32,
408 pub fxaa: bool,
412 pub persistence: f32,
416 pub vsync: bool,
420 pub world_ui_in_scene: bool,
424}
425
426impl Default for RenderConfig {
427 fn default() -> Self {
428 Self {
429 bloom_enabled: true,
430 bloom_intensity: 0.0,
431 bloom_radius: 0.0,
432 distortion_enabled: false,
433 motion_blur_enabled: false,
434 motion_blur_samples: 2,
435 chromatic_aberration: 0.0,
436 film_grain: 0.0,
437 scanlines_enabled: false,
438 scanline_intensity: 0.15,
439 exposure: 1.0,
442 tonemap: 0.0,
443 halation: 0.0,
444 saturation: 1.0,
445 contrast: 1.05,
446 brightness: 0.0,
447 vignette: 0.25,
448 vignette_softness: 0.35,
449 tint: [1.0, 1.0, 1.0],
450 lift: [0.0, 0.0, 0.0],
451 gain: [1.0, 1.0, 1.0],
452 bloom_threshold: 0.0,
453 bloom_knee: 0.1,
454 anamorphic: 0.0,
455 sharpen: 0.0,
456 dither: 0.0,
457 barrel: 0.0,
458 font_size: 32,
459 fullscreen: false,
460 render_scale: 1.0,
461 particle_multiplier: 1.0,
462 shadow_quality: ShadowQuality::Medium,
463 antialiasing: true,
464 color_depth: 8,
465 global_illumination: true,
469 volumetric_fog: true,
470 indirect_light: 0.0,
471 light_shafts: 0.0,
472 lens_flare: 0.0,
473 shake_pixels: 0.0,
474 fxaa: false,
475 persistence: 0.0,
476 vsync: true,
477 world_ui_in_scene: true,
478 }
479 }
480}
481
482impl RenderConfig {
483 pub fn validate(&mut self) {
484 self.bloom_intensity = self.bloom_intensity.clamp(0.0, 20.0);
485 self.bloom_radius = self.bloom_radius.clamp(1.0, 128.0);
486 self.chromatic_aberration = self.chromatic_aberration.clamp(0.0, 0.2);
487 self.film_grain = self.film_grain.clamp(0.0, 0.5);
488 self.scanline_intensity = self.scanline_intensity.clamp(0.0, 1.0);
489 self.font_size = self.font_size.clamp(8, 64);
490 self.render_scale = self.render_scale.clamp(0.25, 8.0);
491 self.particle_multiplier = self.particle_multiplier.max(0.0);
492 self.motion_blur_samples = self.motion_blur_samples.clamp(1, 16);
493 self.indirect_light = self.indirect_light.clamp(0.0, 8.0);
494 self.light_shafts = self.light_shafts.clamp(0.0, 8.0);
495 self.lens_flare = self.lens_flare.clamp(0.0, 4.0);
496 self.shake_pixels = self.shake_pixels.clamp(0.0, 200.0);
497 self.persistence = self.persistence.clamp(0.0, 0.98);
498 }
499}
500
501#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
502pub enum ShadowQuality { Off, Low, Medium, High, Ultra }
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct PhysicsConfig {
508 pub fixed_dt: f32,
510 pub max_sub_steps: u32,
512 pub fluid_grid_size: usize,
514 pub soft_body_iters: usize,
516 pub gravity_y: f32,
518 pub broadphase: BroadphaseStrategy,
520 pub sleep_enabled: bool,
522 pub sleep_threshold: f32,
524}
525
526impl Default for PhysicsConfig {
527 fn default() -> Self {
528 Self {
529 fixed_dt: 1.0 / 60.0,
530 max_sub_steps: 4,
531 fluid_grid_size: 64,
532 soft_body_iters: 4,
533 gravity_y: -9.8,
534 broadphase: BroadphaseStrategy::Grid,
535 sleep_enabled: true,
536 sleep_threshold: 0.01,
537 }
538 }
539}
540
541impl PhysicsConfig {
542 pub fn validate(&mut self) {
543 self.fixed_dt = self.fixed_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
544 self.max_sub_steps = self.max_sub_steps.clamp(1, 16);
545 self.fluid_grid_size = self.fluid_grid_size.clamp(8, 256);
546 self.soft_body_iters = self.soft_body_iters.clamp(1, 32);
547 self.sleep_threshold = self.sleep_threshold.clamp(0.0, 1.0);
548 }
549}
550
551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
552pub enum BroadphaseStrategy { BruteForce, Grid, BvhTree }
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct InputConfig {
558 pub mouse_sensitivity: f32,
560 pub mouse_invert_y: bool,
562 pub gamepad_deadzone: f32,
564 pub gamepad_rumble: f32,
566 pub key_repeat: bool,
568 pub key_repeat_delay: f32,
570 pub key_repeat_rate: f32,
572}
573
574impl Default for InputConfig {
575 fn default() -> Self {
576 Self {
577 mouse_sensitivity: 1.0,
578 mouse_invert_y: false,
579 gamepad_deadzone: 0.15,
580 gamepad_rumble: 0.7,
581 key_repeat: true,
582 key_repeat_delay: 0.4,
583 key_repeat_rate: 30.0,
584 }
585 }
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct DebugConfig {
592 pub show_fps: bool,
593 pub show_frame_graph: bool,
594 pub show_physics: bool,
595 pub show_spawn_zones: bool,
596 pub show_entity_ids: bool,
597 pub show_force_fields: bool,
598 pub show_particle_count: bool,
599 pub log_level: LogLevel,
600 pub log_rate_limit: usize,
602 pub profiler_enabled: bool,
604}
605
606impl Default for DebugConfig {
607 fn default() -> Self {
608 Self {
609 show_fps: false,
610 show_frame_graph: false,
611 show_physics: false,
612 show_spawn_zones: false,
613 show_entity_ids: false,
614 show_force_fields: false,
615 show_particle_count: false,
616 log_level: LogLevel::Info,
617 log_rate_limit: 1_000_000,
618 profiler_enabled: false,
619 }
620 }
621}
622
623#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
624pub enum LogLevel { Off, Error, Warn, Info, Debug, Trace }
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct GameplayConfig {
630 pub world_seed: u64,
632 pub start_depth: u32,
634 pub difficulty: f32,
636 pub permadeath: bool,
638 pub autosave_interval: u32,
640 pub cheats_enabled: bool,
642 pub entity_tick_budget: usize,
644 pub max_enemies: usize,
646}
647
648impl Default for GameplayConfig {
649 fn default() -> Self {
650 Self {
651 world_seed: 0,
652 start_depth: 1,
653 difficulty: 1.0,
654 permadeath: false,
655 autosave_interval: 60,
656 cheats_enabled: false,
657 entity_tick_budget: 256,
658 max_enemies: 64,
659 }
660 }
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
666pub struct AccessibilityConfig {
667 pub colorblind_mode: ColorblindMode,
669 pub text_size: f32,
671 pub reduce_motion: bool,
673 pub high_contrast: bool,
675 pub flash_warning: f32,
677 pub audio_cues_visual: bool,
679}
680
681impl Default for AccessibilityConfig {
682 fn default() -> Self {
683 Self {
684 colorblind_mode: ColorblindMode::None,
685 text_size: 1.0,
686 reduce_motion: false,
687 high_contrast: false,
688 flash_warning: 0.5,
689 audio_cues_visual: false,
690 }
691 }
692}
693
694#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
695pub enum ColorblindMode { None, Deuteranopia, Protanopia, Tritanopia, Achromatopsia }
696
697#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn test_default_config() {
705 let c = EngineConfig::default();
706 assert_eq!(c.window_width, 1280);
707 assert_eq!(c.window_height, 800);
708 assert_eq!(c.target_fps, 60);
709 assert!(c.audio.enabled);
710 assert!(c.render.bloom_enabled);
711 }
712
713 #[test]
714 fn test_validate_clamps() {
715 let mut c = EngineConfig::default();
716 c.window_width = 99999;
717 c.window_height = 0;
718 c.render.bloom_intensity = 100.0;
719 c.validate();
720 assert_eq!(c.window_width, 7680);
721 assert_eq!(c.window_height, 240);
722 assert!(c.render.bloom_intensity <= 20.0);
723 }
724
725 #[test]
726 fn test_apply_args_width_height() {
727 let mut c = EngineConfig::default();
728 c.apply_args(&["--width".to_string(), "1920".to_string(),
729 "--height".to_string(), "1080".to_string()]);
730 assert_eq!(c.window_width, 1920);
731 assert_eq!(c.window_height, 1080);
732 }
733
734 #[test]
735 fn test_apply_args_no_audio() {
736 let mut c = EngineConfig::default();
737 c.apply_args(&["--no-audio".to_string()]);
738 assert!(!c.audio.enabled);
739 }
740
741 #[test]
742 fn test_apply_args_no_postfx() {
743 let mut c = EngineConfig::default();
744 c.apply_args(&["--no-postfx".to_string()]);
745 assert!(!c.render.bloom_enabled);
746 assert_eq!(c.render.chromatic_aberration, 0.0);
747 }
748
749 #[test]
750 fn test_profile_low_end() {
751 let c = EngineConfig::profile_low_end();
752 assert!(!c.render.bloom_enabled);
753 assert_eq!(c.target_fps, 30);
754 }
755
756 #[test]
757 fn test_profile_steam_deck() {
758 let c = EngineConfig::profile_steam_deck();
759 assert_eq!(c.window_width, 1280);
760 assert_eq!(c.window_height, 800);
761 }
762
763 #[test]
764 fn test_aspect_ratio() {
765 let c = EngineConfig::default(); let ar = c.aspect_ratio();
767 assert!((ar - 1.6).abs() < 0.01);
768 }
769
770 #[test]
771 fn test_diff_detects_changes() {
772 let a = EngineConfig::default();
773 let mut b = a.clone();
774 b.window_width = 1920;
775 let diff = a.diff(&b);
776 assert!(diff.window_changed);
777 assert!(!diff.audio_changed);
778 }
779
780 #[test]
781 fn test_round_trip_toml() {
782 let c = EngineConfig::default();
783 let toml_str = toml::to_string_pretty(&c).expect("serialize");
784 let c2: EngineConfig = toml::from_str(&toml_str).expect("deserialize");
785 assert_eq!(c.window_width, c2.window_width);
786 assert_eq!(c.target_fps, c2.target_fps);
787 }
788}