mirror of
https://github.com/waynebian01/Shigure.git
synced 2026-09-03 07:15:24 +08:00
重构条件编辑器,优化单元格点击事件处理,添加首领战下拉选择功能
This commit is contained in:
@@ -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`。
|
||||
|
||||
## 关键不变量与失败模式
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 一条完整执行链
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
558
一键打包.py
558
一键打包.py
@@ -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())
|
||||
185
打包说明.md
185
打包说明.md
@@ -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` 下的最终发布产物。
|
||||
Reference in New Issue
Block a user