重构条件编辑器,优化单元格点击事件处理,添加首领战下拉选择功能

This commit is contained in:
waynebian01
2026-08-30 12:58:27 +08:00
parent 232fa91aa2
commit 4c435e3c7f
7 changed files with 120 additions and 755 deletions

View File

@@ -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`
## 关键不变量与失败模式

View File

@@ -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`
## 一条完整执行链

View File

@@ -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`

View File

@@ -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<UiDropDownOption>
{
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;
}

View File

@@ -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<BossNumberOption> 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;

View File

@@ -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"<TargetFramework>net(?P<major>\d+)\.(?P<minor>\d+)(?:-[^<]+)?</TargetFramework>",
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"(<Company>).*?(</Company>)",
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"项目文件中找不到 <Company> 配置: {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())

View File

@@ -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
<AssemblyName>Shigure</AssemblyName>
```
对应输出:
```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` 下的最终发布产物。