Angepasste Garage, verbessertes Icon Asset system, range der türme vorschau beim ziehen

This commit is contained in:
PBM2H25BEB committed 2026-11-05 07:52:47 +01:00
1 parent 5486444dde
commit 5af8655c97
59 files changed
+12079 -457

No files matched your search

@@ -0,0 +1,60 @@
using UnityEngine;
namespace DepthsOfGreed.HandbookUI
{
public enum AttackCoverage { Automatic, Circle, Sector }
public static class AttackRangeGeometry
{
public static float CoverageAngle(CombatDefinition c)
{
if(c.coverage==AttackCoverage.Circle)return 360;
if(c.coverage==AttackCoverage.Sector || (c.rotation==RotationMode.Fixed && c.attackShape==AttackShape.Cone))
return Mathf.Clamp(c.coneAngle,1,360);
return 360;
}
// Shared optional targeting helper. No shots or damage are performed here.
public static bool CanReach(CombatDefinition c,Vector2 origin,Vector2 target,float baseAngleDegrees)
{
if(c==null)return false;
Vector2 delta=target-origin;float distance=delta.magnitude;
if(distance<Mathf.Max(0,c.minimumRange) || (!c.unlimitedRange && distance>Mathf.Max(0,c.range)))return false;
float coverage=CoverageAngle(c);
return coverage>=360 || distance<.0001f || Mathf.Abs(Mathf.DeltaAngle(baseAngleDegrees+c.forwardAngleOffset,Mathf.Atan2(delta.y,delta.x)*Mathf.Rad2Deg))<=coverage*.5f;
}
}
public sealed class AttackRangePreview : MonoBehaviour
{
public LineRenderer outerEdge,minimumEdge,sectorLeft,sectorRight;
public bool showRange=true;
[Min(1)] public float unlimitedDirectionLength=10;
public Color rangeColor=new Color(.25f,.75f,1f,.35f);
public void Show(HandbookTowerEntry entry)
{
GetComponent<RangeArenaClip>()?.Configure(entry);
var c=entry?entry.combat:null;
bool ability=entry && entry.use==BuildUse.Ability;
bool visible=showRange && c!=null && (ability?c.areaRadius>0:c.range>0 || c.unlimitedRange);
gameObject.SetActive(visible);if(!visible)return;
float max=ability?c.areaRadius:c.unlimitedRange?unlimitedDirectionLength:c.range;
float min=ability?0:Mathf.Clamp(c.minimumRange,0,max);
float angle=ability?360:AttackRangeGeometry.CoverageAngle(c);
float offset=ability?0:c.forwardAngleOffset;
bool unlimited=!ability && c.unlimitedRange;
outerEdge.gameObject.SetActive(!unlimited);minimumEdge.gameObject.SetActive(min>0);sectorLeft.gameObject.SetActive(angle<360);sectorRight.gameObject.SetActive(angle<360);
Arc(outerEdge,max,angle,offset);Arc(minimumEdge,min,angle,offset);
sectorLeft.loop=sectorRight.loop=false;sectorLeft.positionCount=sectorRight.positionCount=2;
sectorLeft.SetPosition(0,Point(min,offset-angle*.5f));sectorLeft.SetPosition(1,Point(max,offset-angle*.5f));
sectorRight.SetPosition(0,Point(min,offset+angle*.5f));sectorRight.SetPosition(1,Point(max,offset+angle*.5f));
foreach(var line in new[]{outerEdge,minimumEdge,sectorLeft,sectorRight})line.startColor=line.endColor=rangeColor;
}
static Vector3 Point(float radius,float angle)=>new Vector3(Mathf.Cos(angle*Mathf.Deg2Rad)*radius,Mathf.Sin(angle*Mathf.Deg2Rad)*radius,0);
static void Arc(LineRenderer line,float radius,float angle,float offset)
{
int count=97;line.loop=angle>=360;line.positionCount=angle>=360?count-1:count;
for(int i=0;i<line.positionCount;i++)line.SetPosition(i,Point(radius,offset-angle*.5f+angle*i/(count-1)));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2424492d0690534fb0a6e3fda8eea2a
@@ -0,0 +1,44 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace DepthsOfGreed.HandbookUI
{
public sealed class BasementBattleOverview : MonoBehaviour
{
[Tooltip("Optionale Komponente mit ICombatOverview. Keine eigene Kampf- oder Schleusenlogik.")]
[SerializeField] private MonoBehaviour combatBackend;
public TMP_Text floorText;
public Image[] gateIcons;
public TMP_Text[] gateLabels;
[Range(0,1)]public float criticalFraction=.25f;
private readonly long[] lastHits=new long[4];
private readonly float[] flashUntil=new float[4];
private readonly bool[] initialized=new bool[4];
private float nextRefresh;
private CombatOverviewState state;
private string lastFloor;
public void BindCombatBackend(MonoBehaviour backend)
{if(backend && !(backend is ICombatOverview)){Debug.LogError("Schleusenübersicht benötigt ICombatOverview.",backend);return;}combatBackend=backend;System.Array.Clear(initialized,0,4);nextRefresh=0;}
void Update()
{
if(Time.unscaledTime>=nextRefresh){nextRefresh=Time.unscaledTime+.15f;state=combatBackend && combatBackend is ICombatOverview source?source.ReadCombatOverview():null;}
if(lastFloor!=state?.floorName){System.Array.Clear(initialized,0,4);lastFloor=state?.floorName;}
floorText.text=string.IsNullOrEmpty(state?.floorName)?"Ebene —":state.floorName;
string[] directions={"N","O","S","W"};
for(int i=0;i<4;i++)
{
GateOverviewState gate=null;
if(state?.gates!=null)foreach(var item in state.gates)if(item!=null && (int)item.direction==i){gate=item;break;}
if(gate==null){gateLabels[i].text=directions[i]+" —";gateIcons[i].color=new Color(.5f,.5f,.5f);initialized[i]=false;continue;}
if(initialized[i] && gate.hitSequence>lastHits[i])flashUntil[i]=Time.unscaledTime+.65f;
initialized[i]=true;lastHits[i]=gate.hitSequence;
bool known=gate.maxHealth>0;float ratio=known?Mathf.Clamp01(gate.health/gate.maxHealth):1;
bool critical=known && ratio<=criticalFraction;
bool alarm=critical || gate.underAttack || Time.unscaledTime<flashUntil[i];
gateIcons[i].color=alarm?Color.Lerp(new Color(.5f,.12f,.1f),new Color(1,.4f,.2f),.5f+.5f*Mathf.Sin(Time.unscaledTime*9)):gate.isOpen?new Color(.8f,.6f,.2f):new Color(.3f,.3f,.3f);
gateLabels[i].text=directions[i]+" "+(known?Mathf.Max(0,gate.health).ToString("0"):"—")+(known && gate.health<=0?" ×":(gate.isOpen?" offen":"")+(gate.underAttack?" !":""));
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4419706a197fada4593f62a6ccb8a37a
@@ -0,0 +1,35 @@
using UnityEngine;
using UnityEngine.UI;
namespace DepthsOfGreed.HandbookUI
{
// Only rendering and input change; all GameObjects and gameplay keep running.
public sealed class BasementRoomView : MonoBehaviour
{
public Canvas roomCanvas;
public GraphicRaycaster roomRaycaster;
public Camera roomCamera;
public RectTransform[] horizontalSurfaces;
public RectTransform leftBoundary,rightBoundary;
public Vector2 referenceSize=new Vector2(1920,1080);
public bool IsVisible {get;private set;}
public void SetVisible(bool visible)
{
IsVisible=visible;
roomCanvas.enabled=visible;roomRaycaster.enabled=visible;roomCamera.enabled=visible;
Fit();
}
void Awake()=>SetVisible(false);
void LateUpdate(){if(IsVisible)Fit();}
void Fit()
{
float scale=Mathf.Abs(roomCanvas.transform.lossyScale.y);
roomCamera.orthographicSize=Mathf.Max(referenceSize.y*.5f,referenceSize.x*.5f/Mathf.Max(.1f,roomCamera.aspect))*scale;
float width=Mathf.Max(referenceSize.x,roomCamera.orthographicSize*2*roomCamera.aspect/Mathf.Max(.00001f,scale));
((RectTransform)roomCanvas.transform).sizeDelta=new Vector2(width,referenceSize.y);
if(horizontalSurfaces!=null)foreach(var surface in horizontalSurfaces)if(surface)surface.sizeDelta=new Vector2(width,surface.sizeDelta.y);
if(leftBoundary)leftBoundary.anchoredPosition=new Vector2(-width*.5f+10,leftBoundary.anchoredPosition.y);
if(rightBoundary)rightBoundary.anchoredPosition=new Vector2(width*.5f-10,rightBoundary.anchoredPosition.y);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a21ab60c5c4c98647bd5c8a6374c550d
@@ -0,0 +1,10 @@
using TMPro;
using UnityEngine;
namespace DepthsOfGreed.HandbookUI
{
public sealed class BasementSupplyMonitor:MonoBehaviour
{
public SupplyBasementController source;public TMP_Text text;float next;
void Update(){if(Time.unscaledTime<next)return;next=Time.unscaledTime+.3f;var state=source.SupplyBackend?.ReadState();var result="VERSORGUNG\n";string[] names={"Physisch","Zellen","Sonder"};for(int i=0;i<3;i++){SupplyChannelState c=null;if(state?.channels!=null)foreach(var x in state.channels)if((int)x.kind==i)c=x;result+="\n"+names[i]+"\n"+(c==null?"—":c.buffer.ToString("0")+" / "+c.bufferCapacity.ToString("0"))+" · "+HandbookStatisticsController.FormatRate(c?.consumedPerSecond,true)+"\n"+HandbookStatisticsController.Estimate(c==null?0:c.buffer,c?.consumedPerSecond);}text.text=result;}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fbfb71b7cc3d28d4ba8986b421e0ce57
@@ -38,7 +38,7 @@ public static class HandbookInspectorValidation
var menu=UnityEngine.Object.FindFirstObjectByType<BuildMenuController>();int[] expected={6,6,3,4};for(int i=0;i<4;i++){menu.SelectCategory(i);if(menu.GetComponentsInChildren<TowerCardView>().Length!=expected[i])throw new Exception("Category count incorrect: "+i);}
var p=root.GetComponent<TestPlacementController>();var catalog=AssetDatabase.LoadAssetAtPath<HandbookTowerCatalog>("Assets/HandbookData/TestTurmkatalog.asset");var gun=catalog.towers.First(t=>t.id=="ammo_0");var energy=catalog.towers.First(t=>t.id=="energy_0");
if(!p.Backend.ExecuteBuild(new BuildRequest(gun,new Vector3(3.5f,0,0))).Allowed||!p.Backend.ExecuteBuild(new BuildRequest(energy,new Vector3(0,3.5f,0))).Allowed)throw new Exception("Base placement failed");
var obj=p.Backend.BuiltObjects[0].instance;var renderer=obj.GetComponent<SpriteRenderer>();if(!renderer || !renderer.sprite || (gun.baseSprite && renderer.sprite!=gun.baseSprite) || renderer.bounds.size.x>0.8f)throw new Exception("Base sprite or placeholder scaling incorrect");
var obj=p.Backend.BuiltObjects[0].instance;var renderer=obj.GetComponent<SpriteRenderer>();if(!renderer || !renderer.sprite || renderer.bounds.size.x>0.8f)throw new Exception("Base sprite or placeholder scaling incorrect");
int before=p.Backend.Energy;if(!p.Backend.ExecuteUpgrade(p.Backend.BuiltObjects[0].instanceId).Allowed||p.Backend.Energy>=before)throw new Exception("Upgrade failed");
c.Open(2);var upgrades=UnityEngine.Object.FindFirstObjectByType<HandbookUpgradesController>();if(upgrades.VisibleUpgradeCount!=2)throw new Exception("Upgrade listing failed");
c.Open(3);var index=UnityEngine.Object.FindFirstObjectByType<HandbookIndexController>();if(index.VisibleEntryCount!=24)throw new Exception("All entries not displayed");int[] counts={24,12,3,4,5};for(int i=0;i<5;i++){index.SelectFilter(i);if(index.VisibleEntryCount!=counts[i])throw new Exception("Index filter count incorrect");}
@@ -0,0 +1,23 @@
using System;
using System.Linq;
using UnityEngine;
using UnityEditor;
using UnityEditor.SceneManagement;
using DepthsOfGreed.HandbookUI;
public static class PreviewLayersValidation
{
static void Check(bool ok,string message){if(!ok)throw new Exception(message);}
public static void Validate(){GameObject root=null,source=null;Texture2D texture=null;Sprite a=null,b=null;HandbookTowerEntry t=null;try{
EditorSceneManager.OpenScene("Assets/Scenes/Tim_Test_UI.unity");
root=new GameObject("TEMP_LayersTest");var preview=root.AddComponent<LayeredTowerPreview>();texture=new Texture2D(32,32);a=Sprite.Create(texture,new Rect(0,0,32,32),new Vector2(.25f,.75f),32);b=Sprite.Create(texture,new Rect(0,0,32,32),new Vector2(.8f,.1f),32);
t=ScriptableObject.CreateInstance<HandbookTowerEntry>();t.id="layer-test";t.previewLayers=new[]{new PreviewSpriteLayer{sprite=a},new PreviewSpriteLayer{sprite=b,rotateWithAim=false}};preview.Show(t,a);
var layers=root.GetComponentsInChildren<SpriteRenderer>();Check(layers.Length==2 && layers[0].sortingOrder>layers[1].sortingOrder,"Front-to-back order");Check(layers[0].transform.position==layers[1].transform.position,"Different sprite pivots share mouse anchor");Check(layers[0].transform.localScale==layers[1].transform.localScale,"Shared scale preserves proportions");
t.baseShape=BaseShape.Rectangle;t.baseSize=new Vector2(3,1);t.previewLayers=new PreviewSpriteLayer[2];preview.Show(t,a);Check(Mathf.Abs(layers[0].transform.localScale.x/layers[0].transform.localScale.y-3)<.01f,"Rectangular fallback retains base proportions");t.baseShape=BaseShape.Circle;
var gameplay=root.AddComponent<PrototypeHandbookGameplay>();gameplay.Initialize(root.transform,Vector3.zero,3,4,null);source=new GameObject("ActualGameplayPrefab",typeof(SpriteRenderer));source.transform.localScale=Vector3.one*1.2f;t.gameplayPrefab=source;t.baseRadius=.2f;
Check(gameplay.ExecuteBuild(new BuildRequest(t,new Vector3(3.5f,0,0),30)).Allowed,"Prefab placement");Check(gameplay.BuiltObjects[0].instance.transform.localScale==source.transform.localScale,"Assigned prefab instantiated without rescaling");
Check(Shader.Find("Handbook/ArenaRange"),"Arena clipping shader available");
UnityEngine.Object.DestroyImmediate(root);UnityEngine.Object.DestroyImmediate(source);UnityEngine.Object.DestroyImmediate(t);UnityEngine.Object.DestroyImmediate(a);UnityEngine.Object.DestroyImmediate(b);UnityEngine.Object.DestroyImmediate(texture);Debug.Log("PREVIEW_LAYERS_VALIDATION_OK");EditorApplication.Exit(0);
}catch(Exception e){Debug.LogException(e);EditorApplication.Exit(1);}}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1efe7a0c358869143800296d28875a94
@@ -31,7 +31,7 @@ public static class SupplyBasementValidation
{
c=UnityEngine.Object.FindFirstObjectByType<SupplyBasementController>();
Assert(c && c.connectionButtons.Length==8 && c.priorityButtons.Length==3,"Authored references");
Assert(!c.basementView.activeSelf && c.handbookRoot.activeSelf,"Starts on tower floor");
Assert((c.roomView ? !c.roomView.IsVisible && c.basementView.activeSelf : !c.basementView.activeSelf) && c.handbookRoot.activeSelf,"Starts on tower floor; room remains active");
count=Count(c);bookScale=c.handbook.transform.Find("OpenBook").localScale;
var catalog=AssetDatabase.LoadAssetAtPath<HandbookTowerCatalog>("Assets/HandbookData/TestTurmkatalog.asset");
var result=c.placement.Backend.ExecuteBuild(new BuildRequest(catalog.towers.First(t=>t.id=="ammo_0"),new Vector3(3.5f,0,0)));
@@ -73,17 +73,19 @@ public static class SupplyBasementValidation
catch(Exception e){EditorApplication.update-=run;Debug.LogException(e);EditorApplication.Exit(1);}
};EditorApplication.update+=run;
}
static int Count(SupplyBasementController c)=>c.GetComponentInParent<Canvas>().GetComponentsInChildren<RectTransform>(true).Count(t=>!t.name.StartsWith("TMP"));
static int Count(SupplyBasementController c)=>c.GetComponentInParent<Canvas>().GetComponentsInChildren<RectTransform>(true).Concat(c.basementView.GetComponentsInChildren<RectTransform>(true)).Distinct().Count(t=>!t.name.StartsWith("TMP"));
static void Assert(bool condition,string message){if(!condition)throw new Exception(message);}
static void Capture(SupplyBasementController c)
{
var camera=Camera.main;var canvas=c.GetComponentInParent<Canvas>();
var camera=c.roomView?c.roomView.roomCamera:Camera.main;var canvas=c.GetComponentInParent<Canvas>();
var rt=new RenderTexture(1920,1080,24);rt.Create();camera.targetTexture=rt;camera.aspect=1920f/1080;
canvas.renderMode=RenderMode.ScreenSpaceCamera;canvas.worldCamera=camera;canvas.planeDistance=1;canvas.sortingOrder=200;
Canvas.ForceUpdateCanvases();RenderPipeline.SubmitRenderRequest(camera,new UniversalRenderPipeline.SingleCameraRequest{destination=rt});
var previous=RenderTexture.active;RenderTexture.active=rt;var tex=new Texture2D(1920,1080,TextureFormat.RGB24,false);tex.ReadPixels(new Rect(0,0,1920,1080),0,0);tex.Apply();RenderTexture.active=previous;
var folder=Path.Combine(Path.GetDirectoryName(Application.dataPath),"Temp","HandbookValidation");
var folder=Path.Combine(Path.GetDirectoryName(Application.dataPath),"Logs","HandbookValidation");
Directory.CreateDirectory(folder);File.WriteAllBytes(Path.Combine(folder,"basement-preview.png"),tex.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(tex);camera.targetTexture=null;rt.Release();UnityEngine.Object.DestroyImmediate(rt);canvas.renderMode=RenderMode.ScreenSpaceOverlay;
}
}
@@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.EventSystems;
using DepthsOfGreed.HandbookUI;
[InitializeOnLoad]
public static class UINotesValidation
{
static UINotesValidation(){if(SessionState.GetBool("NotesValidation",false))EditorApplication.playModeStateChanged+=Ready;}
public static void Validate(){EditorSceneManager.OpenScene("Assets/Scenes/Tim_Test_UI.unity");SessionState.SetBool("NotesValidation",true);EditorApplication.playModeStateChanged+=Ready;EditorApplication.EnterPlaymode();}
static void Check(bool condition,string message){if(!condition)throw new Exception(message);}
static void Ready(PlayModeStateChange state)
{
if(state!=PlayModeStateChange.EnteredPlayMode)return;SessionState.SetBool("NotesValidation",false);EditorApplication.playModeStateChanged-=Ready;
int stage=0;double due=EditorApplication.timeSinceStartup+1;SupplyBasementController c=null;HandbookStatisticsController stats=null;SupplyContractTestBackend supply=null;UIOverviewTestBackend battle=null;BasementBattleOverview monitor=null;HandbookTowerCatalog catalog=null;float truckX=0;Camera main=null;
EditorApplication.CallbackFunction run=null;run=()=>
{
if(EditorApplication.timeSinceStartup<due)return;
try
{
if(stage==0)
{
c=UnityEngine.Object.FindFirstObjectByType<SupplyBasementController>();main=Camera.main;catalog=AssetDatabase.LoadAssetAtPath<HandbookTowerCatalog>("Assets/HandbookData/TestTurmkatalog.asset");
Check(c.roomView && c.roomView.roomCanvas.renderMode==RenderMode.WorldSpace && c.roomView.roomCanvas.transform.position.x>30,"Room exists outside game area");
Check(c.basementView.activeInHierarchy && !c.roomView.IsVisible && main.enabled,"Room active while hidden, main camera active");
var combat=new CombatDefinition{range=5,minimumRange=1.5f,rotation=RotationMode.Free,attackShape=AttackShape.Cone,coneAngle=60,forwardAngleOffset=90};
Check(AttackRangeGeometry.CoverageAngle(combat)==360,"Rotating cone covers full circle");
Check(!AttackRangeGeometry.CanReach(combat,Vector2.zero,Vector2.up,0) && AttackRangeGeometry.CanReach(combat,Vector2.zero,Vector2.up*3,0),"Minimum range respected");
combat.coverage=AttackCoverage.Sector;Check(AttackRangeGeometry.CanReach(combat,Vector2.zero,Vector2.up*3,0) && !AttackRangeGeometry.CanReach(combat,Vector2.zero,Vector2.right*3,0),"Cone angle and orientation");
combat.coverage=AttackCoverage.Circle;combat.unlimitedRange=true;Check(AttackRangeGeometry.CanReach(combat,Vector2.zero,Vector2.right*1000,0),"Unlimited range has no false maximum");
truckX=c.truck.anchoredPosition.x;
typeof(SupplyBasementController).GetField("truckPauseUntil",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(c,0f);
stage=1;due=EditorApplication.timeSinceStartup+.8;
}
else if(stage==1)
{
Check(Mathf.Abs(c.truck.anchoredPosition.x-truckX)>.1f,"Truck preview continues outside basement");
var howitzer=catalog.towers.First(t=>t.id=="ammo_4");Check(howitzer.combat.minimumRange>0,"Howitzer minimum seeded");
var range=UnityEngine.Object.FindFirstObjectByType<AttackRangePreview>(FindObjectsInactive.Include);
c.handbook.Open(0);Check(c.placement.Begin(howitzer,main.WorldToScreenPoint(new Vector3(3.5f,0,0))),"Begin range preview");
Check(range.gameObject.activeInHierarchy && range.minimumEdge.gameObject.activeSelf && range.outerEdge.loop,"Annular placement preview");
stage=2;due=EditorApplication.timeSinceStartup+.7;
}
else if(stage==2)
{
c.placement.Move(main.WorldToScreenPoint(new Vector3(3.5f,0,0)));
Capture(main,c.handbook.GetComponentInParent<Canvas>(),c.GetComponentInParent<Canvas>(),"range-preview.png");c.placement.Cancel();
supply=new GameObject("TEST_ONLY_Supply").AddComponent<SupplyContractTestBackend>();
supply.state.channels=new[]{new SupplyChannelState{kind=SupplyKind.Physical,storage=180,storageCapacity=300,buffer=60,bufferCapacity=100,consumedPerSecond=2}};
supply.state.towers=new[]{new TowerSupplyState{type=catalog.towers.First(t=>t.id=="ammo_0"),instanceId=811,magazine=12,magazineCapacity=30,consumedPerSecond=2}};
c.BindSupplyBackend(supply);c.handbook.Open(1);stats=UnityEngine.Object.FindFirstObjectByType<HandbookStatisticsController>();stats.Refresh();
Check(stats.channelRates[0].text.Contains("120 / min") && stats.channelTimes[0].text.Contains("00:30"),"Measured rates and reserve estimate");
Check(stats.towerNames[0].text.Contains("811") && stats.towerMagazines[0].text=="12 / 30","Per-instance magazine and rate");
stats.SetUnit(false);stats.Refresh();Check(stats.channelRates[0].text.Contains("2 / s"),"Rate unit conversion");
Check(HandbookStatisticsController.FormatRate(null,true)=="—" && HandbookStatisticsController.Estimate(60,0)=="kein Verbrauch","Unknown differs from zero");
stats.SetUnit(true);stats.Refresh();stage=3;due=EditorApplication.timeSinceStartup+.4;
}
else if(stage==3)
{
Capture(main,c.handbook.GetComponentInParent<Canvas>(),c.GetComponentInParent<Canvas>(),"statistics-preview.png");
battle=new GameObject("TEST_ONLY_CombatOverview").AddComponent<UIOverviewTestBackend>();
battle.state.floorName="Ebene 2";battle.state.gates=new[]{new GateOverviewState{direction=GateDirection.North,health=100,maxHealth=100},new GateOverviewState{direction=GateDirection.East,health=80,maxHealth=100,isOpen=true},new GateOverviewState{direction=GateDirection.South,health=100,maxHealth=100},new GateOverviewState{direction=GateDirection.West,health=100,maxHealth=100}};
monitor=UnityEngine.Object.FindFirstObjectByType<BasementBattleOverview>();monitor.BindCombatBackend(battle);monitor.SendMessage("Update");
Check(monitor.gateLabels[0].text.Contains("100") && monitor.gateLabels[1].text.Contains("offen"),"Gate state and open gate separate");
battle.state.gates[0].health=20;battle.state.gates[0].underAttack=true;battle.state.gates[0].hitSequence++;
c.UseLift();stage=4;due=EditorApplication.timeSinceStartup+1.7;
}
else if(stage==4)
{
Check(c.IsInBasement && c.roomView.IsVisible && c.basementView.activeInHierarchy && c.roomView.roomCamera.enabled && main.enabled,"Only view changes, both rooms stay active");
Check(monitor.gateLabels[0].text.Contains("20") && monitor.gateLabels[0].text.Contains("!"),"Gate damage appears in basement");
Check(monitor.gateIcons[0].color.r>monitor.gateIcons[0].color.g,"Critical gate warning color");
var pointer=new PointerEventData(EventSystem.current){position=RectTransformUtility.WorldToScreenPoint(c.roomView.roomCamera,c.connectionButtons[0].transform.position)};
var hits=new List<RaycastResult>();EventSystem.current.RaycastAll(pointer,hits);
Check(hits.Any(hit=>hit.gameObject==c.connectionButtons[0].gameObject),"World-space room buttons receive UI pointer hits");
c.roomView.roomCamera.aspect=2.5f;c.roomView.SendMessage("LateUpdate");
Check(((RectTransform)c.roomView.roomCanvas.transform).rect.width>=2699,"Room expands to wide screen");
Capture(c.roomView.roomCamera,null,c.GetComponentInParent<Canvas>(),"basement-monitor-preview.png");
c.UseLift();stage=5;due=EditorApplication.timeSinceStartup+1.7;
}
else
{
Check(!c.IsInBasement && !c.roomView.roomCamera.enabled && c.basementView.activeInHierarchy && main.enabled,"Return keeps room and main simulation active");
Debug.Log("UI_NOTES_VALIDATION_OK: separate active room, background preview, attack/minimum/sector ranges, statistics, unit conversion and gate alerts.");EditorApplication.update-=run;EditorApplication.Exit(0);
}
}
catch(Exception e){EditorApplication.update-=run;Debug.LogException(e);EditorApplication.Exit(1);}
};EditorApplication.update+=run;
}
static void Capture(Camera camera,Canvas book,Canvas lift,string filename)
{
if(SystemInfo.graphicsDeviceType==GraphicsDeviceType.Null)return;
var target=new RenderTexture(1920,1080,24);target.Create();camera.targetTexture=target;camera.aspect=1920f/1080;
var room=camera.GetComponentInParent<BasementRoomView>();if(room)room.SendMessage("LateUpdate");
int oldMask=camera.cullingMask;camera.cullingMask|=1;
if(book){book.renderMode=RenderMode.ScreenSpaceCamera;book.worldCamera=camera;book.planeDistance=1;book.sortingOrder=100;}
lift.renderMode=RenderMode.ScreenSpaceCamera;lift.worldCamera=camera;lift.planeDistance=.5f;lift.sortingOrder=200;
Canvas.ForceUpdateCanvases();RenderPipeline.SubmitRenderRequest(camera,new UniversalRenderPipeline.SingleCameraRequest{destination=target});
var previous=RenderTexture.active;RenderTexture.active=target;var texture=new Texture2D(1920,1080,TextureFormat.RGB24,false);texture.ReadPixels(new Rect(0,0,1920,1080),0,0);texture.Apply();RenderTexture.active=previous;
var folder=Path.Combine(Path.GetDirectoryName(Application.dataPath),"Logs","HandbookValidation");Directory.CreateDirectory(folder);File.WriteAllBytes(Path.Combine(folder,filename),texture.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(texture);camera.targetTexture=null;camera.cullingMask=oldMask;target.Release();UnityEngine.Object.DestroyImmediate(target);if(book)book.renderMode=RenderMode.ScreenSpaceOverlay;lift.renderMode=RenderMode.ScreenSpaceOverlay;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f370da3130a64348937147e95cf6d8f
@@ -0,0 +1,44 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using DepthsOfGreed.HandbookUI;
public static class WallBuildAreaValidation
{
static void Check(bool condition,string message){if(!condition)throw new Exception(message);}
public static void Validate()
{
GameObject test=null;HandbookTowerEntry tower=null;
try
{
EditorSceneManager.OpenScene("Assets/Scenes/Tim_Test_UI.unity");
var area=UnityEngine.Object.FindFirstObjectByType<WallBuildArea>();
Check(area && area.TryGetRadii(out _,out _,out _),"Scene has configured build boundaries");
Check(new SerializedObject(UnityEngine.Object.FindFirstObjectByType<TestPlacementController>()).FindProperty("buildArea").objectReferenceValue==area,"Inspector reference wired");
test=new GameObject("TEMP_WallAreaValidation");var settings=test.AddComponent<WallBuildArea>();
settings.innerBoundarySprite=area.innerBoundarySprite;settings.outerBoundarySprite=area.outerBoundarySprite;settings.boundarySource=WallBoundarySource.SpriteRenderers;
string oldInner=settings.innerBoundarySprite.name,oldOuter=settings.outerBoundarySprite.name;
try
{
settings.innerBoundarySprite.name="ArbitraryInnerObject";settings.outerBoundarySprite.name="ArbitraryOuterObject";
Check(settings.TryGetRadii(out float a,out float b,out _) && b>a,"Sprite assignment works after renaming");
}
finally{settings.innerBoundarySprite.name=oldInner;settings.outerBoundarySprite.name=oldOuter;}
settings.boundarySource=WallBoundarySource.CustomRadii;settings.innerBoundarySprite=null;settings.outerBoundarySprite=null;settings.innerRadius=1;settings.outerRadius=2;
settings.transform.localScale=Vector3.one*5;
Check(settings.TryGetRadii(out float inner,out float outer,out _) && inner==1 && outer==2,"Custom world radii ignore missing sprites and transform scale");
var prototype=test.AddComponent<PrototypeHandbookGameplay>();prototype.Initialize(test.transform,Vector3.zero,inner,outer,null);
var catalog=AssetDatabase.LoadAssetAtPath<HandbookTowerCatalog>("Assets/HandbookData/TestTurmkatalog.asset");tower=UnityEngine.Object.Instantiate(catalog.towers.First(t=>t.id=="ammo_0"));tower.baseShape=BaseShape.Circle;tower.baseRadius=.2f;
Check(prototype.CheckBuild(new BuildRequest(tower,new Vector3(1.5f,0,0))).Allowed,"Custom ring accepts fitting base");
Check(!prototype.CheckBuild(new BuildRequest(tower,new Vector3(1.1f,0,0))).Allowed,"Base must clear inner edge");
Check(!prototype.CheckBuild(new BuildRequest(tower,new Vector3(1.9f,0,0))).Allowed,"Base must clear outer edge");
settings.innerRadius=2;Check(!settings.TryGetRadii(out _,out _,out _),"Invalid custom ring rejected");
settings.boundarySource=WallBoundarySource.SpriteRenderers;Check(!settings.TryGetRadii(out _,out _,out _),"Missing sprite references rejected");
UnityEngine.Object.DestroyImmediate(tower);UnityEngine.Object.DestroyImmediate(test);
Debug.Log("WALL_AREA_VALIDATION_OK: inspector binding, renamed sprites, custom radii, invalid values, placement and edge clearance.");EditorApplication.Exit(0);
}
catch(Exception e){if(tower)UnityEngine.Object.DestroyImmediate(tower);if(test)UnityEngine.Object.DestroyImmediate(test);Debug.LogException(e);EditorApplication.Exit(1);}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f68c3932a0690f4ca3434bf439c096d
@@ -17,6 +17,12 @@ namespace DepthsOfGreed.HandbookUI
[Tooltip("Used for continuous weapons instead of damagePerHit / ammoPerShot.")]
[Min(0)] public float continuousDamagePerSecond, ammoPerSecond;
public bool continuousAttack, unlimitedRange;
[Header("Angriffsbereich / Bauvorschau")]
[Min(0)] public float minimumRange;
[Tooltip("Automatic: fest gerichtete Kegel als Sektor, frei drehbare Waffen rundum.")]
public AttackCoverage coverage = AttackCoverage.Automatic;
[Tooltip("Vorwärtsrichtung relativ zur Turmrotation, Grad in der XY-Ebene (90 = oben).")]
public float forwardAngleOffset = 90;
public AttackShape attackShape;
public TargetMode targets;
public RotationMode rotation;
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace DepthsOfGreed.HandbookUI
{
public sealed class HandbookStatisticsController : MonoBehaviour
{
public SupplyBasementController supplyView;
public TestPlacementController placement;
public TMP_Text resourcesText,emptyText;
public TMP_Text[] channelTitles,channelStocks,channelRates,channelTimes;
public TMP_Text[] towerNames,towerMagazines,towerRates;
public GameObject[] towerRows;
public Button perSecondButton,perMinuteButton;
public TMP_Text rateHeading;
private bool perMinute=true;
private float nextRefresh;
private readonly List<TowerSupplyState> towers=new List<TowerSupplyState>();
private readonly string[] names={"Physisch","Energiezellen","Sondermunition"};
void Awake(){perSecondButton.onClick.AddListener(()=>SetUnit(false));perMinuteButton.onClick.AddListener(()=>SetUnit(true));}
void OnEnable(){nextRefresh=0;}
public void SetUnit(bool minute){perMinute=minute;nextRefresh=0;}
public static string FormatRate(float? rate,bool minute)
{
if(!rate.HasValue || float.IsNaN(rate.Value) || float.IsInfinity(rate.Value) || rate.Value<0)return "—";
return (rate.Value*(minute?60:1)).ToString("0.#")+(minute?" / min":" / s");
}
public static string Estimate(float buffer,float? rate)
{
if(!rate.HasValue || float.IsNaN(rate.Value) || float.IsInfinity(rate.Value) || rate.Value<0)return "—";
if(rate.Value==0)return "kein Verbrauch";
float seconds=Mathf.Max(0,buffer)/rate.Value;
if(seconds>5999)return "> 99 min";
int rounded=Mathf.CeilToInt(seconds);return "ca. "+(rounded/60).ToString("00")+":"+(rounded%60).ToString("00");
}
void Update(){if(Time.unscaledTime<nextRefresh)return;nextRefresh=Time.unscaledTime+.25f;Refresh();}
public void Refresh()
{
SupplyState state=supplyView && supplyView.HasSupplyBackend?supplyView.SupplyBackend.ReadState():null;
resourcesText.text=placement && placement.Backend!=null?"Mauerplätze "+placement.Backend.WallCount+" / "+placement.Backend.WallLimit+"\nEnergie "+placement.Backend.Energy+" · Gedärme "+placement.Backend.Entrails:"";
perSecondButton.GetComponent<Image>().color=perMinute?new Color(.82f,.82f,.82f):new Color(.55f,.55f,.55f);
perMinuteButton.GetComponent<Image>().color=perMinute?new Color(.55f,.55f,.55f):new Color(.82f,.82f,.82f);
rateHeading.text=perMinute?"Verbrauch / min":"Verbrauch / s";
for(int i=0;i<3;i++)
{
SupplyChannelState channel=null;
if(state?.channels!=null)foreach(var c in state.channels)if(c!=null && (int)c.kind==i){channel=c;break;}
channelTitles[i].text=names[i];
channelStocks[i].text=channel==null?"Lager — · Verteiler —":"Lager "+channel.storage.ToString("0.#")+" / "+channel.storageCapacity.ToString("0.#")+"\nVerteiler "+channel.buffer.ToString("0.#")+" / "+channel.bufferCapacity.ToString("0.#");
channelRates[i].text="Verbrauch: "+FormatRate(channel?.consumedPerSecond,perMinute);
channelTimes[i].text="Verteiler ohne Nachschub: "+Estimate(channel==null?0:channel.buffer,channel?.consumedPerSecond);
}
towers.Clear();
if(state?.towers!=null){foreach(var t in state.towers)if(t!=null && t.type)towers.Add(t);}
else if(placement && placement.Backend!=null && !(supplyView && supplyView.HasSupplyBackend))
foreach(var t in placement.Backend.BuiltObjects)if(t.type && t.type.supply.kind!=SupplyKind.None)
towers.Add(new TowerSupplyState{instanceId=t.instanceId,type=t.type,magazine=-1,magazineCapacity=-1});
towers.Sort((a,b)=>{int type=string.CompareOrdinal(a.type.id,b.type.id);return type!=0?type:a.instanceId.CompareTo(b.instanceId);});
emptyText.gameObject.SetActive(towers.Count==0);
for(int i=0;i<towerRows.Length;i++)
{
towerRows[i].SetActive(i<towers.Count);if(i>=towers.Count)continue;var t=towers[i];
towerNames[i].text=t.type.displayName+" #"+t.instanceId;
towerMagazines[i].text=t.magazine<0?"—":t.magazine.ToString("0.#")+" / "+t.magazineCapacity.ToString("0.#");
towerRates[i].text=FormatRate(t.consumedPerSecond,perMinute);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a70fe0fa232119479715883bcc9816b
@@ -28,16 +28,14 @@ namespace DepthsOfGreed.HandbookUI
[Header("Grafiken und Prefabs")]
[Tooltip("Bild im Bau-, Index- und Upgrademenü.")]
public Sprite icon;
[Tooltip("Grafik, die während der Platzierung dem Mauszeiger folgt.")]
public Sprite previewSprite;
[Tooltip("Grafik des unbeweglichen Unterteils. Wird vom Platzierungsprototyp genutzt.")]
public Sprite baseSprite;
[Tooltip("Vorbereitetes Unterteil als GameObject. Noch nicht vom Platzierungsprototyp verwendet.")]
public GameObject basePrefab;
[InspectorName("Book Icon")] public Sprite icon;
[Tooltip("Vollständiges Objekt für die spätere Gameplay-Anbindung.")]
public GameObject gameplayPrefab;
[Header("Ziehvorschau: vorne nach hinten")]
public PreviewSpriteLayer[] previewLayers = { new PreviewSpriteLayer(), new PreviewSpriteLayer { rotateWithAim = false } };
[Tooltip("Eine gemeinsame Skalierung für alle Ebenen. 0 = aus unterster belegter Ebene und Basisgröße ableiten.")]
[Min(0)] public float previewScale;
[Header("Platzierung und Platzbedarf")]
public BuildRegion region;
public BaseShape baseShape;
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace DepthsOfGreed.HandbookUI
{
public enum GateDirection { North, East, South, West }
public sealed class GateOverviewState
{
public GateDirection direction;
public float health,maxHealth;
public bool isOpen,underAttack;
// Increase on every hit. It stays unchanged between snapshots.
public long hitSequence;
}
public sealed class CombatOverviewState
{
public string floorName;
public IReadOnlyList<GateOverviewState> gates=Array.Empty<GateOverviewState>();
}
public interface ICombatOverview
{
CombatOverviewState ReadCombatOverview();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a61348f97e25cc44bb55e1c05c75b28
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace DepthsOfGreed.HandbookUI
{
[Serializable] public sealed class PreviewSpriteLayer
{
public Sprite sprite;
public Vector2 offset;
public Vector2 scale=Vector2.one;
public float rotation;
public bool rotateWithAim=true;
}
public sealed class LayeredTowerPreview:MonoBehaviour
{
readonly List<SpriteRenderer> renderers=new List<SpriteRenderer>();
PreviewSpriteLayer[] layers;
public void Show(HandbookTowerEntry entry,Sprite fallback)
{
layers=entry.previewLayers??Array.Empty<PreviewSpriteLayer>();
Sprite reference=null;for(int i=layers.Length-1;i>=0;i--)if(layers[i]?.sprite){reference=layers[i].sprite;break;}
bool empty=!reference;if(empty)reference=fallback;
float size=entry.baseShape==BaseShape.Rectangle?entry.baseSize.x:entry.baseRadius*1.4142136f;
float common=entry.previewScale>0?entry.previewScale:size/Mathf.Max(.001f,reference.bounds.size.x);
int count=empty?1:layers.Length;
while(renderers.Count<count){var go=new GameObject("PreviewLayer_"+renderers.Count);go.transform.SetParent(transform,false);renderers.Add(go.AddComponent<SpriteRenderer>());}
for(int i=0;i<renderers.Count;i++)
{
var r=renderers[i];bool visible=i<count && (empty || layers[i]?.sprite);r.gameObject.SetActive(visible);if(!visible)continue;
var layer=empty?new PreviewSpriteLayer{sprite=fallback}:layers[i];r.sprite=layer.sprite;r.sortingOrder=20+count-i;
r.transform.localPosition=(Vector3)(layer.offset*common);r.transform.localScale=new Vector3(common*layer.scale.x,common*layer.scale.y,1);
if(empty && entry.baseShape==BaseShape.Rectangle && entry.previewScale<=0)r.transform.localScale=new Vector3(entry.baseSize.x/reference.bounds.size.x,entry.baseSize.y/reference.bounds.size.y,1);
}
LateUpdate();
}
public void SetOpacity(float alpha){foreach(var r in renderers)r.color=new Color(1,1,1,alpha);}
void LateUpdate()
{for(int i=0;i<renderers.Count;i++)if(renderers[i].gameObject.activeSelf){var l=layers!=null && i<layers.Length?layers[i]:null;renderers[i].transform.localRotation=Quaternion.Euler(0,0,(l?.rotation??0)-(l!=null && !l.rotateWithAim?transform.eulerAngles.z:0));}}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4669741d7ab05c24db6d0ecfe58e2a68
@@ -87,7 +87,7 @@ namespace DepthsOfGreed.HandbookUI
obj.name = t.displayName + " #" + nextId;
if (!t.gameplayPrefab)
{
var renderer = obj.GetComponent<SpriteRenderer>(); if (t.baseSprite && renderer) { renderer.sprite = t.baseSprite; renderer.color = Color.white; }
var renderer = obj.GetComponent<SpriteRenderer>();
Vector2 size = t.baseShape == BaseShape.Rectangle ? t.baseSize : Vector2.one * t.baseRadius * 1.4142136f;
Vector2 source = renderer && renderer.sprite ? renderer.sprite.bounds.size : Vector2.one;
obj.transform.localScale = new Vector3(size.x / source.x, size.y / source.y, 1);
@@ -0,0 +1,26 @@
using UnityEngine;
namespace DepthsOfGreed.HandbookUI
{
public sealed class RangeArenaClip:MonoBehaviour
{
public WallBuildArea arena;
public Renderer[] outlines;
public Renderer fill;
public TestPlacementController placement;
CombatDefinition combat;bool ability;
MaterialPropertyBlock block;
public void Configure(HandbookTowerEntry entry){combat=entry.combat;ability=entry.use==BuildUse.Ability;}
void LateUpdate()
{
if(!arena || combat==null || !arena.TryGetRadii(out float radius,out _,out _))return;
float angle=transform.eulerAngles.z+combat.forwardAngleOffset;
if(block==null)block=new MaterialPropertyBlock();
block.Clear();block.SetVector("_ArenaCenter",arena.Center);block.SetFloat("_ArenaRadius",radius);
block.SetVector("_Tower",transform.position);block.SetFloat("_Minimum",ability?0:combat.minimumRange);block.SetFloat("_Maximum",ability?combat.areaRadius:combat.unlimitedRange?100000:combat.range);
block.SetVector("_Forward",new Vector4(Mathf.Cos(angle*Mathf.Deg2Rad),Mathf.Sin(angle*Mathf.Deg2Rad),0,0));block.SetFloat("_CosHalf",Mathf.Cos((ability?360:AttackRangeGeometry.CoverageAngle(combat))*.5f*Mathf.Deg2Rad));
block.SetFloat("_Fill",0);
for(int i=0;i<outlines.Length;i++){block.SetFloat("_Dashed",i==1?1:0);outlines[i].SetPropertyBlock(block);}
if(fill){fill.transform.position=arena.Center;fill.transform.rotation=Quaternion.identity;fill.transform.localScale=Vector3.one*(radius*2);block.SetFloat("_Fill",1);block.SetFloat("_Dashed",0);fill.SetPropertyBlock(block);}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 648bfe9143bff7b47b435fc829f73753
@@ -17,6 +17,7 @@ namespace DepthsOfGreed.HandbookUI
public event System.Action<int> PriorityChanged;
[Header("Authored scene references")]
public GameObject basementView;
public BasementRoomView roomView;
public GameObject handbookRoot;
public CanvasGroup handbookVisibility;
public HandbookController handbook;
@@ -61,7 +62,7 @@ namespace DepthsOfGreed.HandbookUI
{
signScale = liftSign.localScale;
truckScale = truck.localScale;
basementView.SetActive(false);
if(roomView)roomView.SetVisible(false);else basementView.SetActive(false);
doorGroup.gameObject.SetActive(false);
liftButton.onClick.AddListener(UseLift);
for (int i = 0; i < connectionButtons.Length; i++)
@@ -105,7 +106,7 @@ namespace DepthsOfGreed.HandbookUI
yield return Doors(true);
if (handbook) handbook.Close();
IsInBasement = !IsInBasement;
basementView.SetActive(IsInBasement);
if(roomView)roomView.SetVisible(IsInBasement);else basementView.SetActive(IsInBasement);
// Hide only the presentation. The backend on HandbookRoot must keep updating.
handbookVisibility.alpha = IsInBasement ? 0f : 1f;
handbookVisibility.interactable = !IsInBasement;
@@ -181,22 +182,21 @@ namespace DepthsOfGreed.HandbookUI
}
private void Update()
{
if (!IsInBasement) return;
if (Time.unscaledTime > nextRefresh) { nextRefresh = Time.unscaledTime + 0.25f; RefreshConnections(); }
if (HasSupplyBackend) { RenderBackendTruck(); return; }
if (!animateTruckPreview) { truckStatus.text = "LKW bereit"; return; }
if (Time.unscaledTime < truckPauseUntil) return;
if (Time.time < truckPauseUntil) return;
float destination = unloading ? unloadingPosition : loadingPositions[previewKind];
var p = truck.anchoredPosition;
float direction = destination - p.x;
if (Mathf.Abs(direction) > 0.1f)
truck.localScale = new Vector3(Mathf.Abs(truckScale.x) * Mathf.Sign(direction), truckScale.y, truckScale.z);
p.x = Mathf.MoveTowards(p.x, destination, Mathf.Max(0, truckSpeed) * Time.unscaledDeltaTime); truck.anchoredPosition = p;
p.x = Mathf.MoveTowards(p.x, destination, Mathf.Max(0, truckSpeed) * Time.deltaTime); truck.anchoredPosition = p;
truckStatus.text = (unloading ? "Zur Verteilerstation" : "Zum Behälter: " + kinds[previewKind]);
if (Mathf.Abs(p.x - destination) < 0.1f)
{
truckStatus.text = (unloading ? "Abladen" : "Beladen: " + kinds[previewKind]);
truckPauseUntil = Time.unscaledTime + loadingPause;
truckPauseUntil = Time.time + loadingPause;
if (unloading) previewKind = Priority >= 0 ? Priority : (previewKind + 1) % 3;
unloading = !unloading;
}
@@ -34,6 +34,9 @@ namespace DepthsOfGreed.HandbookUI
private CanvasGroup bookGroup;
private GameObject prefab, preview, controls;
private Transform container, area;
[Header("Mauer-Baubereich")]
[Tooltip("Baugrenzen und Mittelpunkt. Konfiguration auf PlacementTest/BuildArea.")]
[SerializeField] private WallBuildArea buildArea;
private SpriteRenderer ghost, footprint;
private Sprite ghostPlaceholder;
private TMP_Text controlsText, feedback, resources, buildHint;
@@ -46,7 +49,11 @@ namespace DepthsOfGreed.HandbookUI
private LineRenderer previewOutline, innerOutline, outerOutline;
private Material lineMaterial;
private float placementAngle;
private float manualAimOffset;
public bool alignPreviewToCenter = true;
[SerializeField] private LayeredTowerPreview layeredPreview;
private GeneralFeedbackController general;
[SerializeField] private AttackRangePreview attackRangePreview;
public void Initialize(HandbookController owner, BuildMenuController menu, RectTransform openBook, GameObject testPrefab, MonoBehaviour gameplayBackend = null)
{
@@ -62,7 +69,8 @@ namespace DepthsOfGreed.HandbookUI
enabled = false;
return;
}
area = test.transform.Find("BuildArea");
area = buildArea ? buildArea.transform : test.transform.Find("BuildArea");
if (!buildArea && area) buildArea = area.GetComponent<WallBuildArea>();
container = test.transform.Find("PlacedTowers") ?? test.transform.Find("PlaceTowers");
preview = test.transform.Find("PlacementPreview").gameObject;
ghost = preview.transform.Find("GhostVisual").GetComponent<SpriteRenderer>();
@@ -87,8 +95,14 @@ namespace DepthsOfGreed.HandbookUI
}
resources = menu.transform.Find("RightPage/ResourcesText").GetComponent<TMP_Text>();
buildHint = menu.transform.Find("BuildHintText").GetComponent<TMP_Text>();
innerRadius = RadiusOf("BackgroundTower", 3f);
outerRadius = RadiusOf("BackgroundOuterwall", 4f);
if (!buildArea)
{
Debug.LogError("Testplatzierung: WallBuildArea zuweisen.", this); enabled = false; return;
}
if (!buildArea.TryGetRadii(out innerRadius, out outerRadius, out string boundaryError))
{
Debug.LogError("Testplatzierung: " + boundaryError, buildArea); enabled = false; return;
}
if (gameplayBackend && gameplayBackend is IHandbookGameplay real) Backend = real;
else
{
@@ -101,6 +115,7 @@ namespace DepthsOfGreed.HandbookUI
previewOutline = MakeCircle("PlacementOutline", preview.transform, 11);
innerOutline = MakeCircle("BuildArea_Inner", area, 8);
outerOutline = MakeCircle("BuildArea_Outer", area, 8);
innerOutline.useWorldSpace = outerOutline.useWorldSpace = true;
DrawCircle(innerOutline, innerRadius);
DrawCircle(outerOutline, outerRadius);
ShowGuides(false);
@@ -109,28 +124,21 @@ namespace DepthsOfGreed.HandbookUI
RefreshResources();
}
private static float RadiusOf(string name, float fallback)
{
var obj = GameObject.Find(name);
var renderer = obj ? obj.GetComponent<SpriteRenderer>() : null;
return renderer ? Mathf.Min(renderer.bounds.extents.x, renderer.bounds.extents.y) : fallback;
}
public bool Begin(HandbookTowerEntry entry, Vector2 screenPosition)
{
if (!enabled || IsDragging || IsWaitingForReturn || entry == null) return false;
var availability = Backend.CheckAvailability(entry);
if (!availability.Allowed) { failedTypeId = entry.id; Message(availability.message); return false; }
draggingEntry = entry; placementAngle = 0; LastFailure = null;
ghost.sprite = entry.previewSprite ? entry.previewSprite : ghostPlaceholder;
Vector2 visualSize = entry.baseShape == BaseShape.Rectangle ? entry.baseSize : Vector2.one * entry.baseRadius * 1.4142136f;
ghost.transform.localScale = new Vector3(visualSize.x / ghost.sprite.bounds.size.x, visualSize.y / ghost.sprite.bounds.size.y, 1);
draggingEntry = entry; placementAngle = 0; manualAimOffset = 0; LastFailure = null;
if(attackRangePreview)attackRangePreview.Show(entry);
if(layeredPreview)layeredPreview.Show(entry,ghostPlaceholder);
ghost.gameObject.SetActive(!layeredPreview);
IsDragging = true;
preview.SetActive(true);
footprint.transform.localScale = new Vector3(entry.placementRadius * 2f, entry.placementRadius * 2f, 1f);
DrawCircle(previewOutline, entry.placementRadius);
controls.SetActive(false);
general.ShowControls("Loslassen: Bauen\nRechtsklick / Esc: Abbrechen" + (entry.allowPlayerRotation ? "\nR: Drehen" : ""));
general.ShowControls("Loslassen: Bauen\nRechtsklick / Esc: Abbrechen" + (entry.allowPlayerRotation ? "\nR: Drehen" : "") + (entry.combat != null && entry.combat.unlimitedRange ? "\nReichweite: unbegrenzt" : ""));
bookGroup.blocksRaycasts = false;
ShowGuides(true);
foreach (var record in Backend.BuiltObjects)
@@ -153,12 +161,18 @@ namespace DepthsOfGreed.HandbookUI
if (!plane.Raycast(ray, out float distance)) return;
target = ray.GetPoint(distance);
target.z = 0;
if(alignPreviewToCenter)
{
Vector2 inward = area.position-target;
if(inward.sqrMagnitude>.0001f)placementAngle=Mathf.LerpAngle(placementAngle,Mathf.Atan2(inward.y,inward.x)*Mathf.Rad2Deg-(draggingEntry.combat?.forwardAngleOffset??90)+manualAimOffset,1-Mathf.Exp(-16*Time.unscaledDeltaTime));
}
preview.transform.position = target; preview.transform.rotation = Quaternion.Euler(0, 0, placementAngle);
string reason = Check(draggingEntry, target);
bool outside = Backend.CheckBuild(new BuildRequest(draggingEntry, target, placementAngle)).failure == BuildFailure.OutsideRegion;
Color color = outside ? Color.gray : reason == null ? Color.green : Color.red;
footprint.color = new Color(color.r, color.g, color.b, outside ? 0.08f : 0.18f);
ghost.color = new Color(1f, 1f, 1f, outside ? 0.3f : 0.65f);
if(layeredPreview)layeredPreview.SetOpacity(outside?.3f:.8f);
Tint(previewOutline, color);
DrawCircle(previewOutline, draggingEntry.placementRadius, outside);
controlsText.text = "Loslassen: Platzieren · Rechtsklick / Esc: Abbrechen";
@@ -222,7 +236,7 @@ namespace DepthsOfGreed.HandbookUI
{
if (Mouse.current.rightButton.wasPressedThisFrame ||
(Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame)) Cancel();
else { if (draggingEntry.allowPlayerRotation && Keyboard.current != null && Keyboard.current.rKey.wasPressedThisFrame) placementAngle += 90; Move(Mouse.current.position.ReadValue()); }
else { if (draggingEntry.allowPlayerRotation && Keyboard.current != null && Keyboard.current.rKey.wasPressedThisFrame) { if(alignPreviewToCenter)manualAimOffset+=90;else placementAngle+=90; } Move(Mouse.current.position.ReadValue()); }
}
bool lowered = IsDragging || IsWaitingForReturn;
if (!lowered) bookGroup.blocksRaycasts = true;
@@ -293,7 +307,8 @@ namespace DepthsOfGreed.HandbookUI
for (int i = 0; i < segments; i++)
{
float angle = i * Mathf.PI * 2f / segments;
line.SetPosition(i, new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0));
var point = new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0);
line.SetPosition(i, line.useWorldSpace ? line.transform.position + point : point);
}
// Alternating width provides a procedural broken outline without an extra sprite.
if (dashed)
@@ -307,3 +322,5 @@ namespace DepthsOfGreed.HandbookUI
private void OnDestroy() { if (Backend != null) Backend.StateChanged -= RefreshResources; if (lineMaterial) Destroy(lineMaterial); }
}
}
@@ -0,0 +1,10 @@
#if UNITY_EDITOR
using DepthsOfGreed.HandbookUI;
using UnityEngine;
// Isolated editor fixture, never saved in a scene or included in builds.
public sealed class UIOverviewTestBackend : MonoBehaviour,ICombatOverview
{
public CombatOverviewState state=new CombatOverviewState();
public CombatOverviewState ReadCombatOverview()=>state;
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b1f8e752479376549a225c2261492fee
@@ -0,0 +1,57 @@
using UnityEngine;
namespace DepthsOfGreed.HandbookUI
{
public enum WallBoundarySource { SpriteRenderers, CustomRadii }
[DisallowMultipleComponent]
public sealed class WallBuildArea : MonoBehaviour
{
[Header("Mauer-Baugrenzen")]
public WallBoundarySource boundarySource = WallBoundarySource.CustomRadii;
[Tooltip("Äußere Kante dieses Sprites ergibt die innere Baugrenze. Kreisförmiges Sprite, zentriert auf BuildArea verwenden.")]
public SpriteRenderer innerBoundarySprite;
[Tooltip("Äußere Kante dieses Sprites ergibt die äußere Baugrenze. Kreisförmiges Sprite, zentriert auf BuildArea verwenden.")]
public SpriteRenderer outerBoundarySprite;
[Tooltip("Eigener Innenradius in Welt-Einheiten. Wird nur bei Custom Radii verwendet; unabhängig von der Transform-Skalierung.")]
[Min(0)] public float innerRadius = 3f;
[Tooltip("Eigener Außenradius in Welt-Einheiten. Muss größer als der Innenradius sein.")]
[Min(0.01f)] public float outerRadius = 4f;
public bool showSelectedGizmos = true;
public Vector3 Center => transform.position;
public bool TryGetRadii(out float inner, out float outer, out string error)
{
inner = innerRadius; outer = outerRadius; error = null;
if (boundarySource == WallBoundarySource.SpriteRenderers)
{
if (!innerBoundarySprite || !outerBoundarySprite || !innerBoundarySprite.sprite || !outerBoundarySprite.sprite)
{ error = "Beide Baugrenzen benötigen einen zugewiesenen SpriteRenderer mit Sprite."; return false; }
inner = Mathf.Min(innerBoundarySprite.bounds.extents.x, innerBoundarySprite.bounds.extents.y);
outer = Mathf.Min(outerBoundarySprite.bounds.extents.x, outerBoundarySprite.bounds.extents.y);
}
if (float.IsNaN(inner) || float.IsInfinity(inner) || float.IsNaN(outer) || float.IsInfinity(outer) || inner < 0 || outer <= inner)
{ error = "Ungültige Mauer-Baugrenzen: 0 <= Innenradius < Außenradius erforderlich."; return false; }
return true;
}
private void OnDrawGizmosSelected()
{
if (!showSelectedGizmos || !TryGetRadii(out float inner, out float outer, out _)) return;
var old = Gizmos.color;
Gizmos.color = new Color(1f,.65f,.15f); Circle(inner);
Gizmos.color = new Color(.2f,.8f,1f); Circle(outer);
Gizmos.color = old;
}
private void Circle(float radius)
{
var previous = Center + Vector3.right * radius;
for(int i=1;i<=128;i++)
{
float angle=i*Mathf.PI*2f/128;
var next=Center+new Vector3(Mathf.Cos(angle),Mathf.Sin(angle),0)*radius;
Gizmos.DrawLine(previous,next);previous=next;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6fa413815d8f1294b8cec6b33713cc5b