Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
//! Projects: the on-disk unit a game is authored as.
|
||||
//!
|
||||
//! A **project** is a root directory containing a project file plus a defined
|
||||
//! folder layout (scenes, assets, scripts). The project file (RON) records the
|
||||
//! project name, the engine version it was made with, the set of enabled
|
||||
//! [modules](crate::app::Module), and per-project settings. The format lives in
|
||||
//! the engine — not the editor — because the exported runtime and the Stage-16
|
||||
//! packer read it too; the editor adds the create/open/save UI on top.
|
||||
//!
|
||||
//! Per-project settings are stored as **opaque per-section RON blobs**
|
||||
//! (`section name → RON`), so this module stays independent of the typed
|
||||
//! settings framework: that framework serializes its typed sections to these
|
||||
//! strings and back.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The project file's name within the project root.
|
||||
pub const PROJECT_FILE_NAME: &str = "project.oxide";
|
||||
|
||||
/// The subdirectory holding scene files.
|
||||
pub const SCENES_DIR: &str = "scenes";
|
||||
/// The subdirectory holding asset files (meshes, textures, audio, …).
|
||||
pub const ASSETS_DIR: &str = "assets";
|
||||
/// The subdirectory holding game scripts.
|
||||
pub const SCRIPTS_DIR: &str = "scripts";
|
||||
|
||||
/// Errors from project operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectError {
|
||||
/// A project file already exists where a new project was to be created.
|
||||
#[error("a project already exists at {0}")]
|
||||
AlreadyExists(PathBuf),
|
||||
|
||||
/// No project file was found at the given location.
|
||||
#[error("no project file found at {0}")]
|
||||
NotFound(PathBuf),
|
||||
|
||||
/// Filesystem I/O failed.
|
||||
#[error("project i/o error at {path}: {source}")]
|
||||
Io {
|
||||
/// The path involved.
|
||||
path: PathBuf,
|
||||
/// The underlying error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The project file could not be parsed.
|
||||
#[error("malformed project file at {path}: {message}")]
|
||||
Parse {
|
||||
/// The project file path.
|
||||
path: PathBuf,
|
||||
/// The parser message.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The project file could not be serialized.
|
||||
#[error("failed to serialize project: {0}")]
|
||||
Serialize(String),
|
||||
}
|
||||
|
||||
/// The serialized contents of a project file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectMeta {
|
||||
/// Human-readable project name.
|
||||
pub name: String,
|
||||
/// The engine version this project was last saved with.
|
||||
pub engine_version: String,
|
||||
/// Names of the modules enabled for this project.
|
||||
pub enabled_modules: Vec<String>,
|
||||
/// Per-project settings as opaque RON blobs, keyed by section name. The
|
||||
/// typed settings framework round-trips its sections through here.
|
||||
pub settings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ProjectMeta {
|
||||
fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
engine_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
enabled_modules: Vec::new(),
|
||||
settings: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open project: its root directory plus the loaded [`ProjectMeta`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Project {
|
||||
root: PathBuf,
|
||||
meta: ProjectMeta,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Creates a new project rooted at `root` (created if missing), scaffolding
|
||||
/// the `scenes`/`assets`/`scripts` folders and writing the project file.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AlreadyExists`](ProjectError::AlreadyExists) if a project file is
|
||||
/// already present, or [`Io`](ProjectError::Io) on filesystem failure.
|
||||
pub fn create(root: impl AsRef<Path>, name: impl Into<String>) -> Result<Self, ProjectError> {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
let file = root.join(PROJECT_FILE_NAME);
|
||||
if file.exists() {
|
||||
return Err(ProjectError::AlreadyExists(file));
|
||||
}
|
||||
for dir in [
|
||||
&root,
|
||||
&root.join(SCENES_DIR),
|
||||
&root.join(ASSETS_DIR),
|
||||
&root.join(SCRIPTS_DIR),
|
||||
] {
|
||||
std::fs::create_dir_all(dir).map_err(|source| ProjectError::Io {
|
||||
path: dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let project = Self {
|
||||
root,
|
||||
meta: ProjectMeta::new(name),
|
||||
};
|
||||
project.save()?;
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
/// Opens an existing project. `path` may be the project root directory or
|
||||
/// the project file itself.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`NotFound`](ProjectError::NotFound) if no project file is present, or
|
||||
/// [`Parse`](ProjectError::Parse)/[`Io`](ProjectError::Io) on failure.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let path = path.as_ref();
|
||||
let (root, file) = if path.is_dir() {
|
||||
(path.to_path_buf(), path.join(PROJECT_FILE_NAME))
|
||||
} else {
|
||||
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
(root, path.to_path_buf())
|
||||
};
|
||||
if !file.exists() {
|
||||
return Err(ProjectError::NotFound(file));
|
||||
}
|
||||
let text = std::fs::read_to_string(&file).map_err(|source| ProjectError::Io {
|
||||
path: file.clone(),
|
||||
source,
|
||||
})?;
|
||||
let meta: ProjectMeta = ron::from_str(&text).map_err(|err| ProjectError::Parse {
|
||||
path: file.clone(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
Ok(Self { root, meta })
|
||||
}
|
||||
|
||||
/// Writes the project file, stamping it with the current engine version.
|
||||
pub fn save(&self) -> Result<(), ProjectError> {
|
||||
let file = self.project_file_path();
|
||||
let pretty = ron::ser::PrettyConfig::default();
|
||||
let text = ron::ser::to_string_pretty(&self.meta, pretty)
|
||||
.map_err(|err| ProjectError::Serialize(err.to_string()))?;
|
||||
std::fs::write(&file, text).map_err(|source| ProjectError::Io { path: file, source })
|
||||
}
|
||||
|
||||
// --- Layout ------------------------------------------------------------
|
||||
|
||||
/// The project root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The path of the project file.
|
||||
pub fn project_file_path(&self) -> PathBuf {
|
||||
self.root.join(PROJECT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The scenes directory.
|
||||
pub fn scenes_dir(&self) -> PathBuf {
|
||||
self.root.join(SCENES_DIR)
|
||||
}
|
||||
|
||||
/// The assets directory.
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.root.join(ASSETS_DIR)
|
||||
}
|
||||
|
||||
/// The scripts directory.
|
||||
pub fn scripts_dir(&self) -> PathBuf {
|
||||
self.root.join(SCRIPTS_DIR)
|
||||
}
|
||||
|
||||
// --- Metadata ----------------------------------------------------------
|
||||
|
||||
/// The project's metadata (name, modules, settings).
|
||||
pub fn meta(&self) -> &ProjectMeta {
|
||||
&self.meta
|
||||
}
|
||||
|
||||
/// The project name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.meta.name
|
||||
}
|
||||
|
||||
/// Renames the project (call [`save`](Self::save) to persist).
|
||||
pub fn set_name(&mut self, name: impl Into<String>) {
|
||||
self.meta.name = name.into();
|
||||
}
|
||||
|
||||
/// Whether `module` is enabled for this project.
|
||||
pub fn is_module_enabled(&self, module: &str) -> bool {
|
||||
self.meta.enabled_modules.iter().any(|m| m == module)
|
||||
}
|
||||
|
||||
/// Enables `module` (no-op if already enabled).
|
||||
pub fn enable_module(&mut self, module: impl Into<String>) {
|
||||
let module = module.into();
|
||||
if !self.is_module_enabled(&module) {
|
||||
self.meta.enabled_modules.push(module);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables `module`. Returns whether it was enabled.
|
||||
pub fn disable_module(&mut self, module: &str) -> bool {
|
||||
let before = self.meta.enabled_modules.len();
|
||||
self.meta.enabled_modules.retain(|m| m != module);
|
||||
self.meta.enabled_modules.len() != before
|
||||
}
|
||||
|
||||
/// The raw RON blob stored for settings `section`, if any.
|
||||
pub fn settings_section(&self, section: &str) -> Option<&str> {
|
||||
self.meta.settings.get(section).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Stores a raw RON blob for settings `section`.
|
||||
pub fn set_settings_section(&mut self, section: impl Into<String>, ron: impl Into<String>) {
|
||||
self.meta.settings.insert(section.into(), ron.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// A most-recently-used list of project roots, persisted globally (an editor
|
||||
/// preference, not part of any single project).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RecentProjects {
|
||||
entries: Vec<PathBuf>,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
impl RecentProjects {
|
||||
/// A list retaining at most `limit` entries.
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
limit: limit.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records `root` as the most recent project, de-duplicating and capping.
|
||||
pub fn record(&mut self, root: impl AsRef<Path>) {
|
||||
let root = root.as_ref().to_path_buf();
|
||||
self.entries.retain(|p| p != &root);
|
||||
self.entries.insert(0, root);
|
||||
self.entries.truncate(self.limit.max(1));
|
||||
}
|
||||
|
||||
/// The recorded roots, most-recent first.
|
||||
pub fn entries(&self) -> &[PathBuf] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// Loads the list from a RON file, or returns an empty list if absent.
|
||||
pub fn load(path: impl AsRef<Path>) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| ron::from_str(&text).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Saves the list to a RON file.
|
||||
pub fn save(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let text = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"oxide_project_test_{}_{}_{tag}",
|
||||
std::process::id(),
|
||||
// A counter to keep tests isolated within the process.
|
||||
COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
#[test]
|
||||
fn create_scaffolds_layout_and_file() {
|
||||
let root = temp_root("create");
|
||||
let project = Project::create(&root, "My Game").unwrap();
|
||||
assert!(project.project_file_path().exists());
|
||||
assert!(project.scenes_dir().is_dir());
|
||||
assert!(project.assets_dir().is_dir());
|
||||
assert!(project.scripts_dir().is_dir());
|
||||
assert_eq!(project.name(), "My Game");
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_open_round_trips() {
|
||||
let root = temp_root("roundtrip");
|
||||
let mut project = Project::create(&root, "Game").unwrap();
|
||||
project.enable_module("physics");
|
||||
project.enable_module("audio");
|
||||
project.set_settings_section("editor", "(theme:\"dark\")");
|
||||
project.save().unwrap();
|
||||
|
||||
// Open by directory.
|
||||
let opened = Project::open(&root).unwrap();
|
||||
assert_eq!(opened.name(), "Game");
|
||||
assert!(opened.is_module_enabled("physics") && opened.is_module_enabled("audio"));
|
||||
assert_eq!(opened.settings_section("editor"), Some("(theme:\"dark\")"));
|
||||
|
||||
// Open by file path.
|
||||
let by_file = Project::open(opened.project_file_path()).unwrap();
|
||||
assert_eq!(by_file.meta(), opened.meta());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_refuses_to_overwrite() {
|
||||
let root = temp_root("nooverwrite");
|
||||
Project::create(&root, "A").unwrap();
|
||||
let err = Project::create(&root, "B").unwrap_err();
|
||||
assert!(matches!(err, ProjectError::AlreadyExists(_)));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_is_not_found() {
|
||||
let root = temp_root("missing");
|
||||
let err = Project::open(&root).unwrap_err();
|
||||
assert!(matches!(err, ProjectError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_enable_disable() {
|
||||
let root = temp_root("modules");
|
||||
let mut project = Project::create(&root, "M").unwrap();
|
||||
project.enable_module("terrain");
|
||||
project.enable_module("terrain"); // idempotent
|
||||
assert_eq!(project.meta().enabled_modules, vec!["terrain"]);
|
||||
assert!(project.disable_module("terrain"));
|
||||
assert!(!project.disable_module("terrain"));
|
||||
assert!(!project.is_module_enabled("terrain"));
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_dedup_and_cap() {
|
||||
let mut recent = RecentProjects::new(3);
|
||||
recent.record("/a");
|
||||
recent.record("/b");
|
||||
recent.record("/a"); // moves /a to front, no dup
|
||||
recent.record("/c");
|
||||
recent.record("/d"); // evicts the oldest (/b)
|
||||
let entries: Vec<_> = recent
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|p| p.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(entries, vec!["/d", "/c", "/a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_projects_persist() {
|
||||
let root = temp_root("recent");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let file = root.join("recent.ron");
|
||||
let mut recent = RecentProjects::new(5);
|
||||
recent.record("/x");
|
||||
recent.record("/y");
|
||||
recent.save(&file).unwrap();
|
||||
let loaded = RecentProjects::load(&file);
|
||||
assert_eq!(loaded.entries(), recent.entries());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user