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