diff --git a/Obsidian/00-导航/00-Shigure-知识库首页.md b/Obsidian/00-导航/00-Shigure-知识库首页.md index 91b64e90..5f294a7b 100644 --- a/Obsidian/00-导航/00-Shigure-知识库首页.md +++ b/Obsidian/00-导航/00-Shigure-知识库首页.md @@ -110,7 +110,7 @@ verified_at: "2026-08-10" | [[50-参考资料/OPTIMIZATION_zh-CN|Fuyutsui 优化建议]] | 2026-07 静态审计快照 | 旧 `main.lua` 行号已失效,不是当前事实源 | | `CLAUDE.md`(当前开发约定) | 根级架构与开发规则 | 同时说明 Shigure、内置 Fuyutsui 与部署链路 | -项目自身说明位于 Senkoh 库外、同一仓库根目录,以普通路径引用而不创建未解析的 Obsidian 节点:`README.md`、`CLAUDE.md`、`打包说明.md`。 +项目自身说明位于 Senkoh 库外、同一仓库根目录,以普通路径引用而不创建未解析的 Obsidian 节点:`README.md`、`CLAUDE.md`。 ## 关键不变量与失败模式 diff --git a/Obsidian/30-Shigure/00-Shigure-MOC.md b/Obsidian/30-Shigure/00-Shigure-MOC.md index 93dd306c..be014676 100644 --- a/Obsidian/30-Shigure/00-Shigure-MOC.md +++ b/Obsidian/30-Shigure/00-Shigure-MOC.md @@ -43,7 +43,7 @@ verified_at: 2026-08-10 - 上级:[[00-导航/00-Shigure-知识库首页]]、[[10-系统/00-Shigure-双项目系统全景]] - 跨项目入口:[[40-跨项目/00-Shigure-跨项目契约-MOC]] - 插件入口:[[20-Fuyutsui/00-Fuyutsui-MOC]] -- 项目原始说明:`README.md`、`CLAUDE.md`、`打包说明.md` +- 项目原始说明:`README.md`、`CLAUDE.md` ## 一条完整执行链 diff --git a/Obsidian/30-Shigure/11-Shigure-本地数据路径构建与验证.md b/Obsidian/30-Shigure/11-Shigure-本地数据路径构建与验证.md index f7cf4680..770bbdb5 100644 --- a/Obsidian/30-Shigure/11-Shigure-本地数据路径构建与验证.md +++ b/Obsidian/30-Shigure/11-Shigure-本地数据路径构建与验证.md @@ -47,7 +47,6 @@ verified_at: 2026-08-10 - 上级:[[30-Shigure/00-Shigure-MOC]] - 启动/随机副本:[[30-Shigure/01-Shigure-启动随机副本与会话协调]] -- 打包资料:`打包说明.md` - 系统边界:[[10-系统/00-Shigure-双项目系统全景]] ## 范围与非范围 @@ -100,7 +99,7 @@ verified_at: 2026-08-10 - 应用版本在项目文件中为 1.2.1,并被模块编辑器保存到模块元数据。 - 项目没有 `PackageReference`;核心只依赖 .NET/WinForms/System.Drawing 和 Win32 P/Invoke。 - 项目文件声明嵌入 UI assets,并把 `Fuyutsui/**`、配置、Keymap 和 `wow_process.txt` 按规则复制到输出;Fuyutsui 同时明确复制到 publish。模块不随构建/发布复制,由我的文档目录 `{MyDocuments}/Shigure/module` 提供。 -- 实际打包策略还应对照 `打包说明.md`;随机启动器只复制正式输出目录**顶层**运行依赖到临时目录。 +- 实际发布内容以 `Shigure.csproj` 中的规则为准;随机启动器只复制正式输出目录**顶层**运行依赖到临时目录。 ## 当前验证能力 @@ -143,7 +142,7 @@ verified_at: 2026-08-10 - 改 `AppPaths` 或随机环境变量会影响所有本地数据;先做迁移/兼容,再改启动器。 - 新增数据目录必须决定是否随构建复制、是否属于用户可写数据、是否由随机副本读取原目录。 -- 修改内置插件布局或部署服务时,要同步 csproj、README/打包说明、编辑器路径回调和游戏部署验证。 +- 修改内置插件布局或部署服务时,要同步 csproj、README、编辑器路径回调和游戏部署验证。 - 引入 NuGet 或额外 native 文件要同步 csproj、发布说明和随机复制清单。 - 增加测试时优先覆盖纯逻辑组件:条件/公式、迁移、Keymap 转换、Lua round-trip;屏幕和 Win32 层可用端口接口隔离。 @@ -164,4 +163,4 @@ verified_at: 2026-08-10 - 启动路径:[[30-Shigure/01-Shigure-启动随机副本与会话协调]] - 数据同步:[[30-Shigure/09-Shigure-Fuyutsui配置宏编辑与同步]] - 跨项目契约:[[40-跨项目/00-Shigure-跨项目契约-MOC]] -- 原始项目资料:`README.md`、`CLAUDE.md`、`打包说明.md` +- 原始项目资料:`README.md`、`CLAUDE.md` diff --git a/UI/ConditionEditorForm.cs b/UI/ConditionEditorForm.cs index 8698cc9b..f6631e35 100644 --- a/UI/ConditionEditorForm.cs +++ b/UI/ConditionEditorForm.cs @@ -619,7 +619,7 @@ public sealed class ConditionEditorForm : Form _conditionsGrid.CellValueChanged += OnGridCellValueChanged; _conditionsGrid.CellEndEdit += (_, _) => UpdatePreview(); _conditionsGrid.CellFormatting += OnConditionsGridCellFormatting; - _conditionsGrid.CellClick += OnConditionsGridCellClick; + _conditionsGrid.CellMouseClick += OnConditionsGridCellMouseClick; _conditionsGrid.CellPainting += OnConditionsGridCellPainting; _conditionsGrid.KeyDown += OnConditionsGridKeyDown; _conditionsGrid.CellContentClick += (_, e) => @@ -705,7 +705,7 @@ public sealed class ConditionEditorForm : Form SortMode = DataGridViewColumnSortMode.NotSortable }; - private void OnConditionsGridCellClick(object? sender, DataGridViewCellEventArgs e) + private void OnConditionsGridCellMouseClick(object? sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) { @@ -713,6 +713,24 @@ public sealed class ConditionEditorForm : Form } var cell = _conditionsGrid.Rows[e.RowIndex].Cells[e.ColumnIndex]; + if (cell is BossValueCell) + { + var buttonBounds = UiTheme.GetDropDownButtonBounds( + _conditionsGrid, + new Rectangle(0, 0, cell.Size.Width, cell.Size.Height)); + if (buttonBounds.Contains(e.X, e.Y)) + { + ShowBossNumberDropDown(e.RowIndex, e.ColumnIndex); + } + else if (!cell.ReadOnly) + { + CloseConditionComboDropDown(); + _conditionsGrid.CurrentCell = cell; + _conditionsGrid.BeginEdit(selectAll: true); + } + return; + } + if (cell is DataGridViewComboBoxCell combo && !combo.ReadOnly && combo.DisplayStyle != DataGridViewComboBoxDisplayStyle.Nothing) @@ -731,6 +749,15 @@ public sealed class ConditionEditorForm : Form private void OnConditionsGridKeyDown(object? sender, KeyEventArgs e) { + if (_conditionsGrid.CurrentCell is BossValueCell bossCell + && (e.KeyCode == Keys.F4 || e.KeyCode == Keys.Down && e.Alt)) + { + e.Handled = true; + e.SuppressKeyPress = true; + ShowBossNumberDropDown(bossCell.RowIndex, bossCell.ColumnIndex); + return; + } + if (_conditionsGrid.CurrentCell is not DataGridViewComboBoxCell cell || cell.ReadOnly || cell.DisplayStyle == DataGridViewComboBoxDisplayStyle.Nothing @@ -823,6 +850,51 @@ public sealed class ConditionEditorForm : Form _conditionComboDropDown = dropDown; } + private void ShowBossNumberDropDown(int rowIndex, int columnIndex) + { + CloseConditionComboDropDown(); + _conditionsGrid.EndEdit(); + if (rowIndex < 0 || rowIndex >= _conditionsGrid.Rows.Count + || _conditionsGrid.Rows[rowIndex].Cells[columnIndex] is not BossValueCell cell) + { + return; + } + + _conditionsGrid.CurrentCell = cell; + var options = new List + { + new("0", "非首领战 / -", LeadingText: "0") + }; + options.AddRange(StatusForm.GetBossNumberOptions().Select(boss => new UiDropDownOption( + boss.Number.ToString(CultureInfo.InvariantCulture), + $"{boss.Dungeon} / {boss.Name}", + LeadingText: boss.Number.ToString(CultureInfo.InvariantCulture)))); + + var currentValue = cell.Value?.ToString()?.Trim() ?? string.Empty; + var cellBounds = _conditionsGrid.GetCellDisplayRectangle(columnIndex, rowIndex, cutOverflow: true); + ToolStripDropDown? dropDown = null; + dropDown = UiDropDownPopup.Show( + _conditionsGrid, + cellBounds, + options, + currentValue, + selected => + { + cell.Value = selected.Value?.ToString() ?? string.Empty; + _conditionsGrid.InvalidateCell(cell); + UpdatePreview(); + }, + preferredWidth: 390, + closed: () => + { + if (ReferenceEquals(_conditionComboDropDown, dropDown)) + { + _conditionComboDropDown = null; + } + }); + _conditionComboDropDown = dropDown; + } + private static Image? ResolveFieldIcon(FieldItem field) { if (field.IsCustom @@ -849,14 +921,25 @@ public sealed class ConditionEditorForm : Form private void OnConditionsGridCellPainting(object? sender, DataGridViewCellPaintingEventArgs e) { - if (e.RowIndex < 0 - || e.ColumnIndex < 0 - || _conditionsGrid.Rows[e.RowIndex].Cells[e.ColumnIndex] is not DataGridViewComboBoxCell cell) + if (e.RowIndex < 0 || e.ColumnIndex < 0) { return; } - PaintConditionComboBoxCell(e, cell); + + var cell = _conditionsGrid.Rows[e.RowIndex].Cells[e.ColumnIndex]; + if (cell is BossValueCell) + { + UiTheme.PaintDataGridViewComboBoxCell(_conditionsGrid, e, showButton: true); + return; + } + + if (cell is not DataGridViewComboBoxCell combo) + { + return; + } + + PaintConditionComboBoxCell(e, combo); } private void PaintConditionComboBoxCell( @@ -1317,6 +1400,17 @@ public sealed class ConditionEditorForm : Form return; } + if (string.Equals(field?.Name, "首领战", StringComparison.Ordinal)) + { + var bossValue = rawValue?.Trim() ?? string.Empty; + if (!preserveRaw && bossValue.Length == 0) + { + bossValue = "0"; + } + row.Cells[ValueColumn] = new BossValueCell { Value = bossValue.Length == 0 ? "0" : bossValue }; + return; + } + if (field is { IsCustom: false, Type: ConditionFieldType.Bool }) { var combo = new DataGridViewComboBoxCell @@ -1692,4 +1786,6 @@ public sealed class ConditionEditorForm : Form { public override string ToString() => Display; } + + private sealed class BossValueCell : DataGridViewTextBoxCell; } diff --git a/UI/StatusForm.cs b/UI/StatusForm.cs index 3928e099..b274458f 100644 --- a/UI/StatusForm.cs +++ b/UI/StatusForm.cs @@ -35,6 +35,8 @@ internal enum SettingsNavIcon About } +internal sealed record BossNumberOption(int Number, string Dungeon, string Name); + public sealed class StatusForm : Form { private const string AboutWatermarkResourcePath = "Assets.arasaka-icon-transparent.png"; @@ -1597,6 +1599,17 @@ public sealed class StatusForm : Form ReplaceItems(_spellList, items); } + internal static IReadOnlyList GetBossNumberOptions() + => BossNumberGroups + .SelectMany(group => group.Dungeons) + .SelectMany(dungeon => dungeon.Bosses.Select(boss => new BossNumberOption( + boss.Number, + dungeon.Name, + boss.Name))) + .DistinctBy(option => option.Number) + .OrderBy(option => option.Number) + .ToArray(); + private static string DisplaySpellStateKey(string root, string key) { long spellId; diff --git a/一键打包.py b/一键打包.py deleted file mode 100644 index 4be49e80..00000000 --- a/一键打包.py +++ /dev/null @@ -1,558 +0,0 @@ -from __future__ import annotations - -import os -import re -import shutil -import subprocess -import sys -import tempfile -import urllib.request -from pathlib import Path -from xml.sax.saxutils import escape as escape_xml - - -OLD_NAME = "Shigure" -ADDON_OLD_NAME = "Fuyutsui" -OLD_NAMES = (OLD_NAME, ADDON_OLD_NAME) -PROTECTED_TEXTS = ( - "https://www.shigure.club", - "https://api.github.com/repos/waynebian01/Shigure/releases/latest", - "访问 Shigure 官网,浏览并获取可用模块", -) - -SKIP_DIRS = { - ".git", - ".vs", - ".vscode", - ".agents", - ".claude", - "__pycache__", - "Obsidian", - "artifacts", - "bin", - "cache", - "obj", - "outputs", - "SpellIconPackage", -} - -TEXT_EXTENSIONS = { - ".bat", - ".cmd", - ".config", - ".cs", - ".csproj", - ".editorconfig", - ".gitignore", - ".json", - ".lua", - ".md", - ".props", - ".ps1", - ".py", - ".resx", - ".manifest", - ".sln", - ".slnx", - ".targets", - ".toc", - ".txt", - ".xaml", - ".xml", - ".yaml", - ".yml", -} - -NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") -NAME_REPLACEMENT_PATTERN = re.compile( - "|".join(re.escape(name) for name in OLD_NAMES), - re.IGNORECASE, -) -SLASH_COMMAND_PATTERN = re.compile(r"/fu\b", re.IGNORECASE) -TARGET_FRAMEWORK_PATTERN = re.compile( - r"net(?P\d+)\.(?P\d+)(?:-[^<]+)?", - re.IGNORECASE, -) -DOTNET_INSTALL_SCRIPT_URL = "https://dot.net/v1/dotnet-install.ps1" -def configure_console() -> None: - for stream in (sys.stdout, sys.stderr): - if hasattr(stream, "reconfigure"): - stream.reconfigure(encoding="utf-8", errors="replace") - - -def get_app_paths() -> tuple[Path, Path]: - if getattr(sys, "frozen", False): - exe_path = Path(sys.executable).resolve() - return exe_path.parent, exe_path - - script_path = Path(__file__).resolve() - return script_path.parent, script_path - - -def ask_new_name() -> str: - while True: - new_name = input("请输入新的项目名称(英文开头,只能包含英文字母/数字/下划线): ").strip() - if NAME_PATTERN.fullmatch(new_name): - return new_name - print("名称格式不正确:必须以英文字母开头,只能包含英文字母、数字、下划线。") - - -def ask_company_name() -> str: - while True: - company_name = input("请输入公司名称(将自动添加 Corporation 后缀): ").strip() - if company_name and "\n" not in company_name and "\r" not in company_name: - return f"{company_name} Corporation" - print("公司名称不能为空,且不能包含换行。") - - -def update_company_name(project_path: Path, company_name: str) -> None: - result = read_text(project_path) - if result is None: - raise RuntimeError(f"无法读取项目文件: {project_path}") - - project_text, encoding = result - updated_text, replacements = re.subn( - r"().*?()", - lambda match: f"{match.group(1)}{escape_xml(company_name)}{match.group(2)}", - project_text, - count=1, - flags=re.IGNORECASE | re.DOTALL, - ) - if replacements == 0: - raise RuntimeError(f"项目文件中找不到 配置: {project_path}") - - project_path.write_text(updated_text, encoding=encoding, newline="") - - -def is_in_skipped_dir(path: Path, root: Path) -> bool: - rel_parts = path.relative_to(root).parts - return any(part in SKIP_DIRS for part in rel_parts) - - -def iter_text_files(root: Path, script_path: Path): - for dirpath, dirnames, filenames in os.walk(root): - current_dir = Path(dirpath) - dirnames[:] = [name for name in dirnames if name not in SKIP_DIRS] - - for filename in filenames: - path = current_dir / filename - if path == script_path: - continue - if is_in_skipped_dir(path, root): - continue - if path.suffix.lower() not in TEXT_EXTENSIONS and filename.lower() not in TEXT_EXTENSIONS: - continue - yield path - - -def read_text(path: Path) -> tuple[str, str] | None: - for encoding in ("utf-8", "gbk"): - try: - return path.read_text(encoding=encoding), encoding - except UnicodeDecodeError: - continue - print(f"跳过无法识别编码的文件: {path}") - return None - - -def match_name_case(old_name: str, new_name: str) -> str: - if old_name.isupper(): - return new_name.upper() - if old_name.islower(): - return new_name.lower() - if old_name[:1].isupper() and old_name[1:].islower(): - return new_name[:1].upper() + new_name[1:] - return new_name - - -def replace_names(value: str, new_name: str) -> str: - protected_ranges = [ - match.span() - for protected_text in PROTECTED_TEXTS - for match in re.finditer(re.escape(protected_text), value) - ] - - def replace_match(match: re.Match[str]) -> str: - if any(start <= match.start() < end for start, end in protected_ranges): - return match.group(0) - return match_name_case(match.group(0), new_name) - - return NAME_REPLACEMENT_PATTERN.sub( - replace_match, - value, - ) - - -def replace_text(value: str, new_name: str) -> str: - value = replace_names(value, new_name) - slash_command = f"/{new_name[:2].lower()}" - return SLASH_COMMAND_PATTERN.sub(slash_command, value) - - -def contains_text_replacement(value: str) -> bool: - return bool( - NAME_REPLACEMENT_PATTERN.search(value) - or SLASH_COMMAND_PATTERN.search(value) - ) - - -def collect_replacements(root: Path, script_path: Path) -> dict[Path, tuple[str, str]]: - backups: dict[Path, tuple[str, str]] = {} - - for path in iter_text_files(root, script_path): - result = read_text(path) - if result is None: - continue - - text, encoding = result - if contains_text_replacement(text): - backups[path] = (text, encoding) - - return backups - - -def apply_replacements(backups: dict[Path, tuple[str, str]], new_name: str) -> None: - for path, (text, encoding) in backups.items(): - path.write_text(replace_text(text, new_name), encoding=encoding, newline="") - - -def collect_path_renames( - root: Path, - script_path: Path, - new_name: str, -) -> list[tuple[Path, Path]]: - paths: list[Path] = [] - - for dirpath, dirnames, filenames in os.walk(root): - current_dir = Path(dirpath) - dirnames[:] = [name for name in dirnames if name not in SKIP_DIRS] - - for dirname in dirnames: - path = current_dir / dirname - if NAME_REPLACEMENT_PATTERN.search(dirname): - paths.append(path) - - for filename in filenames: - path = current_dir / filename - if path == script_path: - continue - if NAME_REPLACEMENT_PATTERN.search(filename): - paths.append(path) - - paths.sort(key=lambda path: len(path.relative_to(root).parts), reverse=True) - return [ - (path, path.with_name(replace_names(path.name, new_name))) - for path in paths - if replace_names(path.name, new_name) != path.name - ] - - -def validate_path_renames(root: Path, rename_plan: list[tuple[Path, Path]]) -> None: - target_paths: dict[Path, Path] = {} - - for old_path, new_path in rename_plan: - previous_source = target_paths.get(new_path) - if previous_source is not None: - raise FileExistsError( - f"多个路径将被重命名为同一目标,已停止避免覆盖: " - f"{previous_source.relative_to(root)}、{old_path.relative_to(root)} -> " - f"{new_path.relative_to(root)}" - ) - target_paths[new_path] = old_path - - if new_path.exists(): - raise FileExistsError(f"目标路径已存在,已停止避免覆盖: {new_path}") - - -def apply_path_renames( - rename_plan: list[tuple[Path, Path]], - completed_renames: list[tuple[Path, Path]], -) -> None: - for old_path, new_path in rename_plan: - old_path.rename(new_path) - completed_renames.append((old_path, new_path)) - - -def copy_to_build_environment(root: Path, script_path: Path, build_root: Path) -> None: - resolved_root = root.resolve() - - def ignore_files(directory: str, names: list[str]) -> set[str]: - ignored = {name for name in names if name in SKIP_DIRS} - resolved_directory = Path(directory).resolve() - if resolved_directory == resolved_root: - ignored.add(script_path.name) - if resolved_directory == resolved_root / "Assets" / "Spell": - ignored.update(name for name in names if name.startswith("icon-") and name.endswith(".jpg")) - return ignored - - shutil.copytree(root, build_root, ignore=ignore_files, dirs_exist_ok=True) - - -def get_required_dotnet_sdk(project_path: Path) -> tuple[str, int]: - result = read_text(project_path) - if result is None: - raise RuntimeError(f"无法读取项目文件: {project_path}") - - project_text, _ = result - match = TARGET_FRAMEWORK_PATTERN.search(project_text) - if match is None: - raise RuntimeError("无法从项目文件的 TargetFramework 判断所需 .NET SDK 版本。") - - major = int(match.group("major")) - minor = int(match.group("minor")) - return f"{major}.{minor}", major - - -def get_user_dotnet_paths() -> tuple[Path, Path]: - local_app_data = os.environ.get("LOCALAPPDATA") - install_dir = ( - Path(local_app_data) / "Microsoft" / "dotnet" - if local_app_data - else Path.home() / ".dotnet" - ) - return install_dir, install_dir / "dotnet.exe" - - -def get_installed_sdk_versions(dotnet_command: str | Path) -> list[str]: - try: - result = subprocess.run( - [str(dotnet_command), "--list-sdks"], - check=True, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - except (OSError, subprocess.CalledProcessError): - return [] - - versions: list[str] = [] - for line in result.stdout.splitlines(): - version = line.partition(" ")[0].strip() - if version: - versions.append(version) - return versions - - -def find_compatible_dotnet(required_major: int) -> tuple[str | None, list[str]]: - _, user_dotnet = get_user_dotnet_paths() - candidates = [shutil.which("dotnet"), str(user_dotnet)] - checked: set[str] = set() - - for candidate in candidates: - if not candidate: - continue - - normalized = os.path.normcase(os.path.abspath(candidate)) - if normalized in checked: - continue - checked.add(normalized) - - versions = get_installed_sdk_versions(candidate) - if any(version.split(".", 1)[0] == str(required_major) for version in versions): - return candidate, versions - - return None, [] - - -def install_dotnet_sdk(channel: str) -> str: - install_dir, dotnet_path = get_user_dotnet_paths() - powershell = shutil.which("powershell") or shutil.which("pwsh") - if powershell is None: - raise RuntimeError("找不到 PowerShell,无法自动安装 .NET SDK。") - - install_dir.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="shigure-dotnet-") as temp_dir: - installer_path = Path(temp_dir) / "dotnet-install.ps1" - print(f"正在从微软官网下载 .NET {channel} SDK 安装脚本……") - urllib.request.urlretrieve(DOTNET_INSTALL_SCRIPT_URL, installer_path) - - command = [ - powershell, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(installer_path), - "-Channel", - channel, - "-Quality", - "GA", - "-Architecture", - "x64", - "-InstallDir", - str(install_dir), - "-NoPath", - ] - print(f"正在安装到当前用户目录: {install_dir}") - subprocess.run(command, check=True) - - if not dotnet_path.exists(): - raise RuntimeError(f"安装完成后仍找不到 dotnet: {dotnet_path}") - return str(dotnet_path) - - -def ensure_dotnet_sdk(project_path: Path) -> str: - channel, required_major = get_required_dotnet_sdk(project_path) - print() - print(f"正在检测 .NET {channel} SDK 依赖……") - - dotnet_command, versions = find_compatible_dotnet(required_major) - if dotnet_command is not None: - matching_versions = [ - version for version in versions - if version.split(".", 1)[0] == str(required_major) - ] - print(f"已安装兼容的 SDK: {', '.join(matching_versions)}") - return dotnet_command - - print(f"未检测到 .NET {channel} SDK,开始自动下载和安装。") - dotnet_command = install_dotnet_sdk(channel) - versions = get_installed_sdk_versions(dotnet_command) - if not any(version.split(".", 1)[0] == str(required_major) for version in versions): - raise RuntimeError(f".NET {channel} SDK 安装后验证失败。") - - print(f".NET {channel} SDK 安装并验证成功。") - return dotnet_command - - -def publish( - build_root: Path, - new_name: str, - dotnet_command: str, - output_dir: Path, -) -> None: - if output_dir.exists(): - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - command = [ - dotnet_command, - "publish", - f".\\{new_name}.csproj", - "-c", - "Release", - "-r", - "win-x64", - "--self-contained", - "true", - "-p:PublishSingleFile=true", - "-p:EnableCompressionInSingleFile=true", - "-o", - str(output_dir), - ] - - print() - print("开始执行发布命令:") - print(subprocess.list2cmdline(command)) - subprocess.run(command, cwd=build_root, check=True) - - -def open_publish_folder(root: Path) -> None: - publish_dir = root / "artifacts" / "publish" / "win-x64" - if not publish_dir.exists(): - print(f"打包目录不存在,无法打开: {publish_dir}") - return - - os.startfile(publish_dir) - - -def main() -> int: - configure_console() - - root, script_path = get_app_paths() - - new_name = ask_new_name() - company_name = ask_company_name() - should_rename = new_name != OLD_NAME - source_csproj = root / f"{OLD_NAME}.csproj" - if not source_csproj.exists(): - print(f"找不到需要打包的项目文件: {source_csproj}") - return 1 - - if should_rename: - preview_files = list(collect_replacements(root, script_path)) - preview_rename_plan = collect_path_renames(root, script_path, new_name) - try: - validate_path_renames(root, preview_rename_plan) - except (FileExistsError, ValueError) as exc: - print(exc) - return 1 - - print() - print( - f"将把文本和路径中的 {OLD_NAME}、{ADDON_OLD_NAME} 按原文大小写形式 " - f"替换为 {new_name},并把 /fu 替换为 /{new_name[:2].lower()}。" - ) - print(f"将在隔离副本中修改 {len(preview_files)} 个文本文件。") - for path in preview_files: - print(f"- {path.relative_to(root)}") - print(f"将在隔离副本中重命名 {len(preview_rename_plan)} 个文件或目录。") - for old_path, new_path in preview_rename_plan: - print(f"- {old_path.relative_to(root)} -> {new_path.relative_to(root)}") - else: - print() - print(f"新名称和原名称相同,将在隔离副本中使用 {OLD_NAME}.csproj 打包。") - - print(f"公司名称将设置为: {company_name}") - print("所有名称修改仅发生在临时构建环境,不会修改当前项目源码。") - - confirm = input("确认继续?输入 Y/y 继续,其它任意内容取消: ").strip() - if confirm.casefold() != "y": - print("已取消。") - return 0 - - try: - dotnet_command = ensure_dotnet_sdk(source_csproj) - except BaseException as exc: - if isinstance(exc, KeyboardInterrupt): - print("依赖安装已中断,当前项目未被修改。") - else: - print(f"依赖检测或安装失败,当前项目未被修改: {exc}") - return 1 - - try: - with tempfile.TemporaryDirectory(prefix="shigure-build-") as temp_dir: - build_root = Path(temp_dir) / "project" - print() - print(f"正在创建临时构建环境: {build_root}") - copy_to_build_environment(root, script_path, build_root) - - build_script_path = build_root / script_path.name - if should_rename: - build_backups = collect_replacements(build_root, build_script_path) - build_rename_plan = collect_path_renames( - build_root, - build_script_path, - new_name, - ) - validate_path_renames(build_root, build_rename_plan) - - completed_renames: list[tuple[Path, Path]] = [] - apply_replacements(build_backups, new_name) - apply_path_renames(build_rename_plan, completed_renames) - - print(f"隔离副本中已修改 {len(build_backups)} 个文本文件。") - print(f"隔离副本中已重命名 {len(completed_renames)} 个文件或目录。") - - project_path = build_root / f"{new_name}.csproj" - update_company_name(project_path, company_name) - print(f"隔离副本中的公司名称已设置为: {company_name}") - - output_dir = root / "artifacts" / "publish" / "win-x64" - publish(build_root, new_name, dotnet_command, output_dir) - except BaseException as exc: - if isinstance(exc, KeyboardInterrupt): - print("执行已中断。") - else: - print(f"执行失败: {exc}") - print("临时构建环境已清理,当前项目源码未被修改。") - return 1 - - print() - print("打包完成,临时构建环境已自动清理,当前项目源码未被修改。") - open_publish_folder(root) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/打包说明.md b/打包说明.md deleted file mode 100644 index 8b3f2ddb..00000000 --- a/打包说明.md +++ /dev/null @@ -1,185 +0,0 @@ -# 打包说明 - -这份说明面向第一次接触 .NET / VS Code 的用户。推荐优先级是: - -1. 优先使用 `一键打包.py` -2. 没有 Python 环境时,使用 `一键打包.exe` -3. 最后才手动使用 `dotnet publish` - -## 1. 必要软件 - -### 1.1 打包程序必须安装 .NET 10 SDK - -无论使用 `一键打包.py`、`一键打包.exe`,还是手动命令,打包电脑都必须安装 **.NET 10 SDK**。 - -下载地址: - -```text -https://dotnet.microsoft.com/download -``` - -安装完成后,重新打开 PowerShell,输入: - -```powershell -dotnet --version -``` - -如果能看到版本号,说明安装成功。 - -注意:运行最终发布出来的软件,一般不需要安装 .NET SDK;只有“打包的人”需要。 - -### 1.2 使用一键打包.py 需要 Python - -推荐使用 Python 3.10 或更新版本。安装后在 PowerShell 输入: - -```powershell -python --version -``` - -能看到版本号即可。 - -## 2. 推荐方式:使用一键打包.py - -在项目根目录,也就是包含 `一键打包.py` 和 `Shigure.csproj` 的目录,运行: - -```powershell -python .\一键打包.py -``` - -按提示输入新的项目名称,例如: - -```text -MyShigure -``` - -名称要求: - -- 必须以英文字母开头 -- 只能包含英文字母、数字、下划线 -- 会区分大小写,把 `Shigure` 和 `Fuyutsui` 都替换为输入的新名称 - -随后输入公司名称,例如输入 `ABC`,脚本会把发布程序的公司名称设置为 -`ABC Corporation`。 - -确认时输入 `Y` 或 `y` 都可以继续。 - -脚本会自动完成这些事情: - -- 把当前项目复制到系统临时目录,建立独立的构建环境 -- 仅在隔离副本中把 `Shigure` 和 `Fuyutsui` 替换成输入的新名称,包括插件的 `.lua` 和 `.toc` 文件 -- 仅在隔离副本中递归重命名相关文件和目录,并修改公司名称 -- 从隔离副本排除完整技能图标源和清单,不生成或附带 `SpellIcons.shgpack` -- 每次发布前清空旧发布目录,避免旧数据包残留在新发布版中 -- 在隔离副本中运行 `dotnet publish`,不会产生或修改当前项目的源码、`bin`、`obj` -- 发布结束后自动删除临时构建环境 -- 打包成功后自动打开 `artifacts\publish\win-x64` - -## 3. 没有 Python 时:使用一键打包.exe - -项目已经提供了 `一键打包.exe`。如果使用者电脑没有 Python,可以直接运行这个 exe。 - -请确认 `一键打包.exe` 和 `Shigure.csproj` 在同一层,也就是项目根目录。然后运行: - -```powershell -.\一键打包.exe -``` - -`一键打包.exe` 不需要 Python,但仍然需要 .NET 10 SDK,因为它内部还是会调用 `dotnet publish`。 - -## 4. 手动方式:使用 dotnet publish - -如果不使用脚本,也可以在项目根目录手动运行: - -```powershell -dotnet publish .\Shigure.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -o .\artifacts\publish\win-x64 -``` - -参数含义: - -- `-c Release`:使用发布模式打包 -- `-r win-x64`:打包 Windows 64 位版本 -- `--self-contained true`:把运行需要的 .NET 组件一起带上 -- `PublishSingleFile=true`:尽量打成单个 exe -- `-o .\artifacts\publish\win-x64`:指定输出目录 - -手动方式不会自动改名。如果想生成不同名称的 exe,需要自己修改 `.csproj` 里的 `AssemblyName`。 - -## 5. exe 文件在哪里 - -打包完成后,发布文件会在: - -```text -artifacts\publish\win-x64 -``` - -最终程序 exe 的名字来自 `.csproj` 里的 `AssemblyName`。例如: - -```xml -Shigure -``` - -对应输出: - -```text -artifacts\publish\win-x64\Shigure.exe -``` - -如果使用 `一键打包.py` 或 `一键打包.exe` 输入了新名称,例如 `MyShigure`,发布出来的 exe 通常会变成: - -```text -artifacts\publish\win-x64\MyShigure.exe -``` - -## 6. 发给别人时要带哪些文件 - -不要只复制单个 exe。建议把整个目录打包发送: - -```text -artifacts\publish\win-x64 -``` - -程序还会使用这些运行数据(使用改名脚本打包时,下面的 `Fuyutsui` 会变成输入的新名称): - -```text -Fuyutsui -config -keymap -wow_process.txt -``` - -其中插件目录是插件权威源,程序会在启动或“更新配置”时把它部署到已运行游戏的 `Interface\AddOns\插件名称`。如果 `config` 或 `keymap` 缺失或文件不完整,程序启动时会从插件目录中的职业配置和宏自动补齐。完整技能图标库不再由打包流程生成或分发;用户在“设置 → 通用 → 下载数据包”中从 GitHub 最新正式 Release 下载后,程序会把它保存到 `data\SpellIcons.shgpack`。上述运行数据会在发布时复制到输出目录,最稳妥的方式仍是把 `win-x64` 整个文件夹压缩成 zip 再发送。 - -开发构建和直接执行 `dotnet publish` 同样不会复制完整图标、清单或数据包。运行时只读取 `data\SpellIcons.shgpack`;缺少或损坏时所有技能图标留空,添加技能的 spellId 联想关闭,不会再从松散文件或 Wowhead 在线补图。 - -模块不随发布包分发:模块保存在我的文档目录 `{MyDocuments}/Shigure/module`(首次运行自动创建),全新用户首次启动模块列表为空。使用改名脚本打包时该目录随程序名变化,例如改名为 `MyShigure` 后为 `{MyDocuments}/MyShigure/module`。 - -不要只发送单个 exe,也不要删除发布目录中的插件目录。数据包由每位用户按需下载;已下载的数据包不要在程序运行时手工覆盖。缺少内置插件目录时,配置/宏编辑和游戏插件部署会失败。 - -## 7. 常见问题 - -### 运行一键打包.exe 后显示预计修改 0 个文本文件 - -说明 `一键打包.exe` 没有放在项目根目录。请把它放到和 `Shigure.csproj` 同一层再运行。 - -### 提示 dotnet 不是内部或外部命令 - -说明 .NET SDK 没装好,或者安装后没有重新打开 PowerShell。重新安装 .NET 10 SDK 后,再打开新的 PowerShell 试一次。 - -### 提示 python 不是内部或外部命令 - -说明 Python 没装好,或者安装时没有勾选加入 PATH。重新安装 Python 后,再打开新的 PowerShell 试一次。 - -### 打包成功但找不到 exe - -检查这个目录: - -```text -artifacts\publish\win-x64 -``` - -exe 文件名不是固定的,它取决于 `AssemblyName`。 - -### 打包会修改当前项目吗 - -不会。项目名、公司名、源码内容和文件名的修改都发生在系统临时目录中的隔离副本里。 -当前项目只会新增或更新 `artifacts\publish\win-x64` 下的最终发布产物。