Alle 86 Modelle der Godot-Referenz laufen durch die Blender→FBX-Pipeline
(nach bestandenem Vertical Function Test); jede Einheit zeigt ihr eigenes
Modell.
- Exportmanifest aus den Godot-Manifesten (tools/blender/make_manifest.py):
Units, Enemies, Props, Characters, Peoples, Buildings, Environment; die
drei Vergleichs-GLBs als Aliase. Neu in der Pipeline: Tangenten berechnet
Unity mit MikkTSpace, Generatoren laufen in einer temporären Kopie, die
Grüne Kluft wird wie das GLB zusammengefasst, Texturen kommen mit.
1.616 Blender- und 1.247 Unity-Prüfungen grün, zwei Exportläufe
byteidentisch; Kontaktbögen je Gruppe.
- Einheitenbibliothek nach Godots AssetLibrary: 70 Einträge der
Einheitengruppen, Figuren mit Skelett ersetzen das starre Modell ihrer ID.
Starre Modelle werden zur Laufzeit je Material zusammengefasst; Controller
werden an Ort und Stelle aktualisiert.
- Tests mit allen Modellen: EditMode 11/11, PlayMode 2/2. Vertical Function
Test Seed 2 DCE95C96556F3A1A und Seed 9 ab Runde 21 7B8E43AEFC0D60C5,
beide gleich dem Kern (Nachweise mit LUNE_EVIDENCE=u2a-batch; die Belege
von U2C und U2D bleiben unverändert).
- macOS-Player mit allen Modellen: 158-160 fps in der Übersicht
(116 Einheiten), 205-207 fps auf der Mid-Lane; Haupt- und Render-Thread
etwa doppelt so lang wie mit dem Platzhalter, Present-Ausreißer kürzer.
- Befund iCloud Drive: Konfliktkopien ("Name 2.ext") im Build-Cache ließen
den Build die Einheitenbibliothek mit altem Feldlayout schreiben; der
Player meldete sharedassets0.assets als beschädigt. LunePlayerBuild
entfernt solche Kopien vor dem Build und bricht ab, wenn während des
Builds neue entstehen. Bereits committete Kopien: Controller-Duplikate und
World/Generated 2.meta entfernt, drei U2D-Benchmarkläufe als -run1 bis
-run3 benannt.
- Dokumentation: U2-WORLD (Batch-Import), PORT-STATUS (Entscheidungen:
Modelle direkt in Git, Projektort außerhalb von iCloud Drive), PORT-QA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
16 KiB
C#
279 lines
16 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using Luneraria.Core;
|
|
using NUnit.Framework;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
|
|
namespace Luneraria.View.Tests
|
|
{
|
|
/// <summary>
|
|
/// U2D vertical function test: complete bot matches visible in the Unity scene, before the full
|
|
/// asset import. Match 1 (seed 2) runs from round 1 to the fall of an Ancient: lanes, towers
|
|
/// fall, heroes fight, the grotto boss is won and deployed. Match 2 (seed 9) starts at round 21
|
|
/// with the fixture of the golden scenario high_wave_21 (waves above 20). Both must end with the
|
|
/// fingerprint of the plain core run. A camera director photographs the key moments
|
|
/// (docs/port/screenshots/u2d/); results and frame times go to docs/port/logs/u2d/ (or the
|
|
/// LUNE_EVIDENCE folder).
|
|
/// </summary>
|
|
public sealed class VerticalFunctionTests
|
|
{
|
|
const float Speed = 6f;
|
|
|
|
sealed class Shot
|
|
{
|
|
public string Label;
|
|
public Func<LuneMatchRunner, LuneMatchView, bool> Trigger;
|
|
public Action<LuneMatchRunner, LuneMatchView, LuneCameraRig> Aim;
|
|
public float Delay = .35f;
|
|
public bool Done;
|
|
}
|
|
|
|
sealed class Stats
|
|
{
|
|
public int Frames;
|
|
public double Seconds;
|
|
public readonly List<double> FrameMs = new List<double>();
|
|
public double AdvanceMs;
|
|
public double SyncMs;
|
|
public int Units;
|
|
}
|
|
|
|
[UnityTest]
|
|
[Timeout(3600000)]
|
|
public IEnumerator BotMatchesRunVisiblyAndMatchTheCore()
|
|
{
|
|
var report = new StringBuilder("{\n \"runs\": [\n");
|
|
var failures = new List<string>();
|
|
yield return Play(2, 1, 0, "match-seed2", SeedTwoShots(), report, failures, false);
|
|
report.Append(",\n");
|
|
yield return Play(9, 21, 4000, "match-seed9-round21", WaveShots(), report, failures, true);
|
|
report.Append("\n ]\n}\n");
|
|
string logs = PlayModeSupport.Evidence("logs", "u2d");
|
|
Directory.CreateDirectory(logs);
|
|
File.WriteAllText(Path.Combine(logs, "vertical-function-test.json"), report.ToString());
|
|
Assert.IsEmpty(failures, string.Join("\n", failures));
|
|
}
|
|
|
|
static IEnumerator Play(int seed, int startRound, int extraGold, string label, List<Shot> shots, StringBuilder report, List<string> failures, bool measure)
|
|
{
|
|
var options = new MatchOptions { Seed = seed, HumanSlot = -1 };
|
|
LuneTrace core = LuneTrace.RunCore(options, startRound, extraGold, "");
|
|
QualitySettings.vSyncCount = 0;
|
|
Application.targetFrameRate = 60;
|
|
yield return PlayModeSupport.Load();
|
|
var driver = UnityEngine.Object.FindAnyObjectByType<LuneMatchDriver>();
|
|
var view = UnityEngine.Object.FindAnyObjectByType<LuneMatchView>();
|
|
var rig = UnityEngine.Object.FindAnyObjectByType<LuneCameraRig>();
|
|
Camera camera = Camera.main;
|
|
rig.Interactive = false;
|
|
driver.Seed = seed;
|
|
driver.StartRound = startRound;
|
|
driver.ExtraGold = extraGold;
|
|
driver.Speed = Speed;
|
|
driver.Restart();
|
|
LuneMatchRunner runner = driver.Runner;
|
|
rig.Overview(true);
|
|
var rounds = new List<string>();
|
|
var events = new List<string>();
|
|
int maxUnits = 0;
|
|
int maxHeroes = 0;
|
|
runner.Settled += settlement =>
|
|
{
|
|
rounds.Add(string.Format("{{\"round\": {0}, \"combat_seconds\": {1}, \"lost\": [{2}], \"boss\": \"{3}\", \"end\": \"{4}\"}}", settlement.Round,
|
|
PlayModeSupport.Number(settlement.CombatTime), string.Join(", ", settlement.BuildingsLost.Select(b => "\"" + b + "\"")), settlement.Boss, settlement.EndReason));
|
|
foreach (string building in settlement.BuildingsLost)
|
|
events.Add(string.Format("\"round {0}: {1} destroyed\"", settlement.Round, building));
|
|
if (settlement.Boss != "none")
|
|
events.Add(string.Format("\"round {0}: grotto boss {1}\"", settlement.Round, settlement.Boss));
|
|
};
|
|
var captures = new List<string>();
|
|
Shot active = null;
|
|
float activeSince = 0f;
|
|
var stats = new Stats();
|
|
bool measuring = false;
|
|
float realStart = Time.realtimeSinceStartup;
|
|
float overSince = -1f;
|
|
while (Time.realtimeSinceStartup - realStart < 900f)
|
|
{
|
|
yield return null;
|
|
LuneMatch match = runner.Match;
|
|
if (match.Combat != null && runner.Current == LuneMatchRunner.Stage.Combat)
|
|
{
|
|
maxUnits = Math.Max(maxUnits, match.Combat.Units.Count(u => u.IsMobile() && u.Alive));
|
|
maxHeroes = Math.Max(maxHeroes, match.Combat.Units.Count(u => u.Kind == "hero" && u.Alive));
|
|
}
|
|
// Frame times at normal game speed with the most units on the map (match 2, round 21).
|
|
if (measure && runner.Current == LuneMatchRunner.Stage.Combat && match.RoundNumber == startRound)
|
|
{
|
|
double elapsed = match.Combat.Elapsed;
|
|
if (!measuring && elapsed >= 10.0 && stats.Frames == 0)
|
|
{
|
|
measuring = true;
|
|
driver.Speed = 1f;
|
|
Application.targetFrameRate = -1;
|
|
rig.LookAt(new Vec2(0, 0), 64f, true);
|
|
}
|
|
if (measuring)
|
|
{
|
|
stats.Frames++;
|
|
stats.Seconds += Time.unscaledDeltaTime;
|
|
stats.FrameMs.Add(Time.unscaledDeltaTime * 1000.0);
|
|
stats.AdvanceMs += driver.LastAdvanceMs;
|
|
stats.SyncMs += view.LastSyncMs;
|
|
stats.Units = Math.Max(stats.Units, match.Combat.Units.Count(u => u.IsMobile() && u.Alive));
|
|
if (stats.Seconds >= 20.0)
|
|
{
|
|
measuring = false;
|
|
driver.Speed = Speed;
|
|
Application.targetFrameRate = 60;
|
|
}
|
|
}
|
|
}
|
|
if (measuring)
|
|
continue;
|
|
if (active == null)
|
|
{
|
|
active = shots.FirstOrDefault(s => !s.Done && s.Trigger(runner, view));
|
|
if (active != null)
|
|
{
|
|
rig.SetYaw(0f, true);
|
|
active.Aim(runner, view, rig);
|
|
activeSince = Time.realtimeSinceStartup;
|
|
}
|
|
}
|
|
else if (Time.realtimeSinceStartup - activeSince >= active.Delay)
|
|
{
|
|
// Render directly: WaitForEndOfFrame never resumes in batch mode.
|
|
string file = Path.Combine(PlayModeSupport.Evidence("screenshots", "u2d"), active.Label + ".png");
|
|
LuneCapture.Render(camera, file);
|
|
captures.Add("\"" + active.Label + "\"");
|
|
Debug.Log("LUNE_U2D shot " + active.Label + " round " + runner.Match.RoundNumber + " " + runner.Current);
|
|
active.Done = true;
|
|
active = null;
|
|
}
|
|
if (runner.Current == LuneMatchRunner.Stage.Over)
|
|
{
|
|
if (overSince < 0f)
|
|
overSince = Time.realtimeSinceStartup;
|
|
bool pending = shots.Any(s => !s.Done && s.Trigger(runner, view)) || active != null;
|
|
if (!pending && Time.realtimeSinceStartup - overSince > 3.5f)
|
|
break;
|
|
if (Time.realtimeSinceStartup - overSince > 12f)
|
|
break;
|
|
}
|
|
}
|
|
LuneTrace trace = runner.Trace;
|
|
bool same = runner.Current == LuneMatchRunner.Stage.Over && trace.Hash == core.Hash && trace.Steps == core.Steps;
|
|
if (!same)
|
|
failures.Add(label + ": fingerprint " + trace.Hash.ToString("X16") + " / core " + core.Hash.ToString("X16"));
|
|
foreach (Shot shot in shots.Where(s => !s.Done))
|
|
failures.Add(label + ": no shot " + shot.Label);
|
|
double[] frameMs = stats.FrameMs.OrderBy(v => v).ToArray();
|
|
string performance = stats.Frames == 0 ? "null" : string.Format("{{\"window\": \"20 s real time at 1x speed, unlimited frame rate, round {0}\", \"frames\": {1}, \"fps\": {2}, \"frame_ms_avg\": {3}, \"frame_ms_p95\": {4}, \"simulation_ms_per_frame\": {5}, \"views_ms_per_frame\": {6}, \"units_alive_max\": {7}}}",
|
|
startRound, stats.Frames, PlayModeSupport.Number(stats.Frames / stats.Seconds), PlayModeSupport.Number(stats.Seconds * 1000.0 / stats.Frames),
|
|
PlayModeSupport.Number(frameMs[(int)(frameMs.Length * .95)]), PlayModeSupport.Number(stats.AdvanceMs / stats.Frames), PlayModeSupport.Number(stats.SyncMs / stats.Frames), stats.Units);
|
|
report.AppendFormat(" {{\"label\": \"{0}\", \"seed\": {1}, \"start_round\": {2}, \"extra_gold\": {3}, \"speed\": {4}, \"winner\": {5}, \"end_note\": \"{6}\", \"steps\": {7}, \"hash\": \"{8:X16}\", \"core_hash\": \"{9:X16}\", \"same_as_core\": {10},\n \"units_alive_max\": {11}, \"heroes_alive_max\": {12}, \"boss_log\": [{13}],\n \"rounds\": [{14}],\n \"events\": [{15}],\n \"shots\": [{16}],\n \"performance\": {17}}}",
|
|
label, seed, startRound, extraGold, Speed, runner.Match.Winner, runner.Match.EndNote, trace.Steps, trace.Hash, core.Hash, same ? "true" : "false",
|
|
maxUnits, maxHeroes, string.Join(", ", runner.Match.BossLog.Select(e => string.Format("\"round {0}: {1} team {2} {3}\"", e.Round, e.Event, e.Team, e.Lane))),
|
|
string.Join(", ", rounds), string.Join(", ", events), string.Join(", ", captures), performance);
|
|
Application.targetFrameRate = -1;
|
|
}
|
|
|
|
static bool InCombat(LuneMatchRunner runner, int round, double from)
|
|
{
|
|
return runner.Current == LuneMatchRunner.Stage.Combat && runner.Match.RoundNumber == round && runner.Match.Combat.Elapsed >= from;
|
|
}
|
|
|
|
static LuneUnit Find(LuneMatchRunner runner, Func<LuneUnit, bool> predicate)
|
|
{
|
|
return runner.Match.Combat == null ? null : runner.Match.Combat.Units.FirstOrDefault(predicate);
|
|
}
|
|
|
|
static Shot Building(string label, Func<LuneMatchView, LuneBuildingView> pick, LuneBuildingView.Look look, float zoom, float delay)
|
|
{
|
|
LuneBuildingView chosen = null;
|
|
return new Shot
|
|
{
|
|
Label = label,
|
|
Delay = delay,
|
|
Trigger = (runner, view) =>
|
|
{
|
|
chosen = pick(view);
|
|
return chosen != null && chosen.Current == look;
|
|
},
|
|
Aim = (runner, view, rig) =>
|
|
{
|
|
// Towers are watched from their lane (open ground), not through the forest.
|
|
Vec2 at = LuneSpace.Map(chosen.transform.position);
|
|
BuildingDef definition = runner.Match.Map.BuildingDef(chosen.BuildingId);
|
|
float yaw = 0f;
|
|
if (!chosen.IsAncient && definition.Tier != "base")
|
|
yaw = LuneCameraRig.YawTowards(at, runner.Match.Map.LanePoint(definition.Team, definition.Lane, definition.Progress));
|
|
rig.SetYaw(yaw, true);
|
|
rig.LookAt(at, zoom, true);
|
|
},
|
|
};
|
|
}
|
|
|
|
static List<Shot> SeedTwoShots()
|
|
{
|
|
string fallen = null;
|
|
return new List<Shot>
|
|
{
|
|
new Shot { Label = "01-runde1-aufmarsch", Trigger = (r, v) => InCombat(r, 1, 9.0), Aim = (r, v, rig) => rig.LookAt(new Vec2(0, 0), 64f, true) },
|
|
new Shot { Label = "02-runde1-mid-kampf", Trigger = (r, v) => InCombat(r, 1, 24.0), Aim = (r, v, rig) =>
|
|
{
|
|
LuneUnit fighter = Find(r, u => u.IsMobile() && u.Alive && u.Lane == "mid" && u.Target != null && u.InRange);
|
|
rig.LookAt(fighter != null ? fighter.Position : new Vec2(0, 0), 20f, true);
|
|
} },
|
|
Building("03-turm-faellt", v => AnyBuilding(v, b => !b.IsAncient && b.Current == LuneBuildingView.Look.Destroying, ref fallen), LuneBuildingView.Look.Destroying, 22f, .7f),
|
|
Building("04-turm-ruine", v => fallen != null ? v.Building(fallen) : null, LuneBuildingView.Look.Ruin, 22f, .4f),
|
|
new Shot { Label = "05-grotte-bosskampf", Delay = .5f, Trigger = (r, v) => Find(r, u => u.Kind == "boss" && u.Zone == "grotto" && u.Alive && !u.Dormant) != null,
|
|
Aim = (r, v, rig) => rig.LookAt(LuneMap.Grotto, 20f, true) },
|
|
new Shot { Label = "06-held-kaempft", Delay = .3f, Trigger = (r, v) => Find(r, u => u.Kind == "hero" && u.Alive && u.Zone == "lane" && u.Target != null && u.InRange) != null,
|
|
Aim = (r, v, rig) => rig.LookAt(Find(r, u => u.Kind == "hero" && u.Alive && u.Zone == "lane" && u.Target != null && u.InRange).Position, 16f, true) },
|
|
new Shot { Label = "07-boss-auf-der-lane", Delay = .3f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Combat && r.Match.Combat.Elapsed > 12.0 && Find(r, u => u.Kind == "boss" && u.Zone == "lane" && u.Alive) != null,
|
|
Aim = (r, v, rig) => rig.LookAt(Find(r, u => u.Kind == "boss" && u.Zone == "lane" && u.Alive).Position, 22f, true) },
|
|
Building("08-ancient-faellt", v => v.Building("blau_ancient"), LuneBuildingView.Look.Destroying, 30f, .8f),
|
|
Building("09-ancient-ruine", v => v.Building("blau_ancient"), LuneBuildingView.Look.Ruin, 30f, .5f),
|
|
new Shot { Label = "10-ende-uebersicht", Delay = .5f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Over && v.Building("blau_ancient").Current == LuneBuildingView.Look.Ruin,
|
|
Aim = (r, v, rig) => rig.Overview(true) },
|
|
};
|
|
}
|
|
|
|
/// <summary>The first building that satisfies the condition; remembers its id.</summary>
|
|
static LuneBuildingView AnyBuilding(LuneMatchView view, Func<LuneBuildingView, bool> condition, ref string remembered)
|
|
{
|
|
foreach (LuneBuildingView building in UnityEngine.Object.FindObjectsByType<LuneBuildingView>(FindObjectsSortMode.None).OrderBy(b => b.BuildingId, StringComparer.Ordinal))
|
|
{
|
|
if (condition(building))
|
|
{
|
|
remembered = building.BuildingId;
|
|
return building;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static List<Shot> WaveShots()
|
|
{
|
|
return new List<Shot>
|
|
{
|
|
new Shot { Label = "11-welle21-uebersicht", Trigger = (r, v) => InCombat(r, 21, 6.0), Aim = (r, v, rig) => rig.LookAt(new Vec2(0, 0), 70f, true) },
|
|
new Shot { Label = "12-welle21-kampf", Trigger = (r, v) => InCombat(r, 21, 36.0), Aim = (r, v, rig) =>
|
|
{
|
|
LuneUnit fighter = Find(r, u => u.IsMobile() && u.Alive && u.Target != null && u.InRange && u.Zone == "lane");
|
|
rig.LookAt(fighter != null ? fighter.Position : new Vec2(0, 0), 24f, true);
|
|
} },
|
|
Building("13-ancient-rot-faellt", v => v.Building("rot_ancient"), LuneBuildingView.Look.Destroying, 30f, .8f),
|
|
new Shot { Label = "14-welle-ende", Delay = .6f, Trigger = (r, v) => r.Current == LuneMatchRunner.Stage.Over && v.Building("rot_ancient").Current == LuneBuildingView.Look.Ruin,
|
|
Aim = (r, v, rig) => rig.LookAt(r.Match.Map.CorePosition(1), 34f, true) },
|
|
};
|
|
}
|
|
}
|
|
}
|