```
feat(gameplay): 更新单位属性和材质配置 - 调整多个单位的基础生命值和攻击力数值平衡 - 修改CoreBattleGround材质的纹理缩放比例 - 更新地砖材质颜色为白色 - 优化纹理资源设置,调整最大纹理尺寸并添加PlayableAds平台支持 - 修复弓箭手技能目标查找逻辑,改用统一的敌人检测方法 - 为水晶矿添加中立阵营设置和光球视觉效果同步 - 调整预制体中的全局缩放参数 - 为狼人和石巨人添加NavMeshAgent组件支持 - 优化水晶矿血条显示位置计算方式 ```
This commit is contained in:
1 parent
0e7de5f885
commit
53d95802e5
448 files changed
+133333
-472
No files matched your search
+490
@@ -0,0 +1,490 @@
|
||||
using System.Collections.Generic;
|
||||
using Codely.Newtonsoft.Json;
|
||||
using Codely.Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityTcp.Editor.Helpers;
|
||||
|
||||
namespace UnityTcp.Editor.Tools.Jobs
|
||||
{
|
||||
/// <summary>
|
||||
/// Step jobs behind the <c>manage_editor</c> actions that used to answer "pending" and make the
|
||||
/// client poll. Each now runs to the real end state — compilation finished, editor idle, play
|
||||
/// mode entered/paused/stopped — and answers once, on the original request.
|
||||
///
|
||||
/// They are <see cref="StepJob"/>s rather than coroutines because every one of these waits can
|
||||
/// span a domain reload: compiling triggers one by definition, and entering or leaving play
|
||||
/// mode does too unless the project disabled domain reload. A coroutine would die there.
|
||||
///
|
||||
/// Responses carry no operation id: there is nothing for a client to correlate or poll against
|
||||
/// once a call only returns when its work is done. Failures are message-only — the client
|
||||
/// renders an async response as a success whenever it carries a <c>data</c> payload, so error
|
||||
/// details go in the message.
|
||||
/// </summary>
|
||||
internal static class CompileResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the completed-compilation payload the way the old wait_for_compile did, from
|
||||
/// whatever diagnostics the editor can still report after the reload.
|
||||
///
|
||||
/// Everything the caller needs goes in the <c>data</c> payload and nowhere else: the client
|
||||
/// unwraps one level (<c>response.data</c>) and reads the result from there, so a key put
|
||||
/// beside <c>data</c> — a pipeline hint, for one — never reaches the caller. For the
|
||||
/// same reason <c>data</c> carries no <c>success</c> key: the client reads a nested
|
||||
/// <c>success:false</c> as "the command failed" and renders it with a generic message,
|
||||
/// losing the diagnostics. Compile outcome is reported as <c>hasErrors</c> instead, and a
|
||||
/// genuine failure is raised through <see cref="StepJob.Fail"/>.
|
||||
/// </summary>
|
||||
public static object Build(bool compileObserved, bool includeConsole = false)
|
||||
{
|
||||
var errors = CompilationHelper.GetCompilationErrors();
|
||||
var warnings = CompilationHelper.GetCompilationWarnings();
|
||||
JObject console = includeConsole
|
||||
? ReadConsole.ReadForCompilationPipeline()
|
||||
: new JObject
|
||||
{
|
||||
["included"] = false,
|
||||
};
|
||||
bool consoleReadSucceeded =
|
||||
includeConsole && console["read_success"]?.ToObject<bool?>() == true;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["status"] = "completed",
|
||||
["pipeline_kind"] = "compile",
|
||||
// Default pipeline calls include the complete post-compile console, so callers no
|
||||
// longer need a second unity_console.get. If inclusion was explicitly disabled or
|
||||
// the internal read failed, retain the compatibility hint for a manual fallback.
|
||||
["requires_console_validation"] = !consoleReadSucceeded,
|
||||
["console"] = console,
|
||||
// False means compilation never started while we watched. The counts below are then
|
||||
// the previous build's, so the caller must not read them as "this edit is clean".
|
||||
["compile_observed"] = compileObserved,
|
||||
};
|
||||
if (errors.HasValue)
|
||||
{
|
||||
data["errors"] = errors.Value;
|
||||
data["hasErrors"] = errors.Value > 0;
|
||||
}
|
||||
if (warnings.HasValue)
|
||||
{
|
||||
data["warnings"] = warnings.Value;
|
||||
data["hasWarnings"] = warnings.Value > 0;
|
||||
}
|
||||
|
||||
string message;
|
||||
if (!compileObserved)
|
||||
{
|
||||
message = "No compilation was observed — the scripts were already up to date, or " +
|
||||
"the editor never started one. Any error/warning counts below are from " +
|
||||
"the previous build; validate via console.";
|
||||
}
|
||||
else
|
||||
{
|
||||
message = errors.HasValue
|
||||
? (errors.Value > 0 ? "Compilation completed with errors" : "Compilation completed successfully")
|
||||
: "Compilation completed (validate via console)";
|
||||
}
|
||||
|
||||
if (includeConsole && !consoleReadSucceeded)
|
||||
message += " The post-compile console could not be embedded; read unity_console as a fallback.";
|
||||
|
||||
return Response.Success(message, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>data</c> payload of a compile response, which is where every field lives. Returns
|
||||
/// the response itself when it has no payload, so callers can read it either way.
|
||||
/// </summary>
|
||||
public static JObject PayloadOf(JObject response)
|
||||
=> response?["data"] as JObject ?? response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops play mode, refreshes the Asset Database, waits for importing and compilation to become idle, then reads
|
||||
/// diagnostics on a later editor frame. A <see cref="StepJob"/> is required because Unity may
|
||||
/// not publish the latest compile errors and warnings to the Console until after the frame in
|
||||
/// which <see cref="AssetDatabase.Refresh(ImportAssetOptions)"/> returns.
|
||||
/// </summary>
|
||||
public class RefreshJob : StepJob
|
||||
{
|
||||
public bool RefreshStarted;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
JobStep.Nested("ensure-edit-mode",
|
||||
() => new StopPlayModeJob(),
|
||||
onComplete: () =>
|
||||
{
|
||||
if (!LastSubJobSucceeded)
|
||||
CompleteWithJson(LastSubJobResponseJson, failed: true);
|
||||
},
|
||||
skip: PlayModeState.IsStopped),
|
||||
new JobStep("refresh", Refresh),
|
||||
new JobStep("await-editor-idle", null,
|
||||
() => !CompilationHelper.IsCompiling() && !EditorApplication.isUpdating),
|
||||
new JobStep("read-diagnostics", Report),
|
||||
};
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (RefreshStarted) return;
|
||||
RefreshStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
ReadConsole.HandleCommand(new JObject { ["action"] = "clear" });
|
||||
CompilationHelper.ResetCounts();
|
||||
CompilationHelper.EnsureScenesSavedBeforeReload();
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
CodelyLogger.LogError($"[ManageEditor] Refresh failed: {e}");
|
||||
FailWith(Response.ErrorWithCode(
|
||||
"refresh_failed",
|
||||
$"Asset database refresh failed: {e.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private void Report()
|
||||
{
|
||||
int? errors = CompilationHelper.GetCompilationErrors();
|
||||
int? warnings = CompilationHelper.GetCompilationWarnings();
|
||||
JObject console = ReadConsole.ReadForCompilationPipeline();
|
||||
bool consoleReadSucceeded = console["read_success"]?.ToObject<bool?>() == true;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["status"] = "completed",
|
||||
["pipeline_kind"] = "refresh",
|
||||
["requires_console_validation"] = !consoleReadSucceeded,
|
||||
["console"] = console,
|
||||
};
|
||||
if (errors.HasValue)
|
||||
{
|
||||
data["errors"] = errors.Value;
|
||||
data["hasErrors"] = errors.Value > 0;
|
||||
}
|
||||
if (warnings.HasValue)
|
||||
{
|
||||
data["warnings"] = warnings.Value;
|
||||
data["hasWarnings"] = warnings.Value > 0;
|
||||
}
|
||||
|
||||
string message = errors.HasValue && errors.Value > 0
|
||||
? "Asset database refresh completed with errors."
|
||||
: "Asset database refresh completed successfully.";
|
||||
if (!consoleReadSucceeded)
|
||||
message += " The console could not be embedded; read unity_console as a fallback.";
|
||||
Complete(Response.Success(message, data));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the editor is neither compiling nor importing, then reports the compilation
|
||||
/// result. Runs as the second half of <see cref="CompilePipelineJob"/>.
|
||||
/// </summary>
|
||||
public class WaitForCompileJob : StepJob
|
||||
{
|
||||
/// <summary>
|
||||
/// How long to keep looking for compilation to start before concluding it already finished
|
||||
/// (or never began). Wall-clock rather than a frame count: Unity flips isCompiling only
|
||||
/// after the asset refresh that precedes it, and on a large project that refresh runs for
|
||||
/// hundreds of frames, so a frame count expires long before the compile it was waiting for
|
||||
/// begins — answering "compilation completed successfully" with the previous build's error
|
||||
/// counts, before a single script has been rebuilt.
|
||||
///
|
||||
/// The default is short so a bare wait ("is the editor compiling right now?") on an idle
|
||||
/// editor answers the honest "no" without stalling for the long window.
|
||||
/// <see cref="CompilePipelineJob"/>, which has just requested a compile and therefore
|
||||
/// knows one is coming, raises it well past Unity's start-up lag.
|
||||
/// </summary>
|
||||
public double SettleSeconds = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Session-clock deadline for the detect phase, pushed forward for as long as the editor is
|
||||
/// updating so the window only counts down against an idle editor.
|
||||
/// </summary>
|
||||
public double SettleUntil;
|
||||
|
||||
public bool SawCompiling;
|
||||
public bool IncludeConsole;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
// Do not report "done" in the gap between requesting a compile and Unity actually
|
||||
// starting one — that would answer before a single script had been rebuilt.
|
||||
new JobStep("detect-compile",
|
||||
() =>
|
||||
{
|
||||
// An import/refresh still in flight is what compilation comes out of, so the
|
||||
// settle window does not start counting down until it ends — see SettleWindow.
|
||||
SettleUntil = SettleWindow.Advance(
|
||||
SettleUntil, EditorApplication.timeSinceStartup, SettleSeconds,
|
||||
EditorApplication.isUpdating);
|
||||
if (CompilationHelper.IsCompiling()) SawCompiling = true;
|
||||
},
|
||||
() => SawCompiling
|
||||
|| SettleWindow.Expired(SettleUntil, EditorApplication.timeSinceStartup,
|
||||
EditorApplication.isUpdating)),
|
||||
|
||||
new JobStep("await-compile", null,
|
||||
() => !CompilationHelper.IsCompiling() && !EditorApplication.isUpdating),
|
||||
|
||||
new JobStep("report", () => Complete(CompileResult.Build(SawCompiling, IncludeConsole))),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full compile pipeline: clear console, request compilation, then wait for it to finish —
|
||||
/// the second half as a nested <see cref="WaitForCompileJob"/>. Backs
|
||||
/// <c>start_compilation_pipeline</c> (alias <c>request_compile</c>), which can clear the
|
||||
/// script assembly cache first.
|
||||
/// </summary>
|
||||
public class CompilePipelineJob : StepJob
|
||||
{
|
||||
public bool ClearCache;
|
||||
public bool IncludeConsole = true;
|
||||
public bool CacheCleared;
|
||||
public bool Started;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("start-pipeline", StartPipeline),
|
||||
|
||||
JobStep.Nested("await-compile",
|
||||
// We have just requested this compile, so wait properly for Unity to get around to
|
||||
// starting it rather than concluding after a couple of idle seconds that it never
|
||||
// will — see WaitForCompileJob.SettleSeconds.
|
||||
() => new WaitForCompileJob
|
||||
{
|
||||
SettleSeconds = 30,
|
||||
IncludeConsole = IncludeConsole,
|
||||
},
|
||||
onComplete: ReportFromSubJob),
|
||||
};
|
||||
|
||||
private void StartPipeline()
|
||||
{
|
||||
if (Started) return;
|
||||
Started = true;
|
||||
|
||||
var started = CompilationHelper.StartCompilationPipeline(ClearCache);
|
||||
if (started == null)
|
||||
{
|
||||
Fail("The compilation pipeline returned no response.");
|
||||
return;
|
||||
}
|
||||
|
||||
var json = started as JObject ?? JObject.FromObject(started);
|
||||
|
||||
if (json["success"]?.ToObject<bool?>() == false)
|
||||
{
|
||||
// Play-mode block and other refusals: message-only so the client renders a failure.
|
||||
Fail(json["message"]?.ToString()
|
||||
?? json["error"]?.ToString()
|
||||
?? "Failed to start the compilation pipeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
CacheCleared = json?["cache_cleared"]?.ToObject<bool?>() ?? false;
|
||||
}
|
||||
|
||||
// Forward the nested wait's result as this job's own, annotated with what the start step
|
||||
// learned (whether the cache was cleared). The annotations go into the `data` payload,
|
||||
// which is the only part of the response the client unwraps to — see CompileResult.Build.
|
||||
private void ReportFromSubJob()
|
||||
{
|
||||
if (!LastSubJobSucceeded)
|
||||
{
|
||||
CompleteWithJson(LastSubJobResponseJson, failed: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = LastSubJobResponse;
|
||||
var payload = CompileResult.PayloadOf(result);
|
||||
if (payload == null)
|
||||
{
|
||||
Complete(CompileResult.Build(
|
||||
compileObserved: true,
|
||||
includeConsole: IncludeConsole));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ClearCache) payload["cache_cleared"] = CacheCleared;
|
||||
// Lets a caller tell "the compile ran and something went wrong" apart from "the
|
||||
// pipeline was refused before compiling" — only the former is worth retrying.
|
||||
payload["compile_started"] = true;
|
||||
|
||||
CompleteWithJson(result.ToString(Formatting.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until every other in-flight bridge job — async task, step, and coroutine alike, as
|
||||
/// enrolled in <see cref="JobRegistry"/> — has finished, and the editor is neither compiling
|
||||
/// nor importing. Step-job form so the wait survives any domain reload the outstanding work
|
||||
/// triggers.
|
||||
///
|
||||
/// The job is itself registered in the registry it watches, so jobs enrolled under
|
||||
/// <see cref="CommandName"/> are excluded from the wait — that covers this job and any
|
||||
/// concurrent wait_for_idle, which would otherwise hold each other in flight forever.
|
||||
/// </summary>
|
||||
public class WaitForIdleJob : StepJob
|
||||
{
|
||||
/// <summary>Registry name wait_for_idle jobs enroll under; excluded from the wait.</summary>
|
||||
public const string CommandName = "wait_for_idle";
|
||||
|
||||
public double StartedAt;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("await-idle",
|
||||
() => { if (StartedAt <= 0) StartedAt = EditorApplication.timeSinceStartup; },
|
||||
() => PendingJobs() == 0
|
||||
&& !CompilationHelper.IsCompiling()
|
||||
&& !EditorApplication.isUpdating),
|
||||
|
||||
new JobStep("report", () => Complete(Response.Success(
|
||||
"Editor is idle: no jobs in flight, not compiling, not importing.",
|
||||
new
|
||||
{
|
||||
pendingJobs = 0,
|
||||
isCompiling = false,
|
||||
isUpdating = false,
|
||||
elapsed = StartedAt > 0 ? EditorApplication.timeSinceStartup - StartedAt : 0,
|
||||
}))),
|
||||
};
|
||||
|
||||
private static int PendingJobs()
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var handle in JobRegistry.Snapshot())
|
||||
{
|
||||
if (handle.Name != CommandName) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enters play mode and answers once the editor is actually playing.</summary>
|
||||
public class PlayJob : StepJob
|
||||
{
|
||||
public bool Issued;
|
||||
public bool WasAlreadyPlaying;
|
||||
|
||||
/// <summary>
|
||||
/// Frames to allow for the transition to begin. Entering play mode is refused outright when
|
||||
/// scripts do not compile, and Unity reports that only by leaving both isPlaying and
|
||||
/// isPlayingOrWillChangePlaymode false — there is no failure callback to hook.
|
||||
/// </summary>
|
||||
public int GraceFrames = 30;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("enter-play", () =>
|
||||
{
|
||||
if (Issued) return;
|
||||
Issued = true;
|
||||
WasAlreadyPlaying = EditorApplication.isPlaying;
|
||||
if (WasAlreadyPlaying) return;
|
||||
|
||||
// Force the player loop to run at full speed even when the editor is not the
|
||||
// focused OS window; without this Time.frameCount barely advances in the
|
||||
// background. The runtime resets it when play mode ends.
|
||||
Application.runInBackground = true;
|
||||
EditorApplication.isPlaying = true;
|
||||
}),
|
||||
|
||||
new JobStep("await-playing", () =>
|
||||
{
|
||||
if (EditorApplication.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
return;
|
||||
if (--GraceFrames <= 0)
|
||||
Fail("Failed to enter play mode: the editor never started playing. " +
|
||||
"This usually means scripts failed to compile — check the console.");
|
||||
},
|
||||
() => EditorApplication.isPlaying && !EditorApplication.isCompiling),
|
||||
|
||||
new JobStep("report", () => Complete(Response.Success(
|
||||
WasAlreadyPlaying ? "Already in play mode." : "Entered play mode.",
|
||||
new { playMode = "playing" }))),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the requested pause state and answers once the editor reaches it. Legacy pause
|
||||
/// requests omit an explicit target and retain the original toggle behavior.
|
||||
/// </summary>
|
||||
public class PauseJob : StepJob
|
||||
{
|
||||
public bool Issued;
|
||||
public bool TogglePaused;
|
||||
public bool TargetPaused;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("set-pause", () =>
|
||||
{
|
||||
if (Issued) return;
|
||||
Issued = true;
|
||||
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Fail("Cannot pause/resume: Not in play mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TogglePaused)
|
||||
TargetPaused = !EditorApplication.isPaused;
|
||||
EditorApplication.isPaused = TargetPaused;
|
||||
}),
|
||||
|
||||
new JobStep("await-pause", null,
|
||||
() => EditorApplication.isPaused == TargetPaused),
|
||||
|
||||
new JobStep("report", () => Complete(Response.Success(
|
||||
TargetPaused ? "Game paused." : "Game resumed.",
|
||||
new { playMode = TargetPaused ? "paused" : "playing" }))),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Leaves play mode and answers once the editor is back in edit mode.</summary>
|
||||
public class StopPlayModeJob : StepJob
|
||||
{
|
||||
public bool Issued;
|
||||
public bool WasPlaying;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("request-stop", () =>
|
||||
{
|
||||
if (Issued) return;
|
||||
Issued = true;
|
||||
|
||||
WasPlaying = !PlayModeState.IsStopped();
|
||||
if (WasPlaying) EditorApplication.isPlaying = false;
|
||||
}),
|
||||
|
||||
new JobStep("await-stop", null, PlayModeState.IsStopped),
|
||||
|
||||
new JobStep("report", () => Complete(Response.Success(
|
||||
WasPlaying ? "Exited play mode." : "Already stopped (not in play mode).",
|
||||
new { playMode = "stopped" }))),
|
||||
};
|
||||
}
|
||||
|
||||
internal static class PlayModeState
|
||||
{
|
||||
/// <summary>
|
||||
/// True once play mode is fully over. isPlayingOrWillChangePlaymode stays true through the
|
||||
/// teardown, and exiting play mode reloads the domain, so the compile check keeps the job
|
||||
/// from answering while the editor is still rebuilding.
|
||||
/// </summary>
|
||||
public static bool IsStopped()
|
||||
=> !EditorApplication.isPlaying
|
||||
&& !EditorApplication.isPlayingOrWillChangePlaymode
|
||||
&& !EditorApplication.isCompiling;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d0c26d3c37534f4e8365b9caf87dd789
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Codely.Newtonsoft.Json;
|
||||
using Codely.Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.PackageManager;
|
||||
using UnityEditor.PackageManager.Requests;
|
||||
using UnityEngine;
|
||||
using UnityTcp.Editor.Helpers;
|
||||
|
||||
namespace UnityTcp.Editor.Tools.Jobs
|
||||
{
|
||||
/// <summary>
|
||||
/// Step jobs for the UPM actions. Installing or removing a package makes Unity resolve the
|
||||
/// manifest and then reload the domain to load the new assemblies — which is exactly what the
|
||||
/// previous implementation could not survive: it held the <see cref="Request"/> and its
|
||||
/// <c>EditorApplication.update</c> callback in plain statics, so after the reload nothing was
|
||||
/// left to complete the tracker job and <c>wait_for_upm</c> polled until it timed out.
|
||||
///
|
||||
/// A <see cref="StepJob"/> fixes that structurally. The live Request still cannot be persisted,
|
||||
/// so the job does not try: when it comes back in a new domain and finds the request gone, it
|
||||
/// establishes the outcome the only way that is still reliable — by asking UPM what is actually
|
||||
/// installed now.
|
||||
/// </summary>
|
||||
public class UpmJob : StepJob
|
||||
{
|
||||
/// <summary>False installs <see cref="Target"/>; true removes it.</summary>
|
||||
public bool Remove;
|
||||
|
||||
/// <summary>Package id, "id@version", git URL or local path for install; package name for remove.</summary>
|
||||
public string Target;
|
||||
|
||||
/// <summary>Registry name to verify against, or null when Target is a URL or local path.</summary>
|
||||
public string PackageName;
|
||||
|
||||
public bool Started;
|
||||
public bool RequestObserved; // the request completed while we still held it
|
||||
public bool RequestSucceeded;
|
||||
public string RequestError;
|
||||
|
||||
/// <summary>Frames to let the operation settle before concluding the domain reload ate it.</summary>
|
||||
public int SettleFrames = 5;
|
||||
|
||||
public string VerifiedVersion;
|
||||
public bool VerifiedPresent;
|
||||
public bool VerificationRan;
|
||||
|
||||
/// <summary>
|
||||
/// Set to true when the package is in the resolved graph but not listed in
|
||||
/// Packages/manifest.json (a transitive or system dependency). Null when the check
|
||||
/// was skipped (URL targets, manifest unreadable, or the package is a direct entry).
|
||||
/// </summary>
|
||||
public bool? AlreadyPresentAsTransitive;
|
||||
|
||||
// Neither survives a domain reload — that is the whole point of the fallback path.
|
||||
[JsonIgnore] private Request _request;
|
||||
[JsonIgnore] private ListRequest _list;
|
||||
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("start-request", StartRequest),
|
||||
|
||||
new JobStep("await-request", ObserveRequest, CanLeaveRequest),
|
||||
|
||||
// Always runs. A URL or local-path install has no registry name to look up, but the
|
||||
// resolved manifest still records where each package came from, so the install can be
|
||||
// confirmed by matching the source instead — see MatchesTarget. Skipping verification
|
||||
// there would leave exactly the case that needs it most (the domain reload ate the
|
||||
// request, so nothing else knows the outcome) reporting an unchecked success.
|
||||
new JobStep("verify", RunListRequest, () => _list != null && _list.IsCompleted),
|
||||
|
||||
new JobStep("report", Report),
|
||||
};
|
||||
|
||||
private void StartRequest()
|
||||
{
|
||||
if (Started) return;
|
||||
Started = true;
|
||||
|
||||
if (string.IsNullOrEmpty(Target))
|
||||
{
|
||||
Fail(Remove
|
||||
? "'package_name' parameter required for remove_package."
|
||||
: "'id_or_url' parameter required for install_package.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Remove && Target.Contains("com.unity.textmeshpro"))
|
||||
{
|
||||
// Must be armed before the request starts: the flag lives in SessionState so it
|
||||
// survives the reload UPM triggers and is picked up while that reload loads the TMP
|
||||
// assembly, ahead of any validate step. Prevents the interactive "TMP Importer"
|
||||
// window and the missing default-font NullReferenceException.
|
||||
TmpEssentialsAutoImporter.ScheduleImport();
|
||||
}
|
||||
|
||||
_request = Remove
|
||||
? (Request)Client.Remove(Target)
|
||||
: Client.Add(Target);
|
||||
}
|
||||
|
||||
private void ObserveRequest()
|
||||
{
|
||||
if (_request == null)
|
||||
{
|
||||
// A domain reload took the request with it. UPM commits the manifest before
|
||||
// triggering that reload, so the operation itself is done — but give the editor a
|
||||
// few frames to finish importing before we go and verify.
|
||||
SettleFrames--;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_request.IsCompleted) return;
|
||||
|
||||
RequestObserved = true;
|
||||
RequestSucceeded = _request.Status == StatusCode.Success;
|
||||
RequestError = _request.Error?.message;
|
||||
|
||||
// Backstop for an install that did NOT reload the domain (TMP already compiled): the
|
||||
// pre-request flag is still pending, so re-attempt the silent import now. No-op once
|
||||
// the essentials exist.
|
||||
if (RequestSucceeded && _request is AddRequest added &&
|
||||
added.Result?.name == "com.unity.textmeshpro")
|
||||
{
|
||||
TmpEssentialsAutoImporter.ScheduleImport();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanLeaveRequest()
|
||||
{
|
||||
if (_request != null) return _request.IsCompleted;
|
||||
return SettleFrames <= 0 && !EditorApplication.isCompiling && !EditorApplication.isUpdating;
|
||||
}
|
||||
|
||||
private void RunListRequest()
|
||||
{
|
||||
if (_list != null) return;
|
||||
// offlineMode: true — read the resolved manifest rather than hitting the registry.
|
||||
_list = Client.List(offlineMode: true, includeIndirectDependencies: false);
|
||||
}
|
||||
|
||||
private void ReadListResult()
|
||||
{
|
||||
if (_list == null || !_list.IsCompleted) return;
|
||||
// A failed list tells us nothing about the package, so it must not count as
|
||||
// verification — Report falls back on whatever the request itself said, and fails
|
||||
// outright when the request is gone too.
|
||||
if (_list.Status != StatusCode.Success) return;
|
||||
|
||||
VerificationRan = true;
|
||||
|
||||
var found = !string.IsNullOrEmpty(PackageName)
|
||||
? _list.Result?.FirstOrDefault(p => p.name == PackageName)
|
||||
: _list.Result?.FirstOrDefault(MatchesTarget);
|
||||
VerifiedPresent = found != null;
|
||||
VerifiedVersion = found?.version;
|
||||
|
||||
if (VerifiedPresent)
|
||||
{
|
||||
bool? inManifest = CheckUserManifest();
|
||||
if (inManifest == false)
|
||||
AlreadyPresentAsTransitive = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="PackageName"/> is a direct entry in Packages/manifest.json.
|
||||
/// Null when there is no name to look up, or the file cannot be read.
|
||||
/// </summary>
|
||||
private bool? CheckUserManifest()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PackageName)) return null;
|
||||
try
|
||||
{
|
||||
string manifestPath = Path.Combine(
|
||||
Directory.GetParent(Application.dataPath).FullName,
|
||||
"Packages", "manifest.json");
|
||||
if (!File.Exists(manifestPath)) return null;
|
||||
var manifest = JObject.Parse(File.ReadAllText(manifestPath));
|
||||
var deps = manifest["dependencies"] as JObject;
|
||||
return deps?[PackageName] != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches a resolved package against a <see cref="Target"/> that carries no registry name
|
||||
/// — a git URL, a tarball or a local folder. UPM records these in the package id as
|
||||
/// "name@source", so the part after the first '@' is the target we asked for.
|
||||
/// </summary>
|
||||
private bool MatchesTarget(UnityEditor.PackageManager.PackageInfo package)
|
||||
{
|
||||
string id = package?.packageId;
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(Target)) return false;
|
||||
|
||||
int at = id.IndexOf('@');
|
||||
string source = at >= 0 ? id.Substring(at + 1) : id;
|
||||
if (string.IsNullOrEmpty(source)) return false;
|
||||
|
||||
if (string.Equals(source, Target, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Beyond an exact hit, only compare things that are actually locations. A registry
|
||||
// package's source is a bare version ("1.2.3"), and substring-matching that against a
|
||||
// URL would happily "verify" an unrelated package.
|
||||
if (!IsLocation(source) || !IsLocation(Target)) return false;
|
||||
|
||||
// A git target may carry a #revision the manifest normalizes away, and a local path may
|
||||
// be recorded relative rather than as given.
|
||||
return source.IndexOf(Target, StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| Target.IndexOf(source, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static bool IsLocation(string value)
|
||||
=> value.IndexOf("://", StringComparison.Ordinal) >= 0
|
||||
|| value.StartsWith("file:", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.StartsWith("git", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.IndexOf('/') >= 0
|
||||
|| value.IndexOf('\\') >= 0;
|
||||
|
||||
internal void Report()
|
||||
{
|
||||
ReadListResult();
|
||||
|
||||
string what = PackageName ?? Target;
|
||||
|
||||
// An error we actually saw from UPM is the most specific thing we can report —
|
||||
// unless remove failed while the package is still in the resolved graph and is
|
||||
// confirmed not in Packages/manifest.json: that is a transitive / system
|
||||
// dependency, and UPM's own "not in the project manifest" is true but not actionable.
|
||||
if (RequestObserved && !RequestSucceeded)
|
||||
{
|
||||
if (Remove && VerificationRan && VerifiedPresent && AlreadyPresentAsTransitive == true)
|
||||
{
|
||||
Fail(TransitiveRemoveMessage(what));
|
||||
return;
|
||||
}
|
||||
Fail($"[EXPERIMENTAL] Package {(Remove ? "removal" : "installation")} failed for " +
|
||||
$"'{what}': {RequestError ?? "UPM reported no error message."}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (VerificationRan)
|
||||
{
|
||||
bool ok = Remove ? !VerifiedPresent : VerifiedPresent;
|
||||
if (!ok)
|
||||
{
|
||||
if (Remove && VerifiedPresent && AlreadyPresentAsTransitive == true)
|
||||
{
|
||||
Fail(TransitiveRemoveMessage(what));
|
||||
return;
|
||||
}
|
||||
Fail($"[EXPERIMENTAL] Package {(Remove ? "removal" : "installation")} did not take " +
|
||||
$"effect for '{what}': the package is {(Remove ? "still" : "not")} present in " +
|
||||
"the resolved manifest.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Remove && AlreadyPresentAsTransitive == true)
|
||||
{
|
||||
var noopData = new Dictionary<string, object>
|
||||
{
|
||||
["package"] = what,
|
||||
["type"] = "install",
|
||||
["verified"] = true,
|
||||
["already_present"] = true,
|
||||
["in_user_manifest"] = false,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(VerifiedVersion)) noopData["version"] = VerifiedVersion;
|
||||
|
||||
Complete(Response.Success(
|
||||
$"[EXPERIMENTAL] Package '{what}' is already present as a transitive or " +
|
||||
$"system dependency (version {VerifiedVersion ?? "unknown"}). " +
|
||||
"It was not added to Packages/manifest.json because UPM treats it as " +
|
||||
"already installed. The package is usable in code. Note: 'remove_package' " +
|
||||
"cannot remove transitive dependencies — to remove it, remove the package " +
|
||||
"that depends on it.",
|
||||
noopData));
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!RequestObserved)
|
||||
{
|
||||
// The domain reload took the request and the manifest could not be read back, so
|
||||
// nothing here knows whether the operation worked. Reporting success on that would
|
||||
// send the caller off building against a package that may not exist.
|
||||
Fail($"[EXPERIMENTAL] Package {(Remove ? "removal" : "installation")} could not be " +
|
||||
$"confirmed for '{what}': the domain reload took the UPM request with it and " +
|
||||
"the resolved manifest could not be read back. Check the Package Manager " +
|
||||
"window or Packages/manifest.json, then retry if needed.");
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["package"] = what,
|
||||
["type"] = Remove ? "remove" : "install",
|
||||
// False when the resolved manifest could not be read back: the success below then
|
||||
// rests on UPM's own report of the request, which is a good signal but not proof
|
||||
// of what ended up in the manifest.
|
||||
["verified"] = VerificationRan,
|
||||
// True when the outcome was established by re-reading the manifest after the domain
|
||||
// reload rather than from the request object itself.
|
||||
["verified_after_reload"] = !RequestObserved,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(VerifiedVersion)) data["version"] = VerifiedVersion;
|
||||
|
||||
string message = Remove
|
||||
? $"[EXPERIMENTAL] Package removed: {what}"
|
||||
: $"[EXPERIMENTAL] Package installed: {what}" +
|
||||
(string.IsNullOrEmpty(VerifiedVersion) ? "" : $"@{VerifiedVersion}");
|
||||
|
||||
if (!VerificationRan)
|
||||
{
|
||||
message += " — reported by UPM; the resolved manifest could not be read back to " +
|
||||
"confirm it.";
|
||||
}
|
||||
|
||||
Complete(Response.Success(message, data));
|
||||
}
|
||||
|
||||
private static string TransitiveRemoveMessage(string what)
|
||||
=> $"[EXPERIMENTAL] Package '{what}' is present in the resolved project " +
|
||||
"as a transitive or system dependency, but is not listed in " +
|
||||
"Packages/manifest.json as a direct entry. Unity's Package Manager " +
|
||||
"only removes packages you explicitly added. To remove this package, " +
|
||||
"remove the package that depends on it instead.";
|
||||
|
||||
/// <summary>
|
||||
/// Registry name to verify an operation against, or null when the target is a git URL, a
|
||||
/// local tarball or a folder — those carry no name we can look up until UPM resolves them.
|
||||
/// </summary>
|
||||
public static string RegistryNameOf(string idOrUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(idOrUrl)) return null;
|
||||
if (idOrUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase) ||
|
||||
idOrUrl.StartsWith("file:", StringComparison.OrdinalIgnoreCase) ||
|
||||
idOrUrl.StartsWith("git", StringComparison.OrdinalIgnoreCase) ||
|
||||
idOrUrl.Contains("/") || idOrUrl.Contains("\\"))
|
||||
return null;
|
||||
|
||||
int at = idOrUrl.IndexOf('@');
|
||||
return at > 0 ? idOrUrl.Substring(0, at) : idOrUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists installed packages. A step job because <see cref="Client.List"/> is asynchronous: the
|
||||
/// previous implementation spun on <c>Thread.Sleep(100)</c> on the editor thread, freezing the
|
||||
/// whole editor for the duration of the call.
|
||||
/// </summary>
|
||||
public class ListPackagesJob : StepJob
|
||||
{
|
||||
[JsonIgnore] private ListRequest _list;
|
||||
|
||||
// Issuing, awaiting and reporting are one step because the request cannot be persisted: a
|
||||
// domain reload landing anywhere in here leaves _list null in the new domain, and a
|
||||
// separate report step would dereference it. Kept as a single step, the reload simply
|
||||
// re-issues the query. The step ends by calling Complete/Fail rather than by leaving.
|
||||
protected override JobStep[] BuildSteps() => new[]
|
||||
{
|
||||
new JobStep("list-packages", Poll, canLeave: () => false),
|
||||
};
|
||||
|
||||
private void Poll()
|
||||
{
|
||||
if (_list == null)
|
||||
{
|
||||
_list = Client.List(true, false);
|
||||
return;
|
||||
}
|
||||
if (!_list.IsCompleted) return;
|
||||
Report();
|
||||
}
|
||||
|
||||
private void Report()
|
||||
{
|
||||
if (_list.Status != StatusCode.Success)
|
||||
{
|
||||
Fail($"[EXPERIMENTAL] Failed to list packages: " +
|
||||
(_list.Error?.message ?? "UPM reported no error message."));
|
||||
return;
|
||||
}
|
||||
|
||||
var packages = _list.Result.Select(p => new
|
||||
{
|
||||
name = p.name,
|
||||
version = p.version,
|
||||
displayName = p.displayName,
|
||||
description = p.description,
|
||||
source = p.source.ToString(),
|
||||
}).ToList();
|
||||
|
||||
Complete(Response.Success($"[EXPERIMENTAL] Retrieved {packages.Count} packages.", packages));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf04a2762cd6400eb0daf844311e6604
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in new issue
Block a user