feat: Initial commit

This commit is contained in:
2025-11-29 17:31:00 +08:00
commit d647e84db0
82 changed files with 3548 additions and 0 deletions

222
src/commands/components.rs Normal file
View File

@@ -0,0 +1,222 @@
use crate::commands::ComponentsArgs;
use crate::entity;
use crate::utils::file;
use crate::error::Result;
use serde_json::{json, to_string_pretty};
use std::fs;
use std::path::PathBuf;
const COMPONENT_3D_ITEM: &str = "3ditem";
pub fn execute(args: &ComponentsArgs) {
if let Err(e) = run_components(args) {
eprintln!("错误: {}", e);
return;
}
println!("成功: 组件已创建");
}
fn run_components(args: &ComponentsArgs) -> Result<()> {
let project_path = file::find_project_dir(&args.path)?;
validate_input_files(&args.geo, &args.texture)?;
let identifier = args.identifier.as_deref().unwrap_or("unknown");
match args.component.as_str() {
COMPONENT_3D_ITEM => create_3dmodel(
args.geo.as_deref().unwrap_or("./model.geo.json"),
args.texture.as_deref().unwrap_or("./texture.png"),
identifier,
&project_path
),
_ => Err(crate::error::CliError::NotFound(
format!("组件 '{}' 不存在", args.component)
)),
}
}
fn validate_input_files(geo: &Option<String>, texture: &Option<String>) -> Result<()> {
let geo_path = geo.as_deref().unwrap_or("./model.geo.json");
let texture_path = texture.as_deref().unwrap_or("./texture.png");
if !PathBuf::from(geo_path).exists() {
return Err(crate::error::CliError::NotFound(
format!("几何文件 {} 不存在", geo_path)
));
}
if !PathBuf::from(texture_path).exists() {
return Err(crate::error::CliError::NotFound(
format!("材质文件 {} 不存在", texture_path)
));
}
Ok(())
}
fn create_3dmodel(
geo: &str,
texture: &str,
identifier: &str,
project_path: &PathBuf,
) -> Result<()> {
let project_info = entity::get_current_release_info(&project_path)?;
let beh_path = project_path.join(format!(
"behavior_pack_{}",
project_info.behavior_identifier
));
let res_path = project_path.join(format!(
"resource_pack_{}",
project_info.resource_identifier
));
create_item_files(&beh_path, &res_path, identifier)?;
copy_assets(&res_path, geo, texture, identifier)?;
create_attachable_file(&res_path, identifier)?;
Ok(())
}
fn create_item_files(beh_path: &PathBuf, res_path: &PathBuf, identifier: &str) -> Result<()> {
let behavior_item = create_behavior_item_json(identifier);
let resource_item = create_resource_item_json(identifier);
let f_identifier = identifier.replace(":", "_");
let items_beh_dir = beh_path.join("netease_items_beh");
let items_res_dir = res_path.join("netease_items_res");
fs::create_dir_all(&items_beh_dir)?;
fs::create_dir_all(&items_res_dir)?;
let beh_item_path = items_beh_dir.join(format!("{}.json", f_identifier));
let res_item_path = items_res_dir.join(format!("{}.json", f_identifier));
fs::write(&beh_item_path, to_string_pretty(&behavior_item)?)?;
fs::write(&res_item_path, to_string_pretty(&resource_item)?)?;
Ok(())
}
fn create_behavior_item_json(identifier: &str) -> serde_json::Value {
json!({
"format_version": "1.10",
"minecraft:item": {
"components": {
"minecraft:max_damage": 10,
"netease:armor": {
"armor_slot": 3,
"defense": 20,
"enchantment": 10
}
},
"description": {
"category": "Equipment",
"identifier": identifier,
"register_to_create_menu": true
}
}
})
}
fn create_resource_item_json(identifier: &str) -> serde_json::Value {
json!({
"format_version": "1.10",
"minecraft:item": {
"components": {
"minecraft:icon": identifier
},
"description": {
"category": "Equipment",
"identifier": identifier,
"register_to_create_menu": true
}
}
})
}
fn copy_assets(
res_path: &PathBuf,
geo: &str,
texture: &str,
identifier: &str,
) -> Result<()> {
let f_identifier = identifier.replace(":", "_");
copy_texture(res_path, texture, &f_identifier)?;
copy_geometry(res_path, geo, identifier, &f_identifier)?;
Ok(())
}
fn copy_texture(res_path: &PathBuf, texture: &str, f_identifier: &str) -> Result<()> {
let texture_dir = res_path.join("textures/models");
fs::create_dir_all(&texture_dir)?;
let target_texture = texture_dir.join(format!("{}.png", f_identifier));
fs::copy(texture, target_texture)?;
Ok(())
}
fn copy_geometry(
res_path: &PathBuf,
geo: &str,
identifier: &str,
f_identifier: &str,
) -> Result<()> {
let geo_dir = res_path.join("models/entity");
fs::create_dir_all(&geo_dir)?;
let mut geo_value = file::read_file_to_json(&PathBuf::from(geo))?;
let geo_name = format!("geometry.{}", identifier.replace(":", "."));
geo_value["format_version"] = json!("1.12.0");
geo_value["minecraft:geometry"][0]["description"]["identifier"] = json!(geo_name);
let target_geo = geo_dir.join(format!("{}.geo.json", f_identifier));
file::write_json_to_file(&target_geo, &geo_value)?;
Ok(())
}
fn create_attachable_file(res_path: &PathBuf, identifier: &str) -> Result<()> {
let attachable_dir = res_path.join("attachables");
fs::create_dir_all(&attachable_dir)?;
let f_identifier = identifier.replace(":", "_");
let geo_name = identifier.replace(":", ".");
let attachable = json!({
"format_version": "1.10.0",
"minecraft:attachable": {
"description": {
"geometry": {
"default": format!("geometry.{}", &geo_name)
},
"identifier": identifier,
"materials": {
"default": "armor",
"enchanted": "armor_enchanted"
},
"render_controllers": [
"controller.render.armor"
],
"scripts": {
"parent_setup": "variable.chest_layer_visible = 0.0;"
},
"textures": {
"default": format!("textures/models/{}", &f_identifier),
"enchanted": "textures/misc/enchanted_item_glint"
}
}
}
});
let target_file = attachable_dir.join(format!("{}.json", &f_identifier));
fs::write(target_file, to_string_pretty(&attachable)?)?;
Ok(())
}

183
src/commands/create.rs Normal file
View File

@@ -0,0 +1,183 @@
use crate::{
config::Config,
entity::project::ProjectInfo,
utils::{file, http::HttpClient},
};
use std::{fs, path::PathBuf};
use crate::commands::CreateArgs;
use crate::error::Result;
use crate::utils::git;
use uuid::Uuid;
pub fn execute(args: &CreateArgs, temp_dir: &PathBuf) {
if let Err(e) = create_project(&args.name, args.target.as_deref(), temp_dir) {
eprintln!("错误: {}", e);
return;
}
println!("成功: 项目已创建");
}
fn create_project(name: &str, target: Option<&str>, temp_dir: &PathBuf) -> Result<()> {
let target = target.unwrap_or("default");
check_example_exists(target)?;
let local_dir = PathBuf::from(format!("./{}", name));
fs::create_dir(&local_dir)?;
clone_and_copy_template(target, temp_dir, &local_dir)?;
initialize_project(&local_dir, name)?;
Ok(())
}
fn check_example_exists(target: &str) -> Result<()> {
let check_url = format!(
"https://api.github.com/repos/AiYo-Studio/emod-cli/contents/examples/{}",
target
);
let client = if cfg!(debug_assertions) {
HttpClient::new_with_proxy("http://127.0.0.1:1080")?
} else {
HttpClient::new()?
};
let resp = client.get(&check_url)?;
if !resp.status().is_success() {
return Err(crate::error::CliError::NotFound(format!(
"示例模板 '{}' 不存在",
target
)));
}
Ok(())
}
fn clone_and_copy_template(target: &str, temp_dir: &PathBuf, local_dir: &PathBuf) -> Result<()> {
let _ = fs::remove_dir_all(format!("{}/tmp", temp_dir.display()));
let config = Config::load();
let url = &config.repo_url;
git::clone_remote_project(url.to_string(), temp_dir)?;
let target_dir = PathBuf::from(format!("{}/tmp/examples/{}", temp_dir.display(), target));
file::copy_folder(&target_dir, local_dir)?;
Ok(())
}
fn initialize_project(local_dir: &PathBuf, name: &str) -> Result<()> {
let lower_name = format!(
"{}{}",
name.chars().next().unwrap().to_lowercase(),
&name[1..]
);
println!("项目名称: {}", name);
println!("标识名称: {}", lower_name);
let scripts_dir = local_dir.join(format!("behavior_pack/{}Scripts", lower_name));
fs::rename(local_dir.join("behavior_pack/exampleScripts"), &scripts_dir)?;
let project_info = generate_project_info(name, &lower_name);
apply_project_info(local_dir, &scripts_dir, &project_info)?;
rename_pack_folders(local_dir, &project_info)?;
Ok(())
}
fn generate_project_info(name: &str, lower_name: &str) -> ProjectInfo {
ProjectInfo {
name: name.to_string(),
lower_name: lower_name.to_string(),
behavior_pack_uuid: Uuid::new_v4().to_string(),
resource_pack_uuid: Uuid::new_v4().to_string(),
behavior_module_uuid: Uuid::new_v4().to_string(),
resource_module_uuid: Uuid::new_v4().to_string(),
}
}
fn apply_project_info(
local_dir: &PathBuf,
scripts_dir: &PathBuf,
info: &ProjectInfo,
) -> Result<()> {
let manifest_files = vec![
local_dir.join("world_behavior_packs.json"),
local_dir.join("world_resource_packs.json"),
local_dir.join("behavior_pack/pack_manifest.json"),
local_dir.join("resource_pack/pack_manifest.json"),
];
for path in manifest_files {
apply_info_to_json(&path, info)?;
}
process_python_files(scripts_dir, info)?;
Ok(())
}
fn apply_info_to_json(path: &PathBuf, info: &ProjectInfo) -> Result<()> {
println!(" - 修改文件: {}", path.display());
file::update_json_file(path, |json| {
let content = serde_json::to_string(json)?;
let updated = content
.replace("{behavior_pack_uuid}", &info.behavior_pack_uuid)
.replace("{resource_pack_uuid}", &info.resource_pack_uuid)
.replace("{behavior_module_uuid}", &info.behavior_module_uuid)
.replace("{resource_module_uuid}", &info.resource_module_uuid)
.replace("__mod_name__", &info.name)
.replace("__mod_name_lower__", &info.lower_name);
*json = serde_json::from_str(&updated)?;
Ok(())
})
}
fn process_python_files(dir: &PathBuf, info: &ProjectInfo) -> Result<()> {
if !dir.exists() {
return Ok(());
}
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
process_python_files(&entry.path(), info)?;
}
} else if dir.extension().and_then(|s| s.to_str()) == Some("py") {
apply_info_to_python(dir, info)?;
}
Ok(())
}
fn apply_info_to_python(path: &PathBuf, info: &ProjectInfo) -> Result<()> {
let content = fs::read_to_string(path)?;
let updated = content
.replace("__mod_name__", &info.name)
.replace("__mod_name_lower__", &info.lower_name);
fs::write(path, updated)?;
Ok(())
}
fn rename_pack_folders(local_dir: &PathBuf, info: &ProjectInfo) -> Result<()> {
let behavior_suffix: String = info.behavior_pack_uuid.chars().take(8).collect();
let resource_suffix: String = info.resource_pack_uuid.chars().take(8).collect();
fs::rename(
local_dir.join("behavior_pack"),
local_dir.join(format!("behavior_pack_{}", behavior_suffix)),
)?;
fs::rename(
local_dir.join("resource_pack"),
local_dir.join(format!("resource_pack_{}", resource_suffix)),
)?;
Ok(())
}

68
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,68 @@
use clap::{arg, Args, Parser, Subcommand};
pub mod components;
pub mod create;
pub mod release;
#[derive(Parser)]
#[command(
name = "emod-cli",
version = "1.0.0",
about = "Convenient Management of NetEase Minecraft Mod Project",
allow_external_subcommands = true,
long_about = None,
propagate_version = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Release a new version
Release(ReleaseArgs),
/// Create a new mod project
Create(CreateArgs),
/// Create a new component
Components(ComponentsArgs),
}
#[derive(Args)]
pub struct ReleaseArgs {
/// The path of the project
#[arg(short, long)]
pub path: Option<String>,
/// The version of the project
#[arg(short, long)]
pub ver: Option<String>,
}
#[derive(Args)]
pub struct CreateArgs {
/// The name of the mod
#[arg(short, long)]
pub name: String,
/// Example target, default example is 'default'
#[arg(short, long)]
pub target: Option<String>,
}
#[derive(Args)]
pub struct ComponentsArgs {
/// The path of the project
#[arg(short, long)]
pub path: Option<String>,
/// The name of the component
#[arg(short, long)]
pub component: String,
/// Import the path of the geo file.
#[arg(short, long)]
pub geo: Option<String>,
/// Import the path of the texture file.
#[arg(short, long)]
pub texture: Option<String>,
/// The item's identifier
#[arg(short, long)]
pub identifier: Option<String>
}

200
src/commands/release.rs Normal file
View File

@@ -0,0 +1,200 @@
use std::{
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
};
use serde_json::Value;
use walkdir;
use zip::write::SimpleFileOptions;
use crate::commands::ReleaseArgs;
use crate::utils::file;
use crate::{entity, entity::project::ReleaseInfo};
use crate::error::Result;
pub fn execute(args: &ReleaseArgs) {
if let Err(e) = run_release(args) {
eprintln!("❌ 组件打包失败: {}", e);
return;
}
println!("🍀 组件打包完成");
}
fn run_release(args: &ReleaseArgs) -> Result<()> {
let project_dir = file::find_project_dir(&args.path)?;
let release_info = entity::get_current_release_info(&project_dir)?;
println!("🔖 当前行为包版本: {:?}", release_info.behavior_version);
println!("🔖 当前资源包版本: {:?}", release_info.resource_version);
release(&args.ver, &project_dir, &release_info)?;
Ok(())
}
fn release(
version: &Option<String>,
project_dir: &PathBuf,
release_info: &ReleaseInfo,
) -> Result<()> {
let new_version = calculate_version(version, &release_info.behavior_version)?;
let version_value = Value::Array(new_version.iter().map(|v| Value::from(*v)).collect());
println!("📦 开始打包, 版本号: {:?}", &new_version);
update_versions(&project_dir, &release_info, &version_value)?;
let version_str = format!("{}.{}.{}", new_version[0], new_version[1], new_version[2]);
let output_path = package_project(&project_dir, &release_info, &version_str)?;
println!("📦 打包完成: {}", output_path.replace("\\", "/"));
Ok(())
}
fn calculate_version(version: &Option<String>, current: &[u32]) -> Result<Vec<u32>> {
if let Some(ver_str) = version {
ver_str
.split(".")
.map(|s| s.parse::<u32>().map_err(|e| e.into()))
.collect()
} else {
Ok(vec![current[0], current[1], current[2] + 1])
}
}
fn update_versions(
project_dir: &PathBuf,
release_info: &ReleaseInfo,
version: &Value,
) -> Result<()> {
update_pack_json(&project_dir, &version)?;
update_manifest_json(&project_dir, &release_info, &version)?;
Ok(())
}
fn update_pack_json(project_dir: &PathBuf, version: &Value) -> Result<()> {
let paths = vec![
project_dir.join("world_behavior_packs.json"),
project_dir.join("world_resource_packs.json"),
];
for path in paths {
file::update_json_file(&path, |json| {
json[0]["version"] = version.clone();
Ok(())
})?;
}
Ok(())
}
fn update_manifest_json(
project_dir: &PathBuf,
release_info: &ReleaseInfo,
version: &Value,
) -> Result<()> {
let behavior_dir = project_dir.join(format!(
"behavior_pack_{}",
release_info.behavior_identifier
));
let resource_dir = project_dir.join(format!(
"resource_pack_{}",
release_info.resource_identifier
));
for pack_dir in [behavior_dir, resource_dir] {
let manifest_path = pack_dir.join("pack_manifest.json");
file::update_json_file(&manifest_path, |json| {
json["header"]["version"] = version.clone();
json["modules"][0]["version"] = version.clone();
Ok(())
})?;
}
Ok(())
}
fn package_project(
project_dir: &PathBuf,
release_info: &ReleaseInfo,
version: &str,
) -> Result<String> {
let output_path = format!("{}/release_{}.zip", project_dir.display(), version);
let file = fs::File::create(&output_path)?;
let mut zip = zip::ZipWriter::new(file);
let behavior_dir = project_dir.join(format!(
"behavior_pack_{}",
release_info.behavior_identifier
));
let resource_dir = project_dir.join(format!(
"resource_pack_{}",
release_info.resource_identifier
));
add_directory_to_zip(&mut zip, &project_dir, &behavior_dir)?;
add_directory_to_zip(&mut zip, &project_dir, &resource_dir)?;
zip.finish()?;
Ok(output_path)
}
fn add_directory_to_zip(
zip: &mut zip::ZipWriter<File>,
project_dir: &PathBuf,
src_dir: &PathBuf,
) -> Result<()> {
if !src_dir.is_dir() {
return Err(crate::error::CliError::InvalidData(
format!("{} 不是目录", src_dir.display())
));
}
if count_files(src_dir)? == 0 {
return Ok(());
}
let options = SimpleFileOptions::default();
let mut buffer = Vec::new();
for entry in walkdir::WalkDir::new(src_dir) {
let entry = entry?;
let path = entry.path();
let relative_path = path.strip_prefix(project_dir)
.map_err(|e| crate::error::CliError::InvalidData(e.to_string()))?;
let path_str = relative_path
.to_str()
.ok_or_else(|| crate::error::CliError::InvalidData(
format!("{:?} 不是有效的 UTF-8 路径", relative_path)
))?;
if path.is_file() {
if path_str.ends_with(".gitkeep") {
continue;
}
zip.start_file(path_str, options)?;
let mut f = File::open(path)?;
f.read_to_end(&mut buffer)?;
zip.write_all(&buffer)?;
buffer.clear();
} else if !relative_path.as_os_str().is_empty() && count_files(path)? > 0 {
zip.add_directory(path_str, options)?;
}
}
Ok(())
}
fn count_files(dir: &Path) -> Result<usize> {
let mut count = 0;
for entry in walkdir::WalkDir::new(dir) {
let entry = entry?;
if entry.path().is_file()
&& !entry.path().display().to_string().ends_with(".gitkeep") {
count += 1;
}
}
Ok(count)
}