This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish UPM package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Fetch tagged revision
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ref="${{ gitea.sha }}"
|
||||||
|
test -n "$ref"
|
||||||
|
git init .
|
||||||
|
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkModFramework.git"
|
||||||
|
git fetch --depth=1 origin "$ref"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Validate immutable release version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tag="$(git describe --exact-match --tags HEAD)"
|
||||||
|
version="$(node -p "require('./package.json').version")"
|
||||||
|
test "$tag" = "v$version"
|
||||||
|
npm pack --dry-run
|
||||||
|
|
||||||
|
- name: Publish to ShrinkSDK registry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
: "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
|
||||||
|
npmrc="$HOME/.npmrc"
|
||||||
|
cleanup() { rm -f "$npmrc"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf '%s\n' \
|
||||||
|
'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \
|
||||||
|
'//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc"
|
||||||
|
npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Verify standalone Unity package
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
editmode:
|
||||||
|
runs-on: unity-2022.3.62f3
|
||||||
|
container:
|
||||||
|
image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3
|
||||||
|
volumes:
|
||||||
|
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity
|
||||||
|
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses
|
||||||
|
steps:
|
||||||
|
- name: Fetch selected revision
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ref="${{ gitea.sha }}"
|
||||||
|
git init .
|
||||||
|
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkModFramework.git"
|
||||||
|
git fetch --depth=1 origin "$ref"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Run package EditMode tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)"
|
||||||
|
test -n "$unity_bin"
|
||||||
|
"$unity_bin" \
|
||||||
|
-batchmode \
|
||||||
|
-nographics \
|
||||||
|
-quit \
|
||||||
|
-projectPath "$PWD/Development~/UnityProject" \
|
||||||
|
-runTests \
|
||||||
|
-testPlatform EditMode \
|
||||||
|
-testResults "$PWD/TestResults/editmode.xml" \
|
||||||
|
-logFile "$PWD/TestResults/unity.log"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/Development~/UnityProject/[Ll]ibrary/
|
||||||
|
/Development~/UnityProject/[Tt]emp/
|
||||||
|
/Development~/UnityProject/[Oo]bj/
|
||||||
|
/Development~/UnityProject/[Ll]ogs/
|
||||||
|
/Development~/UnityProject/[Uu]ser[Ss]ettings/
|
||||||
|
/Development~/UnityProject/TestResults/
|
||||||
|
/Tools~/**/[Bb]in/
|
||||||
|
/Tools~/**/[Oo]bj/
|
||||||
|
*.user
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
Development~/
|
||||||
|
Tools~/
|
||||||
|
*.csproj
|
||||||
|
*.sln
|
||||||
|
*.user
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
本文件记录 `ShrinkModFramework` 在当前工作区中的包内变更。
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-08-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 新增 `ShrinkModContextHost` / `ShrinkModComponentSource`,把模组生命周期、Registry、Network handler 与 Harmony lease 纳入可逆组件事务。
|
||||||
|
- 新增真实外部 DLL fixture 测试,覆盖失败替换恢复、损坏文件、依赖缺失、watcher 去抖与删除卸载。
|
||||||
|
- 注册表新增 owner namespace、显式优先级覆盖、同优先级冲突拒绝和覆盖候选诊断;owner 卸载时会恢复下一个覆盖或基础项。
|
||||||
|
- `ShrinkModContext` 新增 `RegisterContent` / `OverrideContent`,模组不再手工传入 owner;查询改为 `IReadOnlyShrinkModRegistry<T>`。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `ShrinkModLoader` 默认使用 ContextHost;外部 DLL 以 SHA-256 作为 revision,同内容幂等。
|
||||||
|
- watcher 监听新增、修改、删除与重命名,并把 burst 文件事件折叠为一次主线程组合提交。
|
||||||
|
- 移除允许任意完整键的 `ShrinkModRegistry.Register(owner, key, value)`;基础内容必须通过 owner namespace 注册。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 新模组 apply 失败时同时恢复旧组件组合和外部 DLL revision 缓存,避免失败程序集继续被当作当前版本。
|
||||||
|
- 删除 DLL 时同步清理程序集解析路径缓存;EditMode 创建 watcher driver 时不再调用 `DontDestroyOnLoad`。
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-04-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 建立模组声明与生命周期基础:`[ShrinkMod]`、`[ShrinkModDependency]`、`IShrinkMod`、`ShrinkModBase`、`ShrinkModLoader`、注册表与运行时设置。
|
||||||
|
- 默认启动入口改为 `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]` 自动引导,不再强依赖场景内手挂 bootstrap。
|
||||||
|
- 支持扫描和增量装载外部 DLL 模组。
|
||||||
|
- 预留可选 Harmony 热补丁与模组网络同步抽象层。
|
||||||
|
- 新增 `ShrinkSDK/Mod/导出 Mod SDK 开发包`,可导出给外部模组开发者使用的 `GeneratedModSdk/`。
|
||||||
|
- 新增 `ShrinkSDK/Mod/生成仓库内模组模板(推荐)`,可在当前 Unity 工程内快速生成模组模板。
|
||||||
|
- 新增 `ShrinkSDK/Mod/生成外部模组模板`,可生成独立 DLL 模组工程。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 运行时目录结构统一拆分为 `Bootstrap / Core / Metadata / Registry / Loading / Network / Integration`。
|
||||||
|
- README 同步更新新的目录结构、模板入口、SDK 导出方式与推荐工作流。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 47a420daff9e1b7468f48a442ad7290e
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Ll]ibrary/
|
||||||
|
[Tt]emp/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]ogs/
|
||||||
|
[Uu]ser[Ss]ettings/
|
||||||
|
TestResults/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"scopedRegistries": [
|
||||||
|
{
|
||||||
|
"name": "ShrinkSDK",
|
||||||
|
"url": "https://git.crash.work/api/packages/ShrinkSDK/npm/",
|
||||||
|
"scopes": [
|
||||||
|
"com.cneicy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.test-framework": "1.1.33",
|
||||||
|
"com.cneicy.shrink-mod-framework": "file:../../.."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
m_EditorVersion: 2022.3.62f3
|
||||||
|
m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2a4090dedcf4f964195877944df52d40
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a4c5850a094f141429521fdc9e82295b
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2b6d34b770edc7b4bb76e93b13ffd2ab
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# __PROJECT_NAME__
|
||||||
|
|
||||||
|
这是由 `ShrinkModFramework` 生成器创建的外部 DLL 模组模板。
|
||||||
|
|
||||||
|
## 模板内容
|
||||||
|
|
||||||
|
- `src/__PROJECT_NAME__Mod.cs`
|
||||||
|
- 模组主入口,演示 `ShrinkModFramework + ShrinkEventBus + ShrinkCommand + ShrinkNetwork`
|
||||||
|
- `src/__PROJECT_NAME__Contracts.cs`
|
||||||
|
- 示例事件、RPC 请求与响应
|
||||||
|
- `__PROJECT_NAME__.csproj`
|
||||||
|
- 独立类库工程,默认引用当前 SDK 仓库里的运行时工程
|
||||||
|
- `build.ps1`
|
||||||
|
- 一键构建,可选复制 DLL 到你的模组目录
|
||||||
|
- `dev-env.ps1`
|
||||||
|
- 先设置 `MSBuildEnableWorkloadResolver=false`,适合有些 IDE 打开项目前先跑一遍
|
||||||
|
- `global.json`
|
||||||
|
- 锁定到生成模板时检测到的 `dotnet SDK` 版本
|
||||||
|
|
||||||
|
## 默认标识
|
||||||
|
|
||||||
|
- `ProjectName`: `__PROJECT_NAME__`
|
||||||
|
- `Namespace`: `__ROOT_NAMESPACE__`
|
||||||
|
- `ModId`: `__MOD_ID__`
|
||||||
|
- `DisplayName`: `__DISPLAY_NAME__`
|
||||||
|
|
||||||
|
生成后建议你先改这几处,再开始写正式业务。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build .\__PROJECT_NAME__.csproj -c Release /nr:false
|
||||||
|
```
|
||||||
|
|
||||||
|
或者直接运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你的 IDE 打开项目时报类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Microsoft.NET.SDK.WorkloadAutoImportPropsLocator
|
||||||
|
```
|
||||||
|
|
||||||
|
先在 PowerShell 里执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev-env.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在**同一个 shell** 里启动 IDE,或者直接继续 `dotnet build`。
|
||||||
|
|
||||||
|
默认产物输出到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
artifacts\Release\
|
||||||
|
```
|
||||||
|
|
||||||
|
## 投放到游戏
|
||||||
|
|
||||||
|
`ShrinkModFramework` 当前会扫描:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Application.persistentDataPath/Mods
|
||||||
|
```
|
||||||
|
|
||||||
|
所以常见流程是:
|
||||||
|
|
||||||
|
1. 先构建出 `__PROJECT_NAME__.dll`
|
||||||
|
2. 把 DLL 复制到游戏实际使用的 `Mods` 目录
|
||||||
|
3. 启动游戏,让 `ShrinkModLoader` 自动发现
|
||||||
|
|
||||||
|
如果你已经知道目标 `Mods` 目录,也可以直接用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1 -ModsDir "D:\YourGameMods\Mods"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 这个模板示范了什么
|
||||||
|
|
||||||
|
- `[ShrinkMod]` 模组声明
|
||||||
|
- `ShrinkModBase` 生命周期
|
||||||
|
- `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]` 生成式实例接入 Mod Bus
|
||||||
|
- `ShrinkCommandSubscriber` 实例命令自动接入
|
||||||
|
- `ShrinkNetworkSubscriber` 实例网络处理自动接入
|
||||||
|
- `context.RegisterContent(...)` 使用 ModId namespace 注册内容
|
||||||
|
- `context.OverrideContent(...)` 以显式优先级覆盖已有内容
|
||||||
|
- `context.RegisterNetworkHandler(...)` 走模组网络抽象层
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 示例里用到的 `opcode`、命令路径、`modId` 都只是模板默认值,正式模组请自己调整,避免冲突
|
||||||
|
- 当前模板默认依赖当前仓库根目录下的:
|
||||||
|
- `ShrinkModFramework.Runtime.csproj`
|
||||||
|
- `ShrinkEventBus.Runtime.csproj`
|
||||||
|
- `Assets/Modules/ShrinkEventBus/Tools~/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj`(作为 Roslyn analyzer)
|
||||||
|
- `ShrinkCommand.Runtime.csproj`
|
||||||
|
- `ShrinkNetwork.Runtime.csproj`
|
||||||
|
- 如果你把模板工程移到别处,需要同步修正 `__PROJECT_NAME__.csproj` 里的 `ProjectReference`
|
||||||
|
- 某些 IDE / MSBuild 环境里,`MSBuildEnableWorkloadResolver=false` 写在项目文件内仍然太晚;这种情况要靠 `dev-env.ps1` 或系统环境变量在**打开项目之前**先设置
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dfa94a3d7a41c6b4e81e7675db94e55f
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net471</TargetFramework>
|
||||||
|
<LangVersion>9.0</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>
|
||||||
|
<RootNamespace>__ROOT_NAMESPACE__</RootNamespace>
|
||||||
|
<AssemblyName>__PROJECT_NAME__</AssemblyName>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
|
||||||
|
<OutputPath>artifacts\$(Configuration)\</OutputPath>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkModFramework.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkEventBus.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="__SDK_ROOT_RELATIVE__\Assets\Modules\ShrinkEventBus\Tools~\DotNet\ShrinkEventBus.Generator\ShrinkEventBus.Generator.csproj"
|
||||||
|
OutputItemType="Analyzer"
|
||||||
|
ReferenceOutputAssembly="false" />
|
||||||
|
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkCommand.Runtime.csproj" />
|
||||||
|
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkNetwork.Runtime.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 770057fe3b5782f4ba28e2e36f087b96
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
param(
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
[string]$ModsDir = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$projectPath = Join-Path $PSScriptRoot "__PROJECT_NAME__.csproj"
|
||||||
|
dotnet build $projectPath -c $Configuration /nr:false
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$artifactDir = Join-Path $PSScriptRoot ("artifacts\" + $Configuration)
|
||||||
|
$dllPath = Join-Path $artifactDir "__PROJECT_NAME__.dll"
|
||||||
|
|
||||||
|
Write-Host "Build completed: $dllPath"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ModsDir)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $ModsDir | Out-Null
|
||||||
|
Copy-Item -LiteralPath $dllPath -Destination (Join-Path $ModsDir "__PROJECT_NAME__.dll") -Force
|
||||||
|
|
||||||
|
$pdbPath = Join-Path $artifactDir "__PROJECT_NAME__.pdb"
|
||||||
|
if (Test-Path -LiteralPath $pdbPath) {
|
||||||
|
Copy-Item -LiteralPath $pdbPath -Destination (Join-Path $ModsDir "__PROJECT_NAME__.pdb") -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Copied artifacts to: $ModsDir"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 956930660ab00e74692b22857f3159b7
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
$env:MSBuildEnableWorkloadResolver = "false"
|
||||||
|
|
||||||
|
Write-Host "MSBuildEnableWorkloadResolver=false"
|
||||||
|
Write-Host "Current dotnet: $(dotnet --version)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "现在请在这个 PowerShell 里启动你的 IDE,或直接在这个 shell 里运行 dotnet build。"
|
||||||
|
Write-Host "示例:"
|
||||||
|
Write-Host " dotnet build .\\__PROJECT_NAME__.csproj -c Release /nr:false"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 56c53370b8dbbab4ea70d4566bc1fed8
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "__DOTNET_SDK_VERSION__",
|
||||||
|
"rollForward": "latestPatch"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 48ebb276437630c4495d82c4c8e4ae2e
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c8713158fd023ce48abfb7365184ac7c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace __ROOT_NAMESPACE__
|
||||||
|
{
|
||||||
|
public sealed class __PROJECT_NAME__Event : IShrinkEvent
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(__REQUEST_OPCODE__, "__ROUTE_PREFIX__/ping")]
|
||||||
|
public sealed class __PROJECT_NAME__PingRequest : IShrinkNetworkRequest
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(__RESPONSE_OPCODE__, "__ROUTE_PREFIX__/ping_response")]
|
||||||
|
public sealed class __PROJECT_NAME__PingResponse : ShrinkRpcResponseBase
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public int Counter { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f4ff81758d9539947ba6f4df94c2db91
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using ShrinkCommand;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkModFramework;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace __ROOT_NAMESPACE__
|
||||||
|
{
|
||||||
|
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
|
||||||
|
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
|
||||||
|
[ShrinkCommandSubscriber]
|
||||||
|
[ShrinkNetworkSubscriber]
|
||||||
|
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
|
||||||
|
{
|
||||||
|
private readonly RuntimeState _state = new();
|
||||||
|
|
||||||
|
public override void OnConstruct(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.Log("模组已构造。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnRegisterContent(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterNetworkHandler<int>("sync.counter", (_, value) =>
|
||||||
|
{
|
||||||
|
_state.Counter = value;
|
||||||
|
context.Log("收到模组网络计数:" + value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkSubscribe]
|
||||||
|
private void OnExampleEvent(__PROJECT_NAME__Event evt)
|
||||||
|
{
|
||||||
|
_state.LastEventMessage = evt.Message ?? string.Empty;
|
||||||
|
_state.Counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkCommand("__COMMAND_ROOT__ ping", Description = "测试模组命令", Permission = "__MOD_ID__.command.ping")]
|
||||||
|
private string Ping()
|
||||||
|
{
|
||||||
|
return "pong:" + _state.Counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkSubscribe(Permission = "__MOD_ID__.rpc.ping")]
|
||||||
|
private __PROJECT_NAME__PingResponse HandlePing(__PROJECT_NAME__PingRequest request)
|
||||||
|
{
|
||||||
|
return new __PROJECT_NAME__PingResponse
|
||||||
|
{
|
||||||
|
Message = "pong:" + (request.Message ?? string.Empty),
|
||||||
|
Counter = _state.Counter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RuntimeState
|
||||||
|
{
|
||||||
|
public int Counter { get; set; }
|
||||||
|
public string LastEventMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a2a0ac418f07ef74393b89e9612e3f5f
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8b337545ac56d6f46a969397d332e35c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# __PROJECT_NAME__
|
||||||
|
|
||||||
|
这是 `ShrinkModFramework` 生成的仓库内模组模板。
|
||||||
|
|
||||||
|
## 为什么推荐这个模板
|
||||||
|
|
||||||
|
这个模板直接生成到当前 Unity 项目的 `Assets` 目录下,复用:
|
||||||
|
|
||||||
|
- 当前 Unity 工程
|
||||||
|
- 当前 `ShrinkSDK.sln`
|
||||||
|
- 当前 asmdef 编译链
|
||||||
|
|
||||||
|
所以它比“独立外部 csproj 模板”更省事,也不会碰到额外的 IDE / workload SDK 解析问题。
|
||||||
|
|
||||||
|
## 生成位置
|
||||||
|
|
||||||
|
- 当前输出目录:`__ASSET_RELATIVE_OUTPUT__`
|
||||||
|
|
||||||
|
## 模板内容
|
||||||
|
|
||||||
|
- `Runtime/__ASMDEF_NAME__.asmdef`
|
||||||
|
- `Runtime/__PROJECT_NAME__Mod.cs`
|
||||||
|
- `Runtime/__PROJECT_NAME__Contracts.cs`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## 建议流程
|
||||||
|
|
||||||
|
1. 生成模板
|
||||||
|
2. 在现有 Unity/Rider/VS 解决方案里直接修改代码
|
||||||
|
3. 等 Unity 正常编译通过
|
||||||
|
4. 后续如果需要,再单独补“导出 DLL 到 Mods 目录”的步骤
|
||||||
|
|
||||||
|
## 默认标识
|
||||||
|
|
||||||
|
- `ProjectName`: `__PROJECT_NAME__`
|
||||||
|
- `Namespace`: `__ROOT_NAMESPACE__`
|
||||||
|
- `Assembly`: `__ASMDEF_NAME__`
|
||||||
|
- `ModId`: `__MOD_ID__`
|
||||||
|
- `DisplayName`: `__DISPLAY_NAME__`
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8aa46c7dafda5194b94ab88513c56b44
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f7a48cec05ee9c842ad9bc0b7e7ec425
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "__ASMDEF_NAME__",
|
||||||
|
"rootNamespace": "__ROOT_NAMESPACE__",
|
||||||
|
"references": [
|
||||||
|
"ShrinkModFramework.Runtime",
|
||||||
|
"ShrinkEventBus.Runtime",
|
||||||
|
"ShrinkCommand.Runtime",
|
||||||
|
"ShrinkNetwork.Runtime"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9358a269a6e4a9740a69798dba1c1655
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace __ROOT_NAMESPACE__
|
||||||
|
{
|
||||||
|
public sealed class __PROJECT_NAME__Event : IShrinkEvent
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(32001, "__MOD_ID__/ping")]
|
||||||
|
public sealed class __PROJECT_NAME__PingRequest : IShrinkNetworkRequest
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(32002, "__MOD_ID__/ping_response")]
|
||||||
|
public sealed class __PROJECT_NAME__PingResponse : ShrinkRpcResponseBase
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public int Counter { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2dbfd0d836e24d84eabaff73259c7e90
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using ShrinkCommand;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkModFramework;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace __ROOT_NAMESPACE__
|
||||||
|
{
|
||||||
|
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
|
||||||
|
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
|
||||||
|
[ShrinkCommandSubscriber]
|
||||||
|
[ShrinkNetworkSubscriber]
|
||||||
|
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
|
||||||
|
{
|
||||||
|
private readonly RuntimeState _state = new();
|
||||||
|
|
||||||
|
public override void OnConstruct(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.Log("模组已构造。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnRegisterContent(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterNetworkHandler<int>("sync.counter", (_, value) =>
|
||||||
|
{
|
||||||
|
_state.Counter = value;
|
||||||
|
context.Log("收到模组网络计数:" + value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkSubscribe]
|
||||||
|
private void OnExampleEvent(__PROJECT_NAME__Event evt)
|
||||||
|
{
|
||||||
|
_state.LastEventMessage = evt.Message ?? string.Empty;
|
||||||
|
_state.Counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkCommand("__COMMAND_ROOT__ ping", Description = "测试模组命令", Permission = "__MOD_ID__.command.ping")]
|
||||||
|
private string Ping()
|
||||||
|
{
|
||||||
|
return "pong:" + _state.Counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkSubscribe(Permission = "__MOD_ID__.rpc.ping")]
|
||||||
|
private __PROJECT_NAME__PingResponse HandlePing(__PROJECT_NAME__PingRequest request)
|
||||||
|
{
|
||||||
|
return new __PROJECT_NAME__PingResponse
|
||||||
|
{
|
||||||
|
Message = "pong:" + (request.Message ?? string.Empty),
|
||||||
|
Counter = _state.Counter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RuntimeState
|
||||||
|
{
|
||||||
|
public int Counter { get; set; }
|
||||||
|
public string LastEventMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5e1319b1cd7dc0d4b918d12152b358ab
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public static class ShrinkExternalModTemplateGenerator
|
||||||
|
{
|
||||||
|
private const string GenerateMenuPath = "ShrinkSDK/Mod/生成外部模组模板";
|
||||||
|
private const string TemplateRelativePath = "Assets/Modules/ShrinkModFramework/Editor/Scaffolding/ExternalModProjectTemplate";
|
||||||
|
private const string DefaultGeneratedModsRelativePath = "GeneratedMods";
|
||||||
|
private const string TemplateStampFileName = ".shrink-mod-template.json";
|
||||||
|
private const string TemplateManifestFileName = ".shrink-mod-template-files.txt";
|
||||||
|
|
||||||
|
[MenuItem(GenerateMenuPath)]
|
||||||
|
public static void GenerateTemplate()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = BuildContext();
|
||||||
|
if (context == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CopyTemplateProject(context.Value.TemplateRoot, context.Value.OutputRoot, context.Value.Replacements);
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
UnityEngine.Debug.Log($"[ShrinkModFramework] 已生成外部模组模板:{context.Value.OutputRoot}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UnityEngine.Debug.LogError($"[ShrinkModFramework] 生成外部模组模板失败:{ex.Message}");
|
||||||
|
UnityEngine.Debug.LogException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string ProjectRoot, string TemplateRoot, string OutputRoot, Dictionary<string, string> Replacements)? BuildContext()
|
||||||
|
{
|
||||||
|
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||||
|
var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (!Directory.Exists(templateRoot))
|
||||||
|
throw new DirectoryNotFoundException($"未找到模组模板目录:{templateRoot}");
|
||||||
|
|
||||||
|
var generatedModsRoot = Path.Combine(projectRoot, DefaultGeneratedModsRelativePath);
|
||||||
|
Directory.CreateDirectory(generatedModsRoot);
|
||||||
|
|
||||||
|
var outputRoot = EditorUtility.SaveFolderPanel(
|
||||||
|
"选择外部模组模板输出目录",
|
||||||
|
generatedModsRoot,
|
||||||
|
"SampleShrinkMod");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(outputRoot))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
outputRoot = Path.GetFullPath(outputRoot);
|
||||||
|
var directoryName = new DirectoryInfo(outputRoot).Name;
|
||||||
|
var projectName = SanitizeProjectName(directoryName);
|
||||||
|
var rootNamespace = projectName;
|
||||||
|
var displayName = projectName;
|
||||||
|
var modId = BuildDefaultModId(projectName);
|
||||||
|
var commandRoot = ToCommandToken(projectName);
|
||||||
|
var routePrefix = modId.Replace('.', '/');
|
||||||
|
var sdkRootRelativePath = NormalizePath(Path.GetRelativePath(outputRoot, projectRoot));
|
||||||
|
|
||||||
|
var replacements = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["__PROJECT_NAME__"] = projectName,
|
||||||
|
["__ROOT_NAMESPACE__"] = rootNamespace,
|
||||||
|
["__DISPLAY_NAME__"] = displayName,
|
||||||
|
["__MOD_ID__"] = modId,
|
||||||
|
["__COMMAND_ROOT__"] = commandRoot,
|
||||||
|
["__ROUTE_PREFIX__"] = routePrefix,
|
||||||
|
["__SDK_ROOT_RELATIVE__"] = sdkRootRelativePath,
|
||||||
|
["__DOTNET_SDK_VERSION__"] = GetPreferredSdkVersion(),
|
||||||
|
["__REQUEST_OPCODE__"] = "31001",
|
||||||
|
["__RESPONSE_OPCODE__"] = "31002"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (projectRoot, templateRoot, outputRoot, replacements);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyTemplateProject(string templateRoot, string outputRoot, IReadOnlyDictionary<string, string> replacements)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputRoot);
|
||||||
|
var stampPath = Path.Combine(outputRoot, TemplateStampFileName);
|
||||||
|
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"目标目录已存在且不是 ShrinkModFramework 自动生成的模组模板,为避免覆盖人工修改,已中止:{outputRoot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedFiles = new List<string>();
|
||||||
|
foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(templateRoot, templateFile);
|
||||||
|
relativePath = ReplaceTokens(relativePath, replacements);
|
||||||
|
var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal)
|
||||||
|
? relativePath[..^".txt".Length]
|
||||||
|
: relativePath;
|
||||||
|
managedFiles.Add(NormalizePath(outputRelativePath));
|
||||||
|
|
||||||
|
var outputFile = Path.Combine(outputRoot, outputRelativePath);
|
||||||
|
var outputDir = Path.GetDirectoryName(outputFile);
|
||||||
|
if (!string.IsNullOrWhiteSpace(outputDir))
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
|
var content = File.ReadAllText(templateFile, Encoding.UTF8);
|
||||||
|
content = ReplaceTokens(content, replacements);
|
||||||
|
File.WriteAllText(outputFile, content, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
|
||||||
|
if (File.Exists(manifestPath))
|
||||||
|
{
|
||||||
|
var previousManagedFiles = File.ReadAllLines(manifestPath, Encoding.UTF8)
|
||||||
|
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var previousManagedFile in previousManagedFiles)
|
||||||
|
{
|
||||||
|
if (managedFiles.Contains(previousManagedFile, StringComparer.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var staleFile = Path.Combine(outputRoot, previousManagedFile.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (File.Exists(staleFile))
|
||||||
|
File.Delete(staleFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllLines(manifestPath, managedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false));
|
||||||
|
|
||||||
|
var stamp = new StringBuilder();
|
||||||
|
stamp.AppendLine("{");
|
||||||
|
stamp.AppendLine(@" ""generatedBy"": ""ShrinkExternalModTemplateGenerator"",");
|
||||||
|
stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
|
||||||
|
stamp.AppendLine("}");
|
||||||
|
File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceTokens(string input, IReadOnlyDictionary<string, string> replacements)
|
||||||
|
{
|
||||||
|
var result = input;
|
||||||
|
foreach (var pair in replacements)
|
||||||
|
result = result.Replace(pair.Key, pair.Value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeProjectName(string rawName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawName))
|
||||||
|
return "SampleShrinkMod";
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var capitalizeNext = true;
|
||||||
|
foreach (var ch in rawName)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
builder.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch);
|
||||||
|
capitalizeNext = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
capitalizeNext = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (builder.Length == 0)
|
||||||
|
builder.Append("SampleShrinkMod");
|
||||||
|
|
||||||
|
if (char.IsDigit(builder[0]))
|
||||||
|
builder.Insert(0, 'M');
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDefaultModId(string projectName)
|
||||||
|
{
|
||||||
|
var token = ToKebabCase(projectName);
|
||||||
|
return string.IsNullOrWhiteSpace(token) ? "example.sample-mod" : $"example.{token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToCommandToken(string projectName)
|
||||||
|
{
|
||||||
|
var token = ToKebabCase(projectName).Replace("-", "_");
|
||||||
|
return string.IsNullOrWhiteSpace(token) ? "sample_mod" : token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToKebabCase(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
var ch = value[i];
|
||||||
|
if (!char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
if (builder.Length > 0 && builder[^1] != '-')
|
||||||
|
builder.Append('-');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char.IsUpper(ch) && i > 0 && builder.Length > 0 && builder[^1] != '-')
|
||||||
|
builder.Append('-');
|
||||||
|
|
||||||
|
builder.Append(char.ToLowerInvariant(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString().Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePath(string path)
|
||||||
|
=> path.Replace('\\', '/');
|
||||||
|
|
||||||
|
private static string GetPreferredSdkVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = "--version",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
if (process == null)
|
||||||
|
return "9.0.312";
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit(3000);
|
||||||
|
return string.IsNullOrWhiteSpace(output) ? "9.0.312" : output;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "9.0.312";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 92c78e493d52a144389ddc6073111b0e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public static class ShrinkInRepoModTemplateGenerator
|
||||||
|
{
|
||||||
|
private const string GenerateMenuPath = "ShrinkSDK/Mod/生成仓库内模组模板(推荐)";
|
||||||
|
private const string TemplateRelativePath = "Assets/Modules/ShrinkModFramework/Editor/Scaffolding/InRepoModTemplate";
|
||||||
|
private const string DefaultOutputRootRelativePath = "Assets/GeneratedMods";
|
||||||
|
private const string TemplateStampFileName = ".shrink-inrepo-mod-template.json";
|
||||||
|
private const string TemplateManifestFileName = ".shrink-inrepo-mod-template-files.txt";
|
||||||
|
|
||||||
|
[MenuItem(GenerateMenuPath)]
|
||||||
|
public static void GenerateTemplate()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = BuildContext();
|
||||||
|
if (context == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CopyTemplateProject(context.Value.TemplateRoot, context.Value.OutputRoot, context.Value.Replacements);
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
UnityEngine.Debug.Log($"[ShrinkModFramework] 已生成仓库内模组模板:{context.Value.OutputRoot}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UnityEngine.Debug.LogError($"[ShrinkModFramework] 生成仓库内模组模板失败:{ex.Message}");
|
||||||
|
UnityEngine.Debug.LogException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string TemplateRoot, string OutputRoot, Dictionary<string, string> Replacements)? BuildContext()
|
||||||
|
{
|
||||||
|
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||||
|
var templateRoot = Path.Combine(projectRoot, TemplateRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
if (!Directory.Exists(templateRoot))
|
||||||
|
throw new DirectoryNotFoundException($"未找到仓库内模组模板目录:{templateRoot}");
|
||||||
|
|
||||||
|
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
Directory.CreateDirectory(defaultOutputRoot);
|
||||||
|
|
||||||
|
var outputRoot = EditorUtility.SaveFolderPanel(
|
||||||
|
"选择仓库内模组模板输出目录",
|
||||||
|
defaultOutputRoot,
|
||||||
|
"SampleShrinkMod");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(outputRoot))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
outputRoot = Path.GetFullPath(outputRoot);
|
||||||
|
var assetsRoot = Path.GetFullPath(Application.dataPath);
|
||||||
|
if (!outputRoot.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidOperationException("仓库内模组模板必须生成到当前项目的 Assets 目录下。");
|
||||||
|
|
||||||
|
var directoryName = new DirectoryInfo(outputRoot).Name;
|
||||||
|
var projectName = SanitizeProjectName(directoryName);
|
||||||
|
var rootNamespace = projectName;
|
||||||
|
var displayName = projectName;
|
||||||
|
var modId = BuildDefaultModId(projectName);
|
||||||
|
var commandRoot = ToCommandToken(projectName);
|
||||||
|
var asmdefName = projectName + ".Runtime";
|
||||||
|
var assetRelativeOutput = "Assets" + outputRoot.Substring(assetsRoot.Length).Replace('\\', '/');
|
||||||
|
|
||||||
|
var replacements = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["__PROJECT_NAME__"] = projectName,
|
||||||
|
["__ROOT_NAMESPACE__"] = rootNamespace,
|
||||||
|
["__DISPLAY_NAME__"] = displayName,
|
||||||
|
["__MOD_ID__"] = modId,
|
||||||
|
["__COMMAND_ROOT__"] = commandRoot,
|
||||||
|
["__ASMDEF_NAME__"] = asmdefName,
|
||||||
|
["__ASSET_RELATIVE_OUTPUT__"] = assetRelativeOutput
|
||||||
|
};
|
||||||
|
|
||||||
|
return (templateRoot, outputRoot, replacements);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyTemplateProject(string templateRoot, string outputRoot, IReadOnlyDictionary<string, string> replacements)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputRoot);
|
||||||
|
var stampPath = Path.Combine(outputRoot, TemplateStampFileName);
|
||||||
|
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"目标目录已存在且不是 ShrinkModFramework 自动生成的仓库内模组模板,为避免覆盖人工修改,已中止:{outputRoot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedFiles = new List<string>();
|
||||||
|
foreach (var templateFile in Directory.EnumerateFiles(templateRoot, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(templateRoot, templateFile);
|
||||||
|
relativePath = ReplaceTokens(relativePath, replacements);
|
||||||
|
var outputRelativePath = relativePath.EndsWith(".txt", StringComparison.Ordinal)
|
||||||
|
? relativePath[..^".txt".Length]
|
||||||
|
: relativePath;
|
||||||
|
managedFiles.Add(NormalizePath(outputRelativePath));
|
||||||
|
|
||||||
|
var outputFile = Path.Combine(outputRoot, outputRelativePath);
|
||||||
|
var outputDir = Path.GetDirectoryName(outputFile);
|
||||||
|
if (!string.IsNullOrWhiteSpace(outputDir))
|
||||||
|
Directory.CreateDirectory(outputDir);
|
||||||
|
|
||||||
|
var content = File.ReadAllText(templateFile, Encoding.UTF8);
|
||||||
|
content = ReplaceTokens(content, replacements);
|
||||||
|
File.WriteAllText(outputFile, content, new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestPath = Path.Combine(outputRoot, TemplateManifestFileName);
|
||||||
|
File.WriteAllLines(manifestPath, managedFiles.OrderBy(item => item, StringComparer.Ordinal), new UTF8Encoding(false));
|
||||||
|
|
||||||
|
var stamp = new StringBuilder();
|
||||||
|
stamp.AppendLine("{");
|
||||||
|
stamp.AppendLine(@" ""generatedBy"": ""ShrinkInRepoModTemplateGenerator"",");
|
||||||
|
stamp.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
|
||||||
|
stamp.AppendLine("}");
|
||||||
|
File.WriteAllText(stampPath, stamp.ToString(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReplaceTokens(string input, IReadOnlyDictionary<string, string> replacements)
|
||||||
|
{
|
||||||
|
var result = input;
|
||||||
|
foreach (var pair in replacements)
|
||||||
|
result = result.Replace(pair.Key, pair.Value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeProjectName(string rawName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawName))
|
||||||
|
return "SampleShrinkMod";
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var capitalizeNext = true;
|
||||||
|
foreach (var ch in rawName)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
builder.Append(capitalizeNext ? char.ToUpperInvariant(ch) : ch);
|
||||||
|
capitalizeNext = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
capitalizeNext = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (builder.Length == 0)
|
||||||
|
builder.Append("SampleShrinkMod");
|
||||||
|
|
||||||
|
if (char.IsDigit(builder[0]))
|
||||||
|
builder.Insert(0, 'M');
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDefaultModId(string projectName)
|
||||||
|
{
|
||||||
|
var token = ToKebabCase(projectName);
|
||||||
|
return string.IsNullOrWhiteSpace(token) ? "example.sample-mod" : $"example.{token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToCommandToken(string projectName)
|
||||||
|
{
|
||||||
|
var token = ToKebabCase(projectName).Replace("-", "_");
|
||||||
|
return string.IsNullOrWhiteSpace(token) ? "sample_mod" : token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToKebabCase(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
for (var i = 0; i < value.Length; i++)
|
||||||
|
{
|
||||||
|
var ch = value[i];
|
||||||
|
if (!char.IsLetterOrDigit(ch))
|
||||||
|
{
|
||||||
|
if (builder.Length > 0 && builder[^1] != '-')
|
||||||
|
builder.Append('-');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char.IsUpper(ch) && i > 0 && builder.Length > 0 && builder[^1] != '-')
|
||||||
|
builder.Append('-');
|
||||||
|
|
||||||
|
builder.Append(char.ToLowerInvariant(ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString().Trim('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePath(string path)
|
||||||
|
=> path.Replace('\\', '/');
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b6cd6b51094b7e442bf0e5c09fcd42d3
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.PackageManager;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
public static class ShrinkModSdkExporter
|
||||||
|
{
|
||||||
|
private const string ExportMenuPath = "ShrinkSDK/Mod/导出 Mod SDK 开发包";
|
||||||
|
private const string DefaultOutputRootRelativePath = "GeneratedModSdk";
|
||||||
|
private const string StampFileName = ".shrink-mod-sdk.json";
|
||||||
|
private const string TemplateProjectName = "SampleShrinkMod";
|
||||||
|
private const string EventBusPackageName = "com.cneicy.shrink-eventbus";
|
||||||
|
private const string ModFrameworkPackageName = "com.cneicy.shrink-mod-framework";
|
||||||
|
private const string EventBusGeneratorProjectRelativePath = "Tools~/DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj";
|
||||||
|
private const string BundledGeneratorRelativePath = "Editor/Scaffolding/Support/ShrinkEventBus.Generator.dll.bytes";
|
||||||
|
|
||||||
|
private static readonly string[] ExportAssemblyNames =
|
||||||
|
{
|
||||||
|
"ShrinkModFramework.Runtime",
|
||||||
|
"ShrinkEventBus.Runtime",
|
||||||
|
"ShrinkEventBus.Generator",
|
||||||
|
"ShrinkCommand.Runtime",
|
||||||
|
"ShrinkCommand.Integration.EventBus",
|
||||||
|
"ShrinkCommand.Integration.Network",
|
||||||
|
"ShrinkNetwork.Runtime",
|
||||||
|
"ShrinkNetwork.Integration.EventBus",
|
||||||
|
"ShrinkDataSaver.Runtime",
|
||||||
|
"ShrinkDataSaver.Integration.EventBus",
|
||||||
|
"Assembly-CSharp",
|
||||||
|
"UniTask",
|
||||||
|
"Newtonsoft.Json",
|
||||||
|
"Kcp-CSharp",
|
||||||
|
"System.Runtime.CompilerServices.Unsafe",
|
||||||
|
"UnityEngine",
|
||||||
|
"UnityEngine.CoreModule"
|
||||||
|
};
|
||||||
|
|
||||||
|
[MenuItem(ExportMenuPath)]
|
||||||
|
public static void ExportSdk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||||
|
BuildSolution(projectRoot);
|
||||||
|
var generatorAssemblyPath = BuildOrResolveEventBusGenerator(projectRoot);
|
||||||
|
|
||||||
|
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath);
|
||||||
|
Directory.CreateDirectory(defaultOutputRoot);
|
||||||
|
|
||||||
|
var outputRoot = EditorUtility.SaveFolderPanel(
|
||||||
|
"选择 Mod SDK 导出目录",
|
||||||
|
defaultOutputRoot,
|
||||||
|
"ShrinkModSdk");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(outputRoot))
|
||||||
|
return;
|
||||||
|
|
||||||
|
outputRoot = Path.GetFullPath(outputRoot);
|
||||||
|
PrepareOutputDirectory(outputRoot);
|
||||||
|
|
||||||
|
var libsRoot = Path.Combine(outputRoot, "Libs");
|
||||||
|
var templateRoot = Path.Combine(outputRoot, "Templates", "ExternalMod", TemplateProjectName);
|
||||||
|
Directory.CreateDirectory(libsRoot);
|
||||||
|
Directory.CreateDirectory(templateRoot);
|
||||||
|
|
||||||
|
var exportedFiles = ExportLibraries(projectRoot, generatorAssemblyPath, libsRoot);
|
||||||
|
WriteSdkReadme(outputRoot, exportedFiles);
|
||||||
|
WriteSdkManifest(outputRoot, exportedFiles);
|
||||||
|
WriteExternalTemplate(templateRoot);
|
||||||
|
WriteStamp(outputRoot);
|
||||||
|
|
||||||
|
UnityEngine.Debug.Log($"[ShrinkModFramework] 已导出 Mod SDK 开发包:{outputRoot}");
|
||||||
|
EditorUtility.RevealInFinder(outputRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
UnityEngine.Debug.LogError($"[ShrinkModFramework] 导出 Mod SDK 开发包失败:{ex.Message}");
|
||||||
|
UnityEngine.Debug.LogException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildSolution(string projectRoot)
|
||||||
|
{
|
||||||
|
var solutionPath = Path.Combine(projectRoot, "ShrinkSDK.sln");
|
||||||
|
if (!File.Exists(solutionPath))
|
||||||
|
{
|
||||||
|
UnityEngine.Debug.Log("[ShrinkModFramework] 未检测到 Workspace 解决方案,直接使用当前 Unity 已编译程序集导出 Mod SDK。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = "build .\\ShrinkSDK.sln /m:1 /nr:false -p:BaseIntermediateOutputPath=Temp\\CliObj\\ModSdkExport\\",
|
||||||
|
WorkingDirectory = projectRoot,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
if (process == null)
|
||||||
|
throw new InvalidOperationException("无法启动 dotnet build。");
|
||||||
|
|
||||||
|
var stdout = process.StandardOutput.ReadToEnd();
|
||||||
|
var stderr = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"构建 ShrinkSDK.sln 失败。\n" +
|
||||||
|
stdout + "\n" +
|
||||||
|
stderr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildOrResolveEventBusGenerator(string projectRoot)
|
||||||
|
{
|
||||||
|
var generatorProjectPath = ResolveEventBusGeneratorProjectPath(projectRoot);
|
||||||
|
if (generatorProjectPath == null)
|
||||||
|
{
|
||||||
|
var bundledGeneratorPath = Path.Combine(ResolveModFrameworkPackagePath(projectRoot), BundledGeneratorRelativePath);
|
||||||
|
if (File.Exists(bundledGeneratorPath))
|
||||||
|
return bundledGeneratorPath;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"未找到 ShrinkEventBus.Generator 源码或随包分发的分析器。请安装 com.cneicy.shrink-eventbus,或使用包含 ShrinkModFramework 支持资源的完整包版本。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = "build \"" + generatorProjectPath + "\" -c Release /nr:false",
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(generatorProjectPath),
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(startInfo);
|
||||||
|
if (process == null)
|
||||||
|
throw new InvalidOperationException("无法启动 ShrinkEventBus.Generator 构建。");
|
||||||
|
|
||||||
|
var stdout = process.StandardOutput.ReadToEnd();
|
||||||
|
var stderr = process.StandardError.ReadToEnd();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
throw new InvalidOperationException("构建 ShrinkEventBus.Generator 失败。\n" + stdout + "\n" + stderr);
|
||||||
|
|
||||||
|
var generatorAssemblyPath = Path.Combine(
|
||||||
|
Path.GetDirectoryName(generatorProjectPath) ?? string.Empty,
|
||||||
|
"bin",
|
||||||
|
"Release",
|
||||||
|
"netstandard2.0",
|
||||||
|
"ShrinkEventBus.Generator.dll");
|
||||||
|
if (!File.Exists(generatorAssemblyPath))
|
||||||
|
throw new InvalidOperationException("ShrinkEventBus.Generator 构建完成但未找到输出 DLL:" + generatorAssemblyPath);
|
||||||
|
|
||||||
|
return generatorAssemblyPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveEventBusGeneratorProjectPath(string projectRoot)
|
||||||
|
{
|
||||||
|
var workspaceProjectPath = Path.Combine(
|
||||||
|
projectRoot,
|
||||||
|
"Assets",
|
||||||
|
"Modules",
|
||||||
|
"ShrinkEventBus",
|
||||||
|
EventBusGeneratorProjectRelativePath);
|
||||||
|
if (File.Exists(workspaceProjectPath))
|
||||||
|
return workspaceProjectPath;
|
||||||
|
|
||||||
|
var package = PackageInfo.GetAllRegisteredPackages()
|
||||||
|
.FirstOrDefault(candidate => string.Equals(candidate.name, EventBusPackageName, StringComparison.Ordinal));
|
||||||
|
if (package == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var packageProjectPath = Path.Combine(package.resolvedPath, EventBusGeneratorProjectRelativePath);
|
||||||
|
return File.Exists(packageProjectPath) ? packageProjectPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveModFrameworkPackagePath(string projectRoot)
|
||||||
|
{
|
||||||
|
var workspacePackagePath = Path.Combine(projectRoot, "Assets", "Modules", "ShrinkModFramework");
|
||||||
|
if (File.Exists(Path.Combine(workspacePackagePath, "package.json")))
|
||||||
|
return workspacePackagePath;
|
||||||
|
|
||||||
|
var package = PackageInfo.GetAllRegisteredPackages()
|
||||||
|
.FirstOrDefault(candidate => string.Equals(candidate.name, ModFrameworkPackageName, StringComparison.Ordinal));
|
||||||
|
if (package != null && Directory.Exists(package.resolvedPath))
|
||||||
|
return package.resolvedPath;
|
||||||
|
|
||||||
|
throw new InvalidOperationException("无法解析 ShrinkModFramework 包路径。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrepareOutputDirectory(string outputRoot)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputRoot);
|
||||||
|
var stampPath = Path.Combine(outputRoot, StampFileName);
|
||||||
|
if (Directory.EnumerateFileSystemEntries(outputRoot).Any() && !File.Exists(stampPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"目标目录已存在且不是 ShrinkModFramework 自动导出的 Mod SDK 目录,为避免覆盖人工内容,已中止:{outputRoot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entry in Directory.EnumerateFileSystemEntries(outputRoot).ToArray())
|
||||||
|
{
|
||||||
|
if (Directory.Exists(entry))
|
||||||
|
Directory.Delete(entry, true);
|
||||||
|
else
|
||||||
|
File.Delete(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ExportLibraries(string projectRoot, string generatorAssemblyPath, string libsRoot)
|
||||||
|
{
|
||||||
|
var candidateDirectories = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "Assembly-CSharp"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkModFramework.Runtime"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkEventBus.Runtime"),
|
||||||
|
Path.GetDirectoryName(generatorAssemblyPath) ?? string.Empty,
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Runtime"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.EventBus"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.Network"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Runtime"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkNetwork.Integration.EventBus"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Runtime"),
|
||||||
|
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkDataSaver.Integration.EventBus")
|
||||||
|
};
|
||||||
|
|
||||||
|
var copied = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var exported = new List<string>();
|
||||||
|
foreach (var assemblyName in ExportAssemblyNames)
|
||||||
|
{
|
||||||
|
foreach (var extension in new[] { ".dll", ".pdb", ".xml" })
|
||||||
|
{
|
||||||
|
var fileName = assemblyName + extension;
|
||||||
|
if (copied.Contains(fileName))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var source = candidateDirectories
|
||||||
|
.Select(directory => Path.Combine(directory, fileName))
|
||||||
|
.FirstOrDefault(File.Exists);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(source))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var destination = Path.Combine(libsRoot, fileName);
|
||||||
|
File.Copy(source, destination, true);
|
||||||
|
copied.Add(fileName);
|
||||||
|
exported.Add(fileName.Replace('\\', '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exported.OrderBy(item => item, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSdkReadme(string outputRoot, IReadOnlyList<string> exportedFiles)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("# Shrink Mod SDK");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("这是给外部模组开发者使用的开发包。");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## 内容");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("- `Libs/`");
|
||||||
|
builder.AppendLine(" - 已编译好的框架运行时 DLL、EventBus 代码生成器、当前游戏 DLL,以及模板需要的基础依赖");
|
||||||
|
builder.AppendLine("- `Templates/ExternalMod/SampleShrinkMod/`");
|
||||||
|
builder.AppendLine(" - 可直接复制一份开始写外部模组的模板工程");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## 推荐用法");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("1. 复制 `Templates/ExternalMod/SampleShrinkMod/` 到你自己的工作目录");
|
||||||
|
builder.AppendLine("2. 修改项目名、命名空间、`modId`、命令路径、消息 opcode");
|
||||||
|
builder.AppendLine("3. 优先使用模板里的 `build.cmd` 构建");
|
||||||
|
builder.AppendLine("4. 把构建出来的 DLL 投放到游戏的 `Mods` 目录");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## 为什么不用直接引用仓库 csproj");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("- 外部开发者通常拿不到完整仓库环境");
|
||||||
|
builder.AppendLine("- 独立打开 `Sdk-style csproj` 时,有些 IDE/MSBuild 环境会碰到 workload SDK 解析问题");
|
||||||
|
builder.AppendLine("- 这个开发包的目标就是让外部开发者只依赖 `Libs/` 就能开始写模组");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## 当前游戏 API");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("- 当前先直接导出 `Assembly-CSharp.dll` 作为游戏侧 API");
|
||||||
|
builder.AppendLine("- 长期更推荐单独抽一个稳定的 `Game.ModAPI.dll`,避免把整个游戏主程序集暴露给外部模组");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## 已导出的文件");
|
||||||
|
builder.AppendLine();
|
||||||
|
foreach (var exportedFile in exportedFiles)
|
||||||
|
builder.AppendLine("- `Libs/" + exportedFile + "`");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(outputRoot, "README.md"), builder.ToString(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteSdkManifest(string outputRoot, IReadOnlyList<string> exportedFiles)
|
||||||
|
{
|
||||||
|
var unityVersion = File.Exists(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt"))
|
||||||
|
? File.ReadAllLines(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), "ProjectSettings", "ProjectVersion.txt"))
|
||||||
|
.FirstOrDefault(line => line.StartsWith("m_EditorVersion:", StringComparison.Ordinal))?
|
||||||
|
.Split(':').Last().Trim() ?? string.Empty
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("{");
|
||||||
|
builder.AppendLine($@" ""exportedAt"": ""{DateTime.UtcNow:O}"",");
|
||||||
|
builder.AppendLine($@" ""unityVersion"": ""{unityVersion}"",");
|
||||||
|
builder.AppendLine(@" ""assemblies"": [");
|
||||||
|
for (var i = 0; i < exportedFiles.Count; i++)
|
||||||
|
{
|
||||||
|
var suffix = i == exportedFiles.Count - 1 ? string.Empty : ",";
|
||||||
|
builder.AppendLine($@" ""{exportedFiles[i]}""{suffix}");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" ]");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(outputRoot, "manifest.json"), builder.ToString(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteExternalTemplate(string templateRoot)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.Combine(templateRoot, "src"));
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "README.md"), BuildTemplateReadme(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "global.json"), BuildTemplateGlobalJson(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "build.cmd"), BuildTemplateBuildCmd(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "dev-shell.cmd"), BuildTemplateDevShellCmd(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, TemplateProjectName + ".csproj"), BuildTemplateCsproj(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Contracts.cs"), BuildTemplateContractsCs(), new UTF8Encoding(false));
|
||||||
|
File.WriteAllText(Path.Combine(templateRoot, "src", TemplateProjectName + "Mod.cs"), BuildTemplateModCs(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateReadme()
|
||||||
|
{
|
||||||
|
return @"# SampleShrinkMod
|
||||||
|
|
||||||
|
这是给外部模组开发者的 DLL 模组模板。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `SampleShrinkMod.csproj`
|
||||||
|
- 引用 `..\..\..\Libs\` 下的 SDK DLL
|
||||||
|
- `build.cmd`
|
||||||
|
- 推荐的构建入口,会先设置 `MSBuildEnableWorkloadResolver=false`
|
||||||
|
- `dev-shell.cmd`
|
||||||
|
- 打开一个已经设置好环境变量的命令行
|
||||||
|
- `src/`
|
||||||
|
- 示例模组代码
|
||||||
|
|
||||||
|
EventBus handler 只使用 `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]`。项目已把 `Libs/ShrinkEventBus.Generator.dll` 作为 Roslyn analyzer 引入,因此 Attach/Post 路径不依赖运行时反射。
|
||||||
|
|
||||||
|
## 推荐构建方式
|
||||||
|
|
||||||
|
双击或在命令行运行:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
build.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
## 如果你想开 IDE
|
||||||
|
|
||||||
|
先运行:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
dev-shell.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在这个 shell 里再启动 IDE,或者继续执行 `build.cmd`。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 当前模板默认引用 `..\..\..\Libs\Assembly-CSharp.dll` 作为游戏 API
|
||||||
|
- 长期更推荐游戏方单独提供一个稳定的 `Game.ModAPI.dll`
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateGlobalJson()
|
||||||
|
{
|
||||||
|
return @"{
|
||||||
|
""sdk"": {
|
||||||
|
""version"": ""9.0.312"",
|
||||||
|
""rollForward"": ""latestPatch""
|
||||||
|
}
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateBuildCmd()
|
||||||
|
{
|
||||||
|
return @"@echo off
|
||||||
|
setlocal
|
||||||
|
set MSBuildEnableWorkloadResolver=false
|
||||||
|
dotnet build .\SampleShrinkMod.csproj -c Release /nr:false
|
||||||
|
endlocal";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateDevShellCmd()
|
||||||
|
{
|
||||||
|
return @"@echo off
|
||||||
|
set MSBuildEnableWorkloadResolver=false
|
||||||
|
echo MSBuildEnableWorkloadResolver=false
|
||||||
|
echo Current dotnet:
|
||||||
|
dotnet --version
|
||||||
|
cmd /k";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateCsproj()
|
||||||
|
{
|
||||||
|
var references = new[]
|
||||||
|
{
|
||||||
|
"ShrinkModFramework.Runtime",
|
||||||
|
"ShrinkEventBus.Runtime",
|
||||||
|
"ShrinkCommand.Runtime",
|
||||||
|
"ShrinkCommand.Integration.EventBus",
|
||||||
|
"ShrinkCommand.Integration.Network",
|
||||||
|
"ShrinkNetwork.Runtime",
|
||||||
|
"ShrinkNetwork.Integration.EventBus",
|
||||||
|
"ShrinkDataSaver.Runtime",
|
||||||
|
"ShrinkDataSaver.Integration.EventBus",
|
||||||
|
"Assembly-CSharp",
|
||||||
|
"UniTask",
|
||||||
|
"Newtonsoft.Json",
|
||||||
|
"Kcp-CSharp",
|
||||||
|
"System.Runtime.CompilerServices.Unsafe",
|
||||||
|
"UnityEngine",
|
||||||
|
"UnityEngine.CoreModule"
|
||||||
|
};
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("<Project Sdk=\"Microsoft.NET.Sdk\">");
|
||||||
|
builder.AppendLine(" <PropertyGroup>");
|
||||||
|
builder.AppendLine(" <TargetFramework>net471</TargetFramework>");
|
||||||
|
builder.AppendLine(" <LangVersion>9.0</LangVersion>");
|
||||||
|
builder.AppendLine(" <Nullable>enable</Nullable>");
|
||||||
|
builder.AppendLine(" <ImplicitUsings>disable</ImplicitUsings>");
|
||||||
|
builder.AppendLine(" <MSBuildEnableWorkloadResolver>false</MSBuildEnableWorkloadResolver>");
|
||||||
|
builder.AppendLine(" <RootNamespace>SampleShrinkMod</RootNamespace>");
|
||||||
|
builder.AppendLine(" <AssemblyName>SampleShrinkMod</AssemblyName>");
|
||||||
|
builder.AppendLine(" <GenerateAssemblyInfo>false</GenerateAssemblyInfo>");
|
||||||
|
builder.AppendLine(" <EnableDefaultCompileItems>true</EnableDefaultCompileItems>");
|
||||||
|
builder.AppendLine(" <OutputPath>artifacts\\$(Configuration)\\</OutputPath>");
|
||||||
|
builder.AppendLine(" <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>");
|
||||||
|
builder.AppendLine(" </PropertyGroup>");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" <ItemGroup>");
|
||||||
|
foreach (var reference in references)
|
||||||
|
{
|
||||||
|
builder.AppendLine($" <Reference Include=\"{reference}\">");
|
||||||
|
builder.AppendLine($" <HintPath>..\\..\\..\\Libs\\{reference}.dll</HintPath>");
|
||||||
|
builder.AppendLine(" <Private>true</Private>");
|
||||||
|
builder.AppendLine(" </Reference>");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine(" </ItemGroup>");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine(" <ItemGroup>");
|
||||||
|
builder.AppendLine(" <Analyzer Include=\"..\\..\\..\\Libs\\ShrinkEventBus.Generator.dll\" />");
|
||||||
|
builder.AppendLine(" </ItemGroup>");
|
||||||
|
builder.AppendLine("</Project>");
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateContractsCs()
|
||||||
|
{
|
||||||
|
return @"#nullable enable
|
||||||
|
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace SampleShrinkMod
|
||||||
|
{
|
||||||
|
public sealed class SampleShrinkModEvent : IShrinkEvent
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(41001, ""sample.shrink.mod/ping"")]
|
||||||
|
public sealed class SampleShrinkModPingRequest : IShrinkNetworkRequest
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkMessage(41002, ""sample.shrink.mod/ping_response"")]
|
||||||
|
public sealed class SampleShrinkModPingResponse : ShrinkRpcResponseBase
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public int Counter { get; set; }
|
||||||
|
}
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildTemplateModCs()
|
||||||
|
{
|
||||||
|
return @"#nullable enable
|
||||||
|
|
||||||
|
using ShrinkCommand;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkModFramework;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
namespace SampleShrinkMod
|
||||||
|
{
|
||||||
|
[ShrinkMod(""sample.shrink.mod"", ""Sample Shrink Mod"", ""1.0.0"")]
|
||||||
|
[ShrinkEventSubscriber(OwnerId = ""sample.shrink.mod"", DefaultBus = ""mod:sample.shrink.mod"")]
|
||||||
|
[ShrinkCommandSubscriber]
|
||||||
|
[ShrinkNetworkSubscriber]
|
||||||
|
public sealed partial class SampleShrinkModMod : ShrinkModBase
|
||||||
|
{
|
||||||
|
private readonly RuntimeState _state = new();
|
||||||
|
|
||||||
|
public override void OnConstruct(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.Log(""模组已构造。"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnRegisterContent(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterContent(""example.items"", ""example_item"", ""Sample Shrink Mod Item"");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterNetworkHandler<int>(""sync.counter"", (_, value) =>
|
||||||
|
{
|
||||||
|
_state.Counter = value;
|
||||||
|
context.Log(""收到模组网络计数:"" + value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkSubscribe]
|
||||||
|
private void OnExampleEvent(SampleShrinkModEvent evt)
|
||||||
|
{
|
||||||
|
_state.LastEventMessage = evt.Message ?? string.Empty;
|
||||||
|
_state.Counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkCommand(""sample_shrink_mod ping"", Description = ""测试模组命令"", Permission = ""sample.shrink.mod.command.ping"")]
|
||||||
|
private string Ping()
|
||||||
|
{
|
||||||
|
return ""pong:"" + _state.Counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkSubscribe(Permission = ""sample.shrink.mod.rpc.ping"")]
|
||||||
|
private SampleShrinkModPingResponse HandlePing(SampleShrinkModPingRequest request)
|
||||||
|
{
|
||||||
|
return new SampleShrinkModPingResponse
|
||||||
|
{
|
||||||
|
Message = ""pong:"" + (request.Message ?? string.Empty),
|
||||||
|
Counter = _state.Counter
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RuntimeState
|
||||||
|
{
|
||||||
|
public int Counter { get; set; }
|
||||||
|
public string LastEventMessage { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteStamp(string outputRoot)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
builder.AppendLine("{");
|
||||||
|
builder.AppendLine(@" ""generatedBy"": ""ShrinkModSdkExporter"",");
|
||||||
|
builder.AppendLine($@" ""generatedAt"": ""{DateTime.UtcNow:O}""");
|
||||||
|
builder.AppendLine("}");
|
||||||
|
File.WriteAllText(Path.Combine(outputRoot, StampFileName), builder.ToString(), new UTF8Encoding(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ebad25910ea711d41b17c9dd2bbc6032
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
@@ -0,0 +1,710 @@
|
|||||||
|
# ShrinkModFramework
|
||||||
|
|
||||||
|
一个面向 Unity 的轻量模组框架,目标是给 `Shrink` 系列提供接近 Forge 的核心能力:
|
||||||
|
|
||||||
|
- 模组声明
|
||||||
|
- 模组发现
|
||||||
|
- 依赖解析
|
||||||
|
- 生命周期阶段
|
||||||
|
- 内容注册表
|
||||||
|
- 自动启动
|
||||||
|
- 外部 DLL 模组热加载
|
||||||
|
- Harmony 热补丁接入
|
||||||
|
- 网络同步通道
|
||||||
|
|
||||||
|
## 当前定位
|
||||||
|
|
||||||
|
这是一套 Unity 可落地的 Forge 风格核心框架,不是 Minecraft Forge 的逐项复刻。
|
||||||
|
|
||||||
|
当前重点是:
|
||||||
|
|
||||||
|
- 已编译进工程内的模组
|
||||||
|
- 外部 DLL 模组按内容 revision 的增量发现与替换
|
||||||
|
- `ShrinkContextHost` 驱动的模组组件生命周期与失败恢复
|
||||||
|
- Harmony 补丁自动应用
|
||||||
|
- 与具体联网库解耦的网络同步框架
|
||||||
|
|
||||||
|
当前不包含:
|
||||||
|
|
||||||
|
- 运行时卸载已加载程序集(程序集仍受 Unity/Mono AppDomain 限制驻留)
|
||||||
|
- IL2CPP Player 下的外部 DLL 动态加载
|
||||||
|
- 内置的资源包系统、命令系统、配方编辑器
|
||||||
|
- 内置的具体联网实现
|
||||||
|
|
||||||
|
## 自动启动
|
||||||
|
|
||||||
|
现在默认**不需要**把 `ShrinkModBootstrap` 挂到场景里。
|
||||||
|
|
||||||
|
框架会通过:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||||
|
```
|
||||||
|
|
||||||
|
自动读取 `ShrinkModFrameworkSettings` 并调用装载流程。
|
||||||
|
|
||||||
|
相关入口:
|
||||||
|
|
||||||
|
- `Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs`
|
||||||
|
- `Runtime/Loading/ShrinkModLoader.cs`
|
||||||
|
|
||||||
|
`ShrinkModBootstrap` 仍然保留,作为手动覆盖或调试入口,但不是必需品。
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
创建 `ShrinkModFrameworkSettings` 资产后,框架会自动查找它。
|
||||||
|
|
||||||
|
关键配置包括:
|
||||||
|
|
||||||
|
- `autoLoadOnStartup`
|
||||||
|
- 是否在启动时自动装载模组
|
||||||
|
- `useContextHost`
|
||||||
|
- 默认开启:把模组四阶段放进 `ShrinkModContextHost`,替换失败恢复旧组件源;关闭后回退旧 Loader
|
||||||
|
- `verboseLogging`
|
||||||
|
- 是否输出详细日志
|
||||||
|
- `assemblyNamePrefixes`
|
||||||
|
- 只扫描指定前缀的程序集
|
||||||
|
- `enableExternalDllMods`
|
||||||
|
- 是否启用外部 DLL 模组
|
||||||
|
- `externalModsFolderName`
|
||||||
|
- 外部模组目录名,默认 `Mods`
|
||||||
|
- `watchExternalModsDirectory`
|
||||||
|
- 是否自动监听目录变化并协调外部 DLL revision 变化
|
||||||
|
- `externalModsReloadDelaySeconds`
|
||||||
|
- 文件变更后延迟多少秒再尝试热加载
|
||||||
|
- `externalAssemblyRevisionSoftLimit`
|
||||||
|
- 外部 DLL 常驻 revision 的软阈值;达到阈值时提示执行 Domain Reload 或重启进程,默认 `16`
|
||||||
|
- `enableHarmonyPatching`
|
||||||
|
- 是否启用 Harmony 自动补丁
|
||||||
|
- `enableNetworkSync`
|
||||||
|
- 是否启用网络同步框架
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
当前 `Assets/Modules/ShrinkModFramework/Runtime/` 按职责拆成:
|
||||||
|
|
||||||
|
- `Bootstrap/`
|
||||||
|
- 启动入口、运行时驱动、设置资产
|
||||||
|
- `Core/`
|
||||||
|
- 模组实例生命周期核心类型
|
||||||
|
- `Metadata/`
|
||||||
|
- 模组特性、依赖、状态、版本信息
|
||||||
|
- `Registry/`
|
||||||
|
- 模组注册表与注册表管理器
|
||||||
|
- `Loading/`
|
||||||
|
- 模组发现、装载、外部 DLL 与 Harmony 接入
|
||||||
|
- `Network/`
|
||||||
|
- 模组网络抽象层
|
||||||
|
- `Integration/`
|
||||||
|
- 对现有 Shrink 模块的可选自动接入胶水
|
||||||
|
|
||||||
|
推荐阅读顺序:
|
||||||
|
|
||||||
|
1. `Bootstrap/`
|
||||||
|
2. `Core/` + `Metadata/`
|
||||||
|
3. `Loading/`
|
||||||
|
4. `Registry/` + `Network/` + `Integration/`
|
||||||
|
|
||||||
|
另外还有一层编辑器脚手架:
|
||||||
|
|
||||||
|
- `Editor/Scaffolding/`
|
||||||
|
- `ShrinkExternalModTemplateGenerator.cs`
|
||||||
|
- `ExternalModProjectTemplate/`
|
||||||
|
|
||||||
|
这层专门给模组开发者生成外部 DLL 模组模板,不参与运行时装载。
|
||||||
|
|
||||||
|
## 模组定义
|
||||||
|
|
||||||
|
### 基础模组
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ShrinkModFramework;
|
||||||
|
|
||||||
|
[ShrinkMod("demo.core", "Demo Core", "1.0.0")]
|
||||||
|
public class DemoCoreMod : ShrinkModBase
|
||||||
|
{
|
||||||
|
public override void OnRegisterContent(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.RegisterContent("items", "iron_hammer", "Iron Hammer");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.Log("初始化完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 声明依赖
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ShrinkModFramework;
|
||||||
|
|
||||||
|
[ShrinkMod("demo.magic", "Demo Magic", "1.0.0")]
|
||||||
|
[ShrinkModDependency("demo.core", "1.0.0")]
|
||||||
|
public class DemoMagicMod : ShrinkModBase
|
||||||
|
{
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
context.Log("我会在 demo.core 之后初始化");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关闭模组自动补丁
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ShrinkMod("demo.safe", "Demo Safe", "1.0.0", AutoApplyHarmonyPatches = false)]
|
||||||
|
public class DemoSafeMod : ShrinkModBase
|
||||||
|
{
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生命周期
|
||||||
|
|
||||||
|
每个模组按依赖顺序进入以下阶段:
|
||||||
|
|
||||||
|
1. `OnConstruct`
|
||||||
|
2. `OnRegisterContent`
|
||||||
|
3. `OnInitialize`
|
||||||
|
4. `OnReady`
|
||||||
|
|
||||||
|
推荐职责:
|
||||||
|
|
||||||
|
- `OnConstruct`
|
||||||
|
- 初始化本模组运行时对象
|
||||||
|
- `OnRegisterContent`
|
||||||
|
- 向注册表注册物品、方块、能力、配方等
|
||||||
|
- `OnInitialize`
|
||||||
|
- 注册事件、接线系统、绑定网络频道
|
||||||
|
- `OnReady`
|
||||||
|
- 做依赖其他模组最终状态的收尾逻辑
|
||||||
|
|
||||||
|
## 外部 DLL 模组热加载
|
||||||
|
|
||||||
|
框架会扫描:
|
||||||
|
|
||||||
|
`Application.persistentDataPath/<externalModsFolderName>`
|
||||||
|
|
||||||
|
默认就是:
|
||||||
|
|
||||||
|
`Application.persistentDataPath/Mods`
|
||||||
|
|
||||||
|
ContextLoader 装载规则:
|
||||||
|
|
||||||
|
- 以 DLL 内容 SHA-256 作为 revision;同 revision 幂等,不触发重载
|
||||||
|
- 文件变更会加载新程序集并提交新的模组组件源;旧程序集仍驻留,但旧 fiber 的效应会先回滚
|
||||||
|
- 新模组任何阶段失败时,Host 会重新协调旧 source;旧注册表内容和已登记效应恢复
|
||||||
|
- 文件删除会移除当前 source 并卸载模组实例;不会宣称程序集已从 AppDomain 卸载
|
||||||
|
- 不支持 IL2CPP Player 动态程序集加载
|
||||||
|
- 支持同目录依赖程序集解析
|
||||||
|
|
||||||
|
你可以在运行时调用:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ShrinkModLoader.LoadNewExternalMods();
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会扫描当前 DLL revision,并把新增、替换、删除映射为一个完整期望组合;
|
||||||
|
变更事务失败时保留旧模组组合。设置 `useContextHost = false` 才回退为仅新增 DLL 的旧路径。
|
||||||
|
|
||||||
|
如果 `watchExternalModsDirectory = true`,框架还会监听新增、修改、删除、重命名事件,经过主线程 debouncer 后提交一次完整组合;同一 burst 内的中间坏文件不会覆盖当前有效 revision。
|
||||||
|
|
||||||
|
### 常驻 revision 诊断
|
||||||
|
|
||||||
|
Unity/Mono 不能从当前 AppDomain 单独卸载已载入程序集。框架保留当前 revision 的生效语义,同时通过以下接口暴露实际常驻情况:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
|
||||||
|
```
|
||||||
|
|
||||||
|
快照包含当前与历史 revision、来源路径、程序集名、SHA-256 revision、载入字节数、常驻数量和软阈值状态。失败 revision 可以成为已载入的历史程序集,但不会成为 current;达到软阈值只告警,不伪造卸载行为。
|
||||||
|
|
||||||
|
导入 `ShrinkContext.AppAdapter` 的 Editor 工具后,也可以从 `ShrinkSDK/Cordis/诊断与组合` 查看同一份常驻快照;该窗口通过可选反射读取,不会让 AppAdapter 对 ModFramework 建立硬依赖。
|
||||||
|
|
||||||
|
## Harmony 热补丁
|
||||||
|
|
||||||
|
如果运行环境里存在 `0Harmony`,ContextHost 会把每个模组的补丁租约作为可逆效应管理:激活时调用
|
||||||
|
`PatchAll(modAssembly)`,卸载/替换时调用 `UnpatchSelf`(或兼容的 `UnpatchAll(id)`)。旧 Loader
|
||||||
|
路径仍按原逻辑只应用一次。
|
||||||
|
|
||||||
|
旧路径的调用形态是:
|
||||||
|
|
||||||
|
- `Harmony("shrink.mod.<modId>")`
|
||||||
|
- `PatchAll(modAssembly)`
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 模组可以在自己的程序集里直接写 Harmony Patch 类
|
||||||
|
- 框架负责发现模组后自动补丁,并在 ContextHost 路径登记逆操作
|
||||||
|
- 如果没有 Harmony,框架只会跳过,不会强依赖崩溃
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 当前是“可选桥接”,不是把 Harmony 打进框架里
|
||||||
|
- 需要你自己把 `0Harmony.dll` 放进项目环境或外部模组依赖中
|
||||||
|
|
||||||
|
## 注册表
|
||||||
|
|
||||||
|
### 注册内容
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var key = context.RegisterContent("items", "sword", "Sword");
|
||||||
|
// key == "demo.core:sword"
|
||||||
|
```
|
||||||
|
|
||||||
|
基础注册不接受任意完整键。框架始终使用当前 `ModId` 生成 `<owner>:<localKey>`,避免模组误写其它 owner 的 namespace。
|
||||||
|
|
||||||
|
### 覆盖已有内容
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
context.OverrideContent(
|
||||||
|
"items",
|
||||||
|
"core:iron_sword",
|
||||||
|
priority: 100,
|
||||||
|
value: "Overridden Sword");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 读取内容
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var itemRegistry = context.GetRegistry<string>("items");
|
||||||
|
if (itemRegistry.TryGet("demo.core:sword", out var itemName))
|
||||||
|
{
|
||||||
|
context.Log($"找到内容:{itemName}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 基础项的完整键固定为 `<ownerModId>:<localKey>`
|
||||||
|
- `context.GetRegistry<T>()` 只返回 `IReadOnlyShrinkModRegistry<T>`;写入只能经过 `RegisterContent` / `OverrideContent`
|
||||||
|
- 覆盖目标必须已经存在
|
||||||
|
- 优先级越高越先命中;同目标、同优先级直接报冲突
|
||||||
|
- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值
|
||||||
|
- `Entries` 返回当前生效项,`BaseEntries` 返回未应用覆盖的基础项
|
||||||
|
- 注册表按类型和名称双重隔离
|
||||||
|
|
||||||
|
## 网络同步
|
||||||
|
|
||||||
|
框架内置的是**网络同步抽象层**,不是具体联网库。
|
||||||
|
|
||||||
|
核心接口:
|
||||||
|
|
||||||
|
- `IShrinkModNetworkTransport`
|
||||||
|
- `ShrinkModNetworkManager`
|
||||||
|
|
||||||
|
这意味着你可以把它接到:
|
||||||
|
|
||||||
|
- NGO
|
||||||
|
- Mirror
|
||||||
|
- FishNet
|
||||||
|
- 自己的 Socket/Relay 层
|
||||||
|
|
||||||
|
### 注册消息处理器
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
context.RegisterNetworkHandler<int>("sync.hp", (messageContext, value) =>
|
||||||
|
{
|
||||||
|
Debug.Log($"收到 HP:{value}");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送消息
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
context.SendToServer("sync.hp", 100);
|
||||||
|
context.SendToAllClients("sync.hp", 100);
|
||||||
|
context.SendToClient("sync.hp", 100, "client-1");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 绑定传输层
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class MyTransport : IShrinkModNetworkTransport
|
||||||
|
{
|
||||||
|
public bool IsServer => true;
|
||||||
|
public bool IsClient => true;
|
||||||
|
|
||||||
|
public event Action<ShrinkModNetworkEnvelope> OnEnvelopeReceived;
|
||||||
|
|
||||||
|
public void Send(ShrinkModNetworkEnvelope envelope)
|
||||||
|
{
|
||||||
|
// 这里接你自己的网络库
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShrinkModNetworkManager.SetTransport(new MyTransport());
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与现有模块协作
|
||||||
|
|
||||||
|
当前仓库里已经有这些可复用模块:
|
||||||
|
|
||||||
|
- `ShrinkEventBus`
|
||||||
|
- `ShrinkDataSaver`
|
||||||
|
- `ShrinkCommand`
|
||||||
|
- `ShrinkCommand.Integration.EventBus`
|
||||||
|
- `ShrinkCommand.Integration.Network`
|
||||||
|
- `ShrinkNetwork`
|
||||||
|
- `ShrinkNetwork.Integration.EventBus`
|
||||||
|
- `ShrinkDataSaver.Integration.EventBus`
|
||||||
|
|
||||||
|
`ShrinkModFramework` 当前的定位不是“重新包一层这些模块”,而是给模组提供统一生命周期,然后在合适阶段把模组实例接到这些模块现成的注册入口上。
|
||||||
|
|
||||||
|
### 自动接入了什么
|
||||||
|
|
||||||
|
从当前版本开始,模组经过:
|
||||||
|
|
||||||
|
1. `OnConstruct`
|
||||||
|
2. `OnRegisterContent`
|
||||||
|
|
||||||
|
之后,框架会自动尝试把**模组实例本身**接入这些现有模块:
|
||||||
|
|
||||||
|
- 如果模组类带 `[ShrinkEventSubscriber]` 且携带生成绑定,自动 `Attach` 到 `ShrinkBusKey.Mod(modId)`,并在模组生命周期结束时释放 binding
|
||||||
|
- 如果模组类带 `[ShrinkCommandSubscriber]`,自动调用 `ShrinkCommandRuntime.Default.RegisterCommands(modInstance)`
|
||||||
|
- 如果模组类带 `[ShrinkNetworkSubscriber]`,自动调用 `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
|
||||||
|
- 如果项目里存在 `ShrinkNetwork.Integration.EventBus`,会额外调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`,把新模组里声明的网络事件类型补进桥接层
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- **项目内模组**和**运行时外部 DLL 模组**都可以把实例方法挂到 `EventBus / Command / Network`
|
||||||
|
- 模组作者不需要自己再写一遍宿主层胶水
|
||||||
|
- EventBus 是模组框架的正式依赖;Command、Network 及其桥接仍按安装情况接入
|
||||||
|
|
||||||
|
### 推荐写法
|
||||||
|
|
||||||
|
最稳的做法是把“和现有模块交互的入口”直接写在模组类实例上,而不是依赖外部 DLL 的静态自动扫描。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using ShrinkCommand;
|
||||||
|
using ShrinkDataSaver;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using ShrinkModFramework;
|
||||||
|
using ShrinkNetwork;
|
||||||
|
|
||||||
|
[ShrinkMod("demo.full", "Demo Full", "1.0.0")]
|
||||||
|
[ShrinkEventSubscriber(OwnerId = "demo.full", DefaultBus = "mod:demo.full")]
|
||||||
|
[ShrinkCommandSubscriber]
|
||||||
|
[ShrinkNetworkSubscriber]
|
||||||
|
public sealed partial class DemoFullMod : ShrinkModBase
|
||||||
|
{
|
||||||
|
private DemoSaveData _saveData = new();
|
||||||
|
|
||||||
|
public override void OnInitialize(ShrinkModContext context)
|
||||||
|
{
|
||||||
|
ShrinkSave.RegisterModule(
|
||||||
|
$"{context.ModInfo.ModId}.save",
|
||||||
|
() => _saveData,
|
||||||
|
data => _saveData = data ?? new DemoSaveData());
|
||||||
|
|
||||||
|
context.RegisterNetworkHandler<int>("sync.level", (messageContext, level) =>
|
||||||
|
{
|
||||||
|
_saveData.Level = level;
|
||||||
|
context.Log($"同步等级:{level}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkSubscribe]
|
||||||
|
private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt)
|
||||||
|
{
|
||||||
|
// 这里只是示意:模组类实例会被框架自动接到 EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkCommand("demo set-level <value>", Permission = "demo.admin")]
|
||||||
|
private string SetLevel(int value)
|
||||||
|
{
|
||||||
|
_saveData.Level = value;
|
||||||
|
return $"level={value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[ShrinkNetworkSubscribe]
|
||||||
|
private UniTask<DemoPingResponse> HandlePing(DemoPingRequest request)
|
||||||
|
{
|
||||||
|
return UniTask.FromResult(new DemoPingResponse
|
||||||
|
{
|
||||||
|
Message = "pong:" + request.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各模块怎么用
|
||||||
|
|
||||||
|
#### 1. `ShrinkEventBus`
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 模组内部系统解耦
|
||||||
|
- 监听 `ShrinkDataSaver.Integration.EventBus`
|
||||||
|
- 配合 `ShrinkNetwork.Integration.EventBus` 做事件即网络消息
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 模组类本身带 `[ShrinkEventSubscriber]` 并声明 `DefaultBus = "mod:<modId>"`
|
||||||
|
- 实例方法上写 `[ShrinkSubscribe]`
|
||||||
|
- 类型声明为 `partial`,由 ILPostProcessor 或 Roslyn incremental generator 生成强类型 binding
|
||||||
|
- 由 `ShrinkModFramework` 自动 Attach 到模组 Bus,并随模组生命周期释放
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- Unity 项目内类型由 ILPostProcessor 生成 binding;外部 DLL 使用导出 SDK 中的 `ShrinkEventBus.Generator` 分析器
|
||||||
|
- 外部 DLL 必须携带生成合同;正式运行时不会反射扫描没有生成合同的旧程序集
|
||||||
|
- 模组框架只负责把已生成的实例 binding Attach 到对应 Mod Bus,不会枚举方法或调用 `MethodInfo.Invoke`
|
||||||
|
|
||||||
|
#### 2. `ShrinkDataSaver`
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 模组自己的配置和进度持久化
|
||||||
|
- 跨模组只读查询
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 在 `OnInitialize` 里调用 `ShrinkSave.RegisterModule(...)`
|
||||||
|
- 模块 key 用 `modId` 做前缀,例如 `demo.full.save`
|
||||||
|
- 读取别的模块数据时优先走 `ShrinkSave.QueryModule<T>(...)`
|
||||||
|
|
||||||
|
当前边界:
|
||||||
|
|
||||||
|
- `ShrinkModFramework` 不会替你自动注册存档模块,因为每个模组要保存什么只能由业务自己决定
|
||||||
|
- 当前也没有热卸载流程,所以运行时新增 DLL 后注册的存档模块默认跟随本轮进程直到退出
|
||||||
|
|
||||||
|
#### 3. `ShrinkCommand`
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 模组开放调试命令
|
||||||
|
- 服务端管理命令
|
||||||
|
- 配合网络桥接做远程命令
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 模组类带 `[ShrinkCommandSubscriber]`
|
||||||
|
- 实例方法写 `[ShrinkCommand("path ...")]`
|
||||||
|
- 交给框架自动注册到 `ShrinkCommandRuntime.Default`
|
||||||
|
|
||||||
|
如果要继续往外接:
|
||||||
|
|
||||||
|
- 需要 EventBus 请求式命令时,用 `ShrinkCommand.Integration.EventBus`
|
||||||
|
- 需要远程命令 RPC 时,用 `ShrinkCommand.Integration.Network`
|
||||||
|
|
||||||
|
#### 4. `ShrinkNetwork`
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 模组自己的 RPC / 消息协议
|
||||||
|
- 模组内部状态同步
|
||||||
|
|
||||||
|
推荐分两层:
|
||||||
|
|
||||||
|
- 简单场景:继续用 `context.RegisterNetworkHandler(...)` 和 `context.SendToServer/Client/...`
|
||||||
|
- 需要完整 `ShrinkNetwork` 能力时:模组类带 `[ShrinkNetworkSubscriber]`,实例方法写 `[ShrinkNetworkSubscribe]`
|
||||||
|
|
||||||
|
当前自动接入的是:
|
||||||
|
|
||||||
|
- `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 模组实例上的网络 handler 可以直接进默认服务
|
||||||
|
- handler 参数里的消息类型如果带 `[ShrinkNetworkMessage]`,注册时会一起补齐消息元数据
|
||||||
|
|
||||||
|
#### 5. `ShrinkNetwork.Integration.EventBus`
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 事件本身就是网络协议
|
||||||
|
- 本地 EventBus 与远端 EventBus 保持同一套事件语义
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 事件类型同时满足 `IShrinkEvent + IShrinkNetworkMessage`
|
||||||
|
- 标记 `[ShrinkNetworkEvent] + [ShrinkNetworkMessage(...)]`
|
||||||
|
- 让模组监听或发布这些事件,而不是再写一层重复 DTO
|
||||||
|
|
||||||
|
当前补的胶水:
|
||||||
|
|
||||||
|
- 外部 DLL 模组装载后,框架会调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`
|
||||||
|
|
||||||
|
这能解决:
|
||||||
|
|
||||||
|
- 新模组里的网络事件类型,原来桥接层启动时看不到
|
||||||
|
- 现在新增 DLL 后可把这些事件补进桥接层的入站注册表
|
||||||
|
|
||||||
|
### 项目内模组 vs 外部 DLL 模组
|
||||||
|
|
||||||
|
这两类不要混着理解:
|
||||||
|
|
||||||
|
- **项目内模组**
|
||||||
|
- 编译时就在 Unity 当前 AppDomain 里
|
||||||
|
- 由 Unity ILPostProcessor 生成 EventBus binding
|
||||||
|
- **运行时外部 DLL 模组**
|
||||||
|
- 是 `ShrinkModFramework` 后续动态加载进来的
|
||||||
|
- 必须引用导出包里的 `ShrinkEventBus.Generator.dll` 生成同一份 binding 合同
|
||||||
|
|
||||||
|
所以当前推荐原则很明确:
|
||||||
|
|
||||||
|
- **对外部 DLL,优先写实例方法**
|
||||||
|
- 不要把可发现性建立在“宿主已经提前扫过一次全局静态特性”上
|
||||||
|
|
||||||
|
### 还没自动做的事
|
||||||
|
|
||||||
|
当前版本还**没有**替你自动做这些:
|
||||||
|
|
||||||
|
- 不会自动把模组数据注册进 `ShrinkDataSaver`
|
||||||
|
- 不会自动帮你给 `ShrinkModNetworkManager` 绑定 `ShrinkNetwork` 传输层
|
||||||
|
- 不会为外部 DLL 的静态 `EventBus / Command / Network` 特性类做全局重复扫描
|
||||||
|
|
||||||
|
原因很直接:
|
||||||
|
|
||||||
|
- `DataSaver` 需要业务决定保存什么
|
||||||
|
- `ShrinkModNetworkManager` 只是抽象层,具体要不要复用 `ShrinkNetwork`、Mirror、NGO 取决于宿主策略
|
||||||
|
- 对现有静态扫描入口做“再次全局扫描”容易产生重复注册
|
||||||
|
|
||||||
|
如果后面要继续往前推,最值得补的不是再加更多反射,而是做两块正式基础设施:
|
||||||
|
|
||||||
|
- `ShrinkModFramework.Integration.ShrinkNetwork`
|
||||||
|
- 把 `IShrinkModNetworkTransport` 正式桥到 `ShrinkNetworkSession / ShrinkNetworkService`
|
||||||
|
- `ShrinkModFramework.Integration.DataSaver`
|
||||||
|
- 给模组提供规范化的 `modId` 前缀存档注册和可选卸载清理策略
|
||||||
|
|
||||||
|
## 生成模组模板
|
||||||
|
|
||||||
|
现在已经提供和 `ShrinkNetwork` 独立服务器生成器同风格的模组开发入口:
|
||||||
|
|
||||||
|
- 菜单:`ShrinkSDK/Mod/导出 Mod SDK 开发包`
|
||||||
|
- 菜单:`ShrinkSDK/Mod/生成仓库内模组模板(推荐)`
|
||||||
|
- 菜单:`ShrinkSDK/Mod/生成外部模组模板`
|
||||||
|
|
||||||
|
更推荐优先用“仓库内模组模板”:
|
||||||
|
|
||||||
|
- 直接生成到当前 Unity 项目的 `Assets/GeneratedMods/...`
|
||||||
|
- 复用当前 `ShrinkSDK.sln`
|
||||||
|
- 复用当前 Unity / asmdef 编译链
|
||||||
|
- 不需要单独打开外部 `csproj`
|
||||||
|
- 也就不会撞到独立工程那类 IDE / workload SDK 解析问题
|
||||||
|
|
||||||
|
适合:
|
||||||
|
|
||||||
|
- 你自己就在这个 SDK 仓库里开发模组
|
||||||
|
- 想最快开始写代码
|
||||||
|
- 想直接享受当前解决方案里的跳转、补全、编译和 Unity 刷新
|
||||||
|
|
||||||
|
“外部模组模板”更适合:
|
||||||
|
|
||||||
|
- 真正要把模组工程单独发给外部开发者
|
||||||
|
- 或者明确要独立于当前仓库维护一个 DLL 工程
|
||||||
|
|
||||||
|
如果目标是给**外部模组开发者**发 SDK,当前更推荐直接用:
|
||||||
|
|
||||||
|
- `ShrinkSDK/Mod/导出 Mod SDK 开发包`
|
||||||
|
|
||||||
|
它会导出:
|
||||||
|
|
||||||
|
- `Libs/`
|
||||||
|
- 已编译好的框架运行时 DLL
|
||||||
|
- 当前游戏 DLL
|
||||||
|
- 模板构建所需的基础依赖
|
||||||
|
- `Templates/ExternalMod/SampleShrinkMod/`
|
||||||
|
- 一个已经改成引用 `Libs/*.dll` 的外部模组模板
|
||||||
|
- 自带 `build.cmd` 与 `dev-shell.cmd`
|
||||||
|
- 不再直接引用你本地仓库 `csproj`
|
||||||
|
|
||||||
|
这套开发包比“只给一个模板工程”更适合外发,因为:
|
||||||
|
|
||||||
|
- 外部开发者不需要拿到完整仓库
|
||||||
|
- 不需要依赖你本地 `ShrinkSDK.sln`
|
||||||
|
- 运行时框架和游戏 API 会跟模板一起发出去
|
||||||
|
- 当前默认游戏 API 先用 `Assembly-CSharp.dll`
|
||||||
|
- 后续如果你单独抽出 `Game.ModAPI.dll`,只需要替换导出内容即可
|
||||||
|
|
||||||
|
生成器会:
|
||||||
|
|
||||||
|
- 让你选择一个输出目录
|
||||||
|
- 以目录名推导默认的 `ProjectName / Namespace / DisplayName / ModId`
|
||||||
|
- 生成一个可独立构建的外部 DLL 模组工程
|
||||||
|
|
||||||
|
默认生成内容包括:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `build.ps1`
|
||||||
|
- `.gitignore`
|
||||||
|
- `global.json`
|
||||||
|
- `__PROJECT_NAME__.csproj`
|
||||||
|
- `src/__PROJECT_NAME__Mod.cs`
|
||||||
|
- `src/__PROJECT_NAME__Contracts.cs`
|
||||||
|
|
||||||
|
模板工程默认引用当前仓库里的:
|
||||||
|
|
||||||
|
- `ShrinkModFramework.Runtime.csproj`
|
||||||
|
- `ShrinkEventBus.Runtime.csproj`
|
||||||
|
- `ShrinkCommand.Runtime.csproj`
|
||||||
|
- `ShrinkNetwork.Runtime.csproj`
|
||||||
|
|
||||||
|
所以它不是一个“纯空壳”,而是直接演示:
|
||||||
|
|
||||||
|
- `[ShrinkMod]`
|
||||||
|
- `ShrinkModBase` 生命周期
|
||||||
|
- `EventBus / Command / Network` 实例自动接入
|
||||||
|
- 一个最小 RPC 请求/响应示例
|
||||||
|
|
||||||
|
兼容性处理:
|
||||||
|
|
||||||
|
- 模板 `csproj` 默认关闭 `MSBuild workload resolver`
|
||||||
|
- 模板同时会写入 `global.json`,锁到当前生成时检测到的 `dotnet --version`
|
||||||
|
- 模板还会生成 `dev-env.ps1`,用于在启动 IDE 前先设置 `MSBuildEnableWorkloadResolver=false`
|
||||||
|
|
||||||
|
这样可以尽量避免某些 IDE / MSBuild 环境在打开项目时误触发
|
||||||
|
`Microsoft.NET.SDK.WorkloadAutoImportPropsLocator` 解析失败。
|
||||||
|
|
||||||
|
建议流程:
|
||||||
|
|
||||||
|
1. 你自己在当前仓库开发时,优先用 `ShrinkSDK/Mod/生成仓库内模组模板(推荐)`
|
||||||
|
2. 先改 `modId`、命名空间、命令路径、`opcode`
|
||||||
|
3. 在当前 Unity/IDE 里直接开发和编译
|
||||||
|
4. 如果要发给外部模组开发者,改用 `ShrinkSDK/Mod/导出 Mod SDK 开发包`
|
||||||
|
|
||||||
|
如果你已经知道目标 `Mods` 目录,也可以直接跑模板里的:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1 -ModsDir "D:\YourGame\Mods"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- 一个模组类必须有 `[ShrinkMod]`
|
||||||
|
- 一个模组类必须实现 `IShrinkMod`
|
||||||
|
- 一个模组类必须能被无参构造
|
||||||
|
- 模组 ID 必须全局唯一
|
||||||
|
- 必选依赖缺失或版本不满足时,装载直接失败
|
||||||
|
- 检测到循环依赖时,装载直接失败
|
||||||
|
- 外部 DLL 支持当前组合的添加、替换与删除;已载入的程序集 revision 仍常驻,回滚和卸载单位是模组 fiber 与其效应
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
- `Runtime/Bootstrap/ShrinkModRuntimeBootstrap.cs`
|
||||||
|
- `Runtime/Loading/ShrinkModLoader.cs`
|
||||||
|
- `Runtime/Loading/ShrinkExternalModAssemblyLoader.cs`
|
||||||
|
- `Runtime/Loading/ShrinkHarmonyPatchService.cs`
|
||||||
|
- `Runtime/Registry/ShrinkModRegistry.cs`
|
||||||
|
- `Runtime/Network/ShrinkModNetworkManager.cs`
|
||||||
|
- `Runtime/Network/IShrinkModNetworkTransport.cs`
|
||||||
|
- `Runtime/Integration/ShrinkModOptionalRuntimeIntegration.cs`
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
现在这套框架已经从“只能在工程内静态发现模组”的骨架,升级成了:
|
||||||
|
|
||||||
|
- 自动启动
|
||||||
|
- 可增量接入外部 DLL
|
||||||
|
- 可选 Harmony 补丁
|
||||||
|
- 可扩展的网络同步框架
|
||||||
|
|
||||||
|
如果继续往 Forge 靠,下一步最值得做的是:
|
||||||
|
|
||||||
|
- 模组配置系统
|
||||||
|
- 模组资源系统
|
||||||
|
- 模组命令系统
|
||||||
|
- 模组专属存档与同步策略
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 647c1debb678f9543bb6da0f94a51e90
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ddc14a844da2693418fe69ac4b2c0836
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("ShrinkModFramework.Tests")]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4db6b958232258a439bebcb315ee304d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fb836b5fd9485e94cbefc8f1d743bc0f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[DefaultExecutionOrder(-2100)]
|
||||||
|
public sealed class ShrinkModBootstrap : MonoBehaviour
|
||||||
|
{
|
||||||
|
[Header("Override (留空则只使用组件上的选项)")]
|
||||||
|
[SerializeField] private ShrinkModFrameworkSettings settingsOverride;
|
||||||
|
|
||||||
|
[Header("Bootstrap")]
|
||||||
|
[SerializeField] private bool autoLoadOnAwake = true;
|
||||||
|
|
||||||
|
private static bool _bootstrapped;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (_bootstrapped)
|
||||||
|
{
|
||||||
|
Destroy(gameObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bootstrapped = true;
|
||||||
|
DontDestroyOnLoad(gameObject);
|
||||||
|
|
||||||
|
var shouldAutoLoad = settingsOverride ? settingsOverride.autoLoadOnStartup : autoLoadOnAwake;
|
||||||
|
if (shouldAutoLoad)
|
||||||
|
ShrinkModLoader.LoadAll(settingsOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 23fd762abde4cfe48bf93f9ff86ffdd5
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[CreateAssetMenu(fileName = "ShrinkModFrameworkSettings", menuName = "ShrinkModFramework/Settings")]
|
||||||
|
public class ShrinkModFrameworkSettings : ScriptableObject
|
||||||
|
{
|
||||||
|
private static ShrinkModFrameworkSettings _instance;
|
||||||
|
|
||||||
|
public static ShrinkModFrameworkSettings Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_instance) return _instance;
|
||||||
|
|
||||||
|
_instance = Resources.Load<ShrinkModFrameworkSettings>("ShrinkModFrameworkSettings");
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
if (!_instance)
|
||||||
|
{
|
||||||
|
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkModFrameworkSettings");
|
||||||
|
if (guids.Length > 0)
|
||||||
|
{
|
||||||
|
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||||
|
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkModFrameworkSettings>(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (!_instance)
|
||||||
|
{
|
||||||
|
_instance = CreateInstance<ShrinkModFrameworkSettings>();
|
||||||
|
Debug.LogWarning(
|
||||||
|
"[ShrinkModFramework] 未找到 ShrinkModFrameworkSettings 配置文件,当前使用默认配置。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
internal set => _instance = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Header("Bootstrap")]
|
||||||
|
public bool autoLoadOnStartup = true;
|
||||||
|
[Tooltip("使用 ShrinkContextHost 协调模组组件;关闭时回退到旧的只增不减 ShrinkModLoader。")]
|
||||||
|
public bool useContextHost = true;
|
||||||
|
|
||||||
|
[Header("Logging")]
|
||||||
|
public bool verboseLogging = true;
|
||||||
|
|
||||||
|
[Header("Discovery")]
|
||||||
|
public string[] assemblyNamePrefixes = new string[0];
|
||||||
|
|
||||||
|
[Header("External Mods")]
|
||||||
|
public bool enableExternalDllMods = true;
|
||||||
|
public bool autoCreateExternalModsDirectory = true;
|
||||||
|
public string externalModsFolderName = "Mods";
|
||||||
|
public bool watchExternalModsDirectory = true;
|
||||||
|
public float externalModsReloadDelaySeconds = 0.5f;
|
||||||
|
[Min(1)]
|
||||||
|
[Tooltip("Mono 下外部程序集 revision 会常驻;历史数量达到该软阈值后提示 Domain Reload/重启。")]
|
||||||
|
public int externalAssemblyRevisionSoftLimit = 16;
|
||||||
|
|
||||||
|
[Header("Harmony")]
|
||||||
|
public bool enableHarmonyPatching = true;
|
||||||
|
|
||||||
|
[Header("Networking")]
|
||||||
|
public bool enableNetworkSync = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8f9516fe9a338fb448308a9205c3d448
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
/// <summary>把文件系统的 burst 事件折叠为一次主线程 reload。</summary>
|
||||||
|
internal sealed class ShrinkModReloadDebouncer
|
||||||
|
{
|
||||||
|
private bool _pending;
|
||||||
|
private float _reloadAt;
|
||||||
|
|
||||||
|
public bool IsPending => _pending;
|
||||||
|
|
||||||
|
public void Schedule(float now, float delaySeconds)
|
||||||
|
{
|
||||||
|
_pending = true;
|
||||||
|
_reloadAt = now + Math.Max(0.05f, delaySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryConsume(float now)
|
||||||
|
{
|
||||||
|
if (!_pending || now < _reloadAt)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_pending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
_pending = false;
|
||||||
|
_reloadAt = 0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ec113876ec861b64cb8391eed726108b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public static class ShrinkModRuntimeBootstrap
|
||||||
|
{
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
private static void ResetStaticStateForPlayMode()
|
||||||
|
{
|
||||||
|
ShrinkModLoader.ResetForDomainReload();
|
||||||
|
ShrinkModNetworkManager.ResetForDomainReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
|
||||||
|
private static void AutoLoad()
|
||||||
|
{
|
||||||
|
var settings = ShrinkModFrameworkSettings.Instance;
|
||||||
|
ShrinkModRuntimeDriver.EnsureCreated(settings);
|
||||||
|
|
||||||
|
if (settings && !settings.autoLoadOnStartup)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShrinkModLoader.LoadAll(settings);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogException(ex);
|
||||||
|
Debug.LogError($"[ShrinkModFramework] 自动启动失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 329c6f80f39fd4840b987c16a6124b1c
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[DefaultExecutionOrder(-2099)]
|
||||||
|
internal sealed class ShrinkModRuntimeDriver : MonoBehaviour
|
||||||
|
{
|
||||||
|
private static ShrinkModRuntimeDriver _instance;
|
||||||
|
|
||||||
|
private readonly ConcurrentQueue<Action> _mainThreadActions = new();
|
||||||
|
private readonly ShrinkModReloadDebouncer _externalReloadDebouncer = new();
|
||||||
|
private FileSystemWatcher _watcher;
|
||||||
|
private ShrinkModFrameworkSettings _settings;
|
||||||
|
|
||||||
|
public static void EnsureCreated(ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
if (_instance)
|
||||||
|
{
|
||||||
|
_instance.ApplySettings(settings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var go = new GameObject("ShrinkModRuntimeDriver");
|
||||||
|
if (Application.isPlaying)
|
||||||
|
DontDestroyOnLoad(go);
|
||||||
|
_instance = go.AddComponent<ShrinkModRuntimeDriver>();
|
||||||
|
_instance.ApplySettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Enqueue(Action action)
|
||||||
|
{
|
||||||
|
if (action == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_instance == null)
|
||||||
|
throw new InvalidOperationException("ShrinkModRuntimeDriver 尚未创建。");
|
||||||
|
|
||||||
|
_instance._mainThreadActions.Enqueue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ShrinkModRuntimeDriver InstanceForTesting => _instance;
|
||||||
|
|
||||||
|
internal bool HasWatcherForTesting => _watcher != null;
|
||||||
|
|
||||||
|
internal void PumpForTesting() => Update();
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
while (_mainThreadActions.TryDequeue(out var action))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_externalReloadDebouncer.TryConsume(Time.realtimeSinceStartup))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShrinkModLoader.LoadNewExternalMods(_settings);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogException(ex);
|
||||||
|
Debug.LogError($"[ShrinkModFramework] 自动热加载外部模组失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
DisposeWatcher();
|
||||||
|
if (_instance == this)
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySettings(ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
_settings = settings ?? ShrinkModFrameworkSettings.Instance;
|
||||||
|
|
||||||
|
if (_settings == null || !_settings.enableExternalDllMods || !_settings.watchExternalModsDirectory)
|
||||||
|
{
|
||||||
|
DisposeWatcher();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrySetupWatcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrySetupWatcher()
|
||||||
|
{
|
||||||
|
#if ENABLE_IL2CPP && !UNITY_EDITOR
|
||||||
|
return;
|
||||||
|
#else
|
||||||
|
var directory = ShrinkExternalModAssemblyLoader.GetExternalModsDirectory(_settings);
|
||||||
|
if (_settings.autoCreateExternalModsDirectory)
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_watcher != null && string.Equals(_watcher.Path, directory, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
DisposeWatcher();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_watcher = new FileSystemWatcher(directory, "*.dll")
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime
|
||||||
|
};
|
||||||
|
_watcher.Created += OnExternalModsChanged;
|
||||||
|
_watcher.Changed += OnExternalModsChanged;
|
||||||
|
_watcher.Deleted += OnExternalModsChanged;
|
||||||
|
_watcher.Renamed += OnExternalModsRenamed;
|
||||||
|
_watcher.EnableRaisingEvents = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModFramework] 外部模组目录监听启动失败:{ex.Message}");
|
||||||
|
DisposeWatcher();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExternalModsChanged(object sender, FileSystemEventArgs args)
|
||||||
|
{
|
||||||
|
ScheduleExternalReload(args.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExternalModsRenamed(object sender, RenamedEventArgs args)
|
||||||
|
{
|
||||||
|
ScheduleExternalReload(args.OldFullPath);
|
||||||
|
ScheduleExternalReload(args.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScheduleExternalReload(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_mainThreadActions.Enqueue(() =>
|
||||||
|
{
|
||||||
|
var delay = _settings ? Mathf.Max(0.05f, _settings.externalModsReloadDelaySeconds) : 0.5f;
|
||||||
|
_externalReloadDebouncer.Schedule(Time.realtimeSinceStartup, delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeWatcher()
|
||||||
|
{
|
||||||
|
if (_watcher == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_watcher.EnableRaisingEvents = false;
|
||||||
|
_watcher.Created -= OnExternalModsChanged;
|
||||||
|
_watcher.Changed -= OnExternalModsChanged;
|
||||||
|
_watcher.Deleted -= OnExternalModsChanged;
|
||||||
|
_watcher.Renamed -= OnExternalModsRenamed;
|
||||||
|
_watcher.Dispose();
|
||||||
|
_watcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9a850cdc47a8b5b41a7559c3d1fd9efc
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6eddd3eb11f065f4d912ab35a69576f3
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using ShrinkContext;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
/// <summary>把 IShrinkMod 四阶段生命周期映射为一个可逆 Cordis 组件。</summary>
|
||||||
|
internal sealed class ShrinkModComponent : IShrinkComponent
|
||||||
|
{
|
||||||
|
private readonly ShrinkModContextHost _host;
|
||||||
|
private readonly ShrinkModComponentSource _source;
|
||||||
|
private readonly string[] _inject;
|
||||||
|
private readonly string[] _provide;
|
||||||
|
|
||||||
|
public ShrinkModComponent(ShrinkModContextHost host, ShrinkModComponentSource source)
|
||||||
|
{
|
||||||
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||||
|
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||||
|
_inject = source.Info.Dependencies
|
||||||
|
.Where(dependency => !dependency.Optional)
|
||||||
|
.Select(dependency => ShrinkModContextHost.GetModKey(dependency.ModId))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
_provide = new[] { ShrinkModContextHost.GetModKey(source.Info.ModId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name => "shrink.mod/" + _source.Info.ModId;
|
||||||
|
public IReadOnlyList<string> Inject => _inject;
|
||||||
|
public IReadOnlyList<string> Provide => _provide;
|
||||||
|
|
||||||
|
public UniTask ApplyAsync(ShrinkCtx ctx, object config)
|
||||||
|
{
|
||||||
|
var instance = _source.Factory();
|
||||||
|
if (instance == null)
|
||||||
|
throw new InvalidOperationException($"Mod factory returned null: {_source.Info.ModId}");
|
||||||
|
|
||||||
|
var handle = new ShrinkModHandle(_source.Info, instance)
|
||||||
|
{
|
||||||
|
Generation = _host.NextGeneration()
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.Effect(
|
||||||
|
() => _host.RegisterHandle(handle),
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
_host.UnregisterHandle(handle);
|
||||||
|
handle.State = ShrinkModState.Unloaded;
|
||||||
|
});
|
||||||
|
|
||||||
|
IDisposable harmonyLease = null;
|
||||||
|
ctx.Effect(
|
||||||
|
() => harmonyLease = ShrinkHarmonyPatchService.AcquirePatchesIfNeeded(
|
||||||
|
handle.Info, _host.EnableHarmonyPatching, _host.VerboseLogging),
|
||||||
|
() => harmonyLease?.Dispose());
|
||||||
|
|
||||||
|
var modContext = new ShrinkModContext(handle.Info, _host.RegistryManager,
|
||||||
|
_host.Mods, _host.VerboseLogging, ctx);
|
||||||
|
|
||||||
|
instance.OnConstruct(modContext);
|
||||||
|
handle.State = ShrinkModState.Constructed;
|
||||||
|
|
||||||
|
// 先登记归属清理,OnRegisterContent 中途失败也能撤回已注册的部分内容。
|
||||||
|
ctx.Effect(() => { }, () => _host.RegistryManager.RemoveOwnedEntries(handle.Info.ModId));
|
||||||
|
instance.OnRegisterContent(modContext);
|
||||||
|
handle.State = ShrinkModState.ContentRegistered;
|
||||||
|
|
||||||
|
IDisposable eventBinding = null;
|
||||||
|
ctx.Effect(
|
||||||
|
() => eventBinding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(
|
||||||
|
instance.GetType(), instance, handle.Info.ModId, _host.VerboseLogging),
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
eventBinding?.Dispose();
|
||||||
|
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模组网络 handler 以 ModId 为归属键,可在失败或卸载时整体撤回。
|
||||||
|
ctx.Effect(() => { }, () => ShrinkModNetworkManager.UnregisterHandlers(handle.Info.ModId));
|
||||||
|
instance.OnInitialize(modContext);
|
||||||
|
handle.State = ShrinkModState.Initialized;
|
||||||
|
|
||||||
|
instance.OnReady(modContext);
|
||||||
|
handle.State = ShrinkModState.Ready;
|
||||||
|
ctx.Set(_provide[0], handle);
|
||||||
|
return UniTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 167a3359a4a357a49ab81ba266caa07f
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 把工程内程序集和外部 DLL 的当前 revision 投影为延迟组件源。
|
||||||
|
/// 同一路径的旧外部程序集无法从 Mono AppDomain 卸载,但不会再进入候选集合。
|
||||||
|
/// </summary>
|
||||||
|
internal static class ShrinkModComponentDiscovery
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ShrinkModComponentSource> Discover(ShrinkModFrameworkSettings settings,
|
||||||
|
bool verboseLogging)
|
||||||
|
{
|
||||||
|
ShrinkExternalModAssemblyLoader.ScanExternalAssemblyRevisions(settings, verboseLogging);
|
||||||
|
var results = new List<ShrinkModComponentSource>();
|
||||||
|
var prefixes = settings?.assemblyNamePrefixes;
|
||||||
|
|
||||||
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
if (ShrinkExternalModAssemblyLoader.IsExternalAssembly(assembly) &&
|
||||||
|
!ShrinkExternalModAssemblyLoader.TryGetCurrentRevision(assembly, out _))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ShouldScanAssembly(assembly, prefixes))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var assemblyRevision = GetAssemblyRevision(assembly);
|
||||||
|
foreach (var type in GetTypesSafely(assembly))
|
||||||
|
{
|
||||||
|
if (type == null || type.IsAbstract || type.IsInterface)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var modAttribute = type.GetCustomAttribute<ShrinkModAttribute>(false);
|
||||||
|
if (modAttribute == null)
|
||||||
|
continue;
|
||||||
|
if (!typeof(IShrinkMod).IsAssignableFrom(type))
|
||||||
|
{
|
||||||
|
Debug.LogWarning(
|
||||||
|
$"[ShrinkModFramework] 类型 {type.FullName} 标记了 ShrinkMod,但没有实现 IShrinkMod,已跳过。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (type.GetConstructor(Type.EmptyTypes) == null)
|
||||||
|
throw new InvalidOperationException($"模组 {type.FullName} 缺少无参构造函数,无法实例化。");
|
||||||
|
|
||||||
|
var dependencies = type
|
||||||
|
.GetCustomAttributes<ShrinkModDependencyAttribute>(false)
|
||||||
|
.Select(attribute => new ShrinkModDependency(
|
||||||
|
attribute.ModId, attribute.MinimumVersion, attribute.Optional))
|
||||||
|
.ToArray();
|
||||||
|
var info = new ShrinkModInfo(
|
||||||
|
modAttribute.ModId,
|
||||||
|
modAttribute.DisplayName,
|
||||||
|
modAttribute.Version,
|
||||||
|
modAttribute.LoadOrder,
|
||||||
|
type,
|
||||||
|
modAttribute.AutoApplyHarmonyPatches,
|
||||||
|
dependencies);
|
||||||
|
var revision = $"{assemblyRevision}:{type.FullName}";
|
||||||
|
results.Add(new ShrinkModComponentSource(info, revision,
|
||||||
|
() => (IShrinkMod)Activator.CreateInstance(type)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAssemblyRevision(Assembly assembly)
|
||||||
|
{
|
||||||
|
if (ShrinkExternalModAssemblyLoader.TryGetCurrentRevision(assembly, out var externalRevision))
|
||||||
|
return externalRevision;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return assembly.ManifestModule.ModuleVersionId.ToString("N");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return assembly.FullName ?? assembly.GetName().Name ?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldScanAssembly(Assembly assembly, string[] prefixes)
|
||||||
|
{
|
||||||
|
var name = assembly.GetName().Name;
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
return false;
|
||||||
|
if (name.StartsWith("Unity", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("System", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("netstandard", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prefixes == null || prefixes.Length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return prefixes.Any(prefix => !string.IsNullOrWhiteSpace(prefix) &&
|
||||||
|
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> GetTypesSafely(Assembly assembly)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return assembly.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException exception)
|
||||||
|
{
|
||||||
|
return exception.Types.Where(type => type != null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<Type>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 95e3bad3c1c50b44db3b144af1aafda8
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 延迟模组组件源。Revision 是来源内容的稳定指纹(外部 DLL 可使用文件哈希);
|
||||||
|
/// 同一 ModId 的 revision/factory 变化由 ShrinkModContextHost 作为替换事务处理。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShrinkModComponentSource
|
||||||
|
{
|
||||||
|
public ShrinkModComponentSource(ShrinkModInfo info, string revision, Func<IShrinkMod> factory)
|
||||||
|
{
|
||||||
|
Info = info ?? throw new ArgumentNullException(nameof(info));
|
||||||
|
if (string.IsNullOrWhiteSpace(info.ModId))
|
||||||
|
throw new ArgumentException("ModId must not be empty.", nameof(info));
|
||||||
|
if (string.IsNullOrWhiteSpace(revision))
|
||||||
|
throw new ArgumentException("Revision must not be empty.", nameof(revision));
|
||||||
|
|
||||||
|
Revision = revision.Trim();
|
||||||
|
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShrinkModInfo Info { get; }
|
||||||
|
public string Revision { get; }
|
||||||
|
public Func<IShrinkMod> Factory { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0878db300b244c645bdc69c5b96b70d5
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using ShrinkContext;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public sealed class ShrinkModTransactionException : Exception
|
||||||
|
{
|
||||||
|
public ShrinkModTransactionException(string message, Exception applyError, Exception restoreError = null)
|
||||||
|
: base(message, restoreError == null ? applyError : new AggregateException(applyError, restoreError))
|
||||||
|
{
|
||||||
|
ApplyError = applyError;
|
||||||
|
RestoreError = restoreError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Exception ApplyError { get; }
|
||||||
|
public Exception RestoreError { get; }
|
||||||
|
public bool PreviousCompositionRestored => RestoreError == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模组的 Cordis 组合宿主。配置应用是事务:新组合任一 fiber 失败时,重新协调到旧组件源;
|
||||||
|
/// Mono/IL2CPP 下旧程序集仍驻留,但旧/新模组实例及其已追踪效应会被正确卸载或恢复。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ShrinkModContextHost
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ShrinkModHandle> _mods = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<ShrinkModComponentSource, string> _catalogNames = new();
|
||||||
|
private readonly List<ShrinkModComponentSource> _currentSources = new();
|
||||||
|
private readonly ShrinkComponentCatalog _catalog = new();
|
||||||
|
private long _generation;
|
||||||
|
private int _catalogGeneration;
|
||||||
|
private bool _applying;
|
||||||
|
|
||||||
|
public ShrinkModContextHost(bool enableHarmonyPatching = true, bool verboseLogging = false)
|
||||||
|
{
|
||||||
|
EnableHarmonyPatching = enableHarmonyPatching;
|
||||||
|
VerboseLogging = verboseLogging;
|
||||||
|
Runtime = new ShrinkContextRuntime();
|
||||||
|
Loader = new ShrinkContextLoader(Runtime, _catalog);
|
||||||
|
RegistryManager = new ShrinkModRegistryManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<ShrinkModHandle> OnModReady;
|
||||||
|
public event Action<IReadOnlyDictionary<string, ShrinkModHandle>> OnAllModsReady;
|
||||||
|
|
||||||
|
public bool EnableHarmonyPatching { get; }
|
||||||
|
public bool VerboseLogging { get; }
|
||||||
|
public ShrinkContextRuntime Runtime { get; }
|
||||||
|
public ShrinkContextLoader Loader { get; }
|
||||||
|
public IReadOnlyDictionary<string, ShrinkModHandle> Mods => _mods;
|
||||||
|
public IReadOnlyList<ShrinkModComponentSource> CurrentSources => _currentSources.ToArray();
|
||||||
|
internal ShrinkModRegistryManager RegistryManager { get; }
|
||||||
|
|
||||||
|
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
|
||||||
|
RegistryManager.GetOrCreateRegistry<T>(name);
|
||||||
|
|
||||||
|
public static string GetModKey(string modId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modId))
|
||||||
|
throw new ArgumentException("ModId must not be empty.", nameof(modId));
|
||||||
|
return "shrink.mod/" + modId.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async UniTask ApplyAsync(IReadOnlyList<ShrinkModComponentSource> desiredSources)
|
||||||
|
{
|
||||||
|
if (desiredSources == null)
|
||||||
|
throw new ArgumentNullException(nameof(desiredSources));
|
||||||
|
if (_applying)
|
||||||
|
throw new InvalidOperationException("A mod composition transaction is already running.");
|
||||||
|
|
||||||
|
var desired = ReuseUnchangedSources(ValidateAndOrder(desiredSources));
|
||||||
|
var previous = _currentSources.ToArray();
|
||||||
|
var previousById = previous.ToDictionary(source => source.Info.ModId, StringComparer.Ordinal);
|
||||||
|
_applying = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Loader.ApplyAsync(BuildEntries(desired));
|
||||||
|
EnsureActive(desired);
|
||||||
|
|
||||||
|
_currentSources.Clear();
|
||||||
|
_currentSources.AddRange(desired);
|
||||||
|
foreach (var source in desired)
|
||||||
|
{
|
||||||
|
if (!previousById.TryGetValue(source.Info.ModId, out var oldSource) ||
|
||||||
|
!ReferenceEquals(source, oldSource))
|
||||||
|
{
|
||||||
|
OnModReady?.Invoke(_mods[source.Info.ModId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnAllModsReady?.Invoke(Mods);
|
||||||
|
}
|
||||||
|
catch (Exception applyError)
|
||||||
|
{
|
||||||
|
Exception restoreError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Loader.ApplyAsync(BuildEntries(previous));
|
||||||
|
EnsureActive(previous);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
restoreError = ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ShrinkModTransactionException(
|
||||||
|
restoreError == null
|
||||||
|
? "Mod composition failed; the previous composition was restored."
|
||||||
|
: "Mod composition failed and restoring the previous composition also failed.",
|
||||||
|
applyError,
|
||||||
|
restoreError);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_applying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async UniTask ShutdownAsync()
|
||||||
|
{
|
||||||
|
if (_applying)
|
||||||
|
throw new InvalidOperationException("Cannot shut down while a mod transaction is running.");
|
||||||
|
|
||||||
|
await Loader.ApplyAsync(Array.Empty<ShrinkLoaderEntry>());
|
||||||
|
_currentSources.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long NextGeneration() => ++_generation;
|
||||||
|
|
||||||
|
internal void RegisterHandle(ShrinkModHandle handle)
|
||||||
|
{
|
||||||
|
if (!_mods.TryAdd(handle.Info.ModId, handle))
|
||||||
|
throw new InvalidOperationException($"Mod handle already active: {handle.Info.ModId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void UnregisterHandle(ShrinkModHandle handle)
|
||||||
|
{
|
||||||
|
if (_mods.TryGetValue(handle.Info.ModId, out var current) && ReferenceEquals(current, handle))
|
||||||
|
_mods.Remove(handle.Info.ModId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<ShrinkLoaderEntry> BuildEntries(IEnumerable<ShrinkModComponentSource> sources)
|
||||||
|
{
|
||||||
|
var entries = new List<ShrinkLoaderEntry>();
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
if (!_catalogNames.TryGetValue(source, out var catalogName))
|
||||||
|
{
|
||||||
|
catalogName = $"shrink.mod.source/{source.Info.ModId}/{++_catalogGeneration}";
|
||||||
|
_catalogNames.Add(source, catalogName);
|
||||||
|
_catalog.Register(catalogName, () => new ShrinkModComponent(this, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new ShrinkLoaderEntry(source.Info.ModId, catalogName));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureActive(IEnumerable<ShrinkModComponentSource> sources)
|
||||||
|
{
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
if (!Loader.TryGetFiber(source.Info.ModId, out var fiber))
|
||||||
|
throw new InvalidOperationException($"Mod fiber was not created: {source.Info.ModId}");
|
||||||
|
if (fiber.LastError != null)
|
||||||
|
throw new InvalidOperationException($"Mod {source.Info.ModId} failed during apply.", fiber.LastError);
|
||||||
|
if (fiber.State != ShrinkFiberState.Active)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Mod {source.Info.ModId} did not become active (state={fiber.State}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ShrinkModComponentSource> ValidateAndOrder(
|
||||||
|
IReadOnlyList<ShrinkModComponentSource> sources)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, ShrinkModComponentSource>(StringComparer.Ordinal);
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
if (source == null)
|
||||||
|
throw new ArgumentException("Mod sources must not contain null.", nameof(sources));
|
||||||
|
if (!map.TryAdd(source.Info.ModId, source))
|
||||||
|
throw new InvalidOperationException($"Duplicate mod source id: {source.Info.ModId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
foreach (var dependency in source.Info.Dependencies)
|
||||||
|
{
|
||||||
|
if (!map.TryGetValue(dependency.ModId, out var target))
|
||||||
|
{
|
||||||
|
if (!dependency.Optional)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Mod {source.Info.ModId} is missing required dependency {dependency.ModId}.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(dependency.MinimumVersion) &&
|
||||||
|
ShrinkVersionUtility.Compare(target.Info.Version, dependency.MinimumVersion) < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Mod {source.Info.ModId} requires {dependency.ModId} >= {dependency.MinimumVersion}, " +
|
||||||
|
$"but found {target.Info.Version}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<ShrinkModComponentSource>();
|
||||||
|
var visiting = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var source in sources.OrderBy(item => item.Info.LoadOrder)
|
||||||
|
.ThenBy(item => item.Info.ModId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
Visit(source, map, visiting, visited, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ShrinkModComponentSource> ReuseUnchangedSources(
|
||||||
|
IEnumerable<ShrinkModComponentSource> desired)
|
||||||
|
{
|
||||||
|
var currentById = _currentSources.ToDictionary(source => source.Info.ModId, StringComparer.Ordinal);
|
||||||
|
var normalized = new List<ShrinkModComponentSource>();
|
||||||
|
foreach (var source in desired)
|
||||||
|
{
|
||||||
|
if (currentById.TryGetValue(source.Info.ModId, out var current) &&
|
||||||
|
string.Equals(current.Revision, source.Revision, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
normalized.Add(current);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalized.Add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Visit(ShrinkModComponentSource source,
|
||||||
|
IReadOnlyDictionary<string, ShrinkModComponentSource> map,
|
||||||
|
ISet<string> visiting,
|
||||||
|
ISet<string> visited,
|
||||||
|
ICollection<ShrinkModComponentSource> result)
|
||||||
|
{
|
||||||
|
if (visited.Contains(source.Info.ModId))
|
||||||
|
return;
|
||||||
|
if (!visiting.Add(source.Info.ModId))
|
||||||
|
throw new InvalidOperationException($"Circular mod dependency includes {source.Info.ModId}.");
|
||||||
|
|
||||||
|
foreach (var dependency in source.Info.Dependencies.OrderBy(item => item.ModId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (map.TryGetValue(dependency.ModId, out var target))
|
||||||
|
Visit(target, map, visiting, visited, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.Remove(source.Info.ModId);
|
||||||
|
visited.Add(source.Info.ModId);
|
||||||
|
result.Add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5a1df7a5c357c614a976ff968dac34fd
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
/// <summary>默认模组组件根;目录变更只提交完整的新组合,失败时由 Host 恢复旧组合。</summary>
|
||||||
|
public static class ShrinkModCordisRuntime
|
||||||
|
{
|
||||||
|
private static ShrinkModContextHost _host;
|
||||||
|
|
||||||
|
public static bool IsInitialized => _host != null;
|
||||||
|
public static ShrinkModContextHost Host => _host ?? throw new InvalidOperationException(
|
||||||
|
"ShrinkModCordisRuntime has not been initialized.");
|
||||||
|
public static IReadOnlyDictionary<string, ShrinkModHandle> Mods =>
|
||||||
|
_host?.Mods ?? EmptyMods;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, ShrinkModHandle> EmptyMods =
|
||||||
|
new Dictionary<string, ShrinkModHandle>();
|
||||||
|
|
||||||
|
public static async UniTask<IReadOnlyDictionary<string, ShrinkModHandle>> ApplyDiscoveredAsync(
|
||||||
|
ShrinkModFrameworkSettings settings = null)
|
||||||
|
{
|
||||||
|
settings ??= ShrinkModFrameworkSettings.Instance;
|
||||||
|
var verboseLogging = settings == null || settings.verboseLogging;
|
||||||
|
if (_host == null)
|
||||||
|
{
|
||||||
|
_host = new ShrinkModContextHost(
|
||||||
|
settings == null || settings.enableHarmonyPatching,
|
||||||
|
verboseLogging);
|
||||||
|
}
|
||||||
|
|
||||||
|
var revisionState = ShrinkExternalModAssemblyLoader.CaptureState();
|
||||||
|
ShrinkModNetworkManager.Configure(settings == null || settings.enableNetworkSync, verboseLogging);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sources = ShrinkModComponentDiscovery.Discover(settings, verboseLogging);
|
||||||
|
await _host.ApplyAsync(sources);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// DLL revision discovery is part of the same composition transaction:
|
||||||
|
// a failing replacement must not make the failed assembly current.
|
||||||
|
ShrinkExternalModAssemblyLoader.RestoreState(revisionState);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verboseLogging)
|
||||||
|
UnityEngine.Debug.Log($"[ShrinkModFramework] Cordis 模组组合已提交,共 {_host.Mods.Count} 个。");
|
||||||
|
return _host.Mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
|
||||||
|
Host.GetOrCreateRegistry<T>(name);
|
||||||
|
|
||||||
|
internal static void ResetForDomainReload()
|
||||||
|
{
|
||||||
|
_host = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ResetForTesting()
|
||||||
|
{
|
||||||
|
if (_host != null)
|
||||||
|
_host.ShutdownAsync().GetAwaiter().GetResult();
|
||||||
|
_host = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b8767fd6541a55046a6d2f2ec992c446
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6f3fa4ee5dcc33646922caaf6b2b72d8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public interface IShrinkMod
|
||||||
|
{
|
||||||
|
void OnConstruct(ShrinkModContext context);
|
||||||
|
void OnRegisterContent(ShrinkModContext context);
|
||||||
|
void OnInitialize(ShrinkModContext context);
|
||||||
|
void OnReady(ShrinkModContext context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6736c9aa27396dc4d9ceb5efd0db3ccb
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public abstract class ShrinkModBase : IShrinkMod
|
||||||
|
{
|
||||||
|
public virtual void OnConstruct(ShrinkModContext context) { }
|
||||||
|
public virtual void OnRegisterContent(ShrinkModContext context) { }
|
||||||
|
public virtual void OnInitialize(ShrinkModContext context) { }
|
||||||
|
public virtual void OnReady(ShrinkModContext context) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eb330a4a244f00444a73f5b1786ff80a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using ShrinkContext;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public sealed class ShrinkModContext
|
||||||
|
{
|
||||||
|
private readonly ShrinkModRegistryManager _registryManager;
|
||||||
|
private readonly IReadOnlyDictionary<string, ShrinkModHandle> _loadedMods;
|
||||||
|
private readonly bool _verboseLogging;
|
||||||
|
private readonly ShrinkCtx _effectContext;
|
||||||
|
|
||||||
|
internal ShrinkModContext(ShrinkModInfo modInfo, ShrinkModRegistryManager registryManager,
|
||||||
|
IReadOnlyDictionary<string, ShrinkModHandle> loadedMods, bool verboseLogging,
|
||||||
|
ShrinkCtx effectContext = null)
|
||||||
|
{
|
||||||
|
ModInfo = modInfo;
|
||||||
|
_registryManager = registryManager;
|
||||||
|
_loadedMods = loadedMods;
|
||||||
|
_verboseLogging = verboseLogging;
|
||||||
|
_effectContext = effectContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShrinkModInfo ModInfo { get; }
|
||||||
|
public IReadOnlyDictionary<string, ShrinkModHandle> LoadedMods => _loadedMods;
|
||||||
|
public bool IsContextManaged => _effectContext != null;
|
||||||
|
|
||||||
|
public IReadOnlyShrinkModRegistry<T> GetRegistry<T>(string name) =>
|
||||||
|
_registryManager.GetOrCreateRegistry<T>(name);
|
||||||
|
|
||||||
|
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
|
||||||
|
=> _registryManager.TryGetRegistry(name, out registry);
|
||||||
|
|
||||||
|
/// <summary>用当前 ModId 作为 namespace 注册内容,并返回最终完整键。</summary>
|
||||||
|
public string RegisterContent<T>(string registryName, string localKey, T value) =>
|
||||||
|
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
|
||||||
|
.RegisterNamespaced(ModInfo.ModId, localKey, value);
|
||||||
|
|
||||||
|
/// <summary>以当前模组为 owner 覆盖已有内容;同目标、同优先级冲突会直接失败。</summary>
|
||||||
|
public void OverrideContent<T>(string registryName, string targetKey, int priority, T value) =>
|
||||||
|
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
|
||||||
|
.RegisterOverride(ModInfo.ModId, targetKey, priority, value);
|
||||||
|
|
||||||
|
public bool IsModLoaded(string modId) => !string.IsNullOrWhiteSpace(modId) && _loadedMods.ContainsKey(modId);
|
||||||
|
|
||||||
|
public bool TryGetLoadedMod(string modId, out ShrinkModHandle handle)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(modId))
|
||||||
|
return _loadedMods.TryGetValue(modId, out handle);
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在模组组件上下文中执行前向动作并就地登记逆操作。逆操作随 fiber 卸载按 LIFO 执行。
|
||||||
|
/// 旧 ShrinkModLoader 路径没有可逆上下文,调用本 API 会明确失败而不是静默丢失 cleanup。
|
||||||
|
/// </summary>
|
||||||
|
public void Effect(Action forward, Action inverse)
|
||||||
|
{
|
||||||
|
if (_effectContext == null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"ShrinkModContext.Effect requires ShrinkModContextHost. The legacy ShrinkModLoader cannot track inverses.");
|
||||||
|
_effectContext.Effect(forward, inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>为已完成的前向动作登记同步逆操作。</summary>
|
||||||
|
public void EffectInverse(Action inverse)
|
||||||
|
{
|
||||||
|
if (inverse == null)
|
||||||
|
throw new ArgumentNullException(nameof(inverse));
|
||||||
|
Effect(() => { }, inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log(string message)
|
||||||
|
{
|
||||||
|
if (_verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkMod:{ModInfo.ModId}] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterNetworkHandler<T>(string channel, System.Action<ShrinkModNetworkMessageContext, T> handler)
|
||||||
|
=> ShrinkModNetworkManager.RegisterHandler(ModInfo.ModId, channel, handler);
|
||||||
|
|
||||||
|
public void SendToServer<T>(string channel, T payload, string senderPeerId = null)
|
||||||
|
=> ShrinkModNetworkManager.SendToServer(ModInfo.ModId, channel, payload, senderPeerId);
|
||||||
|
|
||||||
|
public void SendToAllClients<T>(string channel, T payload, string senderPeerId = null)
|
||||||
|
=> ShrinkModNetworkManager.SendToAllClients(ModInfo.ModId, channel, payload, senderPeerId);
|
||||||
|
|
||||||
|
public void SendToClient<T>(string channel, T payload, string targetPeerId, string senderPeerId = null)
|
||||||
|
=> ShrinkModNetworkManager.SendToClient(ModInfo.ModId, channel, payload, targetPeerId, senderPeerId);
|
||||||
|
|
||||||
|
public void LogWarning(string message) => Debug.LogWarning($"[ShrinkMod:{ModInfo.ModId}] {message}");
|
||||||
|
public void LogError(string message) => Debug.LogError($"[ShrinkMod:{ModInfo.ModId}] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3b8a0f4cfa66c5142a114eacadb74b75
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public sealed class ShrinkModHandle
|
||||||
|
{
|
||||||
|
public ShrinkModInfo Info { get; }
|
||||||
|
public IShrinkMod Instance { get; }
|
||||||
|
public ShrinkModState State { get; internal set; }
|
||||||
|
public long Generation { get; internal set; }
|
||||||
|
internal IDisposable EventBinding { get; set; }
|
||||||
|
|
||||||
|
internal ShrinkModHandle(ShrinkModInfo info, IShrinkMod instance)
|
||||||
|
{
|
||||||
|
Info = info;
|
||||||
|
Instance = instance;
|
||||||
|
State = ShrinkModState.Discovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6609ebbb46db87c42a2fa1d45bd12efe
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d81e389d24bac1a45bedd3a97d679a87
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
internal static class ShrinkModOptionalRuntimeIntegration
|
||||||
|
{
|
||||||
|
public static IDisposable RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo,
|
||||||
|
bool verboseLogging)
|
||||||
|
{
|
||||||
|
if (modInstance == null || modInfo == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var modType = modInstance.GetType();
|
||||||
|
var eventBinding = TryAttachEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
|
||||||
|
TryRegisterCommandInstance(modType, modInstance, modInfo.ModId, verboseLogging);
|
||||||
|
TryRegisterNetworkInstance(modType, modInstance, modInfo.ModId, verboseLogging);
|
||||||
|
return eventBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RefreshGlobalBindings(bool verboseLogging)
|
||||||
|
{
|
||||||
|
TryRefreshNetworkEventBusBridge(verboseLogging);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IDisposable TryAttachEventBusInstance(Type modType, object modInstance,
|
||||||
|
string modId, bool verboseLogging)
|
||||||
|
{
|
||||||
|
if (modInstance is not IShrinkGeneratedSubscriber)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var busKey = ShrinkBusKey.Mod(modId);
|
||||||
|
EventBus.GetOrCreateBus(busKey, ShrinkBusOptions.DedicatedThread());
|
||||||
|
var binding = EventBus.Attach(modInstance, busKey);
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkEventBus 实例订阅。");
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkEventBus 失败:{ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRegisterCommandInstance(Type modType, object modInstance, string modId, bool verboseLogging)
|
||||||
|
{
|
||||||
|
if (!HasAttribute(modType, "ShrinkCommand.ShrinkCommandSubscriberAttribute", "ShrinkCommand.Runtime"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtimeType = FindType("ShrinkCommand.ShrinkCommandRuntime", "ShrinkCommand.Runtime");
|
||||||
|
var service = runtimeType?.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||||
|
var registerMethod = service?.GetType().GetMethod("RegisterCommands",
|
||||||
|
BindingFlags.Public | BindingFlags.Instance,
|
||||||
|
null,
|
||||||
|
new[] { typeof(object) },
|
||||||
|
null);
|
||||||
|
if (registerMethod == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
registerMethod.Invoke(service, new[] { modInstance });
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkCommand 默认服务。");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkCommand 失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRegisterNetworkInstance(Type modType, object modInstance, string modId, bool verboseLogging)
|
||||||
|
{
|
||||||
|
if (!HasAttribute(modType, "ShrinkNetwork.ShrinkNetworkSubscriberAttribute", "ShrinkNetwork.Runtime"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtimeType = FindType("ShrinkNetwork.ShrinkNetworkRuntime", "ShrinkNetwork.Runtime");
|
||||||
|
var service = runtimeType?.GetProperty("Default", BindingFlags.Public | BindingFlags.Static)?.GetValue(null);
|
||||||
|
var registerMethod = service?.GetType().GetMethod("RegisterHandlers",
|
||||||
|
BindingFlags.Public | BindingFlags.Instance,
|
||||||
|
null,
|
||||||
|
new[] { typeof(object) },
|
||||||
|
null);
|
||||||
|
if (registerMethod == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
registerMethod.Invoke(service, new[] { modInstance });
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkNetwork 默认服务。");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkNetwork 失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryRefreshNetworkEventBusBridge(bool verboseLogging)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bridgeType = FindType("ShrinkNetwork.Integration.ShrinkNetworkEventBusBridge",
|
||||||
|
"ShrinkNetwork.Integration.EventBus");
|
||||||
|
var refreshMethod = bridgeType?.GetMethod("RefreshBindings",
|
||||||
|
BindingFlags.Public | BindingFlags.Static,
|
||||||
|
null,
|
||||||
|
Type.EmptyTypes,
|
||||||
|
null);
|
||||||
|
if (refreshMethod == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
refreshMethod.Invoke(null, null);
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log("[ShrinkModFramework] 已刷新 ShrinkNetwork.Integration.EventBus 绑定。");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModFramework] 刷新 ShrinkNetwork.Integration.EventBus 绑定失败:{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAttribute(Type targetType, string attributeFullName, string assemblyName)
|
||||||
|
{
|
||||||
|
var attributeType = FindType(attributeFullName, assemblyName);
|
||||||
|
return attributeType != null && targetType.GetCustomAttribute(attributeType, false) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type FindType(string fullName, string assemblyName)
|
||||||
|
{
|
||||||
|
return Type.GetType($"{fullName}, {assemblyName}", false) ??
|
||||||
|
AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(assembly => string.Equals(assembly.GetName().Name, assemblyName, StringComparison.Ordinal))
|
||||||
|
.Select(assembly => assembly.GetType(fullName, false))
|
||||||
|
.FirstOrDefault(type => type != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 733bd8496a2bbcb4fa29594a14d3b0b5
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: fba48516fe357cd4eb44de16cea78788
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
internal static class ShrinkExternalModAssemblyLoader
|
||||||
|
{
|
||||||
|
internal sealed class ExternalAssemblyRevision
|
||||||
|
{
|
||||||
|
public string Path;
|
||||||
|
public string Revision;
|
||||||
|
public Assembly Assembly;
|
||||||
|
public long LoadedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RevisionState
|
||||||
|
{
|
||||||
|
public Dictionary<string, ExternalAssemblyRevision> Current =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public Dictionary<string, string> Known =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, ExternalAssemblyRevision> CurrentAssemblyRevisions =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly Dictionary<Assembly, ExternalAssemblyRevision> ExternalAssemblyHistory = new();
|
||||||
|
private static readonly Dictionary<string, string> KnownAssemblyFiles = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly object ResolveLock = new();
|
||||||
|
private static bool _resolveRegistered;
|
||||||
|
private static int _lastWarnedResidentCount;
|
||||||
|
|
||||||
|
public static IReadOnlyList<Assembly> LoadExternalAssemblies(ShrinkModFrameworkSettings settings, bool verboseLogging)
|
||||||
|
{
|
||||||
|
return ScanExternalAssemblyRevisions(settings, verboseLogging)
|
||||||
|
.Select(revision => revision.Assembly)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<ExternalAssemblyRevision> ScanExternalAssemblyRevisions(
|
||||||
|
ShrinkModFrameworkSettings settings, bool verboseLogging)
|
||||||
|
{
|
||||||
|
if (settings != null && !settings.enableExternalDllMods)
|
||||||
|
{
|
||||||
|
CurrentAssemblyRevisions.Clear();
|
||||||
|
KnownAssemblyFiles.Clear();
|
||||||
|
return Array.Empty<ExternalAssemblyRevision>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#if ENABLE_IL2CPP && !UNITY_EDITOR
|
||||||
|
Debug.LogWarning("[ShrinkModFramework] IL2CPP 运行时不支持外部 DLL 热加载,已跳过外部模组扫描。");
|
||||||
|
return Array.Empty<ExternalAssemblyRevision>();
|
||||||
|
#else
|
||||||
|
var modsDirectory = GetExternalModsDirectory(settings);
|
||||||
|
if (settings == null || settings.autoCreateExternalModsDirectory)
|
||||||
|
Directory.CreateDirectory(modsDirectory);
|
||||||
|
|
||||||
|
RegisterAssemblyResolve();
|
||||||
|
|
||||||
|
var dllPaths = Directory.GetFiles(modsDirectory, "*.dll", SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
var presentPaths = new HashSet<string>(dllPaths, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var dllPath in dllPaths
|
||||||
|
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var assemblyName = Path.GetFileNameWithoutExtension(dllPath);
|
||||||
|
KnownAssemblyFiles[assemblyName] = dllPath;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = File.ReadAllBytes(dllPath);
|
||||||
|
var revision = ComputeSha256(bytes);
|
||||||
|
if (CurrentAssemblyRevisions.TryGetValue(dllPath, out var current) &&
|
||||||
|
string.Equals(current.Revision, revision, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pdbPath = Path.ChangeExtension(dllPath, ".pdb");
|
||||||
|
byte[] pdbBytes = null;
|
||||||
|
if (File.Exists(pdbPath))
|
||||||
|
pdbBytes = File.ReadAllBytes(pdbPath);
|
||||||
|
var assembly = pdbBytes != null
|
||||||
|
? Assembly.Load(bytes, pdbBytes)
|
||||||
|
: Assembly.Load(bytes);
|
||||||
|
|
||||||
|
var loadedRevision = new ExternalAssemblyRevision
|
||||||
|
{
|
||||||
|
Path = dllPath,
|
||||||
|
Revision = revision,
|
||||||
|
Assembly = assembly,
|
||||||
|
LoadedBytes = bytes.LongLength + (pdbBytes?.LongLength ?? 0L)
|
||||||
|
};
|
||||||
|
CurrentAssemblyRevisions[dllPath] = loadedRevision;
|
||||||
|
ExternalAssemblyHistory[assembly] = loadedRevision;
|
||||||
|
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 已加载外部模组程序集:{assembly.GetName().Name} ({revision[..12]})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[ShrinkModFramework] 加载外部 DLL 失败:{dllPath}\n{ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WarnIfResidentLimitExceeded(settings);
|
||||||
|
|
||||||
|
foreach (var missingPath in CurrentAssemblyRevisions.Keys
|
||||||
|
.Where(path => !presentPaths.Contains(path))
|
||||||
|
.ToArray())
|
||||||
|
{
|
||||||
|
CurrentAssemblyRevisions.Remove(missingPath);
|
||||||
|
var assemblyName = Path.GetFileNameWithoutExtension(missingPath);
|
||||||
|
if (KnownAssemblyFiles.TryGetValue(assemblyName, out var knownPath) &&
|
||||||
|
string.Equals(knownPath, missingPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
KnownAssemblyFiles.Remove(assemblyName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CurrentAssemblyRevisions.Values
|
||||||
|
.OrderBy(revision => revision.Path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsExternalAssembly(Assembly assembly) =>
|
||||||
|
assembly != null && ExternalAssemblyHistory.ContainsKey(assembly);
|
||||||
|
|
||||||
|
internal static ShrinkExternalAssemblyDiagnostic CaptureDiagnostic(int softLimit)
|
||||||
|
{
|
||||||
|
var normalizedLimit = Math.Max(1, softLimit);
|
||||||
|
var currentAssemblies = new HashSet<Assembly>(
|
||||||
|
CurrentAssemblyRevisions.Values.Select(item => item.Assembly));
|
||||||
|
var revisions = ExternalAssemblyHistory.Values
|
||||||
|
.OrderBy(item => item.Path, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(item => item.Revision, StringComparer.Ordinal)
|
||||||
|
.Select(item => new ShrinkExternalAssemblyRevisionDiagnostic(
|
||||||
|
item.Path,
|
||||||
|
item.Revision,
|
||||||
|
item.Assembly.GetName().Name ?? string.Empty,
|
||||||
|
item.LoadedBytes,
|
||||||
|
currentAssemblies.Contains(item.Assembly)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new ShrinkExternalAssemblyDiagnostic(
|
||||||
|
CurrentAssemblyRevisions.Count,
|
||||||
|
revisions,
|
||||||
|
revisions.Sum(item => item.LoadedBytes),
|
||||||
|
normalizedLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static RevisionState CaptureState()
|
||||||
|
{
|
||||||
|
return new RevisionState
|
||||||
|
{
|
||||||
|
Current = CurrentAssemblyRevisions.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase),
|
||||||
|
Known = KnownAssemblyFiles.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void RestoreState(RevisionState state)
|
||||||
|
{
|
||||||
|
if (state == null)
|
||||||
|
throw new ArgumentNullException(nameof(state));
|
||||||
|
|
||||||
|
CurrentAssemblyRevisions.Clear();
|
||||||
|
foreach (var pair in state.Current)
|
||||||
|
CurrentAssemblyRevisions[pair.Key] = pair.Value;
|
||||||
|
|
||||||
|
KnownAssemblyFiles.Clear();
|
||||||
|
foreach (var pair in state.Known)
|
||||||
|
KnownAssemblyFiles[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryGetCurrentRevision(Assembly assembly, out string revision)
|
||||||
|
{
|
||||||
|
foreach (var current in CurrentAssemblyRevisions.Values)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(current.Assembly, assembly))
|
||||||
|
{
|
||||||
|
revision = current.Revision;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revision = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetExternalModsDirectory(ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
var folderName = settings != null && !string.IsNullOrWhiteSpace(settings.externalModsFolderName)
|
||||||
|
? settings.externalModsFolderName.Trim()
|
||||||
|
: "Mods";
|
||||||
|
return Path.Combine(Application.persistentDataPath, folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ResetForTesting()
|
||||||
|
{
|
||||||
|
CurrentAssemblyRevisions.Clear();
|
||||||
|
KnownAssemblyFiles.Clear();
|
||||||
|
if (_resolveRegistered)
|
||||||
|
{
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve -= OnAssemblyResolve;
|
||||||
|
_resolveRegistered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ResetForDomainReload()
|
||||||
|
{
|
||||||
|
ResetForTesting();
|
||||||
|
ExternalAssemblyHistory.Clear();
|
||||||
|
_lastWarnedResidentCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WarnIfResidentLimitExceeded(ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
var limit = Math.Max(1, settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
|
||||||
|
var residentCount = ExternalAssemblyHistory.Count;
|
||||||
|
if (residentCount < limit || residentCount == _lastWarnedResidentCount)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastWarnedResidentCount = residentCount;
|
||||||
|
var bytes = ExternalAssemblyHistory.Values.Sum(item => item.LoadedBytes);
|
||||||
|
Debug.LogWarning(
|
||||||
|
$"[ShrinkModFramework] 外部 DLL 常驻 revision 已达到 {residentCount} 个(载入文件约 {bytes} bytes," +
|
||||||
|
$"软阈值 {limit})。Mono 无法卸载这些 Assembly;建议在维护窗口执行 Domain Reload 或重启进程。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256(byte[] bytes)
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
return BitConverter.ToString(sha256.ComputeHash(bytes)).Replace("-", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterAssemblyResolve()
|
||||||
|
{
|
||||||
|
lock (ResolveLock)
|
||||||
|
{
|
||||||
|
if (_resolveRegistered)
|
||||||
|
return;
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
|
||||||
|
_resolveRegistered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args)
|
||||||
|
{
|
||||||
|
var requestedName = new AssemblyName(args.Name).Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(requestedName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!KnownAssemblyFiles.TryGetValue(requestedName, out var path) || !File.Exists(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Assembly.Load(File.ReadAllBytes(path));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bd2c1a02b62d790449c129e226cde40b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Reflection;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
internal static class ShrinkHarmonyPatchService
|
||||||
|
{
|
||||||
|
private sealed class EmptyLease : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly EmptyLease Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HarmonyLease : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Type _harmonyType;
|
||||||
|
private readonly object _harmony;
|
||||||
|
private readonly string _harmonyId;
|
||||||
|
private readonly bool _verboseLogging;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public HarmonyLease(Type harmonyType, object harmony, string harmonyId, bool verboseLogging)
|
||||||
|
{
|
||||||
|
_harmonyType = harmonyType;
|
||||||
|
_harmony = harmony;
|
||||||
|
_harmonyId = harmonyId;
|
||||||
|
_verboseLogging = verboseLogging;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var unpatchSelf = _harmonyType.GetMethod("UnpatchSelf",
|
||||||
|
BindingFlags.Public | BindingFlags.Instance,
|
||||||
|
null,
|
||||||
|
Type.EmptyTypes,
|
||||||
|
null);
|
||||||
|
if (unpatchSelf != null)
|
||||||
|
{
|
||||||
|
unpatchSelf.Invoke(_harmony, null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var unpatchAll = _harmonyType.GetMethod("UnpatchAll",
|
||||||
|
BindingFlags.Public | BindingFlags.Static,
|
||||||
|
null,
|
||||||
|
new[] { typeof(string) },
|
||||||
|
null);
|
||||||
|
if (unpatchAll == null)
|
||||||
|
throw new MissingMethodException("Harmony.UnpatchSelf()/UnpatchAll(string) 不存在。");
|
||||||
|
unpatchAll.Invoke(null, new object[] { _harmonyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 已撤回 Harmony 补丁:{_harmonyId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.LogError($"[ShrinkModFramework] 撤回 Harmony 补丁失败:{_harmonyId}\n{ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
AppliedHarmonyIds.Remove(_harmonyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AppliedHarmonyIds = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public static void ApplyPatchesIfNeeded(ShrinkModInfo modInfo, bool enableHarmonyPatching, bool verboseLogging)
|
||||||
|
{
|
||||||
|
_ = ApplyPatchesCore(modInfo, enableHarmonyPatching, verboseLogging, reversible: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IDisposable AcquirePatchesIfNeeded(ShrinkModInfo modInfo, bool enableHarmonyPatching,
|
||||||
|
bool verboseLogging)
|
||||||
|
{
|
||||||
|
return ApplyPatchesCore(modInfo, enableHarmonyPatching, verboseLogging, reversible: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDisposable ApplyPatchesCore(ShrinkModInfo modInfo, bool enableHarmonyPatching,
|
||||||
|
bool verboseLogging, bool reversible)
|
||||||
|
{
|
||||||
|
if (!enableHarmonyPatching || !modInfo.AutoApplyHarmonyPatches)
|
||||||
|
return EmptyLease.Instance;
|
||||||
|
|
||||||
|
var harmonyType = Type.GetType("HarmonyLib.Harmony, 0Harmony");
|
||||||
|
if (harmonyType == null)
|
||||||
|
{
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 未检测到 Harmony,跳过模组 {modInfo.ModId} 的补丁自动应用。");
|
||||||
|
return EmptyLease.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
var harmonyId = $"shrink.mod.{modInfo.ModId}";
|
||||||
|
if (!AppliedHarmonyIds.Add(harmonyId))
|
||||||
|
return EmptyLease.Instance;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var harmony = Activator.CreateInstance(harmonyType, harmonyId);
|
||||||
|
var patchAll = harmonyType.GetMethod("PatchAll", new[] { typeof(Assembly) });
|
||||||
|
if (patchAll == null)
|
||||||
|
throw new MissingMethodException("Harmony.PatchAll(Assembly) 不存在。");
|
||||||
|
|
||||||
|
patchAll.Invoke(harmony, new object[] { modInfo.EntryType.Assembly });
|
||||||
|
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModFramework] 已应用 Harmony 补丁:{modInfo.ModId}");
|
||||||
|
|
||||||
|
return reversible
|
||||||
|
? new HarmonyLease(harmonyType, harmony, harmonyId, verboseLogging)
|
||||||
|
: EmptyLease.Instance;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppliedHarmonyIds.Remove(harmonyId);
|
||||||
|
throw new InvalidOperationException($"模组 {modInfo.ModId} 应用 Harmony 补丁失败:{ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ResetForTesting() => AppliedHarmonyIds.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c1bf43d28651dea4fa7d5e5c56361da4
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public sealed class ShrinkExternalAssemblyRevisionDiagnostic
|
||||||
|
{
|
||||||
|
internal ShrinkExternalAssemblyRevisionDiagnostic(string path, string revision, string assemblyName,
|
||||||
|
long loadedBytes, bool isCurrent)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Revision = revision;
|
||||||
|
AssemblyName = assemblyName;
|
||||||
|
LoadedBytes = loadedBytes;
|
||||||
|
IsCurrent = isCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
public string Revision { get; }
|
||||||
|
public string AssemblyName { get; }
|
||||||
|
public long LoadedBytes { get; }
|
||||||
|
public bool IsCurrent { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mono 外部程序集的 current 与常驻历史快照;LoadedBytes 仅统计载入 DLL/PDB 文件大小。</summary>
|
||||||
|
public sealed class ShrinkExternalAssemblyDiagnostic
|
||||||
|
{
|
||||||
|
internal ShrinkExternalAssemblyDiagnostic(int currentRevisionCount,
|
||||||
|
IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> residentRevisions,
|
||||||
|
long estimatedResidentBytes, int softLimit)
|
||||||
|
{
|
||||||
|
CurrentRevisionCount = currentRevisionCount;
|
||||||
|
ResidentRevisions = residentRevisions;
|
||||||
|
EstimatedResidentBytes = estimatedResidentBytes;
|
||||||
|
SoftLimit = softLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CurrentRevisionCount { get; }
|
||||||
|
public IReadOnlyList<ShrinkExternalAssemblyRevisionDiagnostic> ResidentRevisions { get; }
|
||||||
|
public int ResidentRevisionCount => ResidentRevisions.Count;
|
||||||
|
public long EstimatedResidentBytes { get; }
|
||||||
|
public int SoftLimit { get; }
|
||||||
|
public bool IsSoftLimitExceeded => ResidentRevisionCount >= SoftLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ShrinkModDiagnostics
|
||||||
|
{
|
||||||
|
public static ShrinkExternalAssemblyDiagnostic CaptureExternalAssemblies(
|
||||||
|
ShrinkModFrameworkSettings settings = null)
|
||||||
|
{
|
||||||
|
if (settings == null)
|
||||||
|
settings = ShrinkModFrameworkSettings.Instance;
|
||||||
|
return ShrinkExternalModAssemblyLoader.CaptureDiagnostic(
|
||||||
|
settings != null ? settings.externalAssemblyRevisionSoftLimit : 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 20342cb92d99bc34ca99a41312b5a95d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using ShrinkEventBus;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
public static class ShrinkModLoader
|
||||||
|
{
|
||||||
|
private sealed class DiscoveredMod
|
||||||
|
{
|
||||||
|
public ShrinkModInfo Info;
|
||||||
|
public Type EntryType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, ShrinkModHandle> LoadedMods = new(StringComparer.Ordinal);
|
||||||
|
private static readonly List<ShrinkModHandle> LoadSequence = new();
|
||||||
|
private static readonly ShrinkModRegistryManager RegistryManager = new();
|
||||||
|
|
||||||
|
public static bool IsLoaded { get; private set; }
|
||||||
|
public static IReadOnlyDictionary<string, ShrinkModHandle> Mods =>
|
||||||
|
ShrinkModCordisRuntime.IsInitialized ? ShrinkModCordisRuntime.Mods : LoadedMods;
|
||||||
|
|
||||||
|
public static event Action<ShrinkModHandle> OnModReady;
|
||||||
|
public static event Action<IReadOnlyDictionary<string, ShrinkModHandle>> OnAllModsReady;
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, ShrinkModHandle> LoadAll(ShrinkModFrameworkSettings settings = null)
|
||||||
|
{
|
||||||
|
settings ??= ShrinkModFrameworkSettings.Instance;
|
||||||
|
|
||||||
|
if (IsLoaded)
|
||||||
|
{
|
||||||
|
Debug.Log("[ShrinkModLoader] 模组已装载,跳过重复装载。");
|
||||||
|
return Mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings == null || settings.useContextHost)
|
||||||
|
return ApplyContextComposition(settings);
|
||||||
|
|
||||||
|
var verboseLogging = settings == null || settings.verboseLogging;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ShrinkModNetworkManager.Configure(settings == null || settings.enableNetworkSync, verboseLogging);
|
||||||
|
ShrinkExternalModAssemblyLoader.LoadExternalAssemblies(settings, verboseLogging);
|
||||||
|
|
||||||
|
var discovered = DiscoverMods(settings);
|
||||||
|
var ordered = ResolveLoadOrder(discovered, allowExistingLoadedDependencies: true);
|
||||||
|
|
||||||
|
foreach (var discoveredMod in ordered)
|
||||||
|
{
|
||||||
|
var handle = InstantiateAndTrack(discoveredMod, settings, verboseLogging);
|
||||||
|
LoadSequence.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLifecycle(LoadSequence, verboseLogging);
|
||||||
|
|
||||||
|
IsLoaded = true;
|
||||||
|
OnAllModsReady?.Invoke(LoadedMods);
|
||||||
|
Debug.Log($"[ShrinkModLoader] 模组装载完成,共 {LoadedMods.Count} 个。");
|
||||||
|
return LoadedMods;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ResetForTesting();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, ShrinkModHandle> LoadNewExternalMods(ShrinkModFrameworkSettings settings = null)
|
||||||
|
{
|
||||||
|
settings ??= ShrinkModFrameworkSettings.Instance;
|
||||||
|
if (settings == null || settings.useContextHost)
|
||||||
|
return ApplyContextComposition(settings);
|
||||||
|
if (!IsLoaded)
|
||||||
|
return LoadAll(settings);
|
||||||
|
|
||||||
|
var verboseLogging = settings == null || settings.verboseLogging;
|
||||||
|
ShrinkExternalModAssemblyLoader.LoadExternalAssemblies(settings, verboseLogging);
|
||||||
|
|
||||||
|
var discovered = DiscoverMods(settings)
|
||||||
|
.Where(mod => !LoadedMods.ContainsKey(mod.Info.ModId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (discovered.Count == 0)
|
||||||
|
return LoadedMods;
|
||||||
|
|
||||||
|
ValidateNewMods(discovered);
|
||||||
|
var ordered = ResolveLoadOrder(discovered, allowExistingLoadedDependencies: true);
|
||||||
|
var newHandles = new List<ShrinkModHandle>(ordered.Count);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var discoveredMod in ordered)
|
||||||
|
{
|
||||||
|
var handle = InstantiateAndTrack(discoveredMod, settings, verboseLogging);
|
||||||
|
LoadSequence.Add(handle);
|
||||||
|
newHandles.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLifecycle(newHandles, verboseLogging);
|
||||||
|
return LoadedMods;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
foreach (var handle in newHandles)
|
||||||
|
{
|
||||||
|
handle.EventBinding?.Dispose();
|
||||||
|
handle.EventBinding = null;
|
||||||
|
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
|
||||||
|
LoadedMods.Remove(handle.Info.ModId);
|
||||||
|
LoadSequence.Remove(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetMod(string modId, out ShrinkModHandle handle)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(modId))
|
||||||
|
return Mods.TryGetValue(modId, out handle);
|
||||||
|
|
||||||
|
handle = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
|
||||||
|
=> ShrinkModCordisRuntime.IsInitialized
|
||||||
|
? ShrinkModCordisRuntime.GetOrCreateRegistry<T>(name)
|
||||||
|
: RegistryManager.GetOrCreateRegistry<T>(name);
|
||||||
|
|
||||||
|
internal static void ResetForTesting()
|
||||||
|
{
|
||||||
|
ShrinkModCordisRuntime.ResetForTesting();
|
||||||
|
DisposeLegacyEventBindings();
|
||||||
|
LoadedMods.Clear();
|
||||||
|
LoadSequence.Clear();
|
||||||
|
RegistryManager.Clear();
|
||||||
|
IsLoaded = false;
|
||||||
|
OnModReady = null;
|
||||||
|
OnAllModsReady = null;
|
||||||
|
ShrinkModNetworkManager.ResetForDomainReload();
|
||||||
|
ShrinkExternalModAssemblyLoader.ResetForTesting();
|
||||||
|
ShrinkHarmonyPatchService.ResetForTesting();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void ResetForDomainReload()
|
||||||
|
{
|
||||||
|
ShrinkModCordisRuntime.ResetForDomainReload();
|
||||||
|
DisposeLegacyEventBindings();
|
||||||
|
LoadedMods.Clear();
|
||||||
|
LoadSequence.Clear();
|
||||||
|
RegistryManager.Clear();
|
||||||
|
IsLoaded = false;
|
||||||
|
OnModReady = null;
|
||||||
|
OnAllModsReady = null;
|
||||||
|
ShrinkModNetworkManager.ResetForDomainReload();
|
||||||
|
ShrinkExternalModAssemblyLoader.ResetForDomainReload();
|
||||||
|
ShrinkHarmonyPatchService.ResetForTesting();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, ShrinkModHandle> ApplyContextComposition(
|
||||||
|
ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
var previousGenerations = Mods.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value.Generation,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var result = ShrinkModCordisRuntime.ApplyDiscoveredAsync(settings).GetAwaiter().GetResult();
|
||||||
|
IsLoaded = true;
|
||||||
|
foreach (var pair in result.OrderBy(pair => pair.Key, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (!previousGenerations.TryGetValue(pair.Key, out var generation) ||
|
||||||
|
generation != pair.Value.Generation)
|
||||||
|
{
|
||||||
|
OnModReady?.Invoke(pair.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OnAllModsReady?.Invoke(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShrinkModHandle InstantiateAndTrack(DiscoveredMod discoveredMod,
|
||||||
|
ShrinkModFrameworkSettings settings, bool verboseLogging)
|
||||||
|
{
|
||||||
|
var instance = CreateInstance(discoveredMod);
|
||||||
|
var handle = new ShrinkModHandle(discoveredMod.Info, instance);
|
||||||
|
LoadedMods.Add(handle.Info.ModId, handle);
|
||||||
|
|
||||||
|
ShrinkHarmonyPatchService.ApplyPatchesIfNeeded(
|
||||||
|
handle.Info,
|
||||||
|
settings == null || settings.enableHarmonyPatching,
|
||||||
|
verboseLogging);
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DiscoveredMod> DiscoverMods(ShrinkModFrameworkSettings settings)
|
||||||
|
{
|
||||||
|
var results = new List<DiscoveredMod>();
|
||||||
|
var prefixes = settings?.assemblyNamePrefixes;
|
||||||
|
|
||||||
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||||
|
{
|
||||||
|
if (!ShouldScanAssembly(assembly, prefixes))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var type in GetTypesSafely(assembly))
|
||||||
|
{
|
||||||
|
if (type == null || type.IsAbstract || type.IsInterface)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var modAttribute = type.GetCustomAttribute<ShrinkModAttribute>(false);
|
||||||
|
if (modAttribute == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!typeof(IShrinkMod).IsAssignableFrom(type))
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkModLoader] 类型 {type.FullName} 标记了 ShrinkMod,但没有实现 IShrinkMod,已跳过。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.GetConstructor(Type.EmptyTypes) == null)
|
||||||
|
throw new InvalidOperationException($"模组 {type.FullName} 缺少无参构造函数,无法实例化。");
|
||||||
|
|
||||||
|
var dependencies = type
|
||||||
|
.GetCustomAttributes<ShrinkModDependencyAttribute>(false)
|
||||||
|
.Select(attr => new ShrinkModDependency(attr.ModId, attr.MinimumVersion, attr.Optional))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
results.Add(new DiscoveredMod
|
||||||
|
{
|
||||||
|
EntryType = type,
|
||||||
|
Info = new ShrinkModInfo(
|
||||||
|
modAttribute.ModId,
|
||||||
|
modAttribute.DisplayName,
|
||||||
|
modAttribute.Version,
|
||||||
|
modAttribute.LoadOrder,
|
||||||
|
type,
|
||||||
|
modAttribute.AutoApplyHarmonyPatches,
|
||||||
|
dependencies)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateDiscoveredMods(results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateDiscoveredMods(List<DiscoveredMod> discovered)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var mod in discovered)
|
||||||
|
{
|
||||||
|
if (!seen.Add(mod.Info.ModId))
|
||||||
|
throw new InvalidOperationException($"发现重复模组 ID:{mod.Info.ModId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var map = discovered.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
|
||||||
|
foreach (var mod in discovered)
|
||||||
|
{
|
||||||
|
foreach (var dependency in mod.Info.Dependencies)
|
||||||
|
{
|
||||||
|
if (!map.TryGetValue(dependency.ModId, out var target))
|
||||||
|
{
|
||||||
|
if (!dependency.Optional && !LoadedMods.ContainsKey(dependency.ModId))
|
||||||
|
throw new InvalidOperationException($"模组 {mod.Info.ModId} 缺少必选依赖 {dependency.ModId}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateDependencyVersion(mod.Info, dependency, target.Info.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateNewMods(List<DiscoveredMod> newMods)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(LoadedMods.Keys, StringComparer.Ordinal);
|
||||||
|
foreach (var mod in newMods)
|
||||||
|
{
|
||||||
|
if (!seen.Add(mod.Info.ModId))
|
||||||
|
throw new InvalidOperationException($"发现重复模组 ID:{mod.Info.ModId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var map = newMods.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
|
||||||
|
foreach (var mod in newMods)
|
||||||
|
{
|
||||||
|
foreach (var dependency in mod.Info.Dependencies)
|
||||||
|
{
|
||||||
|
if (map.TryGetValue(dependency.ModId, out var newTarget))
|
||||||
|
{
|
||||||
|
ValidateDependencyVersion(mod.Info, dependency, newTarget.Info.Version);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LoadedMods.TryGetValue(dependency.ModId, out var loadedTarget))
|
||||||
|
{
|
||||||
|
ValidateDependencyVersion(mod.Info, dependency, loadedTarget.Info.Version);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dependency.Optional)
|
||||||
|
throw new InvalidOperationException($"模组 {mod.Info.ModId} 缺少必选依赖 {dependency.ModId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateDependencyVersion(ShrinkModInfo owner, ShrinkModDependency dependency, string actualVersion)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dependency.MinimumVersion) &&
|
||||||
|
ShrinkVersionUtility.Compare(actualVersion, dependency.MinimumVersion) < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"模组 {owner.ModId} 依赖 {dependency.ModId} >= {dependency.MinimumVersion},但当前只有 {actualVersion}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DiscoveredMod> ResolveLoadOrder(List<DiscoveredMod> discovered, bool allowExistingLoadedDependencies)
|
||||||
|
{
|
||||||
|
var map = discovered.ToDictionary(x => x.Info.ModId, x => x, StringComparer.Ordinal);
|
||||||
|
var result = new List<DiscoveredMod>();
|
||||||
|
var visiting = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var mod in discovered.OrderBy(x => x.Info.LoadOrder).ThenBy(x => x.Info.ModId, StringComparer.Ordinal))
|
||||||
|
Visit(mod, map, visiting, visited, result, allowExistingLoadedDependencies);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Visit(DiscoveredMod current, Dictionary<string, DiscoveredMod> map,
|
||||||
|
HashSet<string> visiting, HashSet<string> visited, List<DiscoveredMod> result,
|
||||||
|
bool allowExistingLoadedDependencies)
|
||||||
|
{
|
||||||
|
if (visited.Contains(current.Info.ModId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!visiting.Add(current.Info.ModId))
|
||||||
|
throw new InvalidOperationException($"检测到模组循环依赖,涉及 {current.Info.ModId}");
|
||||||
|
|
||||||
|
foreach (var dependency in current.Info.Dependencies.OrderBy(x => x.ModId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (map.TryGetValue(dependency.ModId, out var target))
|
||||||
|
{
|
||||||
|
Visit(target, map, visiting, visited, result, allowExistingLoadedDependencies);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowExistingLoadedDependencies && LoadedMods.ContainsKey(dependency.ModId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!dependency.Optional)
|
||||||
|
throw new InvalidOperationException($"模组 {current.Info.ModId} 缺少必选依赖 {dependency.ModId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.Remove(current.Info.ModId);
|
||||||
|
visited.Add(current.Info.ModId);
|
||||||
|
result.Add(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IShrinkMod CreateInstance(DiscoveredMod discovered)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (IShrinkMod)Activator.CreateInstance(discovered.EntryType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"实例化模组 {discovered.Info.ModId} 失败:{ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunLifecycle(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
|
||||||
|
{
|
||||||
|
var stagedHandles = handles.ToList();
|
||||||
|
RunPhase(stagedHandles, ShrinkModState.Constructed, verboseLogging, (mod, context) => mod.OnConstruct(context), "构造");
|
||||||
|
RunPhase(stagedHandles, ShrinkModState.ContentRegistered, verboseLogging,
|
||||||
|
(mod, context) => mod.OnRegisterContent(context), "注册内容");
|
||||||
|
RegisterOptionalRuntimeIntegrations(stagedHandles, verboseLogging);
|
||||||
|
RunPhase(stagedHandles, ShrinkModState.Initialized, verboseLogging,
|
||||||
|
(mod, context) => mod.OnInitialize(context), "初始化");
|
||||||
|
RunPhase(stagedHandles, ShrinkModState.Ready, verboseLogging, (mod, context) => mod.OnReady(context), "完成就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterOptionalRuntimeIntegrations(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
|
||||||
|
{
|
||||||
|
foreach (var handle in handles)
|
||||||
|
handle.EventBinding = ShrinkModOptionalRuntimeIntegration.RegisterModInstance(
|
||||||
|
handle.Instance, handle.Info, verboseLogging);
|
||||||
|
|
||||||
|
ShrinkModOptionalRuntimeIntegration.RefreshGlobalBindings(verboseLogging);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeLegacyEventBindings()
|
||||||
|
{
|
||||||
|
foreach (var handle in LoadedMods.Values)
|
||||||
|
{
|
||||||
|
handle.EventBinding?.Dispose();
|
||||||
|
handle.EventBinding = null;
|
||||||
|
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunPhase(IEnumerable<ShrinkModHandle> handles, ShrinkModState targetState, bool verboseLogging,
|
||||||
|
Action<IShrinkMod, ShrinkModContext> callback, string phaseName)
|
||||||
|
{
|
||||||
|
foreach (var handle in handles)
|
||||||
|
{
|
||||||
|
var context = new ShrinkModContext(handle.Info, RegistryManager, LoadedMods, verboseLogging);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
callback(handle.Instance, context);
|
||||||
|
handle.State = targetState;
|
||||||
|
|
||||||
|
if (verboseLogging)
|
||||||
|
Debug.Log($"[ShrinkModLoader] {handle.Info.ModId} 已完成阶段:{phaseName}");
|
||||||
|
|
||||||
|
if (targetState == ShrinkModState.Ready)
|
||||||
|
OnModReady?.Invoke(handle);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"模组 {handle.Info.ModId} 在阶段 '{phaseName}' 执行失败:{ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldScanAssembly(Assembly assembly, string[] prefixes)
|
||||||
|
{
|
||||||
|
var name = assembly.GetName().Name;
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (name.StartsWith("Unity", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("System", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("mscorlib", StringComparison.Ordinal) ||
|
||||||
|
name.StartsWith("netstandard", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (prefixes == null || prefixes.Length == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (var i = 0; i < prefixes.Length; i++)
|
||||||
|
{
|
||||||
|
var prefix = prefixes[i];
|
||||||
|
if (!string.IsNullOrWhiteSpace(prefix) &&
|
||||||
|
name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Type> GetTypesSafely(Assembly assembly)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return assembly.GetTypes();
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
return ex.Types.Where(t => t != null);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Array.Empty<Type>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3f1310a972c773f479f8e35265c63535
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c93eed41ccfddbd48b13b1791393a0c8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class ShrinkModAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string ModId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string Version { get; }
|
||||||
|
public int LoadOrder { get; }
|
||||||
|
public bool AutoApplyHarmonyPatches { get; set; } = true;
|
||||||
|
|
||||||
|
public ShrinkModAttribute(string modId, string displayName, string version, int loadOrder = 0)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modId))
|
||||||
|
throw new ArgumentException("模组 ID 不能为空。", nameof(modId));
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
|
throw new ArgumentException("模组显示名不能为空。", nameof(displayName));
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
throw new ArgumentException("模组版本不能为空。", nameof(version));
|
||||||
|
|
||||||
|
ModId = modId.Trim();
|
||||||
|
DisplayName = displayName.Trim();
|
||||||
|
Version = version.Trim();
|
||||||
|
LoadOrder = loadOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 976e55706c1035f4ba692957cd335f32
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
|
||||||
|
public sealed class ShrinkModDependencyAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string ModId { get; }
|
||||||
|
public string MinimumVersion { get; }
|
||||||
|
public bool Optional { get; }
|
||||||
|
|
||||||
|
public ShrinkModDependencyAttribute(string modId, string minimumVersion = null, bool optional = false)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modId))
|
||||||
|
throw new ArgumentException("依赖模组 ID 不能为空。", nameof(modId));
|
||||||
|
|
||||||
|
ModId = modId.Trim();
|
||||||
|
MinimumVersion = string.IsNullOrWhiteSpace(minimumVersion) ? null : minimumVersion.Trim();
|
||||||
|
Optional = optional;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d4af615bbf25f3e429a341accf01b17b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ShrinkModFramework
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public sealed class ShrinkModInfo
|
||||||
|
{
|
||||||
|
public string ModId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public string Version { get; }
|
||||||
|
public int LoadOrder { get; }
|
||||||
|
public Type EntryType { get; }
|
||||||
|
public bool AutoApplyHarmonyPatches { get; }
|
||||||
|
public IReadOnlyList<ShrinkModDependency> Dependencies { get; }
|
||||||
|
|
||||||
|
public ShrinkModInfo(string modId, string displayName, string version, int loadOrder, Type entryType,
|
||||||
|
bool autoApplyHarmonyPatches, IReadOnlyList<ShrinkModDependency> dependencies)
|
||||||
|
{
|
||||||
|
ModId = modId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Version = version;
|
||||||
|
LoadOrder = loadOrder;
|
||||||
|
EntryType = entryType;
|
||||||
|
AutoApplyHarmonyPatches = autoApplyHarmonyPatches;
|
||||||
|
Dependencies = dependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"{DisplayName} ({ModId}@{Version})";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public sealed class ShrinkModDependency
|
||||||
|
{
|
||||||
|
public string ModId { get; }
|
||||||
|
public string MinimumVersion { get; }
|
||||||
|
public bool Optional { get; }
|
||||||
|
|
||||||
|
public ShrinkModDependency(string modId, string minimumVersion, bool optional)
|
||||||
|
{
|
||||||
|
ModId = modId;
|
||||||
|
MinimumVersion = minimumVersion;
|
||||||
|
Optional = optional;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(MinimumVersion))
|
||||||
|
return Optional ? $"{ModId} (optional)" : ModId;
|
||||||
|
|
||||||
|
return Optional ? $"{ModId} >= {MinimumVersion} (optional)" : $"{ModId} >= {MinimumVersion}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user