feat(init): 支持补齐内置模板空目录

新增 init 子命令,根据项目中的 world_*_packs.json 解析实际包目录并创建标准空目录。

改用 .empty-dirs 维护内置模板空目录清单,删除会污染用户项目和网易 Bedrock 加载流程的 .gitkeep 占位文件。
This commit is contained in:
2026-05-14 22:16:18 +08:00
parent 55e92c4b4f
commit de2b804aad
42 changed files with 197 additions and 7 deletions

View File

@@ -1,8 +1,11 @@
use std::{ use std::{
collections::BTreeMap,
env, fs, io, env, fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
const EMPTY_DIRS_FILE: &str = ".empty-dirs";
fn main() { fn main() {
println!("cargo:rerun-if-changed=examples"); println!("cargo:rerun-if-changed=examples");
@@ -12,25 +15,37 @@ fn main() {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
let mut files = Vec::new(); let mut files = Vec::new();
let mut empty_dirs_by_example = BTreeMap::new();
if examples_root.is_dir() { if examples_root.is_dir() {
collect_examples(&examples_root, &examples_root, &mut dirs, &mut files) collect_examples(
&examples_root,
&examples_root,
&mut dirs,
&mut files,
&mut empty_dirs_by_example,
)
.expect("failed to collect example templates"); .expect("failed to collect example templates");
} }
dirs.sort(); dirs.sort();
dirs.dedup();
files.sort_by(|a, b| a.0.cmp(&b.0)); files.sort_by(|a, b| a.0.cmp(&b.0));
for empty_dirs in empty_dirs_by_example.values_mut() {
empty_dirs.sort();
empty_dirs.dedup();
}
let mut generated = String::new(); let mut generated = String::new();
generated.push_str( generated.push_str(
"struct EmbeddedFile {\n path: &'static str,\n contents: &'static [u8],\n}\n\n", "#[allow(dead_code)]\nstruct EmbeddedFile {\n path: &'static str,\n contents: &'static [u8],\n}\n\n",
); );
generated.push_str("static EMBEDDED_EXAMPLE_DIRS: &[&str] = &[\n"); generated.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_DIRS: &[&str] = &[\n");
for dir in &dirs { for dir in &dirs {
generated.push_str(&format!(" {:?},\n", dir)); generated.push_str(&format!(" {:?},\n", dir));
} }
generated.push_str("];\n\n"); generated.push_str("];\n\n");
generated.push_str("static EMBEDDED_EXAMPLE_FILES: &[EmbeddedFile] = &[\n"); generated.push_str("#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_FILES: &[EmbeddedFile] = &[\n");
for (relative_path, absolute_path) in &files { for (relative_path, absolute_path) in &files {
generated.push_str(&format!( generated.push_str(&format!(
" EmbeddedFile {{ path: {:?}, contents: include_bytes!(r#\"{}\"#) }},\n", " EmbeddedFile {{ path: {:?}, contents: include_bytes!(r#\"{}\"#) }},\n",
@@ -38,6 +53,23 @@ fn main() {
absolute_path.display() absolute_path.display()
)); ));
} }
generated.push_str("];\n\n");
generated.push_str(
"#[allow(dead_code)]\nstruct EmbeddedExampleEmptyDirs {\n example: &'static str,\n dirs: &'static [&'static str],\n}\n\n",
);
generated.push_str(
"#[allow(dead_code)]\nstatic EMBEDDED_EXAMPLE_EMPTY_DIRS: &[EmbeddedExampleEmptyDirs] = &[\n",
);
for (example, empty_dirs) in &empty_dirs_by_example {
generated.push_str(&format!(
" EmbeddedExampleEmptyDirs {{ example: {:?}, dirs: &[\n",
example
));
for dir in empty_dirs {
generated.push_str(&format!(" {:?},\n", dir));
}
generated.push_str(" ] },\n");
}
generated.push_str("];\n"); generated.push_str("];\n");
fs::write(out_file, generated).expect("failed to write embedded example list"); fs::write(out_file, generated).expect("failed to write embedded example list");
@@ -48,6 +80,7 @@ fn collect_examples(
dir: &Path, dir: &Path,
dirs: &mut Vec<String>, dirs: &mut Vec<String>,
files: &mut Vec<(String, PathBuf)>, files: &mut Vec<(String, PathBuf)>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> { ) -> io::Result<()> {
for entry in fs::read_dir(dir)? { for entry in fs::read_dir(dir)? {
let entry = entry?; let entry = entry?;
@@ -60,11 +93,50 @@ fn collect_examples(
if path.is_dir() { if path.is_dir() {
dirs.push(relative_path); dirs.push(relative_path);
collect_examples(root, &path, dirs, files)?; collect_examples(root, &path, dirs, files, empty_dirs_by_example)?;
} else if path.is_file() { } else if path.is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some(EMPTY_DIRS_FILE) {
collect_empty_dirs(&relative_path, &path, dirs, empty_dirs_by_example)?;
} else {
files.push((relative_path, path.canonicalize()?)); files.push((relative_path, path.canonicalize()?));
} }
} }
}
Ok(())
}
fn collect_empty_dirs(
relative_path: &str,
path: &Path,
dirs: &mut Vec<String>,
empty_dirs_by_example: &mut BTreeMap<String, Vec<String>>,
) -> io::Result<()> {
let (example, file_name) = relative_path.split_once('/').ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{} must be inside an example directory", EMPTY_DIRS_FILE),
)
})?;
if file_name != EMPTY_DIRS_FILE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unexpected {} path: {}", EMPTY_DIRS_FILE, relative_path),
));
}
let contents = fs::read_to_string(path)?;
let empty_dirs = empty_dirs_by_example
.entry(example.to_string())
.or_insert_with(Vec::new);
for line in contents.lines() {
let dir = line.trim();
if dir.is_empty() || dir.starts_with('#') {
continue;
}
dirs.push(format!("{}/{}", example, dir));
empty_dirs.push(dir.to_string());
}
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,37 @@
behavior_pack/BoxData
behavior_pack/entities
behavior_pack/items
behavior_pack/loot_tables
behavior_pack/netease_feature_rules
behavior_pack/netease_features
behavior_pack/netease_items_beh
behavior_pack/Parts
behavior_pack/Presets
behavior_pack/recipes
behavior_pack/spawn_rules
behavior_pack/structures
behavior_pack/trading
behavior_pack/Galaxy/Macro
behavior_pack/Galaxy/Template
behavior_pack/storyline/level
resource_pack/animation_controllers
resource_pack/animations
resource_pack/attachables
resource_pack/effects
resource_pack/entity
resource_pack/font
resource_pack/materials
resource_pack/models/animation
resource_pack/models/editor_materials
resource_pack/models/effect
resource_pack/models/geometry
resource_pack/models/mesh
resource_pack/models/netease_block
resource_pack/render_controllers
resource_pack/shaders/glsl
resource_pack/sounds
resource_pack/textures/entity
resource_pack/textures/models
resource_pack/textures/particle
resource_pack/textures/ui
resource_pack/ui

67
src/commands/init.rs Normal file
View File

@@ -0,0 +1,67 @@
use std::fs;
use crate::commands::InitArgs;
use crate::entity;
use crate::error::{CliError, Result};
use crate::utils::file;
include!(concat!(env!("OUT_DIR"), "/embedded_examples.rs"));
pub fn execute(args: &InitArgs) {
if let Err(e) = run_init(args) {
eprintln!("❌ 项目初始化失败: {}", e);
return;
}
println!("🍀 项目初始化完成");
}
fn run_init(args: &InitArgs) -> Result<()> {
let project_dir = file::find_project_dir(&args.path)?;
let target = args.target.as_deref().unwrap_or("default");
let empty_dirs = lookup_empty_dirs(target)?;
let release_info = entity::get_current_release_info(&project_dir)?;
let behavior_pack = format!("behavior_pack_{}", release_info.behavior_identifier);
let resource_pack = format!("resource_pack_{}", release_info.resource_identifier);
let mut created = 0usize;
let mut skipped = 0usize;
for rel in empty_dirs {
let rewritten = rewrite_pack_path(rel, &behavior_pack, &resource_pack);
let abs = project_dir.join(&rewritten);
if abs.is_dir() {
skipped += 1;
continue;
}
fs::create_dir_all(&abs).map_err(|e| file::io_error("创建目录", &abs, e))?;
println!("📁 创建目录: {}", rewritten);
created += 1;
}
println!("✅ 新建 {} 个目录, 跳过 {} 个已存在目录", created, skipped);
Ok(())
}
fn lookup_empty_dirs(target: &str) -> Result<&'static [&'static str]> {
EMBEDDED_EXAMPLE_EMPTY_DIRS
.iter()
.find(|e| e.example == target)
.map(|e| e.dirs)
.ok_or_else(|| {
CliError::NotFound(format!(
"example target '{}' was not found in the built-in examples",
target
))
})
}
fn rewrite_pack_path(rel: &str, behavior: &str, resource: &str) -> String {
if let Some(rest) = rel.strip_prefix("behavior_pack/") {
format!("{}/{}", behavior, rest)
} else if let Some(rest) = rel.strip_prefix("resource_pack/") {
format!("{}/{}", resource, rest)
} else {
rel.to_string()
}
}

View File

@@ -2,6 +2,7 @@ use clap::{Args, Parser, Subcommand, arg};
pub mod components; pub mod components;
pub mod create; pub mod create;
pub mod init;
pub mod release; pub mod release;
#[derive(Parser)] #[derive(Parser)]
@@ -24,6 +25,8 @@ pub enum Commands {
Release(ReleaseArgs), Release(ReleaseArgs),
/// Create a new mod project /// Create a new mod project
Create(CreateArgs), Create(CreateArgs),
/// Initialize standard empty directories for an existing project
Init(InitArgs),
/// Create a new component /// Create a new component
Components(ComponentsArgs), Components(ComponentsArgs),
} }
@@ -52,6 +55,16 @@ pub struct CreateArgs {
pub target: Option<String>, pub target: Option<String>,
} }
#[derive(Args)]
pub struct InitArgs {
/// The path of the project (default: current directory)
#[arg(short, long)]
pub path: Option<String>,
/// Example target whose layout to apply
#[arg(short, long)]
pub target: Option<String>,
}
#[derive(Args)] #[derive(Args)]
pub struct ComponentsArgs { pub struct ComponentsArgs {
/// The path of the project /// The path of the project

View File

@@ -12,6 +12,7 @@ fn main() {
match &cli.command { match &cli.command {
Commands::Release(args) => commands::release::execute(args), Commands::Release(args) => commands::release::execute(args),
Commands::Create(args) => commands::create::execute(args), Commands::Create(args) => commands::create::execute(args),
Commands::Init(args) => commands::init::execute(args),
Commands::Components(args) => commands::components::execute(args), Commands::Components(args) => commands::components::execute(args),
} }
} }