Skip to main content

kvas_engine/
project.rs

1//! Project layout on disk:
2//!   project.json   – name and start scene
3//!   assets/        – scenes, models, textures, shaders (packed into game.pak on build)
4//!   scripts/       – a Cargo crate (cdylib) with the game's Rust scripts
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// An input action and the inputs bound to it ("Space", "W", "MouseLeft", ...).
10#[derive(Clone, Serialize, Deserialize, PartialEq)]
11pub struct InputBinding {
12    /// Case-sensitive action name used by script input queries.
13    pub name: String,
14    /// Alternative physical input names, e.g. `Space`, `W`, `MouseLeft`.
15    pub keys: Vec<String>,
16}
17
18/// Standard movement, jumping, sprinting, interaction and mouse-fire bindings.
19pub 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/// Serializable project manifest. Missing fields receive backwards-compatible defaults.
35#[derive(Clone, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ProjectConfig {
38    /// Display name shown by the editor and player.
39    pub name: String,
40    /// Initial scene JSON path relative to the assets directory.
41    pub start_scene: String,
42    /// Cargo package name of the scripts crate (also the dll base name).
43    pub scripts_package: String,
44    /// "3D" or "2D" (2D projects start with an orthographic camera).
45    pub kind: String,
46    /// Named input action definitions, editable in Project Settings.
47    pub input: Vec<InputBinding>,
48    /// How the built game looks and starts (set in the Build window).
49    pub build: BuildSettings,
50    /// Fixed-step cadence and catch-up policy shared by editor and standalone games.
51    pub timing: crate::runtime::RuntimeConfig,
52    /// Initial simulation speed (0..10).
53    pub time_scale: f32,
54    /// Initial audio master gain (0..1).
55    pub master_volume: f32,
56}
57
58/// Settings of the standalone game made by "Build Game".
59#[derive(Clone, PartialEq, Serialize, Deserialize)]
60#[serde(default)]
61pub struct BuildSettings {
62    /// Window title; empty uses the project name.
63    pub title: String,
64    /// Executable file name without extension; empty derives it from the project name.
65    pub exe_name: String,
66    /// Shown in the build notes.
67    pub version: String,
68    /// Publisher shown in the build notes.
69    pub company: String,
70    /// Icon image (png/jpg/ico), relative to the project folder or absolute; empty uses the Kvas logo.
71    pub icon: String,
72    /// Initial window width in pixels.
73    pub width: u32,
74    /// Initial window height in pixels.
75    pub height: u32,
76    /// Start in fullscreen.
77    pub fullscreen: bool,
78    /// Start maximized.
79    pub maximized: bool,
80    /// Allow resizing the window.
81    pub resizable: bool,
82    /// Wait for the display refresh (no tearing).
83    pub vsync: bool,
84    /// Optimised build (slower to compile, much faster to run).
85    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    /// Copies input definitions into the representation accepted by Runtime/Input.
109    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    /// Parses a manifest, returning a contextual JSON error on malformed input.
132    /// Use [`Self::validate`] to check semantic settings after parsing.
133    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    /// Reports semantic configuration issues without mutating the manifest.
138    /// Empty action bindings are allowed, but duplicate actions and unknown sources are reported.
139    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/// Open project directory and editable manifest. Cloning does not copy files.
175#[derive(Clone)]
176pub struct Project {
177    /// Absolute project directory after [`Self::open`].
178    pub root: PathBuf,
179    /// In-memory manifest; call [`Self::save`] to persist edits.
180    pub config: ProjectConfig,
181}
182
183impl Project {
184    /// Opens a directory containing `project.json`, resolving its absolute path.
185    /// I/O/JSON failures return contextual strings. Semantic validation is explicit.
186    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        // Absolute, so child processes started in other folders (cargo) still find it.
190        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    /// Atomically replaces `project.json` using a sibling temporary file.
195    /// The project directory must already exist.
196    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    /// Assets directory used for loose-file loading.
202    pub fn assets_dir(&self) -> PathBuf {
203        self.root.join("assets")
204    }
205
206    /// Cargo project directory for native scripts.
207    pub fn scripts_dir(&self) -> PathBuf {
208        self.root.join("scripts")
209    }
210
211    /// File name of the compiled scripts library on this platform.
212    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}