1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Clone, Serialize, Deserialize, PartialEq)]
11pub struct InputBinding {
12 pub name: String,
14 pub keys: Vec<String>,
16}
17
18pub fn default_bindings() -> Vec<InputBinding> {
20 let b = |name: &str, keys: &[&str]| InputBinding { name: name.into(), keys: keys.iter().map(|k| k.to_string()).collect() };
21 vec![
22 b("MoveForward", &["W", "ArrowUp"]),
23 b("MoveBack", &["S", "ArrowDown"]),
24 b("MoveLeft", &["A", "ArrowLeft"]),
25 b("MoveRight", &["D", "ArrowRight"]),
26 b("Jump", &["Space"]),
27 b("Sprint", &["Shift"]),
28 b("Interact", &["E"]),
29 b("Fire", &["MouseLeft"]),
30 b("AltFire", &["MouseRight"]),
31 ]
32}
33
34#[derive(Clone, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ProjectConfig {
38 pub name: String,
40 pub start_scene: String,
42 pub scripts_package: String,
44 pub kind: String,
46 pub input: Vec<InputBinding>,
48 pub build: BuildSettings,
50 pub timing: crate::runtime::RuntimeConfig,
52 pub time_scale: f32,
54 pub master_volume: f32,
56}
57
58#[derive(Clone, PartialEq, Serialize, Deserialize)]
60#[serde(default)]
61pub struct BuildSettings {
62 pub title: String,
64 pub exe_name: String,
66 pub version: String,
68 pub company: String,
70 pub icon: String,
72 pub width: u32,
74 pub height: u32,
76 pub fullscreen: bool,
78 pub maximized: bool,
80 pub resizable: bool,
82 pub vsync: bool,
84 pub release: bool,
86}
87
88impl Default for BuildSettings {
89 fn default() -> Self {
90 Self {
91 title: String::new(),
92 exe_name: String::new(),
93 version: "1.0.0".into(),
94 company: String::new(),
95 icon: String::new(),
96 width: 1280,
97 height: 720,
98 fullscreen: false,
99 maximized: false,
100 resizable: true,
101 vsync: true,
102 release: true,
103 }
104 }
105}
106
107impl ProjectConfig {
108 pub fn binding_defs(&self) -> Vec<(String, Vec<String>)> {
110 self.input.iter().map(|b| (b.name.clone(), b.keys.clone())).collect()
111 }
112}
113
114impl Default for ProjectConfig {
115 fn default() -> Self {
116 Self {
117 name: "Game".into(),
118 start_scene: "scenes/main.scene.json".into(),
119 scripts_package: "game_scripts".into(),
120 kind: "3D".into(),
121 input: default_bindings(),
122 build: BuildSettings::default(),
123 timing: Default::default(),
124 time_scale: 1.0,
125 master_volume: 1.0,
126 }
127 }
128}
129
130impl ProjectConfig {
131 pub fn from_json(text: &str) -> Result<ProjectConfig, String> {
134 serde_json::from_str(text).map_err(|e| format!("project.json: {e}"))
135 }
136
137 pub fn validate(&self) -> Vec<String> {
140 let mut errors = Vec::new();
141 if let Err(e) = self.timing.validate() { errors.push(e); }
142 if !self.time_scale.is_finite() || !(0.0..=10.0).contains(&self.time_scale) { errors.push("time_scale must be between 0 and 10".into()); }
143 if !self.master_volume.is_finite() || !(0.0..=1.0).contains(&self.master_volume) { errors.push("master_volume must be between 0 and 1".into()); }
144 if self.name.trim().is_empty() {
145 errors.push("project name is empty".into());
146 }
147 if crate::assets::normalize_path(&self.start_scene).is_err() {
148 errors.push("start_scene must be an asset-relative path".into());
149 }
150 if !matches!(self.kind.as_str(), "2D" | "3D") {
151 errors.push("project kind must be 2D or 3D".into());
152 }
153 if self.scripts_package.is_empty() || !self.scripts_package.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
154 errors.push("scripts_package must be a non-empty Cargo package name".into());
155 }
156 let mut seen = std::collections::HashSet::new();
157 for binding in &self.input {
158 if binding.name.trim().is_empty() {
159 errors.push("input action name is empty".into());
160 }
161 if !seen.insert(&binding.name) {
162 errors.push(format!("duplicate input action '{}'", binding.name));
163 }
164 for source in &binding.keys {
165 if crate::input::Source::parse(source).is_none() {
166 errors.push(format!("action '{}': unknown input '{source}'", binding.name));
167 }
168 }
169 }
170 errors
171 }
172}
173
174#[derive(Clone)]
176pub struct Project {
177 pub root: PathBuf,
179 pub config: ProjectConfig,
181}
182
183impl Project {
184 pub fn open(root: &Path) -> Result<Project, String> {
187 let text =
188 std::fs::read_to_string(root.join("project.json")).map_err(|e| format!("{}: {e}", root.join("project.json").display()))?;
189 let root = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
191 Ok(Project { root, config: ProjectConfig::from_json(&text)? })
192 }
193
194 pub fn save(&self) -> std::io::Result<()> {
197 let bytes = serde_json::to_vec_pretty(&self.config).map_err(std::io::Error::other)?;
198 crate::file_io::atomic_write(&self.root.join("project.json"), &bytes)
199 }
200
201 pub fn assets_dir(&self) -> PathBuf {
203 self.root.join("assets")
204 }
205
206 pub fn scripts_dir(&self) -> PathBuf {
208 self.root.join("scripts")
209 }
210
211 pub fn script_lib_name(&self) -> String {
213 let base = self.config.scripts_package.replace('-', "_");
214 if cfg!(windows) {
215 format!("{base}.dll")
216 } else if cfg!(target_os = "macos") {
217 format!("lib{base}.dylib")
218 } else {
219 format!("lib{base}.so")
220 }
221 }
222}