use convert_case::{Case, Casing};
use regex::Regex;
use std::{
collections::HashMap,
fmt::Display,
fs::{create_dir_all, read_dir, remove_dir_all, File},
io::{Read, Write},
path::Path,
process::Command,
};
use toml_edit::{table, value, DocumentMut};
use uuid::Uuid;
pub static CURRENT_ENGINE_VERSION: &str = include_str!("../engine.version");
pub static CURRENT_EDITOR_VERSION: &str = include_str!("../editor.version");
pub static CURRENT_SCRIPTS_VERSION: &str = include_str!("../scripts.version");
fn write_file<P: AsRef<Path>, S: AsRef<str>>(path: P, content: S) -> Result<(), String> {
let mut file = File::create(path.as_ref()).map_err(|e| e.to_string())?;
file.write_all(content.as_ref().as_bytes()).map_err(|x| {
format!(
"Error happened while writing to file: {}.\nError:\n{}",
path.as_ref().to_string_lossy(),
x
)
})
}
fn write_file_binary<P: AsRef<Path>>(path: P, content: &[u8]) -> Result<(), String> {
let mut file = File::create(path.as_ref()).map_err(|e| e.to_string())?;
file.write_all(content).map_err(|x| {
format!(
"Error happened while writing to file: {}.\nError:\n{}",
path.as_ref().to_string_lossy(),
x
)
})
}
#[derive(Debug)]
pub enum NameError {
Empty,
CargoReserved(String),
StartsWithNumber,
InvalidCharacter(char),
}
impl std::error::Error for NameError {}
impl Display for NameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CargoReserved(name) => write!(
f,
"The project name cannot be `{name}` due to cargo's reserved keywords"
),
Self::StartsWithNumber => write!(f, "The project name cannot start with a number"),
Self::InvalidCharacter(ch) => write!(
f,
"The project name cannot contain {ch} \
characters! It can start from most letters or '_' symbol and the rest of the name \
must be letters, '-', '_', numbers."
),
NameError::Empty => {
write!(f, "The project name cannot be empty!")
}
}
}
}
pub fn check_name(name: &str) -> Result<&str, NameError> {
const RESERVED_NAMES: [&str; 53] = [
"abstract", "alignof", "as", "become", "box", "break", "const", "continue", "crate", "do",
"else", "enum", "extern", "false", "final", "fn", "for", "if", "impl", "in", "let", "loop",
"macro", "match", "mod", "move", "mut", "offsetof", "override", "priv", "proc", "pub",
"pure", "ref", "return", "self", "sizeof", "static", "struct", "super", "test", "trait",
"true", "type", "typeof", "try", "unsafe", "unsized", "use", "virtual", "where", "while",
"yield",
];
if name.is_empty() {
return Err(NameError::Empty);
}
if RESERVED_NAMES.contains(&name) {
return Err(NameError::CargoReserved(name.to_string()));
}
let mut chars = name.chars();
if let Some(ch) = chars.next() {
if ch.is_ascii_digit() {
return Err(NameError::StartsWithNumber);
}
if !(unicode_xid::UnicodeXID::is_xid_start(ch) || ch == '_') {
return Err(NameError::InvalidCharacter(ch));
}
}
for ch in chars {
if !(unicode_xid::UnicodeXID::is_xid_continue(ch) || ch == '-') {
return Err(NameError::InvalidCharacter(ch));
}
}
Ok(name)
}
pub fn convert_name(name: &str) -> String {
name.replace("-", "_").to_lowercase()
}
fn init_game(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--lib", "--vcs", "none"])
.arg(base_path.join("game"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("game/Cargo.toml"),
format!(
r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[dependencies]
fyrox = {{workspace = true}}
[features]
default = ["fyrox/default"]
dylib-engine = ["fyrox/dylib"]
"#,
),
)?;
write_file(
base_path.join("game/src/lib.rs"),
r#"//! Game project.
#[allow(unused_imports)]
use fyrox::graph::prelude::*;
use fyrox::{
core::pool::Handle, core::visitor::prelude::*, core::reflect::prelude::*,
event::Event,
gui::{message::UiMessage, UserInterface},
plugin::{Plugin, PluginContext, PluginRegistrationContext, error::GameResult},
};
// Re-export the engine.
pub use fyrox;
#[derive(Default, Visit, Reflect, Debug)]
#[reflect(non_cloneable)]
pub struct Game { }
impl Plugin for Game {
fn register(&self, _context: PluginRegistrationContext) -> GameResult {
// Register your scripts here.
Ok(())
}
fn init(&mut self, scene_path: Option<&str>, mut context: PluginContext) -> GameResult {
context.load_scene_or_ui::<Self>(scene_path.unwrap_or("data/scene.rgs"));
Ok(())
}
fn on_deinit(&mut self, _context: PluginContext) -> GameResult {
// Do a cleanup here.
Ok(())
}
fn update(&mut self, _context: &mut PluginContext) -> GameResult {
// Add your global update code here.
Ok(())
}
fn on_os_event(
&mut self,
_event: &Event<()>,
_context: PluginContext,
) -> GameResult {
// Do something on OS event here.
Ok(())
}
fn on_ui_message(
&mut self,
_context: &mut PluginContext,
_message: &UiMessage,
_ui_handle: Handle<UserInterface>
) -> GameResult {
// Handle UI events here.
Ok(())
}
}
"#,
)
}
fn init_executor(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--bin", "--vcs", "none"])
.arg(base_path.join("executor"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("executor/Cargo.toml"),
format!(
r#"
[package]
name = "executor"
version = "0.1.0"
edition = "2021"
[dependencies]
fyrox = {{ workspace = true }}
{name} = {{ path = "../game", optional = true }}
[features]
default = ["{name}"]
dylib = ["fyrox/dylib"]
"#,
),
)?;
write_file(
base_path.join("executor/src/main.rs"),
format!(
r#"//! Executor with your game connected to it as a plugin.
use fyrox::engine::executor::Executor;
use fyrox::event_loop::EventLoop;
use fyrox::core::log::Log;
fn main() {{
Log::set_file_name("{name}.log");
let mut executor = Executor::new(Some(EventLoop::new().unwrap()));
// Dynamic linking with hot reloading.
#[cfg(feature = "dylib")]
{{
#[cfg(target_os = "windows")]
let file_name = "game_dylib.dll";
#[cfg(target_os = "linux")]
let file_name = "libgame_dylib.so";
#[cfg(target_os = "macos")]
let file_name = "libgame_dylib.dylib";
executor.add_dynamic_plugin(file_name, true, true).unwrap();
}}
// Static linking.
#[cfg(not(feature = "dylib"))]
{{
use {name}::Game;
executor.add_plugin(Game::default());
}}
executor.run()
}}"#,
),
)
}
fn init_export_cli(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--bin", "--vcs", "none"])
.arg(base_path.join("export-cli"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("export-cli/Cargo.toml"),
format!(
r#"
[package]
name = "export-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
fyrox = {{ workspace = true }}
fyrox-build-tools = {{ workspace = true }}
{name} = {{ path = "../game" }}
"#,
),
)?;
write_file(
base_path.join("export-cli/src/main.rs"),
format!(
r#"//! Exporter command line interface (CLI) with your game connected to it as a plugin.
//! This tool can be used to automate project export in CI/CD.
//! Typical usage: `cargo run --package export-cli -- --target-platform pc`
//! or `cargo run --package export-cli -- --help` for the docs.
use {name}::Game;
use fyrox::core::log::Log;
use fyrox::engine::executor::Executor;
use fyrox::event_loop::EventLoop;
use fyrox_build_tools::export::cli_export;
fn main() {{
Log::set_file_name("{name}Export.log");
let mut executor = Executor::new(EventLoop::new().ok());
executor.add_plugin(Game::default());
cli_export(executor.resource_manager.clone())
}}"#,
),
)
}
fn init_wasm_executor(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--lib", "--vcs", "none"])
.arg(base_path.join("executor-wasm"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("executor-wasm/Cargo.toml"),
format!(
r#"
[package]
name = "executor-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
fyrox = {{workspace = true}}
{name} = {{ path = "../game" }}"#,
),
)?;
write_file(
base_path.join("executor-wasm/src/lib.rs"),
format!(
r#"//! Executor with your game connected to it as a plugin.
#![cfg(target_arch = "wasm32")]
use fyrox::engine::executor::Executor;
use fyrox::event_loop::EventLoop;
use fyrox::core::wasm_bindgen::{{self, prelude::*}};
use {name}::Game;
#[wasm_bindgen]
pub fn main() {{
let mut executor = Executor::new(Some(EventLoop::new().unwrap()));
executor.add_plugin(Game::default());
executor.run()
}}"#,
),
)?;
write_file_binary(
base_path.join("executor-wasm/index.html"),
include_bytes!("wasm/index.html"),
)?;
write_file_binary(
base_path.join("executor-wasm/styles.css"),
include_bytes!("wasm/styles.css"),
)?;
write_file_binary(
base_path.join("executor-wasm/main.js"),
include_bytes!("wasm/main.js"),
)?;
write_file_binary(
base_path.join("executor-wasm/README.md"),
include_bytes!("wasm/README.md"),
)
}
fn init_editor(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--bin", "--vcs", "none"])
.arg(base_path.join("editor"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("editor/Cargo.toml"),
format!(
r#"
[package]
name = "editor"
version = "0.1.0"
edition = "2021"
[dependencies]
fyrox = {{ workspace = true }}
fyroxed_base = {{ workspace = true }}
{name} = {{ path = "../game", optional = true }}
[features]
default = ["{name}", "fyroxed_base/default"]
dylib = ["fyroxed_base/dylib_engine"]
"#,
),
)?;
write_file(
base_path.join("editor/src/main.rs"),
format!(
r#"//! Editor with your game connected to it as a plugin.
use fyroxed_base::{{fyrox::event_loop::EventLoop, Editor, StartupData, fyrox::core::log::Log}};
fn main() {{
Log::set_file_name("{name}.log");
let event_loop = EventLoop::new().unwrap();
let mut editor = Editor::new(
Some(StartupData {{
working_directory: Default::default(),
scenes: vec!["data/scene.rgs".into()],
named_objects: false
}}),
);
// Dynamic linking with hot reloading.
#[cfg(feature = "dylib")]
{{
#[cfg(target_os = "windows")]
let file_name = "game_dylib.dll";
#[cfg(target_os = "linux")]
let file_name = "libgame_dylib.so";
#[cfg(target_os = "macos")]
let file_name = "libgame_dylib.dylib";
editor.add_dynamic_plugin(file_name, true, true).unwrap();
}}
// Static linking.
#[cfg(not(feature = "dylib"))]
{{
use {name}::Game;
editor.add_game_plugin(Game::default());
}}
editor.run(event_loop)
}}
"#,
),
)
}
fn init_game_dylib(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--lib", "--vcs", "none"])
.arg(base_path.join("game-dylib"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("game-dylib/Cargo.toml"),
format!(
r#"
[package]
name = "game_dylib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
{name} = {{ path = "../game", default-features = false }}
[features]
default = ["{name}/default"]
dylib-engine = ["{name}/dylib-engine"]
"#,
),
)?;
write_file(
base_path.join("game-dylib/src/lib.rs"),
format!(
r#"//! Wrapper for hot-reloadable plugin.
use {name}::{{fyrox::plugin::Plugin, Game}};
#[no_mangle]
pub fn fyrox_plugin() -> Box<dyn Plugin> {{
Box::new(Game::default())
}}
"#,
),
)
}
fn init_android_executor(base_path: &Path, name: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--lib", "--vcs", "none"])
.arg(base_path.join("executor-android"))
.output()
.map_err(|e| e.to_string())?;
write_file(
base_path.join("executor-android/Cargo.toml"),
format!(
r#"
[package]
name = "executor-android"
version = "0.1.0"
edition = "2021"
[package.metadata.android]
# This folder is used as a temporary storage for assets. Project exporter will clone everything
# from data folder to this folder and cargo-apk will create the apk with these assets.
assets = "assets"
strip = "strip"
[package.metadata.android.sdk]
min_sdk_version = 26
target_sdk_version = 30
max_sdk_version = 29
[package.metadata.android.signing.release]
path = "release.keystore"
keystore_password = "fyrox-template"
[lib]
crate-type = ["cdylib"]
[dependencies]
fyrox = {{ workspace = true }}
{name} = {{ path = "../game" }}"#,
),
)?;
write_file(
base_path.join("executor-android/src/lib.rs"),
format!(
r#"//! Android executor with your game connected to it as a plugin.
#![cfg(target_os = "android")]
use fyrox::{{
core::io, engine::executor::Executor, event_loop::EventLoopBuilder,
platform::android::EventLoopBuilderExtAndroid,
}};
use {name}::Game;
#[no_mangle]
fn android_main(app: fyrox::platform::android::activity::AndroidApp) {{
io::ANDROID_APP
.set(app.clone())
.expect("ANDROID_APP cannot be set twice.");
#[allow(deprecated)]
let event_loop = EventLoopBuilder::new().with_android_app(app).build().unwrap();
let mut executor = Executor::from_params(Some(event_loop), Default::default());
executor.add_plugin(Game::default());
executor.run()
}}"#,
),
)?;
write_file_binary(
base_path.join("executor-android/README.md"),
include_bytes!("android/README.md"),
)?;
write_file_binary(
base_path.join("executor-android/release.keystore"),
include_bytes!("android/release.keystore"),
)?;
create_dir_all(base_path.join("executor-android/assets")).map_err(|e| e.to_string())
}
fn init_workspace(base_path: &Path, vcs: &str) -> Result<(), String> {
Command::new("cargo")
.args(["init", "--vcs", vcs])
.arg(base_path)
.output()
.map_err(|e| e.to_string())?;
let src_path = base_path.join("src");
if src_path.exists() {
remove_dir_all(src_path).map_err(|e| e.to_string())?;
}
write_file(
base_path.join("Cargo.toml"),
format!(
r#"
[workspace]
members = ["editor", "executor", "executor-wasm", "executor-android", "export-cli", "game", "game-dylib"]
resolver = "2"
[workspace.dependencies.fyrox]
version = "{CURRENT_ENGINE_VERSION}"
default-features = false
[workspace.dependencies.fyroxed_base]
version = "{CURRENT_EDITOR_VERSION}"
default-features = false
[workspace.dependencies.fyrox-build-tools]
version = "{CURRENT_EDITOR_VERSION}"
# Separate build profiles for hot reloading. These profiles ensures that build artifacts for
# hot reloading will be placed into their own folders and does not interfere with standard (static)
# linking.
[profile.dev-hot-reload]
inherits = "dev"
[profile.release-hot-reload]
inherits = "release"
# Optimize the engine in debug builds, but leave project's code non-optimized.
# By using this technique, you can still debug you code, but engine will be fully
# optimized and debug builds won't be terribly slow. With this option, you can
# compile your game in debug mode, which is much faster (at least x3), than release.
[profile.dev.package."*"]
opt-level = 3
"#,
),
)?;
if vcs == "git" {
write_file(
base_path.join(".gitignore"),
r#"
/target
*.log
"#,
)?;
}
write_file_binary(
base_path.join("flake.nix"),
include_bytes!("nixos/flake.nix"),
)?;
Ok(())
}
fn init_data(base_path: &Path, style: &str) -> Result<(), String> {
let data_path = base_path.join("data");
create_dir_all(&data_path).map_err(|e| e.to_string())?;
let scene_path = data_path.join("scene.rgs");
match style {
"2d" => write_file_binary(scene_path, include_bytes!("2d.rgs")),
"3d" => write_file_binary(scene_path, include_bytes!("3d.rgs")),
_ => Err(format!("Unknown style: {style}. Use either `2d` or `3d`")),
}
}
pub fn init_script(root_path: &Path, raw_name: &str) -> Result<(), String> {
let mut base_path = root_path.join("game/src/");
if !base_path.exists() {
eprintln!("game/src directory does not exists! Fallback to root directory...");
base_path = root_path.to_path_buf();
}
let script_file_stem = raw_name.to_case(Case::Snake);
let script_name = raw_name.to_case(Case::UpperCamel);
let file_name = base_path.join(script_file_stem.clone() + ".rs");
if file_name.exists() {
return Err(format!("Script {script_name} already exists!"));
}
let script_uuid = Uuid::new_v4().to_string();
write_file(
file_name,
format!(
r#"
#[allow(unused_imports)]
use fyrox::graph::prelude::*;
use fyrox::{{
core::{{visitor::prelude::*, reflect::prelude::*, type_traits::prelude::*}},
event::Event, script::{{ScriptContext, ScriptDeinitContext, ScriptTrait}},
plugin::error::GameResult
}};
#[derive(Visit, Reflect, Default, Debug, Clone, TypeUuidProvider, ComponentProvider)]
#[type_uuid(id = "{script_uuid}")]
#[visit(optional)]
pub struct {script_name} {{
// Add fields here.
}}
impl ScriptTrait for {script_name} {{
fn on_init(&mut self, context: &mut ScriptContext) -> GameResult {{
// Put initialization logic here.
Ok(())
}}
fn on_start(&mut self, context: &mut ScriptContext) -> GameResult {{
// There should be a logic that depends on other scripts in scene.
// It is called right after **all** scripts were initialized.
Ok(())
}}
fn on_deinit(&mut self, context: &mut ScriptDeinitContext) -> GameResult {{
// Put de-initialization logic here.
Ok(())
}}
fn on_os_event(&mut self, event: &Event<()>, context: &mut ScriptContext) -> GameResult {{
// Respond to OS events here.
Ok(())
}}
fn on_update(&mut self, context: &mut ScriptContext) -> GameResult {{
// Put object logic here.
Ok(())
}}
}}
"#
),
)
}
pub fn init_project(
root_path: &Path,
name: &str,
style: &str,
vcs: &str,
overwrite: bool,
) -> Result<(), String> {
let name = check_name(name);
let name = match name {
Ok(s) => s,
Err(name_error) => {
println!("{name_error}");
return Err(name_error.to_string());
}
};
let base_path = root_path.join(name);
let base_path = &base_path;
if !overwrite
&& base_path.exists()
&& read_dir(base_path)
.expect("Failed to check if path is not empty")
.next()
.is_some()
{
return Err(format!(
"Non-empty folder named {} already exists, provide --overwrite to create the project anyway",
base_path.display()
));
}
let name = convert_name(name);
init_workspace(base_path, vcs)?;
init_data(base_path, style)?;
init_game(base_path, &name)?;
init_game_dylib(base_path, &name)?;
init_editor(base_path, &name)?;
init_executor(base_path, &name)?;
init_wasm_executor(base_path, &name)?;
init_android_executor(base_path, &name)?;
init_export_cli(base_path, &name)
}
pub fn upgrade_project(root_path: &Path, version: &str, local: bool) -> Result<(), String> {
let semver_regex = Regex::new(include_str!("regex")).map_err(|e| e.to_string())?;
if version != "latest" && version != "nightly" && !semver_regex.is_match(version) {
return Err(format!(
"Invalid version: {version}. Please specify one of the following:\n\
\tnightly - uses latest nightly version of the engine from GitHub directly.\
\tlatest - uses latest stable version of the engine.\n\
\tmajor.minor.patch - uses specific stable version from crates.io (0.30.0 for example).",
));
}
let editor_versions = [
(
CURRENT_ENGINE_VERSION.to_string(),
(
CURRENT_EDITOR_VERSION.to_string(),
Some(CURRENT_SCRIPTS_VERSION.to_string()),
),
),
(
"0.34.0".to_string(),
("0.21.0".to_string(), Some("0.3.0".to_string())),
),
(
"0.33.0".to_string(),
("0.20.0".to_string(), Some("0.2.0".to_string())),
),
(
"0.32.0".to_string(),
("0.19.0".to_string(), Some("0.1.0".to_string())),
),
("0.31.0".to_string(), ("0.18.0".to_string(), None)),
("0.30.0".to_string(), ("0.17.0".to_string(), None)),
("0.29.0".to_string(), ("0.16.0".to_string(), None)),
("0.28.0".to_string(), ("0.15.0".to_string(), None)),
("0.27.1".to_string(), ("0.14.1".to_string(), None)),
("0.27.0".to_string(), ("0.14.0".to_string(), None)),
("0.26.0".to_string(), ("0.13.0".to_string(), None)),
]
.into_iter()
.collect::<HashMap<_, _>>();
let workspace_manifest_path = root_path.join("Cargo.toml");
match File::open(&workspace_manifest_path) {
Ok(mut file) => {
let mut toml = String::new();
if file.read_to_string(&mut toml).is_ok() {
drop(file);
if let Ok(mut document) = toml.parse::<DocumentMut>() {
if let Some(workspace) =
document.get_mut("workspace").and_then(|i| i.as_table_mut())
{
if let Some(dependencies) = workspace
.get_mut("dependencies")
.and_then(|i| i.as_table_mut())
{
if version == "latest" {
if local {
let mut engine_table = table();
engine_table["path"] = value("../Fyrox/fyrox");
dependencies["fyrox"] = engine_table;
let mut editor_table = table();
editor_table["path"] = value("../Fyrox/editor");
dependencies["fyroxed_base"] = editor_table;
if dependencies.contains_key("fyrox_scripts") {
let mut scripts_table = table();
scripts_table["path"] = value("../Fyrox/fyrox-scripts");
dependencies["fyrox_scripts"] = scripts_table;
}
if dependencies.contains_key("fyrox-build-tools") {
let mut scripts_table = table();
scripts_table["path"] = value("../Fyrox/fyrox-build-tools");
dependencies["fyrox-build-tools"] = scripts_table;
}
} else {
dependencies["fyrox"] = value(CURRENT_ENGINE_VERSION);
dependencies["fyroxed_base"] = value(CURRENT_EDITOR_VERSION);
if dependencies.contains_key("fyrox_scripts") {
dependencies["fyrox_scripts"] =
value(CURRENT_SCRIPTS_VERSION);
}
if dependencies.contains_key("fyrox-build-tools") {
dependencies["fyrox-build-tools"] =
value(CURRENT_ENGINE_VERSION);
}
}
} else if version == "nightly" {
let mut table = table();
table["git"] = value("https://github.com/FyroxEngine/Fyrox");
dependencies["fyrox"] = table.clone();
dependencies["fyroxed_base"] = table.clone();
dependencies["fyrox-build-tools"] = table.clone();
} else {
dependencies["fyrox"] = value(version);
if let Some((editor_version, scripts_version)) =
editor_versions.get(version)
{
dependencies["fyroxed_base"] = value(editor_version);
if let Some(scripts_version) = scripts_version {
if dependencies.contains_key("fyrox_scripts") {
dependencies["fyrox_scripts"] = value(scripts_version);
}
}
if dependencies.contains_key("fyrox-build-tools") {
dependencies["fyrox-build-tools"] = value(version);
}
} else {
println!("WARNING: matching editor/scripts version not found!");
}
}
}
}
let mut file =
File::create(&workspace_manifest_path).map_err(|e| e.to_string())?;
file.write_all(document.to_string().as_bytes())
.map_err(|e| e.to_string())?;
}
}
}
Err(err) => {
return Err(err.to_string());
}
}
Command::new("cargo")
.arg("update")
.arg("--manifest-path")
.arg(workspace_manifest_path)
.output()
.map_err(|e| e.to_string())?;
Ok(())
}