- UI SFX v3: procedurally synthesized one-shots (Tools/synth_ui_sfx.py) wired to buttons, rewards, errors and red dots via UiSfx, gated by the SFX setting. - Five formal enemies (Hornlet, Dashlet, Shellhorn, HornCaptain, GreatHorn): Hunyuan 50k -> 2000-tri XPbr, DGXA TokenRig, Idle/Walk/Attack, nested per type in PrototypeArena; PrototypeBattle.Enemy gains presentation-only Moving/Strikes. - Formal partners for all ten pets in the arena and portraits, including new VineSprite, ThunderLeopard and TideWhale. - Pipeline fixes: reject local TokenRig fallback, drop loose mesh islands, configurable Unity run_script timeout, companion static stage. - Also includes concurrent work from another tool in this working tree (FlameLion, enemy combat FX, challenge mode, home explorer, server updates). - Resources registered in Doc/ResDesc_*.md; verified on isolated :18081. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
16 KiB
C#
298 lines
16 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading.Tasks;
|
|
using HotUpdate.Gameplay;
|
|
using HotUpdate.Net;
|
|
using HotUpdate.UI;
|
|
using Newtonsoft.Json;
|
|
using TMPro;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
|
|
/// <summary>G3 guide copy and an editor-only battle frame sample. Not a WeChat device pass.</summary>
|
|
public static class VerifyG3
|
|
{
|
|
const string Endpoint = "http://127.0.0.1:18081";
|
|
static PrototypeFlow Flow => UnityEngine.Object.FindObjectOfType<PrototypeFlow>();
|
|
static void Check(bool value, string message) { if (!value) throw new Exception(message); }
|
|
static T Get<T>(string name) => (T)typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).GetValue(Flow);
|
|
static void Set(string name, object value) => typeof(PrototypeFlow).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic).SetValue(Flow, value);
|
|
static async Task Until(Func<bool> condition, int seconds = 30)
|
|
{
|
|
var end = DateTime.UtcNow.AddSeconds(seconds);
|
|
while (!condition())
|
|
{
|
|
Check(DateTime.UtcNow < end, "G3 timed out: " + Flow?.Page);
|
|
await Task.Delay(100);
|
|
}
|
|
}
|
|
static Button Button(string name) => UnityEngine.Object.FindObjectsOfType<Button>().Single(b => b.name == "Btn_" + name && b.gameObject.activeInHierarchy);
|
|
static void Click(string name)
|
|
{
|
|
var b = Button(name);
|
|
Check(b.interactable, "Disabled " + name);
|
|
Canvas.ForceUpdateCanvases();
|
|
var rect = b.GetComponent<RectTransform>();
|
|
var pointer = new PointerEventData(EventSystem.current) { position = RectTransformUtility.WorldToScreenPoint(null, rect.TransformPoint(rect.rect.center)), button = PointerEventData.InputButton.Left };
|
|
var hits = new List<RaycastResult>();
|
|
EventSystem.current.RaycastAll(pointer, hits);
|
|
Check(hits.Count > 0 && ExecuteEvents.GetEventHandler<IPointerClickHandler>(hits[0].gameObject) == b.gameObject, "Blocked " + name + " by " + (hits.Count == 0 ? "nothing" : hits[0].gameObject.name));
|
|
ExecuteEvents.Execute(b.gameObject, pointer, ExecuteEvents.pointerClickHandler);
|
|
}
|
|
|
|
public static string Domain()
|
|
{
|
|
var save = new PrototypeSave();
|
|
Check(MainHudView.GuideTask(save) == "发射第一枚弹珠", "Fresh guide");
|
|
save.shots = 1; save.tickets = 1;
|
|
Check(MainHudView.GuideTask(save) == "用捕捉券获得芽芽", "Capture guide");
|
|
save.captureTutorialDone = true;
|
|
Check(MainHudView.GuideTask(save) == "用糖果做一次共享训练", "Train guide");
|
|
save.level = 2;
|
|
Check(MainHudView.GuideTask(save) == "布阵并完成教学关 1-1", "Battle guide");
|
|
save.cleared = true;
|
|
Check(MainHudView.GuideTask(save) == "领取首通目标奖励", "Claim guide");
|
|
save.goalClaimed = true;
|
|
Check(MainHudView.GuideTask(save) == "教学目标已完成", "Done guide");
|
|
return "PASS first-day guide order.";
|
|
}
|
|
|
|
public static async Task<string> Play()
|
|
{
|
|
Check(EditorApplication.isPlaying, "Enter Play Mode first");
|
|
await Until(() => Flow?.Game != null && !Flow.IsBusy);
|
|
SessionState.SetString("AIGame.G3.original", JsonUtility.ToJson(Flow.Game.State));
|
|
SessionState.SetString("AIGame.G3.originalEndpoint", Get<string>("_httpBase"));
|
|
int quality = PrototypeClientPrefs.Quality;
|
|
SessionState.SetInt("AIGame.G3.quality", quality);
|
|
PrototypeClientPrefs.Quality = 0;
|
|
|
|
AuthResponse account = null; string error = null; bool done = false;
|
|
string user = "g3_" + Guid.NewGuid().ToString("N");
|
|
string dir = Path.Combine(Application.dataPath, "../../Tools/output/g3");
|
|
Directory.CreateDirectory(dir);
|
|
File.WriteAllText(Path.Combine(dir, "account.txt"), user);
|
|
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-g3-password", (r, e) => { account = r; error = e; done = true; }));
|
|
await Until(() => done);
|
|
Check(account != null, "Isolated register failed " + error);
|
|
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
|
|
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
|
|
Set("_session", new Func<SessionData>(() => session));
|
|
Set("_profile", new Func<long>(() => session.Pid));
|
|
Set("_httpBase", Endpoint);
|
|
Set("_boundProfile", 0L);
|
|
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
|
|
Flow.Handle("Home");
|
|
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Page == "Home");
|
|
var task = UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_Task" && t.gameObject.activeInHierarchy);
|
|
Check(task.text == "发射第一枚弹珠", "Home guide was " + task.text);
|
|
|
|
Click("Adventure");
|
|
await Until(() => Flow.Page == "UI_PrototypeStages");
|
|
Click("Stage1");
|
|
await Until(() => Flow.Page == "UI_PrototypeFormation");
|
|
Click("StartBattle");
|
|
await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null, 40);
|
|
|
|
var samples = new List<float>();
|
|
int heroes = 0, enemies = 0;
|
|
var arena = Get<GameObject>("_arena");
|
|
Flow.StartCoroutine(Sample(samples, () =>
|
|
{
|
|
heroes = Math.Max(heroes, ActiveChildren(arena, "Heroes"));
|
|
enemies = Math.Max(enemies, ActiveChildren(arena, "Enemies"));
|
|
}));
|
|
await Until(() => samples.Count >= 90 || Flow.Battle.Finished, 20);
|
|
Check(samples.Count >= 30, "Too few battle frames");
|
|
Check(heroes >= 1 && heroes <= 6, "Hero count " + heroes);
|
|
Check(enemies <= 40, "Enemy count " + enemies);
|
|
|
|
Click("Abandon");
|
|
await Task.Delay(200);
|
|
Click("Ok");
|
|
await Until(() => UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().Any(t => t.name == "Txt_ResultInfo" && t.gameObject.activeInHierarchy && t.text.Contains("建议:")));
|
|
string advice = UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_ResultInfo" && t.gameObject.activeInHierarchy).text;
|
|
|
|
float sum = samples.Sum();
|
|
var report = new
|
|
{
|
|
device = "Unity Editor " + SystemInfo.operatingSystem + " / " + SystemInfo.graphicsDeviceName,
|
|
quality = "流畅",
|
|
note = "Editor sample only. Does not satisfy the WeChat 30 FPS target.",
|
|
frames = samples.Count,
|
|
averageFps = Math.Round(samples.Count / sum, 1),
|
|
minFrameMs = Math.Round(samples.Min() * 1000, 2),
|
|
maxFrameMs = Math.Round(samples.Max() * 1000, 2),
|
|
heroes,
|
|
enemies,
|
|
advice
|
|
};
|
|
File.WriteAllText(Path.Combine(Application.dataPath, "../../Doc/Design/G3/editor_frame_sample.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
|
|
PrototypeClientPrefs.Quality = quality;
|
|
return "PASS guide, abandon advice, heroes " + heroes + ", enemies " + enemies + ", editor avg fps " + report.averageFps;
|
|
}
|
|
|
|
static IEnumerator Sample(List<float> samples, Action observe)
|
|
{
|
|
while (samples.Count < 90 && Flow.Battle != null && !Flow.Battle.Finished)
|
|
{
|
|
yield return null;
|
|
samples.Add(Time.unscaledDeltaTime);
|
|
observe();
|
|
}
|
|
}
|
|
static int ActiveChildren(GameObject arena, string name)
|
|
{
|
|
var root = arena.transform.Find(name);
|
|
if (root == null) return 0;
|
|
int count = 0;
|
|
for (int i = 0; i < root.childCount; i++)
|
|
if (root.GetChild(i).gameObject.activeInHierarchy) count++;
|
|
return count;
|
|
}
|
|
|
|
static async Task Shot()
|
|
{
|
|
int shots = Flow.Game.State.shots;
|
|
Click("Fire");
|
|
await Until(() => !Flow.IsBusy && Flow.Game.State.shots == shots + 1 && string.IsNullOrEmpty(Flow.Game.State.pendingShot), 20);
|
|
}
|
|
static string HomeTask() => UnityEngine.Object.FindObjectsOfType<TextMeshProUGUI>().First(t => t.name == "Txt_Task" && t.gameObject.activeInHierarchy).text;
|
|
static async Task Home(string guide)
|
|
{
|
|
if (Flow.Page != "Home") { Click("Back"); await Until(() => Flow.Page == "Home" && !Flow.IsBusy); }
|
|
Check(MainHudView.GuideTask(Flow.Game.State) == guide, "Guide state was " + MainHudView.GuideTask(Flow.Game.State));
|
|
Check(HomeTask() == guide, "Home guide was " + HomeTask());
|
|
await Task.Delay(200);
|
|
}
|
|
|
|
public static async Task<string> Loop()
|
|
{
|
|
Check(EditorApplication.isPlaying, "Enter Play Mode first");
|
|
await Until(() => Flow?.Game != null && !Flow.IsBusy);
|
|
SessionState.SetString("AIGame.G3.original", JsonUtility.ToJson(Flow.Game.State));
|
|
SessionState.SetString("AIGame.G3.originalEndpoint", Get<string>("_httpBase"));
|
|
AuthResponse account = null; string error = null; bool done = false;
|
|
string user = "g3loop_" + Guid.NewGuid().ToString("N");
|
|
string dir = Path.Combine(Application.dataPath, "../../Tools/output/g3");
|
|
Directory.CreateDirectory(dir);
|
|
File.WriteAllText(Path.Combine(dir, "account.txt"), user);
|
|
Flow.StartCoroutine(AuthApi.Register(Endpoint, user, "isolated-g3-password", (r, e) => { account = r; error = e; done = true; }));
|
|
await Until(() => done);
|
|
Check(account != null, "Isolated register failed " + error);
|
|
var session = new SessionData { Pid = account.pid, Token = account.token, Username = user };
|
|
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
|
|
Set("_session", new Func<SessionData>(() => session));
|
|
Set("_profile", new Func<long>(() => session.Pid));
|
|
Set("_httpBase", Endpoint);
|
|
Set("_boundProfile", 0L);
|
|
PlayerPrefs.DeleteKey("AIGame.G1.pending." + Uri.EscapeDataString(Endpoint) + "." + session.Pid);
|
|
Flow.Handle("Home");
|
|
await Until(() => Flow.Game != null && !Flow.IsBusy && Flow.Page == "Home");
|
|
var fresh = Flow.Game.State;
|
|
Check(fresh.balls == 20 && fresh.candy == 0 && fresh.coins == 0 && fresh.tickets == 0 && fresh.level == 1 && fresh.shots == 0, "Fresh teaching wallet wrong");
|
|
await Home("发射第一枚弹珠");
|
|
|
|
Click("NavPinball");
|
|
await Until(() => Flow.Page == "UI_PrototypePinball");
|
|
await Shot();
|
|
Check(Flow.Game.State.tickets == 1, "First shot did not grant a ticket");
|
|
int extra = 0;
|
|
while (Flow.Game.State.candy < 30)
|
|
{
|
|
extra++;
|
|
Check(extra <= 6, "Could not reach training candy");
|
|
await Shot();
|
|
}
|
|
int shotCandy = Flow.Game.State.candy, shotCoins = Flow.Game.State.coins, shots = Flow.Game.State.shots;
|
|
await Home("用捕捉券获得芽芽");
|
|
|
|
Click("NavPinball");
|
|
await Until(() => Flow.Page == "UI_PrototypePinball");
|
|
Click("Capture");
|
|
await Until(() => Flow.Page == "UI_PrototypeCapture");
|
|
Click("Capture");
|
|
await Until(() => !Flow.IsBusy && Flow.Game.State.captureTutorialDone && Flow.Game.State.stages[2] == 1 && Flow.Game.State.tickets == 0);
|
|
Click("Ok");
|
|
await Until(() => Flow.Page == "UI_PrototypePets" && !Flow.IsBusy);
|
|
Click("Train");
|
|
await Until(() => !Flow.IsBusy && Flow.Game.State.level == 2);
|
|
Check(Flow.Game.State.candy == shotCandy - 30, "Training did not spend 30 candy");
|
|
await Home("布阵并完成教学关 1-1");
|
|
|
|
int coins = Flow.Game.State.coins, candy = Flow.Game.State.candy, balls = Flow.Game.State.balls;
|
|
Click("Adventure");
|
|
await Until(() => Flow.Page == "UI_PrototypeStages");
|
|
Click("Stage1");
|
|
await Until(() => Flow.Page == "UI_PrototypeFormation");
|
|
Check(Flow.Game.State.formation[0] == 0 && Flow.Game.State.formation[1] == 1, "Teaching formation changed");
|
|
Click("StartBattle");
|
|
await Until(() => Flow.Page == "UI_PrototypeBattle" && Flow.Battle != null, 40);
|
|
Click("Speed");
|
|
await Until(() => Flow.Battle.Finished && !Flow.IsBusy && Flow.Game.State.cleared, 90);
|
|
Check(Flow.Battle.Won, "Stage 1-1 was not won");
|
|
Check(Flow.Game.State.coins == coins + 80 && Flow.Game.State.candy == candy + 40 && Flow.Game.State.balls == balls + 3, "First-clear reward mismatch");
|
|
|
|
Click("Home");
|
|
await Home("领取首通目标奖励");
|
|
Click("Tasks");
|
|
await Until(() => Flow.Page == "UI_PrototypeTasks");
|
|
Click("ClaimGoal");
|
|
await Until(() => !Flow.IsBusy && Flow.Game.State.goalClaimed);
|
|
Check(Flow.Game.State.balls == balls + 8, "Teaching goal was not +5");
|
|
Click("Ok");
|
|
Click("Back");
|
|
await Until(() => Flow.Page == "Home" && !Flow.IsBusy);
|
|
Check(MainHudView.GuideTask(Flow.Game.State) == "教学目标已完成", "Goal flag did not finish the guide");
|
|
int ready = 0;
|
|
if (Flow.Game.State.dailyProgress[0] >= 3) ready++;
|
|
if (Flow.Game.State.dailyProgress[1] >= 1) ready++;
|
|
Check(HomeTask() == ready + "项奖励可领取", "Home did not switch to daily tasks: " + HomeTask());
|
|
Check(Flow.Game.State.coins == shotCoins + 80 && Flow.Game.State.candy == shotCandy + 10 && Flow.Game.State.tickets == 0 && Flow.Game.State.balls == 20 - shots + 8, "Teaching wallet identity failed");
|
|
|
|
var report = new
|
|
{
|
|
account = user,
|
|
note = "Teaching loop only. Mail, sign-in, daily claims, shop and stages 1-2 through 1-5 are not included, so this is not the 79-ball day ceiling.",
|
|
shots,
|
|
extraShotsAfterTicket = extra,
|
|
shotCandy,
|
|
shotCoins,
|
|
balls = Flow.Game.State.balls,
|
|
candy = Flow.Game.State.candy,
|
|
coins = Flow.Game.State.coins,
|
|
tickets = Flow.Game.State.tickets,
|
|
level = Flow.Game.State.level,
|
|
budBudOwned = Flow.Game.State.stages[2] == 1,
|
|
cleared = Flow.Game.State.cleared,
|
|
goalClaimed = Flow.Game.State.goalClaimed,
|
|
dailyReady = ready,
|
|
homeAfterGoal = HomeTask()
|
|
};
|
|
File.WriteAllText(Path.Combine(Application.dataPath, "../../Doc/Design/G3/first_day_loop.json"), JsonConvert.SerializeObject(report, Formatting.Indented));
|
|
return "PASS teaching loop: " + shots + " shots, capture, train Lv.2, 1-1 clear, goal +5. Wallet balls " + report.balls + ", candy " + report.candy + ", coins " + report.coins + ".";
|
|
}
|
|
|
|
public static async Task<string> Cleanup()
|
|
{
|
|
var net = UnityEngine.Object.FindObjectOfType<GameNet>();
|
|
UiManager.Instance.Get(ConfirmDialogView.PrefabName).GetComponent<ConfirmDialogView>().Hide();
|
|
Set("_session", new Func<SessionData>(() => net.Session));
|
|
Set("_profile", new Func<long>(() => net.Session?.Pid ?? 0));
|
|
Set("_httpBase", SessionState.GetString("AIGame.G3.originalEndpoint", "http://127.0.0.1:8080"));
|
|
Set("_boundProfile", 0L);
|
|
PrototypeClientPrefs.Quality = SessionState.GetInt("AIGame.G3.quality", 1);
|
|
Flow.Handle("Home");
|
|
await Until(() => Flow.Game != null && !Flow.IsBusy);
|
|
var original = JsonUtility.FromJson<PrototypeSave>(SessionState.GetString("AIGame.G3.original", ""));
|
|
Check(Flow.Game.State.coins == original.coins && Flow.Game.State.candy == original.candy && Flow.Game.State.shots == original.shots && Flow.Game.State.level == original.level, "Real account was changed by G3 test");
|
|
return "PASS cleanup: real account restored; G3 check used isolated :18081 only.";
|
|
}
|
|
}
|