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 pub font_size: u32,
326 pub fullscreen: bool,
327 pub render_scale: f32,
329 pub particle_multiplier: f32,
331 pub shadow_quality: ShadowQuality,
332 pub antialiasing: bool,
334 pub color_depth: u8,
336}
337
338impl Default for RenderConfig {
339 fn default() -> Self {
340 Self {
341 bloom_enabled: true,
342 bloom_intensity: 1.0,
343 bloom_radius: 4.0,
344 distortion_enabled: true,
345 motion_blur_enabled: true,
346 motion_blur_samples: 4,
347 chromatic_aberration: 0.002,
348 film_grain: 0.02,
349 scanlines_enabled: false,
350 scanline_intensity: 0.15,
351 font_size: 16,
352 fullscreen: false,
353 render_scale: 1.0,
354 particle_multiplier: 1.0,
355 shadow_quality: ShadowQuality::Medium,
356 antialiasing: true,
357 color_depth: 8,
358 }
359 }
360}
361
362impl RenderConfig {
363 pub fn validate(&mut self) {
364 self.bloom_intensity = self.bloom_intensity.clamp(0.0, 5.0);
365 self.bloom_radius = self.bloom_radius.clamp(1.0, 32.0);
366 self.chromatic_aberration = self.chromatic_aberration.clamp(0.0, 0.05);
367 self.film_grain = self.film_grain.clamp(0.0, 0.5);
368 self.scanline_intensity = self.scanline_intensity.clamp(0.0, 1.0);
369 self.font_size = self.font_size.clamp(8, 64);
370 self.render_scale = self.render_scale.clamp(0.25, 2.0);
371 self.particle_multiplier = self.particle_multiplier.clamp(0.0, 4.0);
372 self.motion_blur_samples = self.motion_blur_samples.clamp(1, 16);
373 }
374}
375
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
377pub enum ShadowQuality { Off, Low, Medium, High, Ultra }
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct PhysicsConfig {
383 pub fixed_dt: f32,
385 pub max_sub_steps: u32,
387 pub fluid_grid_size: usize,
389 pub soft_body_iters: usize,
391 pub gravity_y: f32,
393 pub broadphase: BroadphaseStrategy,
395 pub sleep_enabled: bool,
397 pub sleep_threshold: f32,
399}
400
401impl Default for PhysicsConfig {
402 fn default() -> Self {
403 Self {
404 fixed_dt: 1.0 / 60.0,
405 max_sub_steps: 4,
406 fluid_grid_size: 64,
407 soft_body_iters: 4,
408 gravity_y: -9.8,
409 broadphase: BroadphaseStrategy::Grid,
410 sleep_enabled: true,
411 sleep_threshold: 0.01,
412 }
413 }
414}
415
416impl PhysicsConfig {
417 pub fn validate(&mut self) {
418 self.fixed_dt = self.fixed_dt.clamp(1.0 / 240.0, 1.0 / 15.0);
419 self.max_sub_steps = self.max_sub_steps.clamp(1, 16);
420 self.fluid_grid_size = self.fluid_grid_size.clamp(8, 256);
421 self.soft_body_iters = self.soft_body_iters.clamp(1, 32);
422 self.sleep_threshold = self.sleep_threshold.clamp(0.0, 1.0);
423 }
424}
425
426#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
427pub enum BroadphaseStrategy { BruteForce, Grid, BvhTree }
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct InputConfig {
433 pub mouse_sensitivity: f32,
435 pub mouse_invert_y: bool,
437 pub gamepad_deadzone: f32,
439 pub gamepad_rumble: f32,
441 pub key_repeat: bool,
443 pub key_repeat_delay: f32,
445 pub key_repeat_rate: f32,
447}
448
449impl Default for InputConfig {
450 fn default() -> Self {
451 Self {
452 mouse_sensitivity: 1.0,
453 mouse_invert_y: false,
454 gamepad_deadzone: 0.15,
455 gamepad_rumble: 0.7,
456 key_repeat: true,
457 key_repeat_delay: 0.4,
458 key_repeat_rate: 30.0,
459 }
460 }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct DebugConfig {
467 pub show_fps: bool,
468 pub show_frame_graph: bool,
469 pub show_physics: bool,
470 pub show_spawn_zones: bool,
471 pub show_entity_ids: bool,
472 pub show_force_fields: bool,
473 pub show_particle_count: bool,
474 pub log_level: LogLevel,
475 pub log_rate_limit: usize,
477 pub profiler_enabled: bool,
479}
480
481impl Default for DebugConfig {
482 fn default() -> Self {
483 Self {
484 show_fps: false,
485 show_frame_graph: false,
486 show_physics: false,
487 show_spawn_zones: false,
488 show_entity_ids: false,
489 show_force_fields: false,
490 show_particle_count: false,
491 log_level: LogLevel::Info,
492 log_rate_limit: 1_000_000,
493 profiler_enabled: false,
494 }
495 }
496}
497
498#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
499pub enum LogLevel { Off, Error, Warn, Info, Debug, Trace }
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct GameplayConfig {
505 pub world_seed: u64,
507 pub start_depth: u32,
509 pub difficulty: f32,
511 pub permadeath: bool,
513 pub autosave_interval: u32,
515 pub cheats_enabled: bool,
517 pub entity_tick_budget: usize,
519 pub max_enemies: usize,
521}
522
523impl Default for GameplayConfig {
524 fn default() -> Self {
525 Self {
526 world_seed: 0,
527 start_depth: 1,
528 difficulty: 1.0,
529 permadeath: false,
530 autosave_interval: 60,
531 cheats_enabled: false,
532 entity_tick_budget: 256,
533 max_enemies: 64,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct AccessibilityConfig {
542 pub colorblind_mode: ColorblindMode,
544 pub text_size: f32,
546 pub reduce_motion: bool,
548 pub high_contrast: bool,
550 pub flash_warning: f32,
552 pub audio_cues_visual: bool,
554}
555
556impl Default for AccessibilityConfig {
557 fn default() -> Self {
558 Self {
559 colorblind_mode: ColorblindMode::None,
560 text_size: 1.0,
561 reduce_motion: false,
562 high_contrast: false,
563 flash_warning: 0.5,
564 audio_cues_visual: false,
565 }
566 }
567}
568
569#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
570pub enum ColorblindMode { None, Deuteranopia, Protanopia, Tritanopia, Achromatopsia }
571
572#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn test_default_config() {
580 let c = EngineConfig::default();
581 assert_eq!(c.window_width, 1280);
582 assert_eq!(c.window_height, 800);
583 assert_eq!(c.target_fps, 60);
584 assert!(c.audio.enabled);
585 assert!(c.render.bloom_enabled);
586 }
587
588 #[test]
589 fn test_validate_clamps() {
590 let mut c = EngineConfig::default();
591 c.window_width = 99999;
592 c.window_height = 0;
593 c.render.bloom_intensity = 100.0;
594 c.validate();
595 assert_eq!(c.window_width, 7680);
596 assert_eq!(c.window_height, 240);
597 assert!(c.render.bloom_intensity <= 5.0);
598 }
599
600 #[test]
601 fn test_apply_args_width_height() {
602 let mut c = EngineConfig::default();
603 c.apply_args(&["--width".to_string(), "1920".to_string(),
604 "--height".to_string(), "1080".to_string()]);
605 assert_eq!(c.window_width, 1920);
606 assert_eq!(c.window_height, 1080);
607 }
608
609 #[test]
610 fn test_apply_args_no_audio() {
611 let mut c = EngineConfig::default();
612 c.apply_args(&["--no-audio".to_string()]);
613 assert!(!c.audio.enabled);
614 }
615
616 #[test]
617 fn test_apply_args_no_postfx() {
618 let mut c = EngineConfig::default();
619 c.apply_args(&["--no-postfx".to_string()]);
620 assert!(!c.render.bloom_enabled);
621 assert_eq!(c.render.chromatic_aberration, 0.0);
622 }
623
624 #[test]
625 fn test_profile_low_end() {
626 let c = EngineConfig::profile_low_end();
627 assert!(!c.render.bloom_enabled);
628 assert_eq!(c.target_fps, 30);
629 }
630
631 #[test]
632 fn test_profile_steam_deck() {
633 let c = EngineConfig::profile_steam_deck();
634 assert_eq!(c.window_width, 1280);
635 assert_eq!(c.window_height, 800);
636 }
637
638 #[test]
639 fn test_aspect_ratio() {
640 let c = EngineConfig::default(); let ar = c.aspect_ratio();
642 assert!((ar - 1.6).abs() < 0.01);
643 }
644
645 #[test]
646 fn test_diff_detects_changes() {
647 let a = EngineConfig::default();
648 let mut b = a.clone();
649 b.window_width = 1920;
650 let diff = a.diff(&b);
651 assert!(diff.window_changed);
652 assert!(!diff.audio_changed);
653 }
654
655 #[test]
656 fn test_round_trip_toml() {
657 let c = EngineConfig::default();
658 let toml_str = toml::to_string_pretty(&c).expect("serialize");
659 let c2: EngineConfig = toml::from_str(&toml_str).expect("deserialize");
660 assert_eq!(c.window_width, c2.window_width);
661 assert_eq!(c.target_fps, c2.target_fps);
662 }
663}