Files
unity/Assets/Editor/LocalizationSmoke.cs
RCiBot eb819c6688 feat: improve map lighting and expand the playable training range
Add real-time lighting and shadows, training courses and targets, and animated salvage crates. Include bilingual assets and curated verification results.
2026-11-12 18:03:04 +08:00

291 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace Ironspore.Editor
{
// Editor-only integration checks and real Game View captures, invoked through Unity MCP.
public static class LocalizationSmoke
{
static string Folder;
static readonly List<Action> steps = new List<Action>();
static readonly Dictionary<string, byte[]> rangeFiles = new Dictionary<string, byte[]>();
static Result result;
static GameSession game;
static string preference, mainState;
static bool hadPreference;
static int initialChassis, step;
static int[] initialEquipment;
static float nextAt;
static VisualElement Root => game.Hud.GetComponent<UIDocument>().rootVisualElement;
static string MainPath => Path.Combine(Application.persistentDataPath, "Ironspore", "profile.json");
static byte[] mainBytes;
[Serializable]
public sealed class Check
{
public string name;
public bool passed;
}
[Serializable]
public sealed class Result
{
public bool passed;
public int stepsCompleted;
public string error;
public List<Check> checks = new List<Check>();
}
static void CheckThat(string name, bool condition)
{
result.checks.Add(new Check { name = name, passed = condition });
if (!condition)
throw new InvalidOperationException(name);
}
static void Submit(string name)
{
var button = Root.Q<Button>(name);
if (button == null)
throw new InvalidOperationException("Missing UI button: " + name);
button.Focus();
using (var evt = NavigationSubmitEvent.GetPooled())
{
evt.target = button;
button.SendEvent(evt);
}
}
static void Capture(string name)
{
ScreenCapture.CaptureScreenshot(Path.GetFullPath(Folder + "/Screenshots/" + name + ".png"));
}
static void Key(string key, bool shift = false)
{
var input = Event.KeyboardEvent(key);
if (shift)
input.modifiers |= EventModifiers.Shift;
using (var evt = KeyDownEvent.GetPooled(input))
{
var target = Root.focusController.focusedElement as VisualElement ?? Root;
evt.target = target;
target.SendEvent(evt);
}
}
static void CheckBounds(int width, int height)
{
string name = width + "x" + height;
CheckThat(name + " is the actual render resolution", Screen.width == width && Screen.height == height);
var outer = Root.worldBound;
var button = Root.Q<Button>("language-toggle").worldBound;
CheckThat(name + " button remains bottom-right", button.width > 200 && button.xMin > outer.center.x && button.xMax <= outer.xMax && button.yMax <= outer.yMax && outer.xMax - button.xMax < 80 && outer.yMax - button.yMax < 80);
}
public static string Begin(string outputFolder = "Docs/QA/Localization")
{
Folder = outputFolder;
if (!EditorApplication.isPlaying || !GameSession.Active || GameSession.Active.InField)
throw new InvalidOperationException("Run the localization smoke from the playing hangar.");
if (result != null)
throw new InvalidOperationException("Localization smoke is already active.");
game = GameSession.Active;
result = new Result();
hadPreference = PlayerPrefs.HasKey(GameLocalization.PreferenceKey);
preference = PlayerPrefs.GetString(GameLocalization.PreferenceKey, "");
mainState = JsonUtility.ToJson(game.Store.Data);
mainBytes = File.Exists(MainPath) ? File.ReadAllBytes(MainPath) : null;
initialChassis = game.SelectedChassis;
initialEquipment = (int[])game.InitialEquipment.Clone();
rangeFiles.Clear();
string range = Path.Combine(Application.persistentDataPath, "Ironspore", "range.json");
foreach (string path in new[] { range, range + ".bak", range + ".tmp" })
rangeFiles[path] = File.Exists(path) ? File.ReadAllBytes(path) : null;
Directory.CreateDirectory(Folder + "/Screenshots");
Directory.CreateDirectory(Folder + "/TestResults");
Application.runInBackground = true;
SetResolution(1920, 1080);
game.SelectedChassis = 0;
game.InitialEquipment[2] = 1;
var world = GameObject.Find("World");
for (int i = 0; i < 30; i++)
GameLocalization.SelectLocale(i % 2 == 0 ? "zh-Hans" : "en", false);
CheckThat("30 locale changes preserve chassis and loadout", game.SelectedChassis == 0 && game.InitialEquipment[2] == 1);
CheckThat("30 locale changes do not recreate the world", GameObject.Find("World") == world);
CheckThat("30 locale changes do not duplicate the language button", Root.Query<Button>("language-toggle").ToList().Count == 1);
CheckThat("30 locale changes do not mutate save data", mainState == JsonUtility.ToJson(game.Store.Data));
steps.Clear();
steps.Add(() => { CheckBounds(1920, 1080); Capture("hangar-en-1080"); });
steps.Add(() => Submit("language-toggle"));
steps.Add(() => { CheckThat("Submit opens language menu", Root.Q("language-menu") != null); Capture("language-menu-en"); });
steps.Add(() => Submit("locale-zh-Hans"));
steps.Add(() =>
{
CheckThat("Menu choice immediately switches and persists", GameLocalization.CurrentCode == "zh-Hans" && PlayerPrefs.GetString(GameLocalization.PreferenceKey) == "zh-Hans");
CheckThat("Menu closes and restores toggle focus", Root.Q("language-menu") == null && Root.focusController.focusedElement == Root.Q<Button>("language-toggle"));
Capture("hangar-zh-1080");
});
steps.Add(() =>
{
Submit("language-toggle");
using (var evt = KeyDownEvent.GetPooled(Event.KeyboardEvent("escape")))
{
evt.target = Root.Q<Button>("locale-zh-Hans");
Root.SendEvent(evt);
}
CheckThat("Escape dismisses the menu", Root.Q("language-menu") == null);
});
steps.Add(() => Key("return"));
steps.Add(() =>
{
CheckThat("Enter opens focused language toggle", Root.Q("language-menu") != null);
Key("tab");
CheckThat("Tab moves focus to English", Root.focusController.focusedElement == Root.Q<Button>("locale-en"));
});
steps.Add(() => Key("space"));
steps.Add(() =>
{
CheckThat("Space selects focused locale", GameLocalization.CurrentCode == "en" && Root.Q("language-menu") == null);
Key("space");
});
steps.Add(() =>
{
CheckThat("Space opens focused language toggle", Root.Q("language-menu") != null);
Key("tab", true);
CheckThat("Shift Tab moves focus to Chinese", Root.focusController.focusedElement == Root.Q<Button>("locale-zh-Hans"));
});
steps.Add(() => Key("return"));
steps.Add(() => CheckThat("Enter selects focused locale", GameLocalization.CurrentCode == "zh-Hans" && Root.Q("language-menu") == null));
foreach (var resolution in new[] { new Vector2Int(1280, 720), new Vector2Int(2560, 1440), new Vector2Int(2560, 1080) })
{
var size = resolution;
steps.Add(() => SetResolution(size.x, size.y));
steps.Add(() => { CheckBounds(size.x, size.y); Capture("hangar-zh-" + size.x + "x" + size.y); });
}
steps.Add(() =>
{
SetResolution(1920, 1080);
game.StartTrial();
game.SetMenu(true);
game.Store.AddCargo("alloy", 4);
game.Store.AddCargo("blueprint_coil_capacitor", 1);
});
AddPair("hud", game.Hud.ShowField, false);
AddPair("cargo", game.Hud.ShowCargo, true);
AddPair("refit", () => game.Hud.ShowPart(2, 1), true);
AddPair("routes", game.Hud.ShowRoutes, true);
AddPair("pause", game.Hud.ShowPause, true);
steps.Add(() => { game.CloseMenu(); game.EndSortie(true); GameLocalization.SelectLocale("zh-Hans", false); });
steps.Add(() => Capture("report-zh"));
steps.Add(() => GameLocalization.SelectLocale("en", false));
steps.Add(() =>
{
Capture("report-en");
CheckThat("Existing report follows locale", game.Report.StartsWith("Driving range complete"));
CheckThat("Formal inventory and receipts remain unchanged", mainState == JsonUtility.ToJson(game.Store.Data));
CheckThat("Formal profile file remains byte-for-byte unchanged", mainBytes == null ? !File.Exists(MainPath) : mainBytes.SequenceEqual(File.ReadAllBytes(MainPath)));
});
step = 0;
nextAt = Time.realtimeSinceStartup + .7f;
EditorApplication.update += Tick;
return "Localization integration checks and screenshots started; results in about 20 seconds.";
}
static void AddPair(string name, Action show, bool overlay)
{
steps.Add(() => { GameLocalization.SelectLocale("en", false); show(); });
steps.Add(() =>
{
Capture(name + "-en");
});
steps.Add(() =>
{
var world = GameObject.Find("World");
var before = JsonUtility.ToJson(game.Run);
GameLocalization.SelectLocale("zh-Hans", false);
CheckThat(name + " switch preserves world and cargo", world == GameObject.Find("World") && before == JsonUtility.ToJson(game.Run));
CheckThat(name + " switch preserves UI context", overlay == (Root.Q(className: "overlay") != null));
CheckThat(name + " refreshes existing world signs", UnityEngine.Object.FindObjectsByType<TextMesh>().Any(x => x.text == "撤离点"));
});
steps.Add(() => Capture(name + "-zh"));
}
static void Tick()
{
if (Time.realtimeSinceStartup < nextAt)
return;
try
{
if (!EditorApplication.isPlaying || !game)
throw new InvalidOperationException("Play mode ended during localization smoke.");
if (step >= steps.Count)
{
result.passed = true;
Finish();
return;
}
steps[step++]();
result.stepsCompleted = step;
nextAt = Time.realtimeSinceStartup + .55f;
}
catch (Exception ex)
{
result.error = ex.ToString();
Finish();
}
}
static void Finish()
{
EditorApplication.update -= Tick;
if (game)
{
if (game.InField && game.Trial)
game.EndSortie(true);
game.SelectedChassis = initialChassis;
game.InitialEquipment = initialEquipment;
if (hadPreference)
PlayerPrefs.SetString(GameLocalization.PreferenceKey, preference);
else
PlayerPrefs.DeleteKey(GameLocalization.PreferenceKey);
PlayerPrefs.Save();
GameLocalization.Initialize(game.localization);
game.Hud.ShowHangar();
}
// Restore only the three explicit trial files, after the trial session has closed.
foreach (var entry in rangeFiles)
{
if (entry.Value != null)
File.WriteAllBytes(entry.Key, entry.Value);
else if (File.Exists(entry.Key))
File.Delete(entry.Key);
}
File.WriteAllText(Folder + "/TestResults/Integration.json", JsonUtility.ToJson(result, true));
result = null;
}
public static void SetResolution(int width, int height)
{
var assembly = typeof(UnityEditor.Editor).Assembly;
var type = assembly.GetType("UnityEditor.GameViewSizes");
var instance = typeof(ScriptableSingleton<>).MakeGenericType(type).GetProperty("instance").GetValue(null);
var group = type.GetMethod("GetGroup").Invoke(instance, new[] { Enum.ToObject(assembly.GetType("UnityEditor.GameViewSizeGroupType"), 0) });
int count = (int)group.GetType().GetMethod("GetTotalCount").Invoke(group, null);
int index = count;
for (int i = 0; i < count; i++)
{
var existing = group.GetType().GetMethod("GetGameViewSize").Invoke(group, new object[] { i });
if ((int)existing.GetType().GetProperty("width").GetValue(existing) == width && (int)existing.GetType().GetProperty("height").GetValue(existing) == height)
{
index = i;
break;
}
}
if (index == count)
{
var size = Activator.CreateInstance(assembly.GetType("UnityEditor.GameViewSize"), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new object[] { Enum.Parse(assembly.GetType("UnityEditor.GameViewSizeType"), "FixedResolution"), width, height, "Ironspore QA " + width + "x" + height }, null);
group.GetType().GetMethod("AddCustomSize").Invoke(group, new[] { size });
}
var view = EditorWindow.GetWindow(assembly.GetType("UnityEditor.GameView"));
view.GetType().GetProperty("selectedSizeIndex", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(view, index);
view.Focus();
}
}
}