Das G3-HUD ist abgenommen; der Meilenstein U3 ist damit fertig. - Prüfung (Port von verify_g3, Teile A–H plus I): 718 Prüfungen ohne Fehler. Teil I prüft die Feld-Darstellung um das HUD (U3-Zugabe aus dem Motivvergleich): Aufstellungsflächen, Grotteneingang, ruhender Boss, Namensschilder, Eigentümer-Ring, Ancient-Balken. - Feld um das HUD: Namensschilder für Helden (mit Stufe), Angriffs- champions und den eingesetzten Boss („für Blau“), Eigentümer-Ring, Ancient-Balken bei Schaden, ruhende Schlundkrone auf dem Podest, eigener Grotteneingang, Aufstellungsflächen als eigene Objekte. - Schriften korrigiert: Die Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet die Gewichte aber nie an (variation_opentype mit dem String-Schlüssel „wght“ wird von Godot ignoriert). G3 zeigt Regular 400, der Port ebenso — Cinzel-Regular statt Bold, keine Kontur zur Aufdickung. Laufweite aus spacing_glyph umgerechnet; dunkler Saum als Schatten statt TextCore-Kontur. - Werkzeuge: HudViewsTests (14 G3-Motive), HudPlaytest (Spieltest über die echte Oberfläche per Befehlsdateien), ui_ms_per_frame im LuneBenchmark. Layout folgt den echten Panelhöhen, Kamera startet wie Godot _ready auf der eigenen Lane. - Tests: EditMode 23/23, PlayMode 13/13, Kerntests 402/402, Verify 718/0. Fingerabdrücke der Bot-Partien unverändert (DCE95C96556F3A1A, 7B8E43AEFC0D60C5). - Bilder: 14 Motive bei 1920×1080, Auflösungen 1152×720 bis 2560×1440, Prüf-Schlüsselbilder, Spieltest-Screenshots. - Leistung (macOS-Player mit HUD, 116 Einheiten): 169 fps Übersicht, 218 fps Mid nah; HUD-Skripte 0,090 bzw. 0,078 ms je Bild. - Dokumentation: U3-UI (U3D, Schriftbefund), PORT-QA (U3), PORT-STATUS (U3 fertig, U4 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
12 KiB
C#
256 lines
12 KiB
C#
using System.Collections.Generic;
|
|
using Luneraria.Core;
|
|
using UnityEngine;
|
|
|
|
namespace Luneraria.View
|
|
{
|
|
/// <summary>
|
|
/// Shows the driver's match (U2C): unit views per combat, building looks from the authoritative
|
|
/// HP with the Ancients' health bars, tower bolts from the towers' attack events, and the
|
|
/// Schlundkrone resting in its grotto while the grotto is open (U3, from lune_grotto_view.gd).
|
|
/// Runs after the driver in the same frame and only reads the core.
|
|
/// </summary>
|
|
[DefaultExecutionOrder(100)]
|
|
public sealed class LuneMatchView : MonoBehaviour
|
|
{
|
|
public LuneMatchDriver Driver;
|
|
public LuneUnitLibrary Library;
|
|
public Camera ViewCamera;
|
|
/// <summary>
|
|
/// Planning only: the human's draft army in its formation (set by the game controller, U3;
|
|
/// Godot _rebuild_preview). Bot matches leave it empty and show no units while planning.
|
|
/// </summary>
|
|
public LuneCombat Preview;
|
|
|
|
readonly Dictionary<int, LuneUnitView> _units = new Dictionary<int, LuneUnitView>();
|
|
readonly Dictionary<string, LuneBuildingView> _buildings = new Dictionary<string, LuneBuildingView>();
|
|
readonly Dictionary<string, int> _towerSerials = new Dictionary<string, int>();
|
|
readonly List<Bolt> _bolts = new List<Bolt>();
|
|
LuneCombat _combat;
|
|
Transform _unitRoot;
|
|
bool _synced;
|
|
GameObject _restingBoss;
|
|
readonly Dictionary<string, AncientBar> _ancientBars = new Dictionary<string, AncientBar>();
|
|
|
|
/// <summary>Health bar over an Ancient (lune_ancient_view.gd _build_bar: 3.4 m wide, 11.2 m up).</summary>
|
|
sealed class AncientBar
|
|
{
|
|
public Transform Root;
|
|
public Transform Fill;
|
|
}
|
|
|
|
public const float AncientBarWidth = 3.4f;
|
|
public const float AncientBarHeight = 11.2f;
|
|
/// <summary>Godot BOSS_LOCAL and view scale of the resting boss on its dais.</summary>
|
|
public const float RestingBossScale = 1.7f;
|
|
|
|
/// <summary>The resting Schlundkrone (tests); null until the grotto was first open.</summary>
|
|
public GameObject RestingBoss { get { return _restingBoss; } }
|
|
sealed class Bolt
|
|
{
|
|
public LineRenderer Line;
|
|
public float Age;
|
|
public LuneUnitView Target;
|
|
public Vector3 From;
|
|
}
|
|
|
|
public IEnumerable<LuneUnitView> Units { get { return _units.Values; } }
|
|
/// <summary>Main-thread cost of the last frame's view update (milliseconds).</summary>
|
|
public double LastSyncMs { get; private set; }
|
|
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
|
|
|
|
public LuneBuildingView Building(string id)
|
|
{
|
|
_buildings.TryGetValue(id, out LuneBuildingView view);
|
|
return view;
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
foreach (LuneBuildingView building in FindObjectsByType<LuneBuildingView>(FindObjectsSortMode.None))
|
|
_buildings[building.BuildingId] = building;
|
|
_unitRoot = new GameObject("Units").transform;
|
|
if (ViewCamera == null)
|
|
ViewCamera = Camera.main;
|
|
}
|
|
|
|
void LateUpdate()
|
|
{
|
|
if (Driver == null || Driver.Runner == null)
|
|
return;
|
|
_clock.Restart();
|
|
LuneMatch match = Driver.Runner.Match;
|
|
float dt = Time.deltaTime;
|
|
// Planning shows the preview when there is one (human seat); a bot match keeps the last
|
|
// combat's figures standing, unsynced, as in Godot.
|
|
bool planning = Driver.Runner.Current == LuneMatchRunner.Stage.Planning;
|
|
LuneCombat shown = planning && Preview != null ? Preview : match.Combat;
|
|
if (shown != _combat)
|
|
{
|
|
foreach (LuneUnitView view in _units.Values)
|
|
Destroy(view.gameObject);
|
|
_units.Clear();
|
|
_towerSerials.Clear();
|
|
_combat = shown;
|
|
}
|
|
bool showUnits = _combat != null && (!planning || _combat == Preview);
|
|
float alpha = Driver.Runner.Current == LuneMatchRunner.Stage.Combat ? (float)Driver.Runner.Clock.Alpha : 1f;
|
|
if (showUnits)
|
|
{
|
|
foreach (LuneUnit unit in _combat.Units)
|
|
{
|
|
if (!unit.IsMobile())
|
|
continue;
|
|
if (!_units.TryGetValue(unit.Id, out LuneUnitView view))
|
|
{
|
|
view = new GameObject().AddComponent<LuneUnitView>();
|
|
view.transform.SetParent(_unitRoot, false);
|
|
view.Bind(unit, Library);
|
|
_units[unit.Id] = view;
|
|
}
|
|
view.Sync(alpha, dt, ViewCamera != null ? ViewCamera.transform : null);
|
|
}
|
|
}
|
|
SyncBuildings(match);
|
|
SyncBolts(dt);
|
|
SyncRestingBoss(match);
|
|
_synced = true;
|
|
LastSyncMs = _clock.Elapsed.TotalMilliseconds;
|
|
}
|
|
|
|
void SyncBuildings(LuneMatch match)
|
|
{
|
|
bool fighting = _combat != null && match.Phase == Phase.Combat;
|
|
foreach (BuildingDef definition in match.Map.Buildings)
|
|
{
|
|
if (!_buildings.TryGetValue(definition.Id, out LuneBuildingView view))
|
|
continue;
|
|
double hp = match.BuildingHp[definition.Id];
|
|
LuneUnit unit = null;
|
|
if (_combat != null && _combat.Buildings.TryGetValue(definition.Id, out unit) && fighting)
|
|
hp = unit.Hp;
|
|
view.SetHealth((float)hp, (float)definition.MaxHp, _synced);
|
|
if (view.Tier == "core")
|
|
SyncAncientBar(view, (float)hp, (float)definition.MaxHp);
|
|
if (unit != null && unit.Kind == "tower" && _combat != null)
|
|
TowerAttack(view, unit);
|
|
}
|
|
}
|
|
|
|
void TowerAttack(LuneBuildingView view, LuneUnit tower)
|
|
{
|
|
_towerSerials.TryGetValue(tower.BuildingId, out int last);
|
|
_towerSerials[tower.BuildingId] = tower.AttackSerial;
|
|
if (tower.AttackSerial == last || last == 0 && tower.AttackSerial == 0)
|
|
return;
|
|
if (tower.Target == null || !_units.TryGetValue(tower.Target.Id, out LuneUnitView target))
|
|
return;
|
|
var line = new GameObject("Bolt").AddComponent<LineRenderer>();
|
|
line.transform.SetParent(_unitRoot, false);
|
|
line.sharedMaterial = Library.Bolt[Mathf.Clamp(view.Team, 0, 2)];
|
|
line.widthMultiplier = view.Tier == "base" ? .22f : .16f;
|
|
line.positionCount = 2;
|
|
line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
_bolts.Add(new Bolt { Line = line, Target = target, From = view.Muzzle });
|
|
}
|
|
|
|
/// <summary>The bar shows while the Ancient is damaged and standing, always facing the camera.</summary>
|
|
void SyncAncientBar(LuneBuildingView view, float hp, float maxHp)
|
|
{
|
|
float fraction = Mathf.Clamp01(hp / Mathf.Max(1f, maxHp));
|
|
bool visible = hp > 0f && fraction < .999f;
|
|
_ancientBars.TryGetValue(view.BuildingId, out AncientBar bar);
|
|
if (bar == null)
|
|
{
|
|
if (!visible || Library == null || Library.Quad == null)
|
|
return;
|
|
bar = new AncientBar { Root = new GameObject("AncientBar").transform };
|
|
bar.Root.SetParent(view.transform, false);
|
|
bar.Root.localPosition = new Vector3(0f, AncientBarHeight, 0f);
|
|
Transform back = Quad(bar.Root, "Back", Library.BarBack);
|
|
back.localScale = new Vector3(AncientBarWidth + .1f, .26f, 1f);
|
|
bar.Fill = Quad(bar.Root, "Fill", Library.AncientBarFill[Mathf.Clamp(view.Team, 0, 1)]);
|
|
_ancientBars[view.BuildingId] = bar;
|
|
}
|
|
if (bar.Root.gameObject.activeSelf != visible)
|
|
bar.Root.gameObject.SetActive(visible);
|
|
if (!visible)
|
|
return;
|
|
bar.Fill.localScale = new Vector3(AncientBarWidth * Mathf.Max(.001f, fraction), .17f, 1f);
|
|
bar.Fill.localPosition = new Vector3(-AncientBarWidth * .5f * (1f - fraction), 0f, -.001f);
|
|
if (ViewCamera != null)
|
|
bar.Root.rotation = ViewCamera.transform.rotation;
|
|
}
|
|
|
|
Transform Quad(Transform parent, string label, Material material)
|
|
{
|
|
var go = new GameObject(label);
|
|
go.transform.SetParent(parent, false);
|
|
go.AddComponent<MeshFilter>().sharedMesh = Library.Quad;
|
|
var renderer = go.AddComponent<MeshRenderer>();
|
|
renderer.sharedMaterial = material;
|
|
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
renderer.receiveShadows = false;
|
|
return go.transform;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Schlundkrone rests on its dais while the grotto is open and no boss unit stands there
|
|
/// (lune_grotto_view.gd: state AVAILABLE without a fighter): the figure the fight uses,
|
|
/// neutral, facing the arena and the two stairs, in its idle clip. The grotto's light
|
|
/// states (runes, crystals, motes) are not ported yet.
|
|
/// </summary>
|
|
void SyncRestingBoss(LuneMatch match)
|
|
{
|
|
LuneUnit fighter = match.Phase == Phase.Combat && match.Combat != null ? match.Combat.GrottoBoss : null;
|
|
bool visible = match.Boss.Claim == null && match.GrottoAvailable() && fighter == null;
|
|
if (visible && _restingBoss == null)
|
|
_restingBoss = MakeRestingBoss(match.Map);
|
|
if (_restingBoss != null && _restingBoss.activeSelf != visible)
|
|
_restingBoss.SetActive(visible);
|
|
}
|
|
|
|
GameObject MakeRestingBoss(LuneMap map)
|
|
{
|
|
LuneUnitLibrary.UnitModel model = Library != null ? Library.Find(Library.BossModel) : null;
|
|
if (model == null || model.Prefab == null)
|
|
return null;
|
|
var root = new GameObject("RestingBoss");
|
|
root.transform.SetParent(transform, false);
|
|
Vec2 at = map.GrottoBossPoint();
|
|
root.transform.position = LuneSpace.World(at, (Library.Ground != null ? Library.Ground.StandHeight(at) : 0f) + .03f);
|
|
root.transform.rotation = LuneSpace.Facing(System.Math.Atan2(LuneMap.Axis.X, LuneMap.Axis.Y));
|
|
GameObject figure = Instantiate(model.Prefab, root.transform, false);
|
|
figure.name = "Model";
|
|
figure.SetActive(true);
|
|
figure.transform.localScale = Vector3.one * RestingBossScale;
|
|
Library.Materials.Dress(figure, 2);
|
|
var animator = figure.GetComponentInChildren<Animator>();
|
|
if (animator != null && model.Controller != null)
|
|
{
|
|
animator.runtimeAnimatorController = model.Controller;
|
|
animator.applyRootMotion = false;
|
|
if (animator.HasState(0, Animator.StringToHash("idle")))
|
|
animator.Play("idle", 0, 0f);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
void SyncBolts(float dt)
|
|
{
|
|
for (int i = _bolts.Count - 1; i >= 0; i--)
|
|
{
|
|
Bolt bolt = _bolts[i];
|
|
bolt.Age += dt;
|
|
if (bolt.Age > .25f || bolt.Target == null)
|
|
{
|
|
Destroy(bolt.Line.gameObject);
|
|
_bolts.RemoveAt(i);
|
|
continue;
|
|
}
|
|
bolt.Line.SetPosition(0, bolt.From);
|
|
bolt.Line.SetPosition(1, bolt.Target.AimPoint);
|
|
}
|
|
}
|
|
}
|
|
}
|