from __future__ import annotations import os import shutil import subprocess import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] TOOLS_DIR = Path(__file__).resolve().parent def run(cmd: list[str], cwd: Path | None = None) -> str: proc = subprocess.run(cmd, cwd=str(cwd or PROJECT_ROOT), capture_output=True, text=True, shell=False) if proc.returncode != 0: raise RuntimeError(f"Command failed: {' '.join(cmd)}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}") return proc.stdout.strip() def get_current_tag() -> str: # 优先使用当前 tag;若没有打 tag,则用短 commit id try: tag = run(["git", "describe", "--tags", "--exact-match"]).strip() if tag: return tag except Exception: pass # 回退到当前 commit 短哈希 try: short = run(["git", "rev-parse", "--short", "HEAD"]).strip() return f"commit-{short}" except Exception: return "untagged" def read_git_ignored_paths() -> set[str]: """ 读取 .gitignore(若存在)并返回需忽略的模式集合(简单前缀/目录名过滤)。 我们只用于资源复制时的粗过滤;最终以 PyInstaller 的 --exclude/--add-data 控制。 """ ignored: set[str] = set() gi = PROJECT_ROOT / ".gitignore" if not gi.exists(): return ignored for line in gi.read_text(encoding="utf-8").splitlines(): s = line.strip() if not s or s.startswith("#"): continue ignored.add(s) return ignored def is_path_ignored(path: Path, ignored_patterns: set[str]) -> bool: # 仅用简单规则过滤常见目录:logs、台本、cache、__pycache__、*.log、TODO、*.md 临时内容等 name = path.name rel = path.relative_to(PROJECT_ROOT).as_posix() if name in {"logs", "台本", "cache", "__pycache__"}: return True if name.lower() in {"todo"}: return True if rel.startswith("tools/package/"): return True if any(seg == "__pycache__" for seg in path.parts): return True if path.suffix.lower() in {".log"}: return True # 粗略匹配 .gitignore 的以目录结尾的规则 for pat in ignored_patterns: if pat.endswith("/") and rel.startswith(pat.rstrip("/")): return True if pat == rel or rel.startswith(pat.rstrip("/")): # 简化:若规则与开头匹配,则跳过 return True return False def build_with_pyinstaller(output_dir: Path) -> None: # 入口脚本 entry = PROJECT_ROOT / "src" / "run" / "run.py" # 资源目录:assets 与 static(排除 static/local_config.yml) add_data_args: list[str] = [] assets_dir = PROJECT_ROOT / "assets" static_dir = PROJECT_ROOT / "static" tmp_dir = output_dir / "_tmp_resources" tmp_dir.mkdir(parents=True, exist_ok=True) if assets_dir.exists(): add_data_args += ["--add-data", f"{assets_dir}{os.pathsep}assets"] if static_dir.exists(): # 构建一个不包含 local_config.yml 的临时 static 目录 tmp_static = tmp_dir / "static" if tmp_static.exists(): shutil.rmtree(tmp_static) shutil.copytree(static_dir, tmp_static) lc = tmp_static / "local_config.yml" if lc.exists(): lc.unlink() add_data_args += ["--add-data", f"{tmp_static}{os.pathsep}static"] # 额外的排除(减少包体) exclude_modules = [ "tests", "unittest", "tkinter", "pytest", "matplotlib", ] exclude_args: list[str] = [] for m in exclude_modules: exclude_args += ["--exclude-module", m] # 运行 PyInstaller(优先单目录,兼容资源文件;不开启 --onefile 以避免 pygame 资源路径问题) dist_dir = output_dir build_dir = output_dir / "build" spec_path = output_dir build_dir.mkdir(parents=True, exist_ok=True) dist_dir.mkdir(parents=True, exist_ok=True) cmd = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--name", "cultivation-world-simulator", "--distpath", str(dist_dir), "--workpath", str(build_dir), "--specpath", str(spec_path), "--console", # 去掉调试与符号,减小体积 "--optimize", "2", # 隐式集合数据(若依赖包有数据) "--collect-all", "omegaconf", "--collect-all", "json5", "--collect-submodules", "pygame", ] + exclude_args + add_data_args + [str(entry)] print("[1/3] 调用 PyInstaller...") print("命令:", " ".join(cmd)) run(cmd) print("PyInstaller 完成。") def copy_project_side_files(output_dir: Path, tag_name: str) -> None: print("[2/3] 复制说明与许可证...") app_dir = output_dir / "cultivation-world-simulator" app_dir.mkdir(parents=True, exist_ok=True) # 将 README、LICENSE、requirements.txt 复制到应用目录,便于分发 for fname in ["README.md", "EN_README.md", "LICENSE", "requirements.txt"]: src = PROJECT_ROOT / fname if src.exists(): dst = app_dir / fname shutil.copy2(src, dst) # 生成一个运行说明 (app_dir / "HOW_TO_RUN.txt").write_text( ( "运行说明:\n" "1) 双击 cultivation-world-simulator/cultivation-world-simulator.exe 启动\n" "2) 如需配置 LLM,请编辑 static/config.yml 或在外部同目录提供 static/local_config.yml 覆盖\n" f"版本: {tag_name}\n" ), encoding="utf-8", ) def _copy_env_installer(app_dir: Path) -> None: print("[3/3] 放置用户安装脚本...") src_cmd = TOOLS_DIR / "set_env.cmd" if src_cmd.exists(): # 复制到 exe 同目录,并重命名 dst_cmd = app_dir / "启动前点击安装.exe" try: shutil.copy2(src_cmd, dst_cmd) except Exception as e: print(f"警告:复制 set_env.cmd 失败:{e}") else: print("提示:未找到 tools/package/set_env.cmd,跳过复制。") def main() -> None: tag = get_current_tag() # 输出改为项目根 tmp/{tag} release_dir = PROJECT_ROOT / "tmp" / tag # 清理旧目录 if release_dir.exists(): print(f"清理旧目录: {release_dir}") shutil.rmtree(release_dir) release_dir.mkdir(parents=True, exist_ok=True) build_with_pyinstaller(release_dir) copy_project_side_files(release_dir, tag) _copy_env_installer(release_dir / "cultivation-world-simulator") # 删除多余:构建中间产物 build_dir = release_dir / "build" spec_file = release_dir / "cultivation-world-simulator.spec" if build_dir.exists(): shutil.rmtree(build_dir) if spec_file.exists(): spec_file.unlink() # 删除临时资源 tmp_dir = release_dir / "_tmp_resources" if tmp_dir.exists(): shutil.rmtree(tmp_dir) # 确保没有把 gitignore 指定的目录带入(单目录输出仅包含 PyInstaller 产物与我们复制的文件) ignored = read_git_ignored_paths() for path in release_dir.rglob("*"): if is_path_ignored(path, ignored): if path.is_file(): try: path.unlink() except Exception: pass else: try: shutil.rmtree(path) except Exception: pass print(f"打包完成: {release_dir}") if __name__ == "__main__": main()