use std::collections::BTreeMap;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use bevy_ecs::prelude::Resource;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::runtime::{
SceneAlphaMode, SceneCamera, SceneDocument, SceneEntity, SceneMaterial,
SceneMaterialData, SceneMaterialModel, SceneMesh, SceneMeshRenderer,
SceneProjection, SceneTransform, SCENE_FORMAT_VERSION,
};
pub const PROJECT_FORMAT_VERSION: u32 = 1;
pub const HEADLESS_TICKS_ENV: &str = "RUSTING_HEADLESS_TICKS";
pub const STATE_HASH_OUT_ENV: &str = "RUSTING_STATE_HASH_OUT";
pub const REPLAY_OUT_ENV: &str = "RUSTING_REPLAY_OUT";
pub const REPLAY_PLAY_ENV: &str = "RUSTING_REPLAY_PLAY";
pub const FIRST_FRAME_MARKER: &str = "[rusting] first playable frame after";
#[must_use]
pub fn first_frame_ms(output: &str) -> Option<u64> {
output.lines().find_map(|line| {
line.strip_prefix(FIRST_FRAME_MARKER)?
.trim()
.strip_suffix("ms")?
.trim()
.parse()
.ok()
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ReloadDiagnostic {
pub kind: &'static str,
pub file: Option<PathBuf>,
pub line: Option<u32>,
pub message: String,
}
#[must_use]
pub fn reload_diagnostics(output: &str) -> Vec<ReloadDiagnostic> {
const SHADERS: [&str; 5] = [".comp", ".vert", ".frag", ".glsl", ".geom"];
output
.lines()
.filter_map(|line| {
let line = line.trim();
if let Some(failure) = line.strip_prefix("hot reload failed: ") {
let file = failure.split('`').nth(1).map(PathBuf::from);
return Some(ReloadDiagnostic {
kind: "asset",
file,
line: None,
message: failure.to_owned(),
});
}
let (location, message) = match line.find(": error") {
Some(index) => (&line[..index], &line[index + 2..]),
None if line.starts_with("error") => ("", line),
None => return None,
};
if message.starts_with("error: could not compile")
|| message.starts_with("error: aborting")
{
return None;
}
let mut parts = location.rsplitn(3, ':');
let (column, number, file) =
(parts.next(), parts.next(), parts.next());
let (file, number) = match (file, number, column) {
(Some(file), Some(number), Some(_)) => {
(Some(PathBuf::from(file)), number.parse().ok())
}
_ => (None, None),
};
let shader = SHADERS.iter().any(|extension| {
message.contains(&format!("{extension}:"))
|| file.as_ref().is_some_and(|file| {
file.to_string_lossy().ends_with(extension)
})
});
Some(ReloadDiagnostic {
kind: if shader { "shader" } else { "rust" },
file,
line: number,
message: message.to_owned(),
})
})
.collect()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectManifest {
#[serde(default)]
pub format_version: u32,
pub name: String,
pub main_scene: PathBuf,
pub cooked_scene: PathBuf,
#[serde(default)]
pub binary_name: String,
#[serde(
default,
skip_serializing_if = "std::collections::BTreeMap::is_empty"
)]
pub generators:
std::collections::BTreeMap<String, crate::asset_import::GeneratorHook>,
#[serde(default, skip_serializing_if = "is_default")]
pub determinism: crate::runtime::DeterminismMode,
}
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
*value == T::default()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenProject {
pub root: PathBuf,
pub manifest: ProjectManifest,
pub scene_path: PathBuf,
pub code_path: PathBuf,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RecentProject {
pub name: String,
pub path: PathBuf,
}
#[derive(Resource, Clone, Debug)]
pub struct ProjectManagerState {
pub open: bool,
pub project_name: String,
pub parent_directory: PathBuf,
pub recent_projects: Vec<RecentProject>,
pub message: Option<String>,
}
impl Default for ProjectManagerState {
fn default() -> Self {
Self {
open: true,
project_name: "MyGame".into(),
parent_directory: PathBuf::new(),
recent_projects: load_recent_projects().unwrap_or_default(),
message: None,
}
}
}
#[derive(Debug)]
pub enum ProjectError {
InvalidName,
InvalidBinaryName,
MissingParent(PathBuf),
AlreadyExists(PathBuf),
MissingFile(PathBuf),
UnsupportedVersion(u32),
Io(std::io::Error),
Json(serde_json::Error),
}
impl Display for ProjectError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName => formatter.write_str(
"Project name must contain letters, numbers, spaces, - or _",
),
Self::InvalidBinaryName => formatter.write_str(
"Project binary name must contain only letters, numbers, - or _",
),
Self::MissingParent(path) => write!(
formatter,
"Parent directory `{}` does not exist",
path.display()
),
Self::AlreadyExists(path) => write!(
formatter,
"Project folder `{}` already exists; no files were changed",
path.display()
),
Self::MissingFile(path) => {
write!(formatter, "Required file `{}` is missing", path.display())
}
Self::UnsupportedVersion(version) => write!(
formatter,
"Project format {version} is newer than supported format {PROJECT_FORMAT_VERSION}"
),
Self::Io(error) => Display::fmt(error, formatter),
Self::Json(error) => Display::fmt(error, formatter),
}
}
}
impl Error for ProjectError {}
impl From<std::io::Error> for ProjectError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for ProjectError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
pub fn create_project(
parent: &Path,
name: &str,
) -> Result<OpenProject, ProjectError> {
create_project_from(parent, name, ProjectTemplate::Basic3d)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ProjectTemplate {
#[default]
Basic3d,
Platformer2d,
Starter,
}
impl ProjectTemplate {
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"3d" => Some(Self::Basic3d),
"2d" => Some(Self::Platformer2d),
"starter" => Some(Self::Starter),
_ => None,
}
}
}
pub fn create_project_from(
parent: &Path,
name: &str,
template: ProjectTemplate,
) -> Result<OpenProject, ProjectError> {
validate_project_name(name)?;
if !parent.is_dir() {
return Err(ProjectError::MissingParent(parent.to_owned()));
}
let root = parent.join(name.trim());
if root.exists() {
return Err(ProjectError::AlreadyExists(root));
}
let temporary = parent.join(format!(".rusting-project-{}", Uuid::new_v4()));
let result = write_project_template(&temporary, name.trim(), template)
.and_then(|()| {
std::fs::rename(&temporary, &root).map_err(ProjectError::from)
});
if result.is_err() && temporary.exists() {
let _ = std::fs::remove_dir_all(&temporary);
}
result?;
open_project(&root)
}
pub fn open_project(root: &Path) -> Result<OpenProject, ProjectError> {
let root = root.canonicalize()?;
let manifest_path = root.join("project.json");
let cargo_path = root.join("Cargo.toml");
for required in [&manifest_path, &cargo_path] {
if !required.is_file() {
return Err(ProjectError::MissingFile(required.to_path_buf()));
}
}
let manifest_bytes = std::fs::read(&manifest_path)?;
let mut manifest: ProjectManifest =
serde_json::from_slice(&manifest_bytes)?;
let stored_manifest = manifest.clone();
if manifest.format_version > PROJECT_FORMAT_VERSION {
return Err(ProjectError::UnsupportedVersion(manifest.format_version));
}
if manifest.format_version == 0 {
let backup = root.join("project.json.v0.backup");
if !backup.exists() {
std::fs::copy(&manifest_path, &backup)?;
}
manifest.format_version = PROJECT_FORMAT_VERSION;
}
if manifest.binary_name.is_empty() {
manifest.binary_name = cargo_package_name(&manifest.name);
}
if manifest.binary_name.is_empty()
|| manifest.binary_name.chars().any(|character| {
!(character.is_ascii_alphanumeric()
|| matches!(character, '-' | '_'))
})
{
return Err(ProjectError::InvalidBinaryName);
}
if manifest != stored_manifest {
crate::runtime::write_atomic(
&manifest_path,
&serde_json::to_vec_pretty(&manifest)?,
)?;
}
let scene_path = checked_project_path(&root, &manifest.main_scene)?;
if !scene_path.is_file() {
return Err(ProjectError::MissingFile(scene_path));
}
checked_project_path(&root, &manifest.cooked_scene)?;
let code_path = root.join("src/main.rs");
if !code_path.is_file() {
return Err(ProjectError::MissingFile(code_path));
}
Ok(OpenProject {
root,
manifest,
scene_path,
code_path,
})
}
pub fn remember_project(
recent: &mut Vec<RecentProject>,
project: &OpenProject,
) -> Result<(), ProjectError> {
recent.retain(|item| item.path != project.root);
recent.insert(
0,
RecentProject {
name: project.manifest.name.clone(),
path: project.root.clone(),
},
);
recent.truncate(12);
save_recent_projects(recent)
}
fn validate_project_name(name: &str) -> Result<(), ProjectError> {
let name = name.trim();
let upper_name = name.to_ascii_uppercase();
let windows_device_name = matches!(
upper_name.as_str(),
"CON"
| "PRN"
| "AUX"
| "NUL"
| "COM1"
| "COM2"
| "COM3"
| "COM4"
| "COM5"
| "COM6"
| "COM7"
| "COM8"
| "COM9"
| "LPT1"
| "LPT2"
| "LPT3"
| "LPT4"
| "LPT5"
| "LPT6"
| "LPT7"
| "LPT8"
| "LPT9"
);
if name.is_empty()
|| name == "."
|| name == ".."
|| windows_device_name
|| name.chars().any(|character| {
!(character.is_ascii_alphanumeric()
|| matches!(character, ' ' | '-' | '_'))
})
{
return Err(ProjectError::InvalidName);
}
Ok(())
}
fn checked_project_path(
root: &Path,
relative: &Path,
) -> Result<PathBuf, ProjectError> {
if relative.is_absolute()
|| relative
.components()
.any(|part| !matches!(part, std::path::Component::Normal(_)))
{
return Err(ProjectError::MissingFile(root.join(relative)));
}
Ok(root.join(relative))
}
fn write_project_template(
root: &Path,
name: &str,
template: ProjectTemplate,
) -> Result<(), ProjectError> {
for folder in ["src", "scenes", "assets", "shaders", "build"] {
std::fs::create_dir_all(root.join(folder))?;
}
let package_name = cargo_package_name(name);
let engine_source = Path::new(env!("CARGO_MANIFEST_DIR"));
let engine_dependency = if engine_source.join("Cargo.toml").is_file() {
let engine_path = engine_source.to_string_lossy().replace('\\', "\\\\");
format!("path = \"{engine_path}\"")
} else {
format!(
"git = \"https://github.com/GoingRusting/RustingEngine\", tag = \"v{}\"",
env!("CARGO_PKG_VERSION")
)
};
let cargo = format!(
"[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nrusting_engine = {{ {engine_dependency}, default-features = false, features = [\"ui\"] }}\n\n[workspace]\n"
);
std::fs::write(root.join("Cargo.toml"), cargo)?;
let manifest = ProjectManifest {
format_version: PROJECT_FORMAT_VERSION,
name: name.into(),
main_scene: "scenes/main.rscene".into(),
cooked_scene: "build/main.rscene.bin".into(),
binary_name: package_name.clone(),
generators: std::collections::BTreeMap::new(),
determinism: Default::default(),
};
std::fs::write(
root.join("project.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
std::fs::write(root.join("src/main.rs"), default_game_source())?;
std::fs::write(
root.join("scenes/main.rscene"),
serde_json::to_vec_pretty(&match template {
ProjectTemplate::Basic3d => default_scene(name),
ProjectTemplate::Platformer2d => platformer_scene(name),
ProjectTemplate::Starter => starter_scene(name),
})?,
)?;
Ok(())
}
fn cargo_package_name(name: &str) -> String {
let mut package = String::new();
let mut previous_separator = false;
for character in name.trim().chars() {
if character.is_alphanumeric() {
package.extend(character.to_lowercase());
previous_separator = false;
} else if !previous_separator {
package.push('_');
previous_separator = true;
}
}
package.trim_matches('_').to_owned()
}
fn default_game_source() -> &'static str {
"use rusting_engine::prelude::*;\n\nfn update(_scene: &mut GameScene<'_>, _time: &FrameTime) {\n // Add game behaviour here.\n}\n\nrusting_game!(update);\n"
}
fn default_scene(name: &str) -> SceneDocument {
let cube = Uuid::new_v4();
let camera = Uuid::new_v4();
SceneDocument {
format_version: SCENE_FORMAT_VERSION,
name: format!("{name} Main Scene"),
entities: vec![
SceneEntity {
id: cube,
parent: None,
name: Some("Cube".into()),
classes: Vec::new(),
transform: Some(SceneTransform {
position: [0.0, 0.0, 0.0],
rotation: [0.0, 0.0, 0.0],
scale: [1.0, 1.0, 1.0],
}),
mesh_renderer: Some(SceneMeshRenderer {
mesh: SceneMesh::BuiltinCube,
material: SceneMaterial::Inline(SceneMaterialData {
model: SceneMaterialModel::Pbr,
alpha_mode: SceneAlphaMode::Opaque,
base_color: [0.1, 0.45, 0.95, 1.0],
emissive: [0.0; 3],
metallic: 0.0,
roughness: 0.5,
base_color_texture: None,
normal_texture: None,
metallic_roughness_texture: None,
occlusion_texture: None,
emissive_texture: None,
}),
cast_shadows: true,
receive_shadows: true,
}),
camera: None,
visible: Some(true),
physics_body: None,
rigid_body: None,
collider: None,
collision_layers: None,
gpu_physics_watch: None,
components: BTreeMap::new(),
directional_light: None,
point_light: None,
spot_light: None,
},
SceneEntity {
id: camera,
parent: None,
name: Some("Game Camera".into()),
classes: Vec::new(),
transform: Some(SceneTransform {
position: [0.0, 3.0, 8.0],
rotation: [0.0, 0.0, 0.0],
scale: [1.0; 3],
}),
mesh_renderer: None,
camera: Some(SceneCamera {
projection: SceneProjection::Perspective {
vertical_fov_radians: std::f32::consts::FRAC_PI_3,
near: 0.1,
far: 1_000.0,
},
active: true,
priority: 10,
}),
visible: None,
physics_body: None,
rigid_body: None,
collider: None,
collision_layers: None,
gpu_physics_watch: None,
components: BTreeMap::new(),
directional_light: None,
point_light: None,
spot_light: None,
},
],
render: Default::default(),
simulation: Default::default(),
}
}
fn platformer_scene(name: &str) -> SceneDocument {
use serde_json::json;
use crate::runtime::{
BurstEmitter, HudElement, PlatformerController, TileKind, TileMap,
};
fn component(value: &impl Serialize) -> String {
serde_json::to_string(value).expect("components serialize")
}
let sprite = |color: [f32; 4]| {
json!({
"mesh": {"BuiltinPrimitive": "Quad"},
"material": {"Inline": {
"model": "Unlit", "alpha_mode": "Opaque", "base_color": color,
"emissive": [0.0, 0.0, 0.0], "metallic": 0.0, "roughness": 1.0,
"base_color_texture": null, "normal_texture": null,
"metallic_roughness_texture": null,
"occlusion_texture": null, "emissive_texture": null
}},
"cast_shadows": false, "receive_shadows": false
})
};
let transform = |position: [f32; 3], scale: [f32; 3]| json!({"position": position, "rotation": [0.0, 0.0, 0.0], "scale": scale});
let level = TileMap {
tile_size: 1.0,
rows: [
"....................",
"....................",
"....................",
"..............BBB...",
"........BBB.........",
"....................",
"...BB...........###.",
"#######...##########",
"####################",
]
.map(String::from)
.to_vec(),
tiles: BTreeMap::from([
(
"#".into(),
TileKind {
color: [0.25, 0.55, 0.3, 1.0],
..TileKind::default()
},
),
(
"B".into(),
TileKind {
color: [0.55, 0.35, 0.2, 1.0],
..TileKind::default()
},
),
]),
};
let goal_burst = BurstEmitter {
count: 24,
speed: 4.0,
lifetime: 0.8,
particle_scale: 0.25,
..BurstEmitter::default()
};
let help = HudElement {
text: "A/D or arrows run, Space jumps. Reach the gold block.".into(),
..HudElement::default()
};
let player = Uuid::new_v4();
let entities = json!([
{
"id": Uuid::new_v4(), "parent": null, "name": "Level",
"transform": transform([-10.0, 6.0, 0.0], [1.0, 1.0, 1.0]),
"components": {"rusting.tile_map": component(&level)}
},
{
"id": player, "parent": null, "name": "Player",
"transform": transform([-8.0, 0.5, 0.0], [1.0, 1.0, 1.0]),
"physics_body": {"simulation": "Cpu", "solver": "Simplified", "custom_shader": null},
"rigid_body": {"kind": "Kinematic", "mass": 1.0, "linear_velocity": [0.0, 0.0, 0.0], "angular_velocity": [0.0, 0.0, 0.0], "gravity_scale": 1.0},
"collider": {"shape": {"Box": {"half_extents": [0.3, 0.5, 0.3]}}, "friction": 0.0, "restitution": 0.0, "sensor": false},
"components": {
"rusting.platformer_controller":
component(&PlatformerController::default())
}
},
{
"id": Uuid::new_v4(), "parent": player, "name": "Player Sprite",
"transform": transform([0.0, 0.0, 0.1], [0.6, 1.0, 1.0]),
"mesh_renderer": sprite([0.95, 0.45, 0.15, 1.0]),
"visible": true
},
{
"id": Uuid::new_v4(), "parent": player, "name": "Game Camera",
"transform": transform([0.0, 1.0, 10.0], [1.0, 1.0, 1.0]),
"camera": {
"projection": {"Orthographic": {"vertical_size": 12.0, "near": 0.1, "far": 100.0}},
"active": true, "priority": 10
}
},
{
"id": Uuid::new_v4(), "parent": null, "name": "Goal",
"transform": transform([8.5, 0.5, 0.05], [0.6, 1.0, 1.0]),
"mesh_renderer": sprite([1.0, 0.8, 0.1, 1.0]),
"visible": true,
"physics_body": {"simulation": "Cpu", "solver": "Simplified", "custom_shader": null},
"rigid_body": {"kind": "Fixed", "mass": 1.0, "linear_velocity": [0.0, 0.0, 0.0], "angular_velocity": [0.0, 0.0, 0.0], "gravity_scale": 1.0},
"collider": {"shape": {"Box": {"half_extents": [0.5, 0.5, 0.5]}}, "friction": 0.5, "restitution": 0.0, "sensor": true},
"components": {"rusting.burst_emitter": component(&goal_burst)}
},
{
"id": Uuid::new_v4(), "parent": null, "name": "Help",
"components": {"rusting.hud": component(&help)}
}
]);
SceneDocument {
format_version: SCENE_FORMAT_VERSION,
name: format!("{name} Main Scene"),
entities: serde_json::from_value(entities)
.expect("the 2D template is a valid scene"),
render: Default::default(),
simulation: Default::default(),
}
}
pub const STARTER_COINS: i32 = 5;
fn starter_scene(name: &str) -> SceneDocument {
use serde_json::{json, Value};
use crate::runtime::{
BurstEmitter, Counter, HudAnchor, HudElement, Pickup, SceneBackground,
SceneEntity, TileKind, TileMap,
};
fn component(value: &impl Serialize) -> String {
serde_json::to_string(value).expect("components serialize")
}
fn find<'a>(
scene: &'a mut SceneDocument,
name: &str,
) -> &'a mut SceneEntity {
scene
.entities
.iter_mut()
.find(|entity| entity.name.as_deref() == Some(name))
.expect("the 2D template has this entity")
}
let mut scene = platformer_scene(name);
let level = find(&mut scene, "Level");
let mut map: TileMap =
serde_json::from_str(&level.components["rusting.tile_map"])
.expect("the 2D template has a tile map");
map.rows = [
"......................",
"......................",
"......................",
"#....................#",
"#....................#",
"#....................#",
"#....B.......B....####",
"#########...##########",
"######################",
]
.map(String::from)
.to_vec();
map.tiles.insert(
"B".into(),
TileKind {
color: [0.55, 0.35, 0.2, 1.0],
..TileKind::default()
},
);
level
.components
.insert("rusting.tile_map".into(), component(&map));
level.transform.as_mut().expect("placed").position = [-11.0, 6.0, 0.0];
find(&mut scene, "Player")
.transform
.as_mut()
.expect("placed")
.position = [-9.5, 0.5, 0.0];
find(&mut scene, "Game Camera")
.transform
.as_mut()
.expect("placed")
.position = [4.0, 1.0, 10.0];
let goal = find(&mut scene, "Goal");
goal.name = Some("Flag".into());
goal.components.insert(
"rusting.burst_emitter".into(),
component(&BurstEmitter {
count: 32,
speed: 5.0,
lifetime: 1.0,
particle_scale: 0.25,
on_collision: false,
..BurstEmitter::default()
}),
);
goal.components.insert(
"rusting.pickup".into(),
component(&Pickup {
counter: "won".into(),
requires: Some("coins".into()),
..Pickup::default()
}),
);
find(&mut scene, "Help").components.insert(
"rusting.hud".into(),
component(&HudElement {
text: "A/D or arrows run, Space jumps. Grab every coin, then the flag."
.into(),
anchor: HudAnchor::BottomLeft,
..HudElement::default()
}),
);
let mut extra = Vec::new();
let coin_burst = component(&BurstEmitter {
count: 8,
speed: 2.5,
lifetime: 0.4,
particle_scale: 0.5,
on_collision: false,
..BurstEmitter::default()
});
let coin = component(&Pickup {
counter: "coins".into(),
..Pickup::default()
});
let mut coin_sprite =
serde_json::to_value(&find(&mut scene, "Flag").mesh_renderer)
.expect("mesh renderers serialize");
coin_sprite["material"]["Inline"]["base_color"] =
json!([1.0, 0.85, 0.2, 1.0]);
for (index, [x, y]) in [
[-8.0, -0.4],
[-5.5, 1.2],
[-0.5, 0.6],
[2.5, 1.2],
[5.0, -0.4],
]
.into_iter()
.enumerate()
{
extra.push(json!({
"id": Uuid::new_v4(), "parent": null,
"name": format!("Coin {}", index + 1), "classes": ["coin"],
"transform": {"position": [x, y, 0.05], "rotation": [0.0, 0.0, 0.0], "scale": [0.4, 0.4, 1.0]},
"mesh_renderer": coin_sprite,
"visible": true,
"physics_body": {"simulation": "Cpu", "solver": "Simplified", "custom_shader": null},
"rigid_body": {"kind": "Fixed", "mass": 1.0, "linear_velocity": [0.0, 0.0, 0.0], "angular_velocity": [0.0, 0.0, 0.0], "gravity_scale": 1.0},
"collider": {"shape": {"Box": {"half_extents": [0.5, 0.5, 0.5]}}, "friction": 0.5, "restitution": 0.0, "sensor": true},
"components": {"rusting.pickup": coin, "rusting.burst_emitter": coin_burst}
}));
}
let counter = |name: &str, target| {
component(&Counter {
name: name.into(),
value: 0,
target: Some(target),
})
};
let hud = |text: &str, anchor, font_size, color, requires: Option<&str>| {
component(&HudElement {
text: text.into(),
anchor,
font_size,
color,
requires: requires.map(String::from),
..HudElement::default()
})
};
extra.push(json!({
"id": Uuid::new_v4(), "parent": null, "name": "Score",
"components": {
"rusting.counter": counter("coins", STARTER_COINS),
"rusting.hud": hud(&format!("Coins {{coins}}/{STARTER_COINS}"), HudAnchor::TopLeft, 28.0, [1.0, 0.85, 0.2, 1.0], None),
}
}));
extra.push(json!({
"id": Uuid::new_v4(), "parent": null, "name": "Win",
"components": {
"rusting.counter": counter("won", 1),
"rusting.hud": hud("You win!", HudAnchor::Center, 56.0, [1.0, 1.0, 1.0, 1.0], Some("won")),
"rusting.background": component(&SceneBackground {
color: [0.35, 0.55, 0.8, 1.0],
}),
}
}));
scene.entities.extend(
extra
.into_iter()
.map(|entity: Value| serde_json::from_value(entity))
.collect::<Result<Vec<_>, _>>()
.expect("the starter template is a valid scene"),
);
scene
}
fn default_project_parent() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub(super) fn user_config_dir() -> PathBuf {
#[cfg(target_os = "windows")]
let base = std::env::var_os("APPDATA").map(PathBuf::from);
#[cfg(not(target_os = "windows"))]
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".config"))
});
base.unwrap_or_else(default_project_parent)
.join("rusting_engine")
}
fn recent_projects_path() -> PathBuf {
user_config_dir().join("recent_projects.json")
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct EditorPreferences {
pub ui_scale: f32,
pub font_scale: f32,
}
impl Default for EditorPreferences {
fn default() -> Self {
Self {
ui_scale: 1.0,
font_scale: 1.0,
}
}
}
impl EditorPreferences {
pub const UI_SCALES: [f32; 6] = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5];
pub const FONT_SCALES: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
fn path() -> PathBuf {
user_config_dir().join("editor_preferences.json")
}
#[must_use]
pub fn load() -> Self {
Self::load_from(&Self::path())
}
fn load_from(path: &Path) -> Self {
std::fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice::<Self>(&bytes).ok())
.map(|mut preferences| {
preferences.ui_scale = preferences.ui_scale.clamp(0.5, 3.0);
preferences.font_scale = preferences.font_scale.clamp(0.5, 3.0);
preferences
})
.unwrap_or_default()
}
pub fn save(&self) -> Result<(), ProjectError> {
self.save_to(&Self::path())
}
fn save_to(&self, path: &Path) -> Result<(), ProjectError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::runtime::write_atomic(path, &serde_json::to_vec_pretty(self)?)?;
Ok(())
}
}
fn load_recent_projects() -> Result<Vec<RecentProject>, ProjectError> {
let path = recent_projects_path();
if !path.is_file() {
return Ok(Vec::new());
}
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
}
fn save_recent_projects(recent: &[RecentProject]) -> Result<(), ProjectError> {
let path = recent_projects_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::runtime::write_atomic(path, &serde_json::to_vec_pretty(recent)?)?;
Ok(())
}
pub fn executable_name(binary_name: &str, target: Option<&str>) -> String {
let windows = target.map_or(cfg!(target_os = "windows"), |target| {
target.contains("windows")
});
if windows {
format!("{binary_name}.exe")
} else {
binary_name.to_owned()
}
}
pub fn export_built_game(
project_root: &std::path::Path,
manifest: &std::path::Path,
parent: &std::path::Path,
project_name: &str,
binary_name: &str,
cooked_scene: &std::path::Path,
target: Option<&str>,
) -> Result<PathBuf, String> {
if !parent.is_dir() {
return Err(format!("{} is not a folder", parent.display()));
}
let executable =
built_executable(project_root, manifest, binary_name, target, true)?;
package_game_files(
project_root,
&executable,
parent,
project_name,
binary_name,
cooked_scene,
target,
)
}
pub fn package_game_files(
project_root: &std::path::Path,
executable: &std::path::Path,
parent: &std::path::Path,
project_name: &str,
binary_name: &str,
cooked_scene: &std::path::Path,
target: Option<&str>,
) -> Result<PathBuf, String> {
let executable_name = executable_name(binary_name, target);
let cooked_source = project_root.join(cooked_scene);
if !cooked_source.is_file() {
return Err(format!(
"Cooked scene {} is missing",
cooked_source.display()
));
}
let safe_name = match target.and_then(|target| target.split('-').nth(2)) {
Some(system) => format!("{}_{system}", binary_name.replace('-', "_")),
None => binary_name.replace('-', "_"),
};
let mut destination = parent.join(format!("{safe_name}_export"));
for number in 2.. {
if !destination.exists() {
break;
}
destination = parent.join(format!("{safe_name}_export_{number}"));
}
let temporary =
parent.join(format!(".rusting-export-{}", uuid::Uuid::new_v4()));
let result = (|| -> Result<(), String> {
std::fs::create_dir_all(&temporary)
.map_err(|error| error.to_string())?;
std::fs::copy(executable, temporary.join(&executable_name))
.map_err(|error| error.to_string())?;
let cooked_destination = temporary.join(cooked_scene);
if let Some(folder) = cooked_destination.parent() {
std::fs::create_dir_all(folder)
.map_err(|error| error.to_string())?;
}
std::fs::copy(&cooked_source, cooked_destination)
.map_err(|error| error.to_string())?;
let assets = project_root.join("assets");
if assets.is_dir() {
copy_directory(&assets, &temporary.join("assets"))?;
}
let engine_license =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("LICENSE.md");
if engine_license.is_file() {
std::fs::copy(
engine_license,
temporary.join("RUSTING_ENGINE_LICENSE.md"),
)
.map_err(|error| error.to_string())?;
}
std::fs::write(
temporary.join("README.txt"),
format!(
"{project_name}\n\nRun {executable_name} to start the game.\nThe system needs a Vulkan-capable graphics driver.\n"
),
)
.map_err(|error| error.to_string())?;
std::fs::rename(&temporary, &destination)
.map_err(|error| error.to_string())?;
Ok(())
})();
if result.is_err() && temporary.exists() {
let _ = std::fs::remove_dir_all(&temporary);
}
result.map(|()| destination)
}
pub fn copy_directory(
source: &std::path::Path,
destination: &std::path::Path,
) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|error| error.to_string())?;
for entry in std::fs::read_dir(source).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_symlink() {
continue;
}
let target = destination.join(entry.file_name());
if file_type.is_dir() {
copy_directory(&entry.path(), &target)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), target)
.map_err(|error| error.to_string())?;
}
}
Ok(())
}
pub fn built_executable(
project_root: &std::path::Path,
manifest: &std::path::Path,
binary_name: &str,
target: Option<&str>,
release: bool,
) -> Result<PathBuf, String> {
let metadata = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.args(["--manifest-path"])
.arg(manifest)
.current_dir(project_root)
.output()
.map_err(|error| format!("Could not read Cargo metadata: {error}"))?;
if !metadata.status.success() {
return Err(String::from_utf8_lossy(&metadata.stderr).into_owned());
}
let metadata: serde_json::Value = serde_json::from_slice(&metadata.stdout)
.map_err(|error| format!("Invalid Cargo metadata: {error}"))?;
let target_directory = metadata
.get("target_directory")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "Cargo metadata has no target directory".to_owned())?;
let executable = PathBuf::from(target_directory)
.join(target.unwrap_or_default())
.join(if release { "release" } else { "debug" })
.join(executable_name(binary_name, target));
if !executable.is_file() {
return Err(format!(
"Executable {} was not produced",
executable.display()
));
}
Ok(executable)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reload_output_is_split_into_file_diagnostics_and_first_frame_time() {
let output = "\
Compiling game v0.1.0
src/main.rs:12:5: error[E0425]: cannot find value `x` in this scope
src/main.rs:3:1: warning: unused import
/engine/src/shaders/mod.rs:4:1: error: shader.comp:7: error: undeclared identifier
error: linking with `cc` failed
error: could not compile `game` (bin \"game\") due to 2 previous errors
hot reload failed: failed to load `assets/crate.rtexture`: bad header
[rusting] first playable frame after 412 ms
";
let diagnostics = reload_diagnostics(output);
let summary: Vec<_> = diagnostics
.iter()
.map(|d| (d.kind, d.file.clone(), d.line))
.collect();
assert_eq!(
summary,
vec![
("rust", Some(PathBuf::from("src/main.rs")), Some(12)),
(
"shader",
Some(PathBuf::from("/engine/src/shaders/mod.rs")),
Some(4)
),
("rust", None, None),
("asset", Some(PathBuf::from("assets/crate.rtexture")), None),
]
);
assert!(diagnostics[0].message.starts_with("error[E0425]"));
assert_eq!(first_frame_ms(output), Some(412));
assert_eq!(first_frame_ms("no marker"), None);
}
#[test]
fn project_creation_writes_a_complete_openable_template() {
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&parent).unwrap();
let project = create_project(&parent, "Example Game").unwrap();
assert_eq!(project.manifest.name, "Example Game");
assert!(project.root.join("Cargo.toml").is_file());
assert!(project.root.join("src/main.rs").is_file());
assert!(project.root.join("scenes/main.rscene").is_file());
assert!(project.root.join("assets").is_dir());
assert!(project.root.join("shaders").is_dir());
assert!(project.root.join("build").is_dir());
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn project_creation_never_overwrites_an_existing_folder() {
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
let existing = parent.join("Existing");
std::fs::create_dir_all(&existing).unwrap();
std::fs::write(existing.join("keep.txt"), "keep").unwrap();
assert!(matches!(
create_project(&parent, "Existing"),
Err(ProjectError::AlreadyExists(_))
));
assert_eq!(
std::fs::read_to_string(existing.join("keep.txt")).unwrap(),
"keep"
);
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn the_2d_template_is_a_playable_side_view_level() {
use std::time::Duration;
use crate::assets::AssetPlugin;
use crate::runtime::{
load_scene, App, Collider, KeyCode, PlatformerController,
RuntimeInput, SceneLoadMode, TileOf,
};
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&parent).unwrap();
let project = create_project_from(
&parent,
"Jumper",
ProjectTemplate::Platformer2d,
)
.unwrap();
let cargo =
std::fs::read_to_string(project.root.join("Cargo.toml")).unwrap();
assert!(cargo.contains("features = [\"ui\"]"), "{cargo}");
let mut app = App::new();
app.add_plugin(AssetPlugin).unwrap();
load_scene(
app.world_mut(),
project.root.join("scenes/main.rscene"),
SceneLoadMode::Replace,
)
.unwrap();
let tick = |app: &mut App, ticks| {
for _ in 0..ticks {
app.update(Duration::from_secs_f64(1.0 / 60.0)).unwrap();
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
}
};
let player = |app: &mut App| {
let world = app.world_mut();
let mut query =
world.query::<(&PlatformerController, &crate::Transform)>();
let (controller, transform) = query.single(world).unwrap();
(*controller, transform.position)
};
tick(&mut app, 60);
let (controller, start) = player(&mut app);
assert!(controller.grounded, "{start:?}");
assert!((start[1] + 0.5).abs() < 0.02, "{start:?}");
let world = app.world_mut();
let tiles = world.query::<&TileOf>().iter(world).count();
let colliders =
world.query::<(&TileOf, &Collider)>().iter(world).count();
assert_eq!(colliders, 7, "one collider per solid run");
assert!(tiles > 40, "{tiles}");
let mut input = app.world_mut().resource_mut::<RuntimeInput>();
input.record_key(KeyCode::KeyD, true);
input.record_key(KeyCode::Space, true);
tick(&mut app, 20);
assert!(!player(&mut app).0.grounded, "the jump left the ground");
let mut frames = 20;
while !player(&mut app).0.grounded {
assert!(frames < 120, "never landed: {:?}", player(&mut app).1);
tick(&mut app, 1);
frames += 1;
}
let (_, moved) = player(&mut app);
assert!(moved[0] > start[0] + 3.0, "{moved:?} after {frames}");
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn the_starter_template_wins_its_sample_scenario() {
use crate::assets::AssetPlugin;
use crate::runtime::{
load_scene, App, RenderExtractPlugin, SceneLoadMode,
};
use crate::scenario::{run_scenario, Scenario};
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&parent).unwrap();
let project = create_project_from(
&parent,
"Coin Run",
ProjectTemplate::parse("starter").unwrap(),
)
.unwrap();
let mut app = App::new();
app.add_plugin(AssetPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
load_scene(
app.world_mut(),
project.root.join("scenes/main.rscene"),
SceneLoadMode::Replace,
)
.unwrap();
let scenario: Scenario = serde_json::from_str(include_str!(
"../samples/starter_game/win.scenario.json"
))
.unwrap();
let report = run_scenario(&mut app, &scenario, &parent);
assert!(report.passed, "{:#?}", report.first_failure);
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn replays_of_the_starter_template_seek_through_snapshots() {
use crate::assets::AssetPlugin;
use crate::runtime::{
load_scene, App, Counter, Name, RandomSeed, RenderExtractPlugin,
ReplayError, ReplaySeeker, SceneLoadMode,
};
use crate::scenario::{run_scenario, Scenario, StepAction};
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&parent).unwrap();
let project = create_project_from(
&parent,
"Coin Run",
ProjectTemplate::parse("starter").unwrap(),
)
.unwrap();
let scene = project.root.join("scenes/main.rscene");
let make_app = || {
let mut app = App::new();
app.add_plugin(AssetPlugin)?;
app.add_plugin(RenderExtractPlugin)?;
load_scene(app.world_mut(), &scene, SceneLoadMode::Replace)
.expect("the starter scene loads");
Ok(app)
};
let mut scenario: Scenario = serde_json::from_str(include_str!(
"../samples/starter_game/win.scenario.json"
))
.unwrap();
scenario
.steps
.retain(|step| !matches!(step.action, StepAction::Capture(_)));
let mut app = make_app().unwrap();
app.world_mut().resource_mut::<RandomSeed>().0 = scenario.seed;
app.start_recording();
assert!(run_scenario(&mut app, &scenario, &parent).passed);
let replay = app.finish_recording().unwrap();
let counter = |app: &App, wanted: &str| {
let world = app.world();
let mut counters = world.try_query::<(&Name, &Counter)>().unwrap();
counters
.iter(world)
.find(|(name, _)| name.0 == wanted)
.map(|(_, counter)| counter.value)
.unwrap()
};
let mut seeker =
ReplaySeeker::new(replay.clone(), 50, make_app).unwrap();
assert_eq!(seeker.seek(240).unwrap(), 240);
assert_eq!(seeker.snapshot_count(), 4);
assert_eq!(counter(seeker.app(), "Win"), 1);
assert_eq!(seeker.seek(10).unwrap(), 10);
assert_eq!(counter(seeker.app(), "Score"), 0);
assert_eq!(seeker.seek(190).unwrap(), 190);
assert_eq!(counter(seeker.app(), "Score"), 5);
assert_eq!(counter(seeker.app(), "Win"), 0);
assert_eq!(seeker.seek(60).unwrap(), 60);
assert_eq!(seeker.seek(240).unwrap(), 240);
assert_eq!(counter(seeker.app(), "Win"), 1);
assert_eq!(seeker.snapshot_count(), 4);
let mut tampered = replay;
let index = tampered
.hashes
.iter()
.position(|&(tick, _)| tick == 170)
.unwrap();
tampered.hashes[index].1 ^= 1;
let mut seeker = ReplaySeeker::new(tampered, 50, make_app).unwrap();
assert_eq!(seeker.seek(160).unwrap(), 160);
assert!(matches!(
seeker.seek(240),
Err(ReplayError::Diverged { tick: 170 })
));
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn invalid_project_names_are_rejected() {
for name in ["", "../game", "game/name", ".", "..", "CON", "gameā¤"] {
assert!(matches!(
validate_project_name(name),
Err(ProjectError::InvalidName)
));
}
}
#[test]
fn unversioned_project_is_backed_up_and_migrated() {
let parent = std::env::temp_dir()
.join(format!("rusting-project-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&parent).unwrap();
let project = create_project(&parent, "Legacy Game").unwrap();
let manifest_path = project.root.join("project.json");
let mut legacy: serde_json::Value =
serde_json::from_slice(&std::fs::read(&manifest_path).unwrap())
.unwrap();
legacy.as_object_mut().unwrap().remove("format_version");
legacy.as_object_mut().unwrap().remove("binary_name");
std::fs::write(
&manifest_path,
serde_json::to_vec_pretty(&legacy).unwrap(),
)
.unwrap();
let migrated = open_project(&project.root).unwrap();
assert_eq!(migrated.manifest.format_version, PROJECT_FORMAT_VERSION);
assert_eq!(migrated.manifest.binary_name, "legacy_game");
assert!(project.root.join("project.json.v0.backup").is_file());
let stored: ProjectManifest =
serde_json::from_slice(&std::fs::read(&manifest_path).unwrap())
.unwrap();
assert_eq!(stored, migrated.manifest);
std::fs::remove_dir_all(parent).unwrap();
}
#[test]
fn editor_preferences_round_trip_and_fall_back_to_defaults() {
let folder = std::env::temp_dir()
.join(format!("rusting_prefs_{}", Uuid::new_v4()));
let path = folder.join("editor_preferences.json");
assert_eq!(
EditorPreferences::load_from(&path),
EditorPreferences::default()
);
let preferences = EditorPreferences {
ui_scale: 1.25,
font_scale: 1.15,
};
preferences.save_to(&path).unwrap();
assert_eq!(EditorPreferences::load_from(&path), preferences);
std::fs::write(&path, "{\"ui_scale\": 40.0}").unwrap();
let old = EditorPreferences::load_from(&path);
assert_eq!((old.ui_scale, old.font_scale), (3.0, 1.0));
std::fs::write(&path, "not json").unwrap();
assert_eq!(
EditorPreferences::load_from(&path),
EditorPreferences::default()
);
std::fs::remove_dir_all(folder).unwrap();
}
}