From 73f54b5cbac81b179b9a435d2990f64c57b32175 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 10:44:00 +0200 Subject: [PATCH 01/13] feat(presentation): add tactical RTS cursors, controls and audiovisual polish Add dynamic procedural 32x32 hardware RTS cursors (default, move, attack, harvest, repair) and contextual right-click dispatch into RtsDeviceInput for responsive RTS controls. Add color-coded animated ground click feedback pings to SelectionMarkerView and target rings under visible attack targets. Add sound cues for building completion, base-under-attack warnings, low-power alerts, and round victory/defeat in UnitViewManager, ResourceBarHud, and MatchFrameHud. Upgrade 3D visual presence with crystalline Aetherium clusters, progressive construction scaffolding, placement holograms, texture brightness preservation, high-contrast healthbars on hover, and sci-fi HUD styling. --- .../EditMode/Gameplay/FactionTintTests.cs | 15 + .../Scripts/Gameplay/Match/FactionTint.cs | 23 + .../Scripts/Gameplay/Match/MatchBootstrap.cs | 21 + .../Scripts/Gameplay/Match/UnitViewManager.cs | 58 ++- .../Maps/GlutrinneBlockoutView.cs | 173 ++++++- .../Scripts/Presentation/UI/BuildMenuHud.cs | 88 +++- .../Scripts/Presentation/UI/CommandCardHud.cs | 119 ++++- .../UI/ConstructionSiteMarkerView.cs | 147 +++++- .../Scripts/Presentation/UI/HealthBarHud.cs | 44 +- .../Scripts/Presentation/UI/HudChrome.cs | 425 +++++++++++++++- .../Presentation/UI/MainMenuController.cs | 87 ++++ .../Scripts/Presentation/UI/MatchFrameHud.cs | 21 + .../Presentation/UI/PlacementGhostView.cs | 76 ++- .../Scripts/Presentation/UI/ResourceBarHud.cs | 23 +- .../Presentation/UI/RtsCursorController.cs | 319 ++++++++++++ .../UI/RtsCursorController.cs.meta | 2 + .../Scripts/Presentation/UI/RtsDeviceInput.cs | 468 +++++++++++++++++- .../Presentation/UI/SelectionMarkerView.cs | 200 +++++++- CHANGELOG.md | 23 + tools/art/rig_tank_hierarchy.py | 290 +++++++++++ 20 files changed, 2494 insertions(+), 128 deletions(-) create mode 100644 Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs create mode 100644 Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs.meta create mode 100644 tools/art/rig_tank_hierarchy.py diff --git a/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs b/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs index a2553e24..c5f1581b 100644 --- a/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs +++ b/Assets/Tests/EditMode/Gameplay/FactionTintTests.cs @@ -49,5 +49,20 @@ public void ApplyToPropertyBlock_SetsBothShaderColourProperties() Assert.That(block.GetColor("_Color"), Is.EqualTo(tint), "built-in RP reads _Color"); } + + [Test] + public void PrefabBaseColor_PreservesHighAlbedoAndDistinguishesFactions() + { + Color alliance = FactionTint.PrefabBaseColor(FactionId.Alliance); + Color legion = FactionTint.PrefabBaseColor(FactionId.Legion); + + Assert.That(alliance, Is.Not.EqualTo(legion)); + Assert.That(alliance.r, Is.GreaterThan(0.85f), "3D prefab tint must not crush red channel"); + Assert.That(alliance.g, Is.GreaterThan(0.85f), "3D prefab tint must not crush green channel"); + Assert.That(alliance.b, Is.GreaterThan(0.85f), "3D prefab tint must not crush blue channel"); + Assert.That(legion.r, Is.GreaterThan(0.85f), "3D prefab tint must not crush red channel"); + Assert.That(legion.g, Is.GreaterThan(0.85f), "3D prefab tint must not crush green channel"); + Assert.That(legion.b, Is.GreaterThan(0.85f), "3D prefab tint must not crush blue channel"); + } } } diff --git a/Assets/_Project/Scripts/Gameplay/Match/FactionTint.cs b/Assets/_Project/Scripts/Gameplay/Match/FactionTint.cs index 4064986d..41a27d36 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/FactionTint.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/FactionTint.cs @@ -27,6 +27,20 @@ public static class FactionTint /// Legion base tone of the D-072 palette (#7A3524). public static readonly Color LegionBase = ParseHex("#7A3524"); + /// + /// High-albedo tint for textured 3D Alliance prefabs: keeps the + /// authored BaseColor map bright and legible while adding a subtle + /// cool steel-blue grade. + /// + public static readonly Color AlliancePrefabTint = new Color(0.94f, 0.97f, 1.00f, 1f); + + /// + /// High-albedo tint for textured 3D Legion prefabs: keeps the + /// authored BaseColor map bright and legible while adding a subtle + /// warm industrial-amber grade instead of crushing green/blue channels. + /// + public static readonly Color LegionPrefabTint = new Color(1.00f, 0.93f, 0.87f, 1f); + private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor"); private static readonly int ColorPropertyId = Shader.PropertyToID("_Color"); @@ -40,6 +54,15 @@ public static Color BaseColor(FactionId faction) return faction == FactionId.Legion ? LegionBase : AllianceBase; } + /// + /// High-albedo tint for a 3D prefab view whose material already carries + /// a faction-authored BaseColor texture. + /// + public static Color PrefabBaseColor(FactionId faction) + { + return faction == FactionId.Legion ? LegionPrefabTint : AlliancePrefabTint; + } + /// /// Writes the tint into the property block under BOTH shader colour /// properties (_BaseColor and _Color). The block is diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index e687ec21..acdd42df 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -367,6 +367,13 @@ public static Vector2Int[] CanonicalFieldCells /// Entity handle of the opponent HQ. public EntityId EnemyHq => _hq[ConfiguredEnemySlot]; + /// + /// Chosen faction for local single-player skirmish matches (slot 0); + /// slot 1 (AI) receives the opposite faction. Defaults to Alliance so + /// existing callers of stay unchanged. + /// + public FactionId LocalSkirmishFaction { get; set; } = FactionId.Alliance; + /// Entity handle of the human Builder (spawned at 13, 7 — the D-077 opening's only unit). public EntityId LocalBuilder => _builder[ConfiguredLocalSlot]; @@ -495,6 +502,7 @@ public void StartGrayboxMatch() MatchConfig local = MatchConfig.LocalVsAi( _seed, _mapWidth, _mapHeight, _entityCapacity, Nova.Simulation.Economy.EconomySystem.CanonicalMatchStartingCreditsAE); + ApplyLocalSkirmishFaction(local); string relayHost = Environment.GetEnvironmentVariable("NOVA_RELAY_HOST"); if (string.IsNullOrWhiteSpace(relayHost)) @@ -865,6 +873,7 @@ public bool RestartMatch() : MatchConfig.LocalVsAi( _seed, _mapWidth, _mapHeight, _entityCapacity, Nova.Simulation.Economy.EconomySystem.CanonicalMatchStartingCreditsAE); + ApplyLocalSkirmishFaction(restartConfig); if (Runner != null && Runner.IsRunning) { Runner.PauseMatch(); @@ -875,6 +884,18 @@ public bool RestartMatch() return IsMatchReady; } + private void ApplyLocalSkirmishFaction(MatchConfig config) + { + if (config == null || config.IsNetworkMatch || config.FactionPerSlot == null || config.FactionPerSlot.Length < 2) + { + return; + } + FactionId humanFaction = LocalSkirmishFaction == FactionId.Legion ? FactionId.Legion : FactionId.Alliance; + FactionId aiFaction = humanFaction == FactionId.Legion ? FactionId.Alliance : FactionId.Legion; + config.FactionPerSlot[LocalSlot] = humanFaction; + config.FactionPerSlot[EnemySlot] = aiFaction; + } + private static bool Contains(byte[] slots, byte slot) { for (int i = 0; i < slots.Length; i++) if (slots[i] == slot) return true; diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index 1fc45055..c075af8b 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -3,6 +3,7 @@ using UnityEngine; using Nova.Core; using Nova.Data; +using Nova.Gameplay.Audio; using Nova.Gameplay.CombatFeedback; using Nova.Simulation.CommandsV1; using Nova.Simulation.Combat; @@ -150,6 +151,7 @@ public class UnitViewManager : MonoBehaviour private readonly List _deathHolds = new List(16); private readonly Dictionary _transparentMaterialCache = new Dictionary(); private MaterialPropertyBlock _propertyBlock; + private float _lastBaseUnderAttackTime = -20f; // Scratch for the all-renderers tint upload (no per-call allocation). private readonly List _tintScratch = new List(8); // Shared runtime material for graybox primitives: Unity's primitive @@ -332,6 +334,20 @@ private void LateUpdate() { BeginDeathHold(feedback.TargetId); } + + if ((feedback.Kind == CombatFeedbackKind.Hit || feedback.Kind == CombatFeedbackKind.Death) + && SimDefinitions.IsBuildingRole(feedback.TargetRole)) + { + if (entities.TryGetUnit(feedback.TargetId, out UnitState targetBuilding) + && targetBuilding.PlayerId == viewerTeam) + { + if (Time.time >= _lastBaseUnderAttackTime + 20f) + { + _lastBaseUnderAttackTime = Time.time; + AudioServiceLocator.Play2D(SoundEventId.UI_Deny); + } + } + } } _combatEffects?.Present(_combatEvents); @@ -351,8 +367,13 @@ private void LateUpdate() // what promotes the view from the site pad to the finished // building look. bool spawned = false; - if (_viewInstances[slot] == null || _boundIds[slot] != id || _viewRoles[slot] != EffectiveViewRole(in unit)) + UnitRole newEffectiveRole = EffectiveViewRole(in unit); + if (_viewInstances[slot] == null || _boundIds[slot] != id || _viewRoles[slot] != newEffectiveRole) { + if (_boundIds[slot] == id && _viewRoles[slot] == UnitRole.Unit && SimDefinitions.IsBuildingRole(newEffectiveRole) && unit.PlayerId == viewerTeam) + { + AudioServiceLocator.Play2D(SoundEventId.PRD_UnitReady); + } ReleaseView(slot); AcquireView(slot, in unit); spawned = true; @@ -511,7 +532,12 @@ private void AcquireView(int slot, in UnitState unit) } else { - GetRoleShape(EffectiveViewRole(in unit), out PrimitiveType primitive, out Vector3 scale); + UnitRole effectiveRole = EffectiveViewRole(in unit); + GetRoleShape(effectiveRole, out PrimitiveType primitive, out Vector3 scale); + if (effectiveRole == UnitRole.Unit && SimDefinitions.IsBuildingRole(unit.Role)) + { + scale = new Vector3(2.60f, 0.28f, 2.60f); + } shapeKey = (int)primitive; groundOffset = GroundOffset(primitive, scale); @@ -903,6 +929,7 @@ private void ResetCombatFeedback() _combatDiffer.Reset(_viewInstances != null ? _viewInstances.Length : 0); _combatSamples.Clear(); _combatEvents.Clear(); + _lastBaseUnderAttackTime = -20f; CompleteAllDeathHolds(); _combatEffects?.ResetEffects(); } @@ -950,7 +977,8 @@ private void ApplyTint(int slot, byte playerId, int healthStep) GameObject instance = _viewInstances[slot]; if (instance == null) return; - Color color = TintFor(playerId, healthStep); + bool isPrefab = _viewShapeKeys[slot] == PrefabShapeKey; + Color color = TintFor(playerId, healthStep, isPrefab); _propertyBlock.Clear(); // Built-in RP reads _Color, URP/HDRP read _BaseColor. Setting both // keeps the graybox tinted through the pipeline migration instead @@ -974,20 +1002,19 @@ private void ApplyTint(int slot, byte playerId, int healthStep) /// /// Owner colour, darkened toward by the - /// health bucket. A full-health unit returns the owner colour - /// unchanged, so undamaged views look exactly as they did before the - /// readout existed; keeps a share of the - /// owner hue at the brink so colour never stops answering "whose is - /// it?". + /// health bucket. Textured 3D prefabs use + /// so their authored BaseColor maps are not crushed by dark primitive tints. /// - private Color TintFor(byte playerId, int healthStep) + private Color TintFor(byte playerId, int healthStep, bool isPrefab) { - Color owner = ColorForOwner(playerId); + Color owner = ColorForOwner(playerId, isPrefab); if (healthStep >= HealthTintSteps) return owner; float fraction = (float)healthStep / HealthTintSteps; - float blend = Mathf.Lerp(Mathf.Clamp01(_healthTintFloor), 1f, fraction); - return Color.Lerp(_damagedColor, owner, blend); + float floor = isPrefab ? Mathf.Max(0.42f, Mathf.Clamp01(_healthTintFloor)) : Mathf.Clamp01(_healthTintFloor); + float blend = Mathf.Lerp(floor, 1f, fraction); + Color damagedTarget = isPrefab ? new Color(0.62f, 0.22f, 0.18f, 1f) : _damagedColor; + return Color.Lerp(damagedTarget, owner, blend); } /// @@ -1015,14 +1042,15 @@ private static int HealthStep(in UnitState unit) /// (no economy wired, slot outside the declared range) fall back to /// instead of throwing. /// - private Color ColorForOwner(byte playerId) + private Color ColorForOwner(byte playerId, bool isPrefab) { EconomySystem economy = _matchRunner != null ? _matchRunner.Economy : null; if (economy != null && playerId < EconomySystem.MaxPlayers) { - return FactionTint.BaseColor(economy.GetSlotFaction(playerId)); + FactionId faction = economy.GetSlotFaction(playerId); + return isPrefab ? FactionTint.PrefabBaseColor(faction) : FactionTint.BaseColor(faction); } - return _unknownPlayerColor; + return isPrefab ? Color.white : _unknownPlayerColor; } /// diff --git a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs index 6e812b2f..c95962fc 100644 --- a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs +++ b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs @@ -100,9 +100,27 @@ public sealed class GlutrinneBlockoutView : MonoBehaviour private static readonly float[] ClusterHeights = { - 1.10f, 0.65f, 0.80f, 0.55f, 0.70f, 0.50f, 0.60f, + 1.25f, 0.78f, 0.92f, 0.65f, 0.82f, 0.58f, 0.72f, }; + private static readonly float[] ClusterWidths = + { + 0.44f, 0.32f, 0.36f, 0.30f, 0.34f, 0.28f, 0.31f, + }; + + private static readonly Vector3[] ClusterRotations = + { + new Vector3(4f, 15f, -3f), + new Vector3(14f, 42f, -16f), + new Vector3(-16f, 118f, 12f), + new Vector3(-18f, 205f, -10f), + new Vector3(15f, 255f, 14f), + new Vector3(-12f, 310f, -18f), + new Vector3(17f, 75f, 9f), + }; + + private static Mesh _crystalShardMesh; + /// Reserve readout cadence (21.2): the shard stages follow RemainingAE on the HUD's read rail — the harvester-escort interval of RtsDeviceInput, never a per-frame query. private const float ReserveReadIntervalSeconds = 0.5f; @@ -412,26 +430,151 @@ private GameObject[] BuildFieldMarker(Vector2Int cell, string label) marker.transform.SetParent(transform, false); marker.transform.position = new Vector3(cell.x + 0.5f, 0f, cell.y + 0.5f); - Material crystalMaterial = CreateRuntimeMaterial(_crystalColor); + // Mineral socket bed beneath the crystal cluster so shards erupt + // from a dark Aetherium ore vein instead of bare flat sand. + GameObject bed = GameObject.CreatePrimitive(PrimitiveType.Cylinder); + bed.name = "CrystalBed"; + bed.transform.SetParent(marker.transform, false); + bed.transform.localPosition = new Vector3(0f, 0.03f, 0f); + bed.transform.localScale = new Vector3(1.55f, 0.04f, 1.55f); + Destroy(bed.GetComponent()); + Color bedColor = Color.Lerp(_rockColor, _crystalColor, 0.22f) * 0.65f; + bedColor.a = 1f; + bed.GetComponent().sharedMaterial = CreateRuntimeMaterial(bedColor); + + Material crystalMaterial = CreateCrystalMaterial(_crystalColor); + Mesh shardMesh = GetOrCreateCrystalShardMesh(); var shards = new GameObject[ClusterOffsets.Length]; for (int i = 0; i < ClusterOffsets.Length; i++) { float height = ClusterHeights[i]; - GameObject shard = GameObject.CreatePrimitive(PrimitiveType.Cube); - shard.name = $"Crystal_{i}"; + float width = ClusterWidths[i]; + var shard = new GameObject($"Crystal_{i}"); shard.transform.SetParent(marker.transform, false); - shard.transform.localPosition = new Vector3(ClusterOffsets[i].x, height * 0.5f, ClusterOffsets[i].y); - shard.transform.localRotation = Quaternion.Euler(0f, 25f * i, 0f); - shard.transform.localScale = new Vector3(0.35f, height, 0.35f); - - // Pure marker: no collider, so nothing can ever pick or block a crystal. - Destroy(shard.GetComponent()); - shard.GetComponent().sharedMaterial = crystalMaterial; + shard.transform.localPosition = new Vector3(ClusterOffsets[i].x, height * 0.46f, ClusterOffsets[i].y); + shard.transform.localRotation = Quaternion.Euler(ClusterRotations[i]); + shard.transform.localScale = new Vector3(width, height, width); + + MeshFilter meshFilter = shard.AddComponent(); + meshFilter.sharedMesh = shardMesh; + MeshRenderer meshRenderer = shard.AddComponent(); + meshRenderer.sharedMaterial = crystalMaterial; shards[i] = shard; } return shards; } + private static Material CreateCrystalMaterial(Color color) + { + Material material = CreateRuntimeMaterial(color); + if (material.HasProperty("_Smoothness")) material.SetFloat("_Smoothness", 0.86f); + if (material.HasProperty("_Glossiness")) material.SetFloat("_Glossiness", 0.86f); + if (material.HasProperty("_Metallic")) material.SetFloat("_Metallic", 0.20f); + if (material.HasProperty("_EmissionColor")) + { + material.EnableKeyword("_EMISSION"); + material.SetColor("_EmissionColor", color * 0.42f); + } + return material; + } + + /// + /// Procedural flat-shaded hexagonal crystal obelisk spanning y = [-0.5, +0.5] + /// with a tapered shaft and a sharp pyramidal apex. Flat-duplicated vertices per + /// facet give crisp specular highlights under the directional light. + /// + private static Mesh GetOrCreateCrystalShardMesh() + { + if (_crystalShardMesh != null) return _crystalShardMesh; + + const int sides = 6; + const float baseRadius = 0.50f; + const float shoulderRadius = 0.36f; + const float baseY = -0.50f; + const float shoulderY = 0.22f; + const float tipY = 0.50f; + + var bottomRing = new Vector3[sides]; + var shoulderRing = new Vector3[sides]; + for (int i = 0; i < sides; i++) + { + float angle = i * Mathf.PI * 2f / sides; + // Slight asymmetry on alternating vertices for natural crystal faceting. + float radialJitter = (i & 1) == 0 ? 1.0f : 0.86f; + float cos = Mathf.Cos(angle) * radialJitter; + float sin = Mathf.Sin(angle) * radialJitter; + bottomRing[i] = new Vector3(cos * baseRadius, baseY, sin * baseRadius); + shoulderRing[i] = new Vector3(cos * shoulderRadius, shoulderY, sin * shoulderRadius); + } + var tip = new Vector3(0f, tipY, 0f); + var bottomCenter = new Vector3(0f, baseY, 0f); + + var vertices = new List(sides * 12); + var normals = new List(sides * 12); + var triangles = new List(sides * 12); + + for (int i = 0; i < sides; i++) + { + int next = (i + 1) % sides; + // Side quad (two triangles): bottom[i], shoulder[i], shoulder[next], bottom[next] + AddFlatQuad(vertices, normals, triangles, bottomRing[i], shoulderRing[i], shoulderRing[next], bottomRing[next]); + // Apex triangle: shoulder[i], tip, shoulder[next] + AddFlatTriangle(vertices, normals, triangles, shoulderRing[i], tip, shoulderRing[next]); + // Base cap triangle: bottom[next], bottomCenter, bottom[i] + AddFlatTriangle(vertices, normals, triangles, bottomRing[next], bottomCenter, bottomRing[i]); + } + + _crystalShardMesh = new Mesh + { + name = "ProceduralAetheriumShard", + hideFlags = HideFlags.HideAndDontSave, + }; + _crystalShardMesh.SetVertices(vertices); + _crystalShardMesh.SetNormals(normals); + _crystalShardMesh.SetTriangles(triangles, 0); + _crystalShardMesh.RecalculateBounds(); + return _crystalShardMesh; + } + + private static void AddFlatTriangle( + List vertices, List normals, List triangles, + Vector3 a, Vector3 b, Vector3 c) + { + Vector3 normal = Vector3.Cross(b - a, c - a).normalized; + int start = vertices.Count; + vertices.Add(a); + vertices.Add(b); + vertices.Add(c); + normals.Add(normal); + normals.Add(normal); + normals.Add(normal); + triangles.Add(start); + triangles.Add(start + 1); + triangles.Add(start + 2); + } + + private static void AddFlatQuad( + List vertices, List normals, List triangles, + Vector3 a, Vector3 b, Vector3 c, Vector3 d) + { + Vector3 normal = Vector3.Cross(b - a, c - a).normalized; + int start = vertices.Count; + vertices.Add(a); + vertices.Add(b); + vertices.Add(c); + vertices.Add(d); + normals.Add(normal); + normals.Add(normal); + normals.Add(normal); + normals.Add(normal); + triangles.Add(start); + triangles.Add(start + 1); + triangles.Add(start + 2); + triangles.Add(start); + triangles.Add(start + 2); + triangles.Add(start + 3); + } + /// /// Applies one reserve stage: the first /// shards stand lit, the rest go dark — except on an exhausted field, @@ -461,8 +604,10 @@ private void ApplyFieldStage(GameObject[] shards, int stage, bool exhausted) private void RestoreShard(GameObject shard, int clusterIndex) { float height = ClusterHeights[clusterIndex]; - shard.transform.localPosition = new Vector3(ClusterOffsets[clusterIndex].x, height * 0.5f, ClusterOffsets[clusterIndex].y); - shard.transform.localScale = new Vector3(0.35f, height, 0.35f); + float width = ClusterWidths[clusterIndex]; + shard.transform.localPosition = new Vector3(ClusterOffsets[clusterIndex].x, height * 0.46f, ClusterOffsets[clusterIndex].y); + shard.transform.localRotation = Quaternion.Euler(ClusterRotations[clusterIndex]); + shard.transform.localScale = new Vector3(width, height, width); shard.GetComponent().SetPropertyBlock(null); shard.SetActive(true); } @@ -476,6 +621,7 @@ private void RestoreShard(GameObject shard, int clusterIndex) private void ApplyExhaustedStump(GameObject shard) { shard.transform.localPosition = new Vector3(ClusterOffsets[0].x, ExhaustedStumpHeight * 0.5f, ClusterOffsets[0].y); + shard.transform.localRotation = Quaternion.Euler(0f, ClusterRotations[0].y, 0f); shard.transform.localScale = new Vector3(0.45f, ExhaustedStumpHeight, 0.45f); if (_propertyBlock == null) _propertyBlock = new MaterialPropertyBlock(); @@ -483,6 +629,7 @@ private void ApplyExhaustedStump(GameObject shard) Color darkened = _crystalColor * 0.30f; darkened.a = 1f; FactionTint.ApplyToPropertyBlock(_propertyBlock, darkened); + _propertyBlock.SetColor("_EmissionColor", Color.black); shard.GetComponent().SetPropertyBlock(_propertyBlock); shard.SetActive(true); } diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index edd58eb6..a3bb61b2 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs @@ -116,6 +116,8 @@ public sealed class BuildMenuHud : MonoBehaviour private readonly StringBuilder _builder = new StringBuilder(64); private GUIStyle _buttonStyle; + private GUIStyle _badgeStyle; + private GUIStyle _hotkeyBadgeStyle; private GUIStyle _statusStyle; private GUIStyle _powerStatusStyle; private bool _hintDismissed; @@ -364,9 +366,57 @@ private void DrawBar() _input.EnterPlacementMode(def.DefinitionId); } GUI.enabled = wasEnabled; + + DrawButtonAccents(rect, role, def.DefinitionId, available); } } + private void DrawButtonAccents(Rect rect, UnitRole role, ushort defId, bool available) + { + Color prev = GUI.color; + bool armed = _input != null && _input.PlacementModeActive && _input.PlacementDefId == defId; + Color accent = armed + ? new Color(0.32f, 0.96f, 0.72f, 1f) + : HudChrome.RoleAccentColor(role); + if (!available) + { + accent = new Color(accent.r * 0.45f, accent.g * 0.45f, accent.b * 0.45f, 0.55f); + } + + // Top category accent bar inside the button frame. + GUI.color = accent; + GUI.DrawTexture(new Rect(rect.x + 2f, rect.y + 2f, rect.width - 4f, 3f), HudChrome.Pixel); + + // Role badge pill in top-left corner. + Rect badgeRect = new Rect(rect.x + 5f, rect.y + 6f, 28f, 13f); + GUI.color = new Color(accent.r * 0.25f, accent.g * 0.25f, accent.b * 0.25f, available ? 0.88f : 0.45f); + GUI.DrawTexture(badgeRect, HudChrome.Pixel); + GUI.color = available ? accent : new Color(0.65f, 0.70f, 0.76f, 0.55f); + GUI.Label(badgeRect, HudChrome.BuildingBadgeCode(role), _badgeStyle); + + // Hotkey pill in top-right corner. + string hotkey = HotkeyHint(role); + if (hotkey.Length > 0) + { + float hkWidth = Mathf.Min(42f, Mathf.Max(16f, hotkey.Length * 6.5f + 6f)); + Rect hkRect = new Rect(rect.xMax - hkWidth - 5f, rect.y + 6f, hkWidth, 13f); + GUI.color = new Color(0.03f, 0.05f, 0.08f, available ? 0.82f : 0.45f); + GUI.DrawTexture(hkRect, HudChrome.Pixel); + GUI.color = available + ? new Color(0.95f, 0.86f, 0.52f, 0.95f) + : new Color(0.55f, 0.58f, 0.62f, 0.50f); + GUI.Label(hkRect, hotkey, _hotkeyBadgeStyle); + } + + if (armed) + { + GUI.color = new Color(0.32f, 0.96f, 0.72f, 0.85f); + GUI.DrawTexture(new Rect(rect.x + 2f, rect.yMax - 4f, rect.width - 4f, 2f), HudChrome.Pixel); + } + + GUI.color = prev; + } + private static bool PrerequisiteMet( in SimBuildingDefinition def, byte slot, ConstructionSystem construction) { @@ -642,7 +692,37 @@ private void EnsureStyles() { if (_buttonStyle == null) { - _buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 11, wordWrap = false }; + _buttonStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 11, + fontStyle = FontStyle.Bold, + wordWrap = false, + alignment = TextAnchor.LowerCenter, + padding = new RectOffset(6, 6, 18, 5) + }; + HudChrome.ApplyButtonChrome(_buttonStyle); + } + if (_badgeStyle == null) + { + _badgeStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 8, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false, + padding = new RectOffset(0, 0, 0, 0) + }; + } + if (_hotkeyBadgeStyle == null) + { + _hotkeyBadgeStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 8, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = false, + padding = new RectOffset(0, 0, 0, 0) + }; } if (_statusStyle == null) { @@ -651,7 +731,8 @@ private void EnsureStyles() fontSize = 9, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleRight, - wordWrap = false + wordWrap = false, + normal = { textColor = new Color(0.86f, 0.92f, 0.98f, 1f) } }; } if (_powerStatusStyle == null) @@ -661,7 +742,8 @@ private void EnsureStyles() fontSize = 10, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, - wordWrap = false + wordWrap = false, + normal = { textColor = new Color(0.98f, 0.82f, 0.35f, 1f) } }; } } diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs index d546b14d..8531fc48 100644 --- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs @@ -648,6 +648,14 @@ private void OnGUI() GUILayout.BeginVertical(HudChrome.PanelStyle); GUILayout.Label(model.Title, _titleStyle, GUILayout.Height(TitleHeight)); + if (Event.current.type == EventType.Repaint) + { + Rect titleRect = GUILayoutUtility.GetLastRect(); + Color prev = GUI.color; + GUI.color = new Color(0.28f, 0.78f, 0.92f, 0.55f); + GUI.DrawTexture(new Rect(titleRect.x, titleRect.yMax - 2f, titleRect.width, 1.5f), HudChrome.Pixel); + GUI.color = prev; + } if (model.BuildingPowerText != null) { GUILayout.Label(model.BuildingPowerText, _rowStyle, GUILayout.Height(RowHeight)); @@ -669,6 +677,14 @@ private void OnGUI() AudioServiceLocator.Play2D(SoundEventId.UI_Click); _input.FilterSelectionToRole(row.Role); } + if (Event.current.type == EventType.Repaint) + { + Rect rowRect = GUILayoutUtility.GetLastRect(); + Color prev = GUI.color; + GUI.color = HudChrome.RoleAccentColor(row.Role); + GUI.DrawTexture(new Rect(rowRect.x + 2f, rowRect.y + 3f, 3f, rowRect.height - 6f), HudChrome.Pixel); + GUI.color = prev; + } } if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01); if (model.SiteStatusText != null) @@ -687,6 +703,17 @@ private void OnGUI() Dispatch(button, model); } GUI.enabled = wasEnabled; + + if (Event.current.type == EventType.Repaint) + { + Rect btnRect = GUILayoutUtility.GetLastRect(); + Color prev = GUI.color; + Color accent = ActionAccentColor(button.Action); + if (!button.Enabled) accent = new Color(accent.r * 0.45f, accent.g * 0.45f, accent.b * 0.45f, 0.45f); + GUI.color = accent; + GUI.DrawTexture(new Rect(btnRect.x + 2f, btnRect.y + 3f, 3f, btnRect.height - 6f), HudChrome.Pixel); + GUI.color = prev; + } } if (model.QueueHeader != null) @@ -818,17 +845,45 @@ private void DrawProgressBar(float fraction01) { Rect rect = GUILayoutUtility.GetRect(10f, ProgressHeight, GUILayout.ExpandWidth(true)); Color previousColor = GUI.color; - GUI.color = new Color(0f, 0f, 0f, 0.55f); + GUI.color = new Color(0.02f, 0.04f, 0.07f, 0.85f); GUI.DrawTexture(rect, HudChrome.Pixel); + GUI.color = new Color(0.24f, 0.42f, 0.56f, 0.55f); + GUI.DrawTexture(new Rect(rect.x, rect.y, rect.width, 1f), HudChrome.Pixel); + GUI.DrawTexture(new Rect(rect.x, rect.yMax - 1f, rect.width, 1f), HudChrome.Pixel); float fill = Mathf.Clamp01(fraction01); if (fill > 0f) { - GUI.color = new Color(0.35f, 0.9f, 0.45f, 0.9f); - GUI.DrawTexture(new Rect(rect.x, rect.y, rect.width * fill, rect.height), HudChrome.Pixel); + float fillWidth = Mathf.Max(2f, (rect.width - 2f) * fill); + GUI.color = new Color(0.22f, 0.86f, 0.62f, 0.92f); + GUI.DrawTexture(new Rect(rect.x + 1f, rect.y + 1f, fillWidth, rect.height - 2f), HudChrome.Pixel); + GUI.color = new Color(0.65f, 0.98f, 0.90f, 0.95f); + GUI.DrawTexture(new Rect(rect.x + fillWidth - 1f, rect.y + 1f, 2f, rect.height - 2f), HudChrome.Pixel); } GUI.color = previousColor; } + private static Color ActionAccentColor(CardAction action) + { + switch (action) + { + case CardAction.AttackPick: + return new Color(0.96f, 0.34f, 0.28f, 0.95f); + case CardAction.HarvestPick: + case CardAction.ReturnCargo: + case CardAction.QueueUnit: + return new Color(0.22f, 0.86f, 0.94f, 0.95f); + case CardAction.RepairPick: + case CardAction.RepairBuilding: + return new Color(0.98f, 0.76f, 0.22f, 0.95f); + case CardAction.Sell: + case CardAction.CancelConstruction: + case CardAction.CancelProduction: + return new Color(0.92f, 0.48f, 0.24f, 0.95f); + default: + return new Color(0.36f, 0.88f, 0.56f, 0.95f); + } + } + private void AddButton(CardModel model, string label, bool enabled, CardAction action, int payload = 0) { model.Buttons.Add(new CardButton { Label = label, Enabled = enabled, Action = action, Payload = payload }); @@ -871,19 +926,43 @@ private void EnsureStyles() { if (_titleStyle == null) { - _titleStyle = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, wordWrap = false }; + _titleStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + fontStyle = FontStyle.Bold, + wordWrap = false, + normal = { textColor = new Color(0.94f, 0.98f, 1.00f, 1f) } + }; } if (_buttonStyle == null) { - _buttonStyle = new GUIStyle(GUI.skin.button) { fontSize = 11, wordWrap = true }; + _buttonStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 11, + fontStyle = FontStyle.Bold, + wordWrap = true, + padding = new RectOffset(10, 6, 4, 4) + }; + HudChrome.ApplyButtonChrome(_buttonStyle); } if (_sectionStyle == null) { - _sectionStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, fontStyle = FontStyle.Bold, wordWrap = false }; + _sectionStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + fontStyle = FontStyle.Bold, + wordWrap = false, + normal = { textColor = new Color(0.56f, 0.84f, 0.96f, 1f) } + }; } if (_rowStyle == null) { - _rowStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = false }; + _rowStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + wordWrap = false, + normal = { textColor = new Color(0.88f, 0.92f, 0.96f, 1f) } + }; } if (_selectionRowStyle == null) { @@ -898,25 +977,45 @@ private void EnsureStyles() fontSize = 11, wordWrap = false, alignment = TextAnchor.MiddleLeft, + padding = new RectOffset(10, 6, 2, 2), margin = new RectOffset( _rowStyle.margin.left, _rowStyle.margin.right, _rowStyle.margin.top, _rowStyle.margin.bottom), }; + HudChrome.ApplyButtonChrome(_selectionRowStyle); } if (_siteStatusStyle == null) { // Wraps: the no-Builder warning is longer than the panel is // wide and must read in full, never clip (SiteStatusHeight // reserves the two lines it needs). - _siteStatusStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, fontStyle = FontStyle.Italic, wordWrap = true }; + _siteStatusStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + fontStyle = FontStyle.Italic, + wordWrap = true, + normal = { textColor = new Color(0.98f, 0.84f, 0.42f, 1f) } + }; } if (_cancelStyle == null) { - _cancelStyle = new GUIStyle(GUI.skin.button) { fontSize = 10 }; + _cancelStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 10, + fontStyle = FontStyle.Bold + }; + HudChrome.ApplyButtonChrome(_cancelStyle); + _cancelStyle.normal.textColor = new Color(1.0f, 0.55f, 0.50f, 1f); } if (_hintStyle == null) { - _hintStyle = new GUIStyle(GUI.skin.label) { fontSize = 10, fontStyle = FontStyle.Italic, wordWrap = false }; + _hintStyle = new GUIStyle(GUI.skin.label) + { + fontSize = 10, + fontStyle = FontStyle.Italic, + wordWrap = false, + normal = { textColor = new Color(0.68f, 0.78f, 0.88f, 0.92f) } + }; } } diff --git a/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs b/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs index 6356b6ed..8c753204 100644 --- a/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs @@ -46,12 +46,30 @@ private struct SiteVisual { public Vector3 Center; public bool Paused; + public float Progress01; } + private struct SiteRig + { + public GameObject Root; + public Renderer GroundRenderer; + public Transform GantryRing; + public Transform CoreBlock; + public Renderer[] ScaffoldRenderers; + } + + private static readonly Vector2[] CornerOffsets = + { + new Vector2(-1.32f, -1.32f), + new Vector2( 1.32f, -1.32f), + new Vector2( 1.32f, 1.32f), + new Vector2(-1.32f, 1.32f), + }; + private readonly List _sites = new List(8); - private readonly List _pool = new List(8); - private readonly List _renderers = new List(8); + private readonly List _pool = new List(8); private MaterialPropertyBlock _tintBlock; + private Material _scaffoldMaterial; private void Awake() { @@ -70,17 +88,38 @@ private void LateUpdate() for (int i = 0; i < _pool.Count; i++) { + SiteRig rig = _pool[i]; bool used = i < _sites.Count; - if (_pool[i].activeSelf != used) _pool[i].SetActive(used); + if (rig.Root.activeSelf != used) rig.Root.SetActive(used); if (!used) continue; - _pool[i].transform.position = _sites[i].Center; - Color color = _sites[i].Paused + SiteVisual site = _sites[i]; + rig.Root.transform.position = site.Center; + + float progress = Mathf.Clamp01(site.Progress01); + float gantryY = Mathf.Lerp(0.28f, 1.52f, progress); + rig.GantryRing.localPosition = new Vector3(0f, gantryY, 0f); + + float coreHeight = Mathf.Lerp(0.18f, 1.40f, progress); + rig.CoreBlock.localPosition = new Vector3(0f, 0.22f + coreHeight * 0.5f, 0f); + rig.CoreBlock.localScale = new Vector3(1.90f, coreHeight, 1.90f); + + Color color = site.Paused ? new Color(_pausedColor.r, _pausedColor.g, _pausedColor.b, pulse) : _buildingColor; _tintBlock.Clear(); FactionTint.ApplyToPropertyBlock(_tintBlock, color); - _renderers[i].SetPropertyBlock(_tintBlock); + rig.GroundRenderer.SetPropertyBlock(_tintBlock); + + Color scaffoldColor = site.Paused + ? new Color(_pausedColor.r, _pausedColor.g, _pausedColor.b, Mathf.Clamp01(pulse * 0.85f)) + : new Color(_buildingColor.r, _buildingColor.g, _buildingColor.b, 0.72f); + _tintBlock.Clear(); + FactionTint.ApplyToPropertyBlock(_tintBlock, scaffoldColor); + for (int r = 0; r < rig.ScaffoldRenderers.Length; r++) + { + rig.ScaffoldRenderers[r].SetPropertyBlock(_tintBlock); + } } } @@ -108,7 +147,7 @@ private void CollectSites() if (!unit.IsActive || unit.PlayerId != slot) continue; uint raw = UnitCommandStateView.ToRawEntityId(unit.Id); if (raw == 0) continue; - if (!construction.TryGetSite(raw, out _, out _, out uint assignedBuilderRaw)) continue; + if (!construction.TryGetSite(raw, out ushort siteDefId, out int siteProgressRaw, out uint assignedBuilderRaw)) continue; bool paused = true; if (assignedBuilderRaw != 0 @@ -121,12 +160,19 @@ private void CollectSites() originX, originY, SimDefinitions.BuildingFootprintCells); } + float progress01 = 0f; + if (SimDefinitions.TryGetBuilding(siteDefId, out SimBuildingDefinition def)) + { + progress01 = Mathf.Clamp01(ConstructionSiteStatus.Progress01(siteProgressRaw, def.BuildTicks)); + } + // Presentation boundary: SimFixed -> float happens here. _sites.Add(new SiteVisual { Center = new Vector3( unit.Transform.PositionX.ToFloat(), _markerHeight, unit.Transform.PositionY.ToFloat()), - Paused = paused + Paused = paused, + Progress01 = progress01 }); } } @@ -137,27 +183,100 @@ private static int GridCellOf(Nova.Core.SimFixed simCoordinate) return Mathf.Max(0, Mathf.FloorToInt(simCoordinate.ToFloat())); } - /// Grows the quad pool to ; surplus quads stay pooled (deactivated by LateUpdate). + private Material EnsureScaffoldMaterial() + { + if (_scaffoldMaterial != null) return _scaffoldMaterial; + Shader shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit"); + _scaffoldMaterial = new Material(shader) + { + hideFlags = HideFlags.HideAndDontSave, + }; + return _scaffoldMaterial; + } + + /// Grows the site-rig pool to ; surplus rigs stay pooled (deactivated by LateUpdate). private void EnsurePool(int count) { + Material scaffoldMat = EnsureScaffoldMaterial(); while (_pool.Count < count) { - GameObject quad = GroundMarkerVisuals.CreateFlatQuad($"SiteMarker_{_pool.Count}", transform); + var root = new GameObject($"SiteMarker_{_pool.Count}"); + root.transform.SetParent(transform, false); + + GameObject quad = GroundMarkerVisuals.CreateFlatQuad("GroundFrame", root.transform); + quad.transform.localPosition = Vector3.zero; quad.transform.localScale = new Vector3( SimDefinitions.BuildingFootprintCells, SimDefinitions.BuildingFootprintCells, 1f); - _pool.Add(quad); - _renderers.Add(quad.GetComponent()); + + var scaffoldRenderers = new List(9); + const float postHeight = 1.62f; + for (int c = 0; c < CornerOffsets.Length; c++) + { + GameObject post = GameObject.CreatePrimitive(PrimitiveType.Cube); + post.name = $"CornerPylon_{c}"; + Destroy(post.GetComponent()); + post.transform.SetParent(root.transform, false); + post.transform.localPosition = new Vector3(CornerOffsets[c].x, postHeight * 0.5f, CornerOffsets[c].y); + post.transform.localScale = new Vector3(0.11f, postHeight, 0.11f); + var r = post.GetComponent(); + r.sharedMaterial = scaffoldMat; + scaffoldRenderers.Add(r); + } + + var gantry = new GameObject("GantryRing"); + gantry.transform.SetParent(root.transform, false); + CreateGantryBeam(gantry.transform, "BeamN", new Vector3(0f, 0f, 1.32f), new Vector3(2.74f, 0.07f, 0.07f), scaffoldMat, scaffoldRenderers); + CreateGantryBeam(gantry.transform, "BeamS", new Vector3(0f, 0f, -1.32f), new Vector3(2.74f, 0.07f, 0.07f), scaffoldMat, scaffoldRenderers); + CreateGantryBeam(gantry.transform, "BeamE", new Vector3(1.32f, 0f, 0f), new Vector3(0.07f, 0.07f, 2.74f), scaffoldMat, scaffoldRenderers); + CreateGantryBeam(gantry.transform, "BeamW", new Vector3(-1.32f, 0f, 0f), new Vector3(0.07f, 0.07f, 2.74f), scaffoldMat, scaffoldRenderers); + + GameObject core = GameObject.CreatePrimitive(PrimitiveType.Cube); + core.name = "RisingCore"; + Destroy(core.GetComponent()); + core.transform.SetParent(root.transform, false); + var coreRenderer = core.GetComponent(); + coreRenderer.sharedMaterial = scaffoldMat; + scaffoldRenderers.Add(coreRenderer); + + _pool.Add(new SiteRig + { + Root = root, + GroundRenderer = quad.GetComponent(), + GantryRing = gantry.transform, + CoreBlock = core.transform, + ScaffoldRenderers = scaffoldRenderers.ToArray() + }); } } + private static void CreateGantryBeam( + Transform parent, string name, Vector3 localPos, Vector3 localScale, + Material material, List renderers) + { + GameObject beam = GameObject.CreatePrimitive(PrimitiveType.Cube); + beam.name = name; + Destroy(beam.GetComponent()); + beam.transform.SetParent(parent, false); + beam.transform.localPosition = localPos; + beam.transform.localScale = localScale; + var renderer = beam.GetComponent(); + renderer.sharedMaterial = material; + renderers.Add(renderer); + } + private void OnDestroy() { for (int i = 0; i < _pool.Count; i++) { - if (_pool[i] != null) Destroy(_pool[i]); + if (_pool[i].Root != null) Destroy(_pool[i].Root); } _pool.Clear(); - _renderers.Clear(); + if (_scaffoldMaterial != null) + { + Destroy(_scaffoldMaterial); + _scaffoldMaterial = null; + } GroundMarkerVisuals.DestroyShared(); } } diff --git a/Assets/_Project/Scripts/Presentation/UI/HealthBarHud.cs b/Assets/_Project/Scripts/Presentation/UI/HealthBarHud.cs index f5bc4a9b..e5938c8b 100644 --- a/Assets/_Project/Scripts/Presentation/UI/HealthBarHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/HealthBarHud.cs @@ -36,8 +36,8 @@ public sealed class HealthBarHud : MonoBehaviour [Tooltip("Whole HUD is scaled by this factor, matching the other cockpit panels.")] [SerializeField] private float _uiScale = 1.5f; [Tooltip("Bar width in scaled GUI pixels over a unit (buildings get 1.5x).")] - [SerializeField] private float _barWidth = 26f; - [SerializeField] private float _barHeight = 4f; + [SerializeField] private float _barWidth = 36f; + [SerializeField] private float _barHeight = 5f; [Tooltip("World-space height of the bar above the entity's ground position.")] [SerializeField] private float _unitHeightOffset = 1.6f; [SerializeField] private float _buildingHeightOffset = 3.4f; @@ -86,7 +86,8 @@ private void OnGUI() bool selected = Contains(_input.Selection.SelectedEntities, id); bool damaged = unit.MaxHealth > 0 && unit.CurrentHealth < unit.MaxHealth; - if (!selected && !damaged) continue; + bool hovered = _input.HoveredEntity.IsValid && _input.HoveredEntity == id; + if (!selected && !damaged && !hovered) continue; bool isBuilding = SimDefinitions.IsBuildingRole(unit.Role); var world = new Vector3( @@ -106,17 +107,38 @@ private void OnGUI() ? Mathf.Clamp01((float)unit.CurrentHealth / unit.MaxHealth) : 0f; - // Selected-and-undamaged entities get a full green bar (the - // marker confirms the selection), damaged ones a green-to-red - // ramp. Same ramp the health tint on the bodies uses. - GUI.color = new Color(0f, 0f, 0f, 0.7f); + // 1. Dark outer border for contrast against terrain + GUI.color = new Color(0.04f, 0.06f, 0.08f, 0.88f); + GUI.DrawTexture(new Rect(x - 1f, y - 1f, width + 2f, _barHeight + 2f), Pixel); + + // 2. Depleted health background + GUI.color = new Color(0.16f, 0.06f, 0.06f, 0.75f); GUI.DrawTexture(new Rect(x, y, width, _barHeight), Pixel); - GUI.color = fraction > 0.5f - ? new Color(0.25f, 0.85f, 0.3f, 0.95f) + + // 3. Health fill bar + Color fillColor = fraction > 0.5f + ? new Color(0.22f, 0.90f, 0.35f, 0.95f) : fraction > 0.25f - ? new Color(0.9f, 0.75f, 0.2f, 0.95f) - : new Color(0.85f, 0.25f, 0.2f, 0.95f); + ? new Color(1.0f, 0.78f, 0.18f, 0.95f) + : new Color(0.92f, 0.22f, 0.18f, 0.95f); + GUI.color = fillColor; GUI.DrawTexture(new Rect(x, y, width * fraction, _barHeight), Pixel); + + // 4. Subtle top gloss highlight + GUI.color = new Color(1f, 1f, 1f, 0.30f); + GUI.DrawTexture(new Rect(x, y, width * fraction, 1.5f), Pixel); + + // 5. Selection / hover indicator pip + if (selected) + { + GUI.color = new Color(0.35f, 1f, 0.45f, 0.80f); + GUI.DrawTexture(new Rect(x, y - 2f, width, 1f), Pixel); + } + else if (hovered) + { + GUI.color = new Color(0.25f, 0.85f, 1f, 0.80f); + GUI.DrawTexture(new Rect(x, y - 2f, width, 1f), Pixel); + } } GUI.color = previousColor; diff --git a/Assets/_Project/Scripts/Presentation/UI/HudChrome.cs b/Assets/_Project/Scripts/Presentation/UI/HudChrome.cs index bcaacf58..87705476 100644 --- a/Assets/_Project/Scripts/Presentation/UI/HudChrome.cs +++ b/Assets/_Project/Scripts/Presentation/UI/HudChrome.cs @@ -1,17 +1,17 @@ // Graybox throwaway. Legacy Input + OnGUI. Does NOT satisfy G2/G4 UI criteria // (see docs/production/MVPRecoveryPlan.md). Replaced when the new Input System and the real UI land. using UnityEngine; +using Nova.Simulation.State; namespace Nova.Presentation.UI { /// - /// The shared HUD chrome of the graybox cockpit: ONE generated panel - /// texture (dark translucent fill, subtle light border ring) plus a 1x1 - /// white pixel, so the status bar, the build bar, the command card and - /// the minimap all sit on the same restrained frame instead of raw debug - /// text — one consistent style, no per-panel experiments (this is not an - /// art pass). The F3 debug panel deliberately keeps its plain - /// GUI.skin.box: it is a debug view, not cockpit chrome. + /// The shared HUD chrome of the graybox cockpit: generated panel textures, + /// faction-aware C&C Generals ControlBarScheme colours (Alliance + /// High-Tech Steel/Cyan vs. Legion Industrial Bronze/Amber), tactical corner + /// brackets, button states and a 1x1 white pixel, so the status bar, the + /// build bar, the command card and the minimap all sit on one cohesive + /// military command frame. /// /// Same generation convention as : /// everything is built at runtime, marked HideAndDontSave and rebuilt @@ -25,17 +25,92 @@ internal static class HudChrome private const int PanelTextureSize = 32; private const int PanelBorderPixels = 2; - private static readonly Color PanelFill = new Color(0.05f, 0.06f, 0.08f, 0.82f); - private static readonly Color PanelEdge = new Color(0.55f, 0.62f, 0.72f, 0.50f); - private static readonly Color OpaquePanelFill = new Color(0.05f, 0.06f, 0.08f, 1f); + // Alliance scheme (High-Tech Steel & Tactical Cyan) + private static readonly Color AllianceFillBottom = new Color(0.04f, 0.06f, 0.09f, 0.92f); + private static readonly Color AllianceFillTop = new Color(0.08f, 0.12f, 0.17f, 0.94f); + private static readonly Color AllianceEdgeInner = new Color(0.32f, 0.54f, 0.72f, 0.65f); + private static readonly Color AllianceTopCrest = new Color(0.28f, 0.78f, 0.92f, 0.92f); + + // Legion scheme (Industrial Bronze & Amber-Rust) + private static readonly Color LegionFillBottom = new Color(0.08f, 0.05f, 0.04f, 0.92f); + private static readonly Color LegionFillTop = new Color(0.15f, 0.09f, 0.06f, 0.94f); + private static readonly Color LegionEdgeInner = new Color(0.68f, 0.38f, 0.20f, 0.68f); + private static readonly Color LegionTopCrest = new Color(0.95f, 0.48f, 0.20f, 0.92f); + + private static readonly Color PanelEdgeOuter = new Color(0.02f, 0.03f, 0.05f, 0.96f); + private static readonly Color OpaqueFillBottom = new Color(0.04f, 0.06f, 0.09f, 1f); + private static readonly Color OpaqueFillTop = new Color(0.08f, 0.11f, 0.16f, 1f); + + private static FactionId _activeFaction = FactionId.Alliance; + private static bool _schemeInitialized; private static Texture2D _pixel; private static Texture2D _panelTexture; private static Texture2D _opaquePanelTexture; + private static Texture2D _scanlineTexture; + private static Texture2D _btnNormalTex; + private static Texture2D _btnHoverTex; + private static Texture2D _btnActiveTex; + private static Texture2D _btnDisabledTex; private static GUIStyle _panelStyle; private static GUIStyle _opaquePanelStyle; - /// Shared 1x1 white texture for tinted solid fills (progress bars, minimap dots, viewport lines). + /// Currently active faction scheme driving cockpit chrome colours. + public static FactionId ActiveFaction => _activeFaction; + + /// Primary faction crest accent colour (Cyan for Alliance, Industrial Amber-Rust for Legion). + public static Color ActiveCrestColor => + _activeFaction == FactionId.Legion ? LegionTopCrest : AllianceTopCrest; + + /// Inner bevel border colour for the active faction's ControlBarScheme. + public static Color ActiveBorderColor => + _activeFaction == FactionId.Legion ? LegionEdgeInner : AllianceEdgeInner; + + /// Short tactical header label for the active faction's command bar. + public static string FactionCommandLabel(FactionId faction) + { + return faction == FactionId.Legion ? "LEGION // KOMMANDO" : "ALLIANZ // KOMMANDO"; + } + + /// + /// Switches the shared cockpit scheme to match the local player's + /// faction (inspired by C&C Generals ControlBarScheme). + /// Rebuilds the cached panel and button textures only when the faction + /// actually changes. + /// + public static void SetActiveFaction(FactionId faction) + { + if (_schemeInitialized && _activeFaction == faction && _panelTexture != null) return; + _activeFaction = faction; + _schemeInitialized = true; + + if (_panelTexture != null) + { + Object.Destroy(_panelTexture); + _panelTexture = null; + } + if (_btnNormalTex != null) + { + Object.Destroy(_btnNormalTex); + _btnNormalTex = null; + } + if (_btnHoverTex != null) + { + Object.Destroy(_btnHoverTex); + _btnHoverTex = null; + } + if (_btnActiveTex != null) + { + Object.Destroy(_btnActiveTex); + _btnActiveTex = null; + } + if (_panelStyle != null) + { + _panelStyle.normal.background = PanelTexture; + } + } + + /// Shared 1x1 white texture for tinted solid fills (progress bars, minimap dots, viewport lines, accent strips). public static Texture2D Pixel { get @@ -53,12 +128,46 @@ public static Texture2D Pixel } } - /// The chrome panel texture: dark translucent fill with a 2px lighter border ring. + /// Subtle CRT scanline texture for the Radar Dome and tactical military tickers. + public static Texture2D ScanlineTexture + { + get + { + if (_scanlineTexture == null) + { + _scanlineTexture = new Texture2D(4, 4, TextureFormat.RGBA32, mipChain: false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Repeat + }; + for (int y = 0; y < 4; y++) + { + Color c = (y % 2 == 0) + ? new Color(0f, 0f, 0f, 0.24f) + : new Color(1f, 1f, 1f, 0.03f); + for (int x = 0; x < 4; x++) _scanlineTexture.SetPixel(x, y, c); + } + _scanlineTexture.Apply(); + } + return _scanlineTexture; + } + } + + /// The chrome panel texture: dark tactical gradient fill with a crisp dual-layer border ring and faction top crest. public static Texture2D PanelTexture { get { - if (_panelTexture == null) _panelTexture = CreatePanelTexture(); + if (_panelTexture == null) + { + bool legion = _activeFaction == FactionId.Legion; + _panelTexture = CreatePanelTexture( + legion ? LegionFillBottom : AllianceFillBottom, + legion ? LegionFillTop : AllianceFillTop, + legion ? LegionEdgeInner : AllianceEdgeInner, + legion ? LegionTopCrest : AllianceTopCrest); + } return _panelTexture; } } @@ -81,6 +190,10 @@ public static GUIStyle PanelStyle padding = new RectOffset(6, 6, 5, 5) }; } + else if (_panelStyle.normal.background == null) + { + _panelStyle.normal.background = PanelTexture; + } return _panelStyle; } } @@ -97,7 +210,11 @@ public static GUIStyle OpaquePanelStyle { if (_opaquePanelStyle == null) { - if (_opaquePanelTexture == null) _opaquePanelTexture = CreatePanelTexture(OpaquePanelFill); + if (_opaquePanelTexture == null) + { + _opaquePanelTexture = CreatePanelTexture( + OpaqueFillBottom, OpaqueFillTop, AllianceEdgeInner, AllianceTopCrest); + } _opaquePanelStyle = new GUIStyle { normal = { background = _opaquePanelTexture }, @@ -121,6 +238,157 @@ public static void ApplyPanelChrome(GUIStyle style) style.padding = new RectOffset(8, 8, 4, 4); } + /// + /// Applies custom tactical sci-fi button textures and crisp text colors + /// to a button without altering its margins or padding. + /// + public static void ApplyButtonChrome(GUIStyle style) + { + EnsureButtonTextures(); + style.border = new RectOffset(2, 2, 2, 2); + style.normal.background = _btnNormalTex; + style.normal.textColor = new Color(0.92f, 0.96f, 1.00f, 1f); + style.hover.background = _btnHoverTex; + style.hover.textColor = Color.white; + style.active.background = _btnActiveTex; + style.active.textColor = _activeFaction == FactionId.Legion + ? new Color(1.00f, 0.78f, 0.42f, 1f) + : new Color(0.45f, 0.92f, 1.00f, 1f); + style.focused.background = _btnHoverTex; + style.focused.textColor = Color.white; + } + + /// + /// Draws C&C Generals-style L-shaped metallic corner brackets on a + /// HUD panel or button rect. + /// + public static void DrawTacticalCorners(Rect rect, Color color, float armLength = 6f, float thickness = 1.5f) + { + Color prev = GUI.color; + GUI.color = color; + Texture2D px = Pixel; + + // Top-left + GUI.DrawTexture(new Rect(rect.x, rect.y, armLength, thickness), px); + GUI.DrawTexture(new Rect(rect.x, rect.y, thickness, armLength), px); + + // Top-right + GUI.DrawTexture(new Rect(rect.xMax - armLength, rect.y, armLength, thickness), px); + GUI.DrawTexture(new Rect(rect.xMax - thickness, rect.y, thickness, armLength), px); + + // Bottom-left + GUI.DrawTexture(new Rect(rect.x, rect.yMax - thickness, armLength, thickness), px); + GUI.DrawTexture(new Rect(rect.x, rect.yMax - armLength, thickness, armLength), px); + + // Bottom-right + GUI.DrawTexture(new Rect(rect.xMax - armLength, rect.yMax - thickness, armLength, thickness), px); + GUI.DrawTexture(new Rect(rect.xMax - thickness, rect.yMax - armLength, thickness, armLength), px); + + GUI.color = prev; + } + + /// + /// Draws a C&C Generals build/production progress overlay across a + /// button rect: a rising translucent fill plus a bright leading laser + /// edge and bottom progress bar. + /// + public static void DrawCooldownOverlay(Rect rect, float progress01, Color accent) + { + float clamped = Mathf.Clamp01(progress01); + if (clamped <= 0f) return; + + Color prev = GUI.color; + Texture2D px = Pixel; + + // Rising translucent fill from bottom to top (completed portion) + float fillHeight = (rect.height - 4f) * clamped; + Rect fillRect = new Rect(rect.x + 2f, rect.yMax - 2f - fillHeight, rect.width - 4f, fillHeight); + GUI.color = new Color(accent.r, accent.g, accent.b, 0.18f); + GUI.DrawTexture(fillRect, px); + + // Leading horizontal sweep line + if (clamped < 0.995f) + { + GUI.color = new Color(accent.r, accent.g, accent.b, 0.85f); + GUI.DrawTexture(new Rect(rect.x + 2f, fillRect.y, rect.width - 4f, 1.5f), px); + } + + // Crisp bottom progress bar strip + float barWidth = (rect.width - 4f) * clamped; + GUI.color = new Color(0.02f, 0.04f, 0.07f, 0.85f); + GUI.DrawTexture(new Rect(rect.x + 2f, rect.yMax - 5f, rect.width - 4f, 3f), px); + GUI.color = new Color(accent.r, accent.g, accent.b, 0.95f); + GUI.DrawTexture(new Rect(rect.x + 2f, rect.yMax - 5f, barWidth, 3f), px); + + GUI.color = prev; + } + + /// Category accent color for a building or unit role in the HUD. + public static Color RoleAccentColor(UnitRole role) + { + switch (role) + { + case UnitRole.Refinery: + case UnitRole.Storage: + case UnitRole.Harvester: + return new Color(0.20f, 0.88f, 0.92f, 0.95f); // Aetherium Cyan + case UnitRole.Power: + case UnitRole.Builder: + return new Color(0.98f, 0.76f, 0.22f, 0.95f); // Industrial Gold + case UnitRole.Barracks: + case UnitRole.VehicleFactory: + case UnitRole.BasicInfantry: + case UnitRole.AntiArmorInfantry: + case UnitRole.ScoutVehicle: + case UnitRole.LightTank: + case UnitRole.BattleTank: + case UnitRole.Artillery: + return new Color(0.95f, 0.44f, 0.26f, 0.95f); // Combat Coral/Orange + case UnitRole.ResearchLab: + case UnitRole.Radar: + return new Color(0.56f, 0.68f, 0.98f, 0.95f); // Tech Azure + case UnitRole.DefensePlatform: + case UnitRole.HQ: + default: + return new Color(0.36f, 0.88f, 0.56f, 0.95f); // Command Emerald + } + } + + /// Short 3-letter tactical code for a building role on the build bar. + public static string BuildingBadgeCode(UnitRole role) + { + switch (role) + { + case UnitRole.HQ: return "HQ"; + case UnitRole.Refinery: return "RAF"; + case UnitRole.Power: return "NRG"; + case UnitRole.Storage: return "DEP"; + case UnitRole.Barracks: return "KAS"; + case UnitRole.VehicleFactory: return "FAB"; + case UnitRole.ResearchLab: return "LAB"; + case UnitRole.Radar: return "RAD"; + case UnitRole.DefensePlatform: return "DEF"; + default: return "BAU"; + } + } + + /// Short 3-letter tactical code for a mobile unit role on the command card. + public static string UnitBadgeCode(UnitRole role) + { + switch (role) + { + case UnitRole.Builder: return "PIO"; + case UnitRole.Harvester: return "ern".ToUpperInvariant(); + case UnitRole.BasicInfantry: return "INF"; + case UnitRole.AntiArmorInfantry: return "PAK"; + case UnitRole.ScoutVehicle: return "SPÄ"; + case UnitRole.LightTank: return "LPZ"; + case UnitRole.BattleTank: return "KPZ"; + case UnitRole.Artillery: return "ART"; + default: return "EIN"; + } + } + /// Destroys the shared textures/style; the next access lazily recreates them. public static void DestroyShared() { @@ -139,16 +407,85 @@ public static void DestroyShared() Object.Destroy(_opaquePanelTexture); _opaquePanelTexture = null; } + if (_scanlineTexture != null) + { + Object.Destroy(_scanlineTexture); + _scanlineTexture = null; + } + if (_btnNormalTex != null) + { + Object.Destroy(_btnNormalTex); + _btnNormalTex = null; + } + if (_btnHoverTex != null) + { + Object.Destroy(_btnHoverTex); + _btnHoverTex = null; + } + if (_btnActiveTex != null) + { + Object.Destroy(_btnActiveTex); + _btnActiveTex = null; + } + if (_btnDisabledTex != null) + { + Object.Destroy(_btnDisabledTex); + _btnDisabledTex = null; + } _panelStyle = null; _opaquePanelStyle = null; + _schemeInitialized = false; } - private static Texture2D CreatePanelTexture() + private static void EnsureButtonTextures() { - return CreatePanelTexture(PanelFill); + bool legion = _activeFaction == FactionId.Legion; + if (_btnNormalTex == null) + { + _btnNormalTex = legion + ? CreateButtonTexture( + new Color(0.14f, 0.09f, 0.07f, 0.95f), + new Color(0.22f, 0.14f, 0.10f, 0.95f), + new Color(0.54f, 0.32f, 0.18f, 0.75f)) + : CreateButtonTexture( + new Color(0.09f, 0.13f, 0.18f, 0.95f), + new Color(0.14f, 0.19f, 0.26f, 0.95f), + new Color(0.28f, 0.42f, 0.56f, 0.72f)); + } + if (_btnHoverTex == null) + { + _btnHoverTex = legion + ? CreateButtonTexture( + new Color(0.24f, 0.15f, 0.10f, 0.98f), + new Color(0.34f, 0.21f, 0.13f, 0.98f), + new Color(0.95f, 0.56f, 0.24f, 0.95f)) + : CreateButtonTexture( + new Color(0.14f, 0.22f, 0.31f, 0.98f), + new Color(0.20f, 0.32f, 0.44f, 0.98f), + new Color(0.35f, 0.82f, 0.96f, 0.95f)); + } + if (_btnActiveTex == null) + { + _btnActiveTex = legion + ? CreateButtonTexture( + new Color(0.36f, 0.20f, 0.12f, 1.00f), + new Color(0.24f, 0.13f, 0.08f, 1.00f), + new Color(1.00f, 0.68f, 0.32f, 1.00f)) + : CreateButtonTexture( + new Color(0.18f, 0.34f, 0.46f, 1.00f), + new Color(0.12f, 0.24f, 0.34f, 1.00f), + new Color(0.48f, 0.92f, 1.00f, 1.00f)); + } + if (_btnDisabledTex == null) + { + _btnDisabledTex = CreateButtonTexture( + new Color(0.06f, 0.08f, 0.11f, 0.75f), + new Color(0.08f, 0.10f, 0.14f, 0.75f), + new Color(0.20f, 0.25f, 0.32f, 0.40f)); + } } - private static Texture2D CreatePanelTexture(Color fill) + private static Texture2D CreatePanelTexture(Color fillBottom, Color fillTop, Color edgeInner, Color topCrest) { var texture = new Texture2D(PanelTextureSize, PanelTextureSize, TextureFormat.RGBA32, mipChain: false) { @@ -158,11 +495,59 @@ private static Texture2D CreatePanelTexture(Color fill) }; for (int y = 0; y < PanelTextureSize; y++) { + float v = y / (float)(PanelTextureSize - 1); + Color rowFill = Color.Lerp(fillBottom, fillTop, v); + if (y % 2 == 0) + { + rowFill = new Color(rowFill.r * 0.94f, rowFill.g * 0.94f, rowFill.b * 0.94f, rowFill.a); + } for (int x = 0; x < PanelTextureSize; x++) { - bool isBorder = x < PanelBorderPixels || y < PanelBorderPixels - || x >= PanelTextureSize - PanelBorderPixels || y >= PanelTextureSize - PanelBorderPixels; - texture.SetPixel(x, y, isBorder ? PanelEdge : fill); + bool isOuter = x == 0 || y == 0 || x == PanelTextureSize - 1 || y == PanelTextureSize - 1; + bool isInner = x == 1 || y == 1 || x == PanelTextureSize - 2 || y == PanelTextureSize - 2; + Color pixel; + if (isOuter) + { + pixel = PanelEdgeOuter; + } + else if (y == PanelTextureSize - 2) + { + pixel = topCrest; + } + else if (isInner) + { + pixel = edgeInner; + } + else + { + pixel = rowFill; + } + texture.SetPixel(x, y, pixel); + } + } + texture.Apply(); + return texture; + } + + private static Texture2D CreateButtonTexture(Color fillBottom, Color fillTop, Color borderColor) + { + const int size = 16; + var texture = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Bilinear, + wrapMode = TextureWrapMode.Clamp + }; + Color outer = new Color(0.02f, 0.03f, 0.05f, 0.95f); + for (int y = 0; y < size; y++) + { + float v = y / (float)(size - 1); + Color fill = Color.Lerp(fillBottom, fillTop, v); + for (int x = 0; x < size; x++) + { + bool isOuter = x == 0 || y == 0 || x == size - 1 || y == size - 1; + bool isInner = x == 1 || y == 1 || x == size - 2 || y == size - 2; + texture.SetPixel(x, y, isOuter ? outer : (isInner ? borderColor : fill)); } } texture.Apply(); diff --git a/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs b/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs index f290a695..b9984be4 100644 --- a/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs +++ b/Assets/_Project/Scripts/Presentation/UI/MainMenuController.cs @@ -4,6 +4,7 @@ using UnityEngine.UIElements; using Nova.Gameplay.Audio; using Nova.Gameplay.Match; +using Nova.Simulation.State; namespace Nova.Presentation.UI { @@ -126,6 +127,9 @@ public sealed partial class MainMenuController : MonoBehaviour private Button _networkCancel; private Label _networkStatus; private UnitViewManager _views; + private FactionId _skirmishFaction = FactionId.Alliance; + private Button _allianceFactionButton; + private Button _legionFactionButton; /// Deduplicated width x height modes, biggest first; index-parallel to the resolution dropdown. private readonly List _resolutions = new List(16); @@ -342,6 +346,7 @@ private void BuildTitle(VisualElement parent) private void BuildMainButtons(VisualElement parent) { + BuildSkirmishFactionRow(parent); parent.Add(MakeButton("Neues Spiel", StartMatch)); parent.Add(MakeButton("Netzpartie", () => ShowLobbyPanel(true))); @@ -368,6 +373,86 @@ private void BuildMainButtons(VisualElement parent) parent.Add(MakeButton("Beenden", Quit)); } + private void BuildSkirmishFactionRow(VisualElement parent) + { + var row = new VisualElement { name = "skirmish-faction-row" }; + row.style.flexDirection = FlexDirection.Row; + row.style.alignItems = Align.Center; + row.style.width = _buttonWidth; + row.style.marginBottom = 8f; + + var label = new Label("Fraktion:") + { + name = "skirmish-faction-label", + pickingMode = PickingMode.Ignore, + }; + ApplyFont(label, _bodyFont); + label.style.fontSize = 18; + label.style.color = _bodyColor; + label.style.marginRight = 10f; + row.Add(label); + + _allianceFactionButton = MakeFactionChip("Allianz", "skirmish-faction-alliance", () => SetSkirmishFaction(FactionId.Alliance)); + _allianceFactionButton.style.marginRight = 6f; + row.Add(_allianceFactionButton); + + _legionFactionButton = MakeFactionChip("Legion", "skirmish-faction-legion", () => SetSkirmishFaction(FactionId.Legion)); + row.Add(_legionFactionButton); + + parent.Add(row); + RefreshSkirmishFactionChips(); + } + + private Button MakeFactionChip(string text, string elementName, Action onClick) + { + var chip = new Button(onClick) + { + name = elementName, + text = text, + }; + ApplyFont(chip, _titleFont); + chip.style.flexGrow = 1f; + chip.style.height = 38f; + chip.style.fontSize = 19; + chip.style.letterSpacing = 1.5f; + chip.style.unityTextAlign = TextAnchor.MiddleCenter; + chip.style.marginLeft = 0f; + chip.style.marginTop = 0f; + chip.style.marginBottom = 0f; + return chip; + } + + private void SetSkirmishFaction(FactionId faction) + { + _skirmishFaction = faction == FactionId.Legion ? FactionId.Legion : FactionId.Alliance; + AudioServiceLocator.Play2D(SoundEventId.UI_Click); + RefreshSkirmishFactionChips(); + } + + private void RefreshSkirmishFactionChips() + { + bool allianceSelected = _skirmishFaction != FactionId.Legion; + if (_allianceFactionButton != null) + { + _allianceFactionButton.style.backgroundColor = allianceSelected + ? new Color(0.14f, 0.25f, 0.34f, 0.95f) + : _buttonFill; + _allianceFactionButton.style.color = allianceSelected ? _titleColor : _disabledTextColor; + SetBorder(_allianceFactionButton, allianceSelected ? 2f : 1f, + allianceSelected ? _accentColor : _panelEdge, 3f); + } + if (_legionFactionButton != null) + { + Color legionAccent = new Color(0.92f, 0.42f, 0.24f, 1f); + _legionFactionButton.style.backgroundColor = !allianceSelected + ? new Color(0.32f, 0.14f, 0.10f, 0.95f) + : _buttonFill; + _legionFactionButton.style.color = !allianceSelected ? _titleColor : _disabledTextColor; + SetBorder(_legionFactionButton, !allianceSelected ? 2f : 1f, + !allianceSelected ? legionAccent : _panelEdge, 3f); + } + } + private void BuildNetworkJoin(VisualElement parent) { var header = new Label("Netzpartie"); @@ -711,6 +796,7 @@ private void StartMatch() } IsMenuVisible = false; + _bootstrap.LocalSkirmishFaction = _skirmishFaction; // Order matters: match first, rig second. RtsCameraController // resets the minimap channel in its OnEnable, and that reset // belongs to the fresh match rather than to the empty scene. @@ -718,6 +804,7 @@ private void StartMatch() // stacked (RestartMatch rebuilds the kernel and every system). if (_bootstrap.IsMatchReady) { + _views?.ResetViews(); _bootstrap.RestartMatch(); } else diff --git a/Assets/_Project/Scripts/Presentation/UI/MatchFrameHud.cs b/Assets/_Project/Scripts/Presentation/UI/MatchFrameHud.cs index ea4afbc3..c19255a5 100644 --- a/Assets/_Project/Scripts/Presentation/UI/MatchFrameHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/MatchFrameHud.cs @@ -65,6 +65,7 @@ public sealed class MatchFrameHud : MonoBehaviour private VictorySystem _panelVictory; private string _panelNetworkReason; private int _panelStalledSlot; + private bool _resultAudioPlayed; /// /// True while one of this component's modal panels (result or @@ -109,6 +110,11 @@ private bool ComputeModalState() ? _bootstrap.NetworkStalledOnSlot : -1; + if (_panelVictory == null || !_panelVictory.IsDecided) + { + _resultAudioPlayed = false; + } + return (_panelVictory != null && _panelVictory.IsDecided) || !string.IsNullOrEmpty(_panelNetworkReason) || _panelStalledSlot >= 0; @@ -157,6 +163,19 @@ private void DrawResultPanel(VictorySystem victory) { byte localSlot = _runner.Session != null ? _runner.Session.LocalSlot : (byte)0; + if (!_resultAudioPlayed) + { + _resultAudioPlayed = true; + if (victory.Outcome == MatchOutcome.VictoryElimination && victory.WinnerSlot == localSlot) + { + AudioServiceLocator.Play2D(SoundEventId.PRD_UnitReady); + } + else + { + AudioServiceLocator.Play2D(SoundEventId.DTH_Building); + } + } + string headline; switch (victory.Outcome) { @@ -204,6 +223,7 @@ private void DrawResultPanel(VictorySystem victory) /// private void RestartRound() { + _resultAudioPlayed = false; _bootstrap.RestartMatch(); if (_views != null) _views.ResetViews(); MinimapCameraLink.RequestStartFocusReset(); @@ -211,6 +231,7 @@ private void RestartRound() private void ReturnToMenu() { + _resultAudioPlayed = false; if (_menu != null) { _menu.ReturnToMenu(); diff --git a/Assets/_Project/Scripts/Presentation/UI/PlacementGhostView.cs b/Assets/_Project/Scripts/Presentation/UI/PlacementGhostView.cs index 5681f07d..4b33727a 100644 --- a/Assets/_Project/Scripts/Presentation/UI/PlacementGhostView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/PlacementGhostView.cs @@ -27,7 +27,10 @@ public sealed class PlacementGhostView : MonoBehaviour [SerializeField] private Color _invalidColor = new Color(1f, 0.3f, 0.25f, 1f); private GameObject _ghost; - private Renderer _renderer; + private Renderer _groundRenderer; + private Renderer[] _hologramRenderers; + private Renderer _coreRenderer; + private Material _hologramMaterial; private MaterialPropertyBlock _tintBlock; private bool _tintedValid; private bool _hasTint; @@ -52,7 +55,7 @@ private void LateUpdate() EnsureGhost(); if (!_ghost.activeSelf) _ghost.SetActive(true); - // The footprint spans exactly its 3x3 cells; the quad centres on it. + // The footprint spans exactly its 3x3 cells; the ghost centres on it. const float half = SimDefinitions.BuildingFootprintCells * 0.5f; _ghost.transform.position = new Vector3(originX + half, _ghostHeight, originY + half); @@ -63,25 +66,84 @@ private void LateUpdate() // hovering costs no SetPropertyBlock at all. _hasTint = true; _tintedValid = valid; + Color baseTint = valid ? _validColor : _invalidColor; + + _tintBlock.Clear(); + FactionTint.ApplyToPropertyBlock(_tintBlock, baseTint); + _groundRenderer.SetPropertyBlock(_tintBlock); + + Color beamColor = new Color(baseTint.r, baseTint.g, baseTint.b, 0.78f); + _tintBlock.Clear(); + FactionTint.ApplyToPropertyBlock(_tintBlock, beamColor); + for (int i = 0; i < _hologramRenderers.Length; i++) + { + _hologramRenderers[i].SetPropertyBlock(_tintBlock); + } + + Color coreColor = new Color(baseTint.r, baseTint.g, baseTint.b, 0.24f); _tintBlock.Clear(); - FactionTint.ApplyToPropertyBlock(_tintBlock, valid ? _validColor : _invalidColor); - _renderer.SetPropertyBlock(_tintBlock); + FactionTint.ApplyToPropertyBlock(_tintBlock, coreColor); + _coreRenderer.SetPropertyBlock(_tintBlock); } } private void EnsureGhost() { if (_ghost != null) return; - _ghost = GroundMarkerVisuals.CreateFlatQuad("PlacementGhost", transform); - _renderer = _ghost.GetComponent(); + _ghost = new GameObject("PlacementGhost"); + _ghost.transform.SetParent(transform, false); + + GameObject groundQuad = GroundMarkerVisuals.CreateFlatQuad("GhostFootprint", _ghost.transform); + groundQuad.transform.localPosition = Vector3.zero; const int cells = SimDefinitions.BuildingFootprintCells; - _ghost.transform.localScale = new Vector3(cells, cells, 1f); + groundQuad.transform.localScale = new Vector3(cells, cells, 1f); + _groundRenderer = groundQuad.GetComponent(); + + Shader shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit"); + _hologramMaterial = new Material(shader) + { + hideFlags = HideFlags.HideAndDontSave, + }; + + const float e = 1.38f; + const float h = 1.55f; + _hologramRenderers = new Renderer[8]; + _hologramRenderers[0] = CreateHoloStrut(_ghost.transform, "PostSW", new Vector3(-e, h * 0.5f, -e), new Vector3(0.09f, h, 0.09f), _hologramMaterial); + _hologramRenderers[1] = CreateHoloStrut(_ghost.transform, "PostSE", new Vector3( e, h * 0.5f, -e), new Vector3(0.09f, h, 0.09f), _hologramMaterial); + _hologramRenderers[2] = CreateHoloStrut(_ghost.transform, "PostNE", new Vector3( e, h * 0.5f, e), new Vector3(0.09f, h, 0.09f), _hologramMaterial); + _hologramRenderers[3] = CreateHoloStrut(_ghost.transform, "PostNW", new Vector3(-e, h * 0.5f, e), new Vector3(0.09f, h, 0.09f), _hologramMaterial); + _hologramRenderers[4] = CreateHoloStrut(_ghost.transform, "TopN", new Vector3(0f, h, e), new Vector3(e * 2f + 0.09f, 0.07f, 0.07f), _hologramMaterial); + _hologramRenderers[5] = CreateHoloStrut(_ghost.transform, "TopS", new Vector3(0f, h, -e), new Vector3(e * 2f + 0.09f, 0.07f, 0.07f), _hologramMaterial); + _hologramRenderers[6] = CreateHoloStrut(_ghost.transform, "TopE", new Vector3( e, h, 0f), new Vector3(0.07f, 0.07f, e * 2f + 0.09f), _hologramMaterial); + _hologramRenderers[7] = CreateHoloStrut(_ghost.transform, "TopW", new Vector3(-e, h, 0f), new Vector3(0.07f, 0.07f, e * 2f + 0.09f), _hologramMaterial); + + _coreRenderer = CreateHoloStrut(_ghost.transform, "HoloVolume", new Vector3(0f, 0.65f, 0f), new Vector3(2.25f, 1.20f, 2.25f), _hologramMaterial); _tintBlock = new MaterialPropertyBlock(); } + private static Renderer CreateHoloStrut( + Transform parent, string name, Vector3 localPos, Vector3 localScale, Material material) + { + GameObject strut = GameObject.CreatePrimitive(PrimitiveType.Cube); + strut.name = name; + Destroy(strut.GetComponent()); + strut.transform.SetParent(parent, false); + strut.transform.localPosition = localPos; + strut.transform.localScale = localScale; + var renderer = strut.GetComponent(); + renderer.sharedMaterial = material; + return renderer; + } + private void OnDestroy() { if (_ghost != null) Destroy(_ghost); + if (_hologramMaterial != null) + { + Destroy(_hologramMaterial); + _hologramMaterial = null; + } GroundMarkerVisuals.DestroyShared(); } } diff --git a/Assets/_Project/Scripts/Presentation/UI/ResourceBarHud.cs b/Assets/_Project/Scripts/Presentation/UI/ResourceBarHud.cs index 3f8d3814..b0b462b0 100644 --- a/Assets/_Project/Scripts/Presentation/UI/ResourceBarHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/ResourceBarHud.cs @@ -1,5 +1,6 @@ using UnityEngine; using Nova.Gameplay; +using Nova.Gameplay.Audio; using Nova.Gameplay.Match; using Nova.Simulation.Economy; @@ -92,6 +93,7 @@ public sealed class ResourceBarHud : MonoBehaviour // CapacityFor re-sweeps the entity store on every call. private ResourceBarModel _model; private int _modelFrame = -1; + private bool _wasLowPower; private void Awake() { @@ -132,6 +134,14 @@ private ResourceBarModel ModelForCurrentFrame(EconomySystem economy) economy.CapacityFor(slot), playerEconomy.PowerProvided, playerEconomy.PowerRequired); + + bool nowLowPower = _model.IsLowPower; + if (nowLowPower && !_wasLowPower) + { + AudioServiceLocator.Play2D(SoundEventId.UI_Deny); + } + _wasLowPower = nowLowPower; + return _model; } @@ -143,8 +153,8 @@ private ResourceBarModel ModelForCurrentFrame(EconomySystem economy) /// private void DrawBar(in ResourceBarModel model, float scale) { - _aetheriumContent.text = model.AetheriumText; - _powerContent.text = ResourceBarPresenter.SegmentSeparator + model.PowerText; + _aetheriumContent.text = "◆ " + model.AetheriumText; + _powerContent.text = ResourceBarPresenter.SegmentSeparator + "⚡ " + model.PowerText; GUIStyle powerStyle = model.IsLowPower ? _alertValueStyle : _valueStyle; _warningContent.text = model.WarningText != null ? ResourceBarPresenter.SegmentSeparator + model.WarningText @@ -159,6 +169,13 @@ private void DrawBar(in ResourceBarModel model, float scale) HudLayout.GuiWidth(scale), _topOffset, contentWidth, _barHeight, _margin)); GUI.Box(zone, GUIContent.none, HudChrome.PanelStyle); + Color prev = GUI.color; + GUI.color = model.IsCritical + ? _criticalColor + : new Color(0.22f, 0.86f, 0.94f, 0.90f); + GUI.DrawTexture(new Rect(zone.x + 2f, zone.y + 3f, 3f, zone.height - 6f), HudChrome.Pixel); + GUI.color = prev; + float x = zone.x + ContentInset; GUI.Label(new Rect(x, zone.y, aetheriumWidth, zone.height), _aetheriumContent, _valueStyle); x += aetheriumWidth; @@ -183,9 +200,11 @@ private void EnsureStyles() _valueStyle = new GUIStyle(GUI.skin.label) { fontSize = _fontSize, + fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft, wordWrap = false, padding = new RectOffset(0, 0, 0, 0), + normal = { textColor = new Color(0.78f, 0.96f, 1.00f, 1f) } }; } if (_alertValueStyle == null) diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs b/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs new file mode 100644 index 00000000..9c06c388 --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs @@ -0,0 +1,319 @@ +using UnityEngine; + +namespace Nova.Presentation.UI +{ + public enum RtsCursorKind : byte + { + Default, + Move, + Attack, + Harvest, + Repair, + } + + /// + /// Procedural tactical RTS cursor manager. Generates crisp, high-contrast + /// 32x32 software/hardware cursor textures at runtime without external asset + /// dependencies, providing immediate context feedback (Attack over enemy, + /// Harvest over Aetherium, Repair over damaged structure, Move on ground). + /// + public static class RtsCursorController + { + private const int Size = 32; + + private static Texture2D _cursorDefault; + private static Texture2D _cursorMove; + private static Texture2D _cursorAttack; + private static Texture2D _cursorHarvest; + private static Texture2D _cursorRepair; + private static RtsCursorKind _activeKind = (RtsCursorKind)255; + + public static void SetCursor(RtsCursorKind kind) + { + if (_activeKind == kind) return; + _activeKind = kind; + EnsureTextures(); + + switch (kind) + { + case RtsCursorKind.Attack: + Cursor.SetCursor(_cursorAttack, new Vector2(16f, 16f), CursorMode.Auto); + break; + case RtsCursorKind.Harvest: + Cursor.SetCursor(_cursorHarvest, new Vector2(16f, 16f), CursorMode.Auto); + break; + case RtsCursorKind.Repair: + Cursor.SetCursor(_cursorRepair, new Vector2(8f, 8f), CursorMode.Auto); + break; + case RtsCursorKind.Move: + Cursor.SetCursor(_cursorMove, new Vector2(16f, 16f), CursorMode.Auto); + break; + default: + Cursor.SetCursor(_cursorDefault, new Vector2(1f, 1f), CursorMode.Auto); + break; + } + } + + public static void ResetToDefault() + { + SetCursor(RtsCursorKind.Default); + } + + private static void EnsureTextures() + { + if (_cursorDefault != null) return; + + _cursorDefault = CreateDefaultTexture(); + _cursorAttack = CreateAttackTexture(); + _cursorHarvest = CreateHarvestTexture(); + _cursorRepair = CreateRepairTexture(); + _cursorMove = CreateMoveTexture(); + } + + private static Texture2D CreateDefaultTexture() + { + var tex = NewTexture(); + Color border = new Color(0.05f, 0.08f, 0.12f, 1f); + Color fill = new Color(0.88f, 0.92f, 0.98f, 1f); + Color accent = new Color(0.18f, 0.82f, 0.98f, 1f); + + // Sleek tactical arrowhead pointing towards (1, 1) top-left (y inverted in tex coords) + for (int x = 1; x < 20; x++) + { + for (int y = 1; y < 20; y++) + { + int invY = Size - 1 - y; + if (x + y <= 21 && (x <= y * 0.7f + 1 || y <= x * 0.7f + 1 || (x <= 13 && y <= 13))) + { + bool isBorder = x == 1 || y == 1 || x + y == 21 || (x == 13 && y >= 7) || (y == 13 && x >= 7); + bool isAccent = (x == 2 || y == 2) && !isBorder; + tex.SetPixel(x, invY, isBorder ? border : isAccent ? accent : fill); + } + } + } + + // Stem + for (int i = 0; i < 7; i++) + { + int px = 8 + i; + int py = 8 + i; + int invY = Size - 1 - py; + tex.SetPixel(px, invY, border); + tex.SetPixel(px + 1, invY, fill); + tex.SetPixel(px + 2, invY, border); + } + + tex.Apply(); + return tex; + } + + private static Texture2D CreateAttackTexture() + { + var tex = NewTexture(); + Color red = new Color(1f, 0.18f, 0.15f, 1f); + Color white = new Color(1f, 0.95f, 0.95f, 1f); + Color shadow = new Color(0.08f, 0.02f, 0.02f, 0.85f); + + int cx = 16; + int cy = 16; + + // Center red dot + SetPixelWithShadow(tex, cx, cy, white, shadow); + SetPixelWithShadow(tex, cx - 1, cy, red, shadow); + SetPixelWithShadow(tex, cx + 1, cy, red, shadow); + SetPixelWithShadow(tex, cx, cy - 1, red, shadow); + SetPixelWithShadow(tex, cx, cy + 1, red, shadow); + + // 4 reticle chevrons / crosshair notches + for (int r = 5; r <= 11; r++) + { + SetPixelWithShadow(tex, cx + r, cy, red, shadow); + SetPixelWithShadow(tex, cx - r, cy, red, shadow); + SetPixelWithShadow(tex, cx, cy + r, red, shadow); + SetPixelWithShadow(tex, cx, cy - r, red, shadow); + } + + // Outer corner brackets + int cornerDist = 10; + int cornerArm = 3; + for (int a = 0; a <= cornerArm; a++) + { + // Top-left + SetPixelWithShadow(tex, cx - cornerDist + a, cy - cornerDist, red, shadow); + SetPixelWithShadow(tex, cx - cornerDist, cy - cornerDist + a, red, shadow); + // Top-right + SetPixelWithShadow(tex, cx + cornerDist - a, cy - cornerDist, red, shadow); + SetPixelWithShadow(tex, cx + cornerDist, cy - cornerDist + a, red, shadow); + // Bottom-left + SetPixelWithShadow(tex, cx - cornerDist + a, cy + cornerDist, red, shadow); + SetPixelWithShadow(tex, cx - cornerDist, cy + cornerDist - a, red, shadow); + // Bottom-right + SetPixelWithShadow(tex, cx + cornerDist - a, cy + cornerDist, red, shadow); + SetPixelWithShadow(tex, cx + cornerDist, cy + cornerDist - a, red, shadow); + } + + tex.Apply(); + return tex; + } + + private static Texture2D CreateHarvestTexture() + { + var tex = NewTexture(); + Color cyan = new Color(0.18f, 0.92f, 1f, 1f); + Color darkCyan = new Color(0.06f, 0.55f, 0.72f, 1f); + Color highlight = new Color(0.85f, 0.98f, 1f, 1f); + Color border = new Color(0.02f, 0.12f, 0.18f, 0.9f); + + int cx = 16; + int cy = 16; + + // Diamond crystal shape + for (int dy = -9; dy <= 9; dy++) + { + int maxDx = 9 - Mathf.Abs(dy); + for (int dx = -maxDx; dx <= maxDx; dx++) + { + int px = cx + dx; + int py = cy + dy; + if (Mathf.Abs(dx) == maxDx || Mathf.Abs(dy) == 9) + { + SetPixelSafe(tex, px, py, border); + } + else if (dx <= 0 && dy <= 0) + { + SetPixelSafe(tex, px, py, (dx == -1 && dy == -1) ? highlight : cyan); + } + else + { + SetPixelSafe(tex, px, py, darkCyan); + } + } + } + + // Outline shadow + for (int dy = -10; dy <= 10; dy++) + { + int maxDx = 10 - Mathf.Abs(dy); + SetPixelSafe(tex, cx - maxDx, cy + dy, border); + SetPixelSafe(tex, cx + maxDx, cy + dy, border); + } + + tex.Apply(); + return tex; + } + + private static Texture2D CreateRepairTexture() + { + var tex = NewTexture(); + Color yellow = new Color(1f, 0.85f, 0.20f, 1f); + Color bright = new Color(1f, 0.95f, 0.65f, 1f); + Color border = new Color(0.12f, 0.08f, 0.02f, 0.9f); + + // Wrench head at (8, 8) and handle angled down-right + int hx = 9; + int hy = 9; + + // Wrench circle with cutout + for (int dy = -5; dy <= 5; dy++) + { + for (int dx = -5; dx <= 5; dx++) + { + float dist = Mathf.Sqrt(dx * dx + dy * dy); + if (dist <= 5.2f && (dist >= 2.0f || dx > 0 || dy > 0)) + { + bool isBorder = dist >= 4.4f || (dist <= 2.6f && !(dx > 0 && dy > 0)); + SetPixelSafe(tex, hx + dx, hy + dy, isBorder ? border : (dx <= 0 && dy <= 0) ? bright : yellow); + } + } + } + + // Handle + for (int i = 3; i <= 14; i++) + { + int px = hx + i; + int py = hy + i; + SetPixelSafe(tex, px - 1, py, border); + SetPixelSafe(tex, px, py, yellow); + SetPixelSafe(tex, px + 1, py, yellow); + SetPixelSafe(tex, px + 2, py, border); + } + + tex.Apply(); + return tex; + } + + private static Texture2D CreateMoveTexture() + { + var tex = NewTexture(); + Color green = new Color(0.25f, 1f, 0.40f, 1f); + Color white = new Color(0.92f, 1f, 0.92f, 1f); + Color shadow = new Color(0.02f, 0.12f, 0.04f, 0.85f); + + int cx = 16; + int cy = 16; + + // Center cross dot + SetPixelWithShadow(tex, cx, cy, white, shadow); + + // 4 directional arrows + for (int i = 3; i <= 9; i++) + { + SetPixelWithShadow(tex, cx + i, cy, green, shadow); + SetPixelWithShadow(tex, cx - i, cy, green, shadow); + SetPixelWithShadow(tex, cx, cy + i, green, shadow); + SetPixelWithShadow(tex, cx, cy - i, green, shadow); + } + + // Arrow heads + for (int w = 1; w <= 3; w++) + { + SetPixelWithShadow(tex, cx + 9 - w, cy + w, green, shadow); + SetPixelWithShadow(tex, cx + 9 - w, cy - w, green, shadow); + + SetPixelWithShadow(tex, cx - 9 + w, cy + w, green, shadow); + SetPixelWithShadow(tex, cx - 9 + w, cy - w, green, shadow); + + SetPixelWithShadow(tex, cx + w, cy + 9 - w, green, shadow); + SetPixelWithShadow(tex, cx - w, cy + 9 - w, green, shadow); + + SetPixelWithShadow(tex, cx + w, cy - 9 + w, green, shadow); + SetPixelWithShadow(tex, cx - w, cy - 9 + w, green, shadow); + } + + tex.Apply(); + return tex; + } + + private static void SetPixelWithShadow(Texture2D tex, int x, int y, Color c, Color shadow) + { + // Drop shadow offset (+1, +1) + SetPixelSafe(tex, x + 1, y + 1, shadow); + SetPixelSafe(tex, x, y, c); + } + + private static void SetPixelSafe(Texture2D tex, int x, int y, Color c) + { + if (x < 0 || x >= Size || y < 0 || y >= Size) return; + // Texture coords: (0,0) is bottom-left, but cursor coordinates are top-left + tex.SetPixel(x, Size - 1 - y, c); + } + + private static Texture2D NewTexture() + { + var tex = new Texture2D(Size, Size, TextureFormat.RGBA32, mipChain: false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Point, + }; + Color clear = new Color(0f, 0f, 0f, 0f); + for (int x = 0; x < Size; x++) + { + for (int y = 0; y < Size; y++) + { + tex.SetPixel(x, y, clear); + } + } + return tex; + } + } +} diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs.meta b/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs.meta new file mode 100644 index 00000000..99c4c2ab --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/RtsCursorController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9df30349ff3c5423f9602228b0f6d976 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs index ec5d3ead..a8bffa47 100644 --- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs +++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs @@ -1,15 +1,18 @@ // Graybox throwaway. Legacy Input + OnGUI. Does NOT satisfy G2/G4 UI criteria // (see docs/production/MVPRecoveryPlan.md). Replaced when the new Input System and the real UI land. using System; +using System.Collections.Generic; using UnityEngine; using Nova.Gameplay; using Nova.Gameplay.Audio; using Nova.Gameplay.Match; +using Nova.Simulation.Combat; using Nova.Simulation.CommandsV1; using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Economy; using Nova.Simulation.State; +using Nova.Simulation.Vision; using EntityId = Nova.Core.EntityId; namespace Nova.Presentation.UI @@ -105,6 +108,8 @@ public sealed class RtsDeviceInput : MonoBehaviour [SerializeField] private MinimapHud _minimap; [Tooltip("Main menu; while its overlay is visible, all world gestures are suspended.")] [SerializeField] private MainMenuController _menu; + [Tooltip("Selection marker view used for ground command click feedback animations.")] + [SerializeField] private SelectionMarkerView _markerView; [Header("Graybox tuning")] [Tooltip("Ground plane height. Picking projects onto this mathematical plane, not onto view colliders, so the per-role view heights do not matter.")] @@ -228,11 +233,35 @@ private enum PendingOrder private const float HarvesterEscortIntervalSeconds = 0.5f; private float _harvesterEscortNextTime; private string _harvesterEscortLastWarning; + private readonly HashSet _autoAssignedHarvesters = new HashSet(); + + // Attack pursuit cadence and target tracking (sprint 18.2, #51): + // explicit attack orders dispatch Move + Attack and periodically update + // the move destination while a visible target stays outside weapon range. + private const float AttackPursuitIntervalSeconds = 0.5f; + private float _attackPursuitNextTime; + private string _attackPursuitLastWarning; + private EntityId _focusedAttackTarget = EntityId.Invalid; + private readonly Dictionary _pursuitTargets = new Dictionary(); + private readonly List _pursuitSweepScratch = new List(SelectionManager.MaxSelectedEntities); + private readonly EntityId[] _pursuitMoveScratch = new EntityId[SelectionManager.MaxSelectedEntities]; /// Selected unit count (read by ). public int SelectionCount => _selection.SelectedCount; + /// Last enemy entity explicitly targeted by an Attack order (read by ). + public EntityId FocusedAttackTarget => _focusedAttackTarget; + + /// Entity currently hovered by the mouse cursor in world space. + public EntityId HoveredEntity { get; private set; } = EntityId.Invalid; + + /// True when HoveredEntity is a hostile unit or building visible in Fog of War. + public bool HoveredIsHostile { get; private set; } + + /// Aetherium crystal field currently hovered by the mouse cursor, or 0 if none. + public ushort HoveredFieldId { get; private set; } + /// /// The live selection, for read-only observers such as /// , which resolves the lead unit's combat @@ -394,8 +423,16 @@ public void OrderStop() { if (!EnsureDispatcher()) return; ReadOnlySpan mobile = MobileSelection(out int mobileCount); - if (mobileCount == 0) _lastCommandStatus = "Stop: no mobile units in the selection"; - else Report("Stop", _dispatcher.Stop(mobile)); + if (mobileCount == 0) + { + _lastCommandStatus = "Stop: no mobile units in the selection"; + } + else + { + ClearPursuitAndMarkHarvesters(mobile); + _focusedAttackTarget = EntityId.Invalid; + Report("Stop", _dispatcher.Stop(mobile)); + } } /// ReturnCargo for the mobile selection — the R key and the card button share this path. @@ -403,8 +440,15 @@ public void OrderReturnCargo() { if (!EnsureDispatcher()) return; ReadOnlySpan mobile = MobileSelection(out int mobileCount); - if (mobileCount == 0) _lastCommandStatus = "ReturnCargo: no mobile units in the selection"; - else Report("ReturnCargo", _dispatcher.ReturnCargo(mobile)); + if (mobileCount == 0) + { + _lastCommandStatus = "ReturnCargo: no mobile units in the selection"; + } + else + { + ClearPursuitAndMarkHarvesters(mobile); + Report("ReturnCargo", _dispatcher.ReturnCargo(mobile)); + } } /// Sells a COMPLETED own building (50% refund, floor — the construction domain's rule). @@ -484,6 +528,7 @@ private void Awake() if (_commandCard == null) _commandCard = FindAnyObjectByType(); if (_minimap == null) _minimap = FindAnyObjectByType(); if (_menu == null) _menu = FindAnyObjectByType(); + if (_markerView == null) _markerView = FindAnyObjectByType(); _legend = "LMB click/drag select | RMB move — with an own producer building selected: set its rally point | S stop | " + "A attack enemy under cursor (else plain move; armed units auto-acquire visible in-range enemies, D-087) | " + @@ -501,13 +546,23 @@ private void Awake() "Linksklick auf ein Vorkommen: Restbestand anzeigen"; } + private void OnDisable() + { + RtsCursorController.ResetToDefault(); + } + private void Update() { if (!EnsureDispatcher()) return; - if (_menu != null && _menu.IsMenuVisible) return; // the overlay owns every click + if (_menu != null && _menu.IsMenuVisible) + { + RtsCursorController.ResetToDefault(); + return; // the overlay owns every click + } if (ModalSurfaceLink.Open) { + RtsCursorController.ResetToDefault(); // A modal surface (pause menu, result or network panel) owns // the input: no selection, no orders, no hotkeys, no escort // moves into a stopped kernel — and no click falling through @@ -528,8 +583,10 @@ private void Update() Vector2 mouse = Input.mousePosition; UpdatePlacementHover(mouse); + UpdateHoverAndCursor(mouse); HandleBuildZoneOverlayToggle(); UpdateHarvesterEscort(); + UpdateAttackPursuit(); HudPointerLink.Publish(IsPointerOverHud(mouse)); HandleSelection(mouse); HandleOrders(mouse); @@ -583,9 +640,140 @@ private bool EnsureDispatcher() // double-click in the new match. _lastIdleBuilderIndex = -1; _lastPickedEntity = EntityId.Invalid; + _focusedAttackTarget = EntityId.Invalid; + _pursuitTargets.Clear(); + _autoAssignedHarvesters.Clear(); return true; } + private void UpdateHoverAndCursor(Vector2 mouse) + { + if (IsPointerOverHud(mouse)) + { + HoveredEntity = EntityId.Invalid; + HoveredIsHostile = false; + HoveredFieldId = 0; + RtsCursorController.SetCursor(RtsCursorKind.Default); + return; + } + + if (!TryScreenPointToGround(mouse, out Vector3 world)) + { + HoveredEntity = EntityId.Invalid; + HoveredIsHostile = false; + HoveredFieldId = 0; + RtsCursorController.SetCursor(RtsCursorKind.Default); + return; + } + + // 1. Foreign entity in Fog of War + if (TryPickUnit(world, ownedByLocalSlot: false, out EntityId enemy)) + { + HoveredEntity = enemy; + HoveredIsHostile = true; + HoveredFieldId = 0; + } + // 2. Friendly entity + else if (TryPickUnit(world, ownedByLocalSlot: true, out EntityId friendly)) + { + HoveredEntity = friendly; + HoveredIsHostile = false; + HoveredFieldId = 0; + } + // 3. Aetherium field + else if (TryPickField(world, out ushort fieldId)) + { + HoveredEntity = EntityId.Invalid; + HoveredIsHostile = false; + HoveredFieldId = fieldId; + } + else + { + HoveredEntity = EntityId.Invalid; + HoveredIsHostile = false; + HoveredFieldId = 0; + } + + // Update Dynamic RTS Cursor + if (_placementMode) + { + RtsCursorController.SetCursor(RtsCursorKind.Default); + } + else if (_pendingOrder != PendingOrder.None) + { + switch (_pendingOrder) + { + case PendingOrder.Attack: + RtsCursorController.SetCursor(RtsCursorKind.Attack); + break; + case PendingOrder.Harvest: + RtsCursorController.SetCursor(RtsCursorKind.Harvest); + break; + case PendingOrder.Repair: + RtsCursorController.SetCursor(RtsCursorKind.Repair); + break; + default: + RtsCursorController.SetCursor(RtsCursorKind.Move); + break; + } + } + else + { + ReadOnlySpan mobile = MobileSelection(out int mobileCount); + if (mobileCount > 0) + { + if (HoveredEntity.IsValid && HoveredIsHostile) + { + RtsCursorController.SetCursor(RtsCursorKind.Attack); + } + else if (HoveredFieldId > 0 && HasHarvesterInSelection(mobile)) + { + RtsCursorController.SetCursor(RtsCursorKind.Harvest); + } + else if (HoveredEntity.IsValid && !HoveredIsHostile && HasBuilderInSelection(mobile)) + { + RtsCursorController.SetCursor(RtsCursorKind.Repair); + } + else + { + RtsCursorController.SetCursor(RtsCursorKind.Move); + } + } + else + { + RtsCursorController.SetCursor(RtsCursorKind.Default); + } + } + } + + private bool HasHarvesterInSelection(ReadOnlySpan mobile) + { + EntityManager entities = _runner?.Entities; + if (entities == null) return false; + for (int i = 0; i < mobile.Length; i++) + { + if (entities.TryGetUnit(mobile[i], out UnitState unit) && unit.Role == UnitRole.Harvester) + { + return true; + } + } + return false; + } + + private bool HasBuilderInSelection(ReadOnlySpan mobile) + { + EntityManager entities = _runner?.Entities; + if (entities == null) return false; + for (int i = 0; i < mobile.Length; i++) + { + if (entities.TryGetUnit(mobile[i], out UnitState unit) && unit.Role == UnitRole.Builder) + { + return true; + } + } + return false; + } + // ---------------------------------------------------------------- // Placement mode // ---------------------------------------------------------------- @@ -786,6 +974,27 @@ private void UpdateHarvesterEscort() ref readonly UnitState unit = ref units[i]; if (!unit.IsActive || unit.PlayerId != slot || unit.Role != UnitRole.Harvester) continue; + uint rawId = UnitCommandStateView.ToRawEntityId(unit.Id); + if (unit.HarvestFieldId != 0 || unit.IsReturningCargo || unit.CargoAE > 0) + { + _autoAssignedHarvesters.Add(rawId); + } + else if (!unit.IsMoving && !_autoAssignedHarvesters.Contains(rawId)) + { + // Newly produced Harvester that finished its spawn/rally move and + // has never received an order: auto-assign it once to the nearest + // unexhausted Aetherium field over the canonical command path. + var worldPos = new Vector3( + unit.Transform.PositionX.ToFloat(), _groundPlaneY, unit.Transform.PositionY.ToFloat()); + if (TryResolveNearestField(worldPos, out ushort autoFieldId)) + { + _autoAssignedHarvesters.Add(rawId); + _builderScratch[0] = unit.Id; + _dispatcher.Harvest(_builderScratch.AsSpan(0, 1), autoFieldId); + continue; + } + } + int cellX = GridCellOf(unit.Transform.PositionX); int cellY = GridCellOf(unit.Transform.PositionY); @@ -820,6 +1029,98 @@ private void UpdateHarvesterEscort() } } + /// + /// Periodic attack pursuit (sprint 18.2, #51): units with an explicit + /// player Attack order follow their target while it remains alive, + /// visible in the committed Fog of War, and outside weapon range. + /// Idempotency guard omits IsMoving so fleeing targets re-target + /// cleanly on the 0.5 s cadence without flooding the command stream. + /// + private void UpdateAttackPursuit() + { + if (Time.time < _attackPursuitNextTime) return; + _attackPursuitNextTime = Time.time + AttackPursuitIntervalSeconds; + + if (_pursuitTargets.Count == 0) return; + + EntityManager entities = _runner.Entities; + EconomySystem economy = _runner.Economy; + if (entities == null || economy == null) return; + + byte slot = _dispatcher.LocalSlot; + FactionId faction = economy.GetSlotFaction(slot); + + _pursuitSweepScratch.Clear(); + foreach (KeyValuePair kv in _pursuitTargets) + { + _pursuitSweepScratch.Add(kv.Key); + } + + for (int i = 0; i < _pursuitSweepScratch.Count; i++) + { + EntityId attackerId = _pursuitSweepScratch[i]; + if (!_pursuitTargets.TryGetValue(attackerId, out EntityId targetId)) continue; + + if (!entities.TryGetUnit(attackerId, out UnitState attacker) + || attacker.PlayerId != slot + || SimDefinitions.IsBuildingRole(attacker.Role) + || !entities.TryGetUnit(targetId, out UnitState target)) + { + _pursuitTargets.Remove(attackerId); + continue; + } + + if (attacker.AttackTarget.IsValid && attacker.AttackTarget != targetId) + { + _pursuitTargets.Remove(attackerId); + continue; + } + + // Never pursue into the fog: if the target left committed sight, + // hold off until it is visible again. + if (!IsForeignEntityVisibleToLocalSlot(in target)) + { + continue; + } + + WeaponProfile weapon = WeaponProfiles.Get(faction, attacker.Role); + if (!weapon.IsArmed) + { + _pursuitTargets.Remove(attackerId); + continue; + } + + if (IsWithinWeaponRange(in attacker, in target, weapon.AttackRange)) + { + continue; + } + + int targetX = GridCellOf(target.Transform.PositionX); + int targetY = GridCellOf(target.Transform.PositionY); + if (attacker.TargetGridPos.IsValid + && attacker.TargetGridPos.X == targetX + && attacker.TargetGridPos.Y == targetY) + { + continue; + } + + _pursuitMoveScratch[0] = attackerId; + IntentDispatchResult moved = _dispatcher.MoveTo( + _pursuitMoveScratch.AsSpan(0, 1), + Nova.Core.SimFixed.FromInt(targetX), + Nova.Core.SimFixed.FromInt(targetY)); + if (!moved.Accepted) + { + string warning = $"[RtsDeviceInput] Attack pursuit rejected: {moved.Result} ({moved.RejectReason})"; + if (!string.Equals(warning, _attackPursuitLastWarning, StringComparison.Ordinal)) + { + _attackPursuitLastWarning = warning; + Debug.LogWarning(warning); + } + } + } + } + /// /// Issues the escort move unless the harvester already heads there or /// is under a (manual) move order. Rejections are logged once per @@ -1020,10 +1321,11 @@ private void HandleOrders(Vector2 mouse) if (TryGetLeadProducer(out EntityId producer)) { Report("RallyPoint", _dispatcher.SetRallyPoint(producer, moveTo.x, moveTo.z)); + _markerView?.SpawnClickFeedback(moveTo, ClickFeedbackKind.Rally); } else { - ResolveMoveAt(moveTo); + DispatchContextRightClick(moveTo); } } @@ -1282,6 +1584,46 @@ private void HandleOrderPickInput(Vector2 mouse) } } + /// + /// Contextual right-click dispatch (RTS standard): + /// 1. Hostile under cursor -> Attack + /// 2. Aetherium field under cursor (with Harvester in selection) -> Harvest + /// 3. Damaged friendly building under cursor (with Builder in selection) -> Repair + /// 4. Otherwise -> Move to location + /// + private void DispatchContextRightClick(Vector3 moveTo) + { + ReadOnlySpan mobile = MobileSelection(out int mobileCount); + if (mobileCount == 0) + { + _lastCommandStatus = "Move: no mobile units in the selection (buildings cannot move)"; + return; + } + + // 1. Hostile unit / building + if (TryPickUnit(moveTo, ownedByLocalSlot: false, out EntityId _)) + { + ResolveAttackAt(moveTo); + return; + } + + // 2. Aetherium field for harvesters + if (HasHarvesterInSelection(mobile) && TryPickField(moveTo, out _)) + { + ResolveHarvestAt(moveTo); + return; + } + + // 3. Repair for builders + if (HasBuilderInSelection(mobile) && TryResolveRepairAt(moveTo)) + { + return; + } + + // 4. Default move + ResolveMoveAt(moveTo); + } + /// The plain move order; shared by RMB and the card's Move pick. private void ResolveMoveAt(Vector3 moveTo) { @@ -1292,17 +1634,19 @@ private void ResolveMoveAt(Vector3 moveTo) } else { + ClearPursuitAndMarkHarvesters(mobile); + _focusedAttackTarget = EntityId.Invalid; Report("Move", _dispatcher.MoveTo(mobile, moveTo.x, moveTo.z)); + _markerView?.SpawnClickFeedback(moveTo, ClickFeedbackKind.Move); } } /// /// The attack gesture; shared by the A key and the card's Attack pick. /// Schema v1 has no attack-move register entry (see - /// RtsIntentDispatcher.Attack): an enemy under the cursor becomes a - /// real AttackTarget, everything else is a plain Move — Combat does - /// NOT acquire targets on arrival yet (known gap, GB-002), so this is - /// deliberately not labelled attack-move. + /// RtsIntentDispatcher.Attack): a visible enemy under the cursor + /// becomes a real AttackTarget with two-intent pursuit (Move into + /// range + Attack, sprint 18.2 #51), everything else is a plain Move. /// private void ResolveAttackAt(Vector3 attackAt) { @@ -1313,12 +1657,79 @@ private void ResolveAttackAt(Vector3 attackAt) } else if (TryPickUnit(attackAt, ownedByLocalSlot: false, out EntityId enemy)) { - Report("Attack", _dispatcher.Attack(mobile, enemy)); + DispatchMoveAndAttack(mobile, enemy); + _markerView?.SpawnClickFeedback(attackAt, ClickFeedbackKind.Attack); } else { + ClearPursuitAndMarkHarvesters(mobile); + _focusedAttackTarget = EntityId.Invalid; Report("Move (no target)", _dispatcher.MoveTo(mobile, attackAt.x, attackAt.z)); + _markerView?.SpawnClickFeedback(attackAt, ClickFeedbackKind.Move); + } + } + + /// + /// Two-intent attack dispatch (sprint 18.2, #51): units outside weapon + /// range receive a MoveTo toward the target's cell first, followed by + /// AttackTarget for the whole mobile selection, and register for + /// periodic pursuit updates while the target remains visible. + /// + private void DispatchMoveAndAttack(ReadOnlySpan mobile, EntityId enemy) + { + _focusedAttackTarget = enemy; + EntityManager entities = _runner.Entities; + EconomySystem economy = _runner.Economy; + if (entities != null && economy != null && entities.TryGetUnit(enemy, out UnitState target)) + { + byte slot = _dispatcher.LocalSlot; + FactionId faction = economy.GetSlotFaction(slot); + int outOfRangeCount = 0; + for (int i = 0; i < mobile.Length && outOfRangeCount < _pursuitMoveScratch.Length; i++) + { + _pursuitTargets[mobile[i]] = enemy; + if (!entities.TryGetUnit(mobile[i], out UnitState attacker)) continue; + WeaponProfile weapon = WeaponProfiles.Get(faction, attacker.Role); + if (!weapon.IsArmed || !IsWithinWeaponRange(in attacker, in target, weapon.AttackRange)) + { + _pursuitMoveScratch[outOfRangeCount++] = mobile[i]; + } + } + + if (outOfRangeCount > 0) + { + int targetX = GridCellOf(target.Transform.PositionX); + int targetY = GridCellOf(target.Transform.PositionY); + Report("Move (attack approach)", _dispatcher.MoveTo( + _pursuitMoveScratch.AsSpan(0, outOfRangeCount), + Nova.Core.SimFixed.FromInt(targetX), + Nova.Core.SimFixed.FromInt(targetY))); + } } + Report("Attack", _dispatcher.Attack(mobile, enemy)); + } + + private void ClearPursuitAndMarkHarvesters(ReadOnlySpan entities) + { + EntityManager mgr = _runner != null ? _runner.Entities : null; + for (int i = 0; i < entities.Length; i++) + { + _pursuitTargets.Remove(entities[i]); + if (mgr != null && mgr.TryGetUnit(entities[i], out UnitState unit) && unit.Role == UnitRole.Harvester) + { + _autoAssignedHarvesters.Add(UnitCommandStateView.ToRawEntityId(unit.Id)); + } + } + } + + private static bool IsWithinWeaponRange(in UnitState attacker, in UnitState target, Nova.Core.SimFixed weaponRange) + { + long dx = (long)attacker.Transform.PositionX.RawValue - target.Transform.PositionX.RawValue; + long dy = (long)attacker.Transform.PositionY.RawValue - target.Transform.PositionY.RawValue; + long distanceSquared = dx * dx + dy * dy; + Nova.Core.SimFixed reach = weaponRange + target.Radius; + long reachSquared = (long)reach.RawValue * reach.RawValue; + return distanceSquared <= reachSquared; } /// The harvest gesture; shared by the H key and the card's Harvest pick. @@ -1331,7 +1742,10 @@ private void ResolveHarvestAt(Vector3 harvestAt) } else if (TryResolveNearestField(harvestAt, out ushort fieldId)) { + ClearPursuitAndMarkHarvesters(mobile); + _focusedAttackTarget = EntityId.Invalid; Report($"Harvest #{fieldId}", _dispatcher.Harvest(mobile, fieldId)); + _markerView?.SpawnClickFeedback(harvestAt, ClickFeedbackKind.Harvest); } else { @@ -1367,6 +1781,7 @@ private bool TryResolveRepairAt(Vector3 at) } DispatchMoveAndRepair(builders, at, target); + _markerView?.SpawnClickFeedback(at, ClickFeedbackKind.Repair); return true; } @@ -1381,6 +1796,8 @@ private bool TryResolveRepairAt(Vector3 at) /// private void DispatchMoveAndRepair(ReadOnlySpan builders, Vector3 approach, EntityId target) { + ClearPursuitAndMarkHarvesters(builders); + _focusedAttackTarget = EntityId.Invalid; Report("Move (repair approach)", _dispatcher.MoveTo(builders, approach.x, approach.z)); Report("Repair", _dispatcher.Repair(builders, target)); } @@ -1632,7 +2049,11 @@ private void SelectNextIdleBuilder() AudioServiceLocator.Play2D(SoundEventId.UI_Select); } - /// Nearest active unit to a ground point, filtered by ownership. + /// + /// Nearest active unit to a ground point, filtered by ownership and — + /// for foreign units — by the local slot's committed Fog of War view + /// (sprint 18.2, #51: hidden enemies cannot be clicked or targeted). + /// private bool TryPickUnit(Vector3 world, bool ownedByLocalSlot, out EntityId picked) { picked = EntityId.Invalid; @@ -1649,6 +2070,11 @@ private bool TryPickUnit(Vector3 world, bool ownedByLocalSlot, out EntityId pick ref readonly UnitState unit = ref units[i]; if (!unit.IsActive) continue; if ((unit.PlayerId == slot) != ownedByLocalSlot) continue; + if (!ownedByLocalSlot) + { + if (_runner.Construction != null && _runner.Construction.IsActiveSite(unit.Id)) continue; + if (!IsForeignEntityVisibleToLocalSlot(in unit)) continue; + } // Presentation-side boundary conversion (picking is UI, not sim). float dx = unit.Transform.PositionX.ToFloat() - world.x; @@ -1663,6 +2089,24 @@ private bool TryPickUnit(Vector3 world, bool ownedByLocalSlot, out EntityId pick return picked.IsValid; } + /// + /// True when a foreign entity stands in a + /// cell of the local slot's committed Fog of War view (sprint 18.2, #51). + /// + public bool IsForeignEntityVisibleToLocalSlot(in UnitState unit) + { + if (_runner == null || _dispatcher == null) return false; + byte slot = _dispatcher.LocalSlot; + if (unit.PlayerId == slot) return true; + FogOfWarSystem fog = _runner.FogOfWar; + if (fog == null) return true; + if (slot >= fog.TeamCount) return false; + TeamView view = fog.GetTeamView(slot); + int gx = Math.Max(0, Math.Min(view.Width - 1, Nova.Core.SimFixed.WorldToGrid(unit.Transform.PositionX))); + int gy = Math.Max(0, Math.Min(view.Height - 1, Nova.Core.SimFixed.WorldToGrid(unit.Transform.PositionY))); + return view.IsVisible(gx, gy); + } + // ---------------------------------------------------------------- // Target resolution // ---------------------------------------------------------------- diff --git a/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs b/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs index c44404b3..f1b0b79f 100644 --- a/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs @@ -9,6 +9,15 @@ namespace Nova.Presentation.UI { + public enum ClickFeedbackKind : byte + { + Move, + Attack, + Harvest, + Repair, + Rally, + } + /// /// World-space selection feedback: every selected entity — units AND /// buildings — gets a flat marker quad on the ground under it, tinted the @@ -50,9 +59,25 @@ public sealed class SelectionMarkerView : MonoBehaviour [SerializeField] private float _buildingMarkerMargin = 0.4f; [Tooltip("Own-selection green (RTS norm). Alpha comes from the marker texture; this tints border and fill together.")] [SerializeField] private Color _selectionColor = new Color(0.35f, 1f, 0.4f, 1f); + [Tooltip("Hostile attack-target red (sprint 18.2, #51). Drawn under visible enemies targeted by the current selection.")] + [SerializeField] private Color _targetColor = new Color(1f, 0.28f, 0.22f, 1f); + + private struct ClickFeedbackInstance + { + public GameObject Object; + public MeshRenderer Renderer; + public Color BaseColor; + public float Elapsed; + public const float Duration = 0.35f; + } private readonly Stack _pool = new Stack(SelectionManager.MaxSelectedEntities); private readonly List _active = new List(SelectionManager.MaxSelectedEntities); + private readonly Stack _targetPool = new Stack(8); + private readonly List _activeTargets = new List(8); + private readonly List _activeClickPings = new List(8); + private readonly Stack _clickPingPool = new Stack(8); + private readonly HashSet _drawnTargets = new HashSet(); private MaterialPropertyBlock _tintBlock; private void Awake() @@ -69,54 +94,167 @@ private void LateUpdate() EntityManager entities = _runner.Entities; if (entities == null) return; + _drawnTargets.Clear(); ReadOnlySpan selected = _input.Selection.SelectedEntities; for (int i = 0; i < selected.Length; i++) { if (!entities.TryGetUnit(selected[i], out UnitState unit)) continue; - Vector3 anchor; - if (_views != null && _views.TryGetView(selected[i], out GameObject view)) + PlaceMarker(Rent(), in unit, selected[i], _markerHeight); + + if (unit.AttackTarget.IsValid && _drawnTargets.Add(unit.AttackTarget)) { - // The interpolated body the player actually sees. - anchor = view.transform.position; + TryPlaceTargetMarker(entities, unit.AttackTarget); } - else + } + + if (selected.Length > 0 + && _input.FocusedAttackTarget.IsValid + && _drawnTargets.Add(_input.FocusedAttackTarget)) + { + TryPlaceTargetMarker(entities, _input.FocusedAttackTarget); + } + + UpdateClickFeedbackPings(Time.deltaTime); + } + + /// + /// Spawns a snappy animated ground marker ping at the commanded world location, + /// colored by order kind (Green = Move, Red = Attack, Cyan = Harvest, Yellow = Repair, Orange = Rally). + /// + public void SpawnClickFeedback(Vector3 position, ClickFeedbackKind kind) + { + Color color; + switch (kind) + { + case ClickFeedbackKind.Attack: + color = new Color(1f, 0.22f, 0.18f, 0.95f); + break; + case ClickFeedbackKind.Harvest: + color = new Color(0.18f, 0.90f, 1f, 0.95f); + break; + case ClickFeedbackKind.Repair: + color = new Color(1f, 0.85f, 0.20f, 0.95f); + break; + case ClickFeedbackKind.Rally: + color = new Color(1f, 0.55f, 0.15f, 0.95f); + break; + default: + color = new Color(0.30f, 1f, 0.42f, 0.95f); + break; + } + + GameObject obj = _clickPingPool.Count > 0 + ? _clickPingPool.Pop() + : CreateMarker("ClickFeedbackPing", color); + + obj.transform.position = new Vector3(position.x, _markerHeight + 0.02f, position.z); + obj.transform.localScale = new Vector3(2.0f, 2.0f, 1f); + obj.SetActive(true); + + MeshRenderer mr = obj.GetComponent(); + if (_tintBlock == null) _tintBlock = new MaterialPropertyBlock(); + _tintBlock.Clear(); + FactionTint.ApplyToPropertyBlock(_tintBlock, color); + mr.SetPropertyBlock(_tintBlock); + + _activeClickPings.Add(new ClickFeedbackInstance + { + Object = obj, + Renderer = mr, + BaseColor = color, + Elapsed = 0f, + }); + } + + private void UpdateClickFeedbackPings(float deltaTime) + { + for (int i = _activeClickPings.Count - 1; i >= 0; i--) + { + ClickFeedbackInstance ping = _activeClickPings[i]; + ping.Elapsed += deltaTime; + if (ping.Elapsed >= ClickFeedbackInstance.Duration) { - anchor = new Vector3( - unit.Transform.PositionX.ToFloat(), 0f, - unit.Transform.PositionY.ToFloat()); + ping.Object.SetActive(false); + _clickPingPool.Push(ping.Object); + _activeClickPings.RemoveAt(i); + continue; } - bool building = SimDefinitions.IsBuildingRole(unit.Role); - float size = building - ? SimDefinitions.BuildingFootprintCells + _buildingMarkerMargin - : _unitMarkerSize; - float shift = building ? 0.5f : 0f; // footprint centre, see class remarks + float t = ping.Elapsed / ClickFeedbackInstance.Duration; + float currentScale = Mathf.Lerp(2.2f, 0.9f, t); + ping.Object.transform.localScale = new Vector3(currentScale, currentScale, 1f); - GameObject marker = Rent(); - Transform markerTransform = marker.transform; - markerTransform.position = new Vector3(anchor.x + shift, _markerHeight, anchor.z + shift); - markerTransform.localScale = new Vector3(size, size, 1f); + float alpha = (1f - t) * (1f - t); + Color c = new Color(ping.BaseColor.r, ping.BaseColor.g, ping.BaseColor.b, ping.BaseColor.a * alpha); + if (_tintBlock == null) _tintBlock = new MaterialPropertyBlock(); + _tintBlock.Clear(); + FactionTint.ApplyToPropertyBlock(_tintBlock, c); + ping.Renderer.SetPropertyBlock(_tintBlock); + + _activeClickPings[i] = ping; + } + } + + private void TryPlaceTargetMarker(EntityManager entities, EntityId targetId) + { + if (!entities.TryGetUnit(targetId, out UnitState target)) return; + if (!_input.IsForeignEntityVisibleToLocalSlot(in target)) return; + + PlaceMarker(RentTarget(), in target, targetId, _markerHeight + 0.01f); + } + + private void PlaceMarker(GameObject marker, in UnitState unit, EntityId id, float height) + { + Vector3 anchor; + if (_views != null && _views.TryGetView(id, out GameObject view)) + { + // The interpolated body the player actually sees. + anchor = view.transform.position; } + else + { + anchor = new Vector3( + unit.Transform.PositionX.ToFloat(), 0f, + unit.Transform.PositionY.ToFloat()); + } + + bool building = SimDefinitions.IsBuildingRole(unit.Role); + float size = building + ? SimDefinitions.BuildingFootprintCells + _buildingMarkerMargin + : _unitMarkerSize; + float shift = building ? 0.5f : 0f; // footprint centre, see class remarks + + Transform markerTransform = marker.transform; + markerTransform.position = new Vector3(anchor.x + shift, height, anchor.z + shift); + markerTransform.localScale = new Vector3(size, size, 1f); } private GameObject Rent() { - GameObject marker = _pool.Count > 0 ? _pool.Pop() : CreateMarker(); + GameObject marker = _pool.Count > 0 ? _pool.Pop() : CreateMarker("SelectionMarker", _selectionColor); marker.SetActive(true); _active.Add(marker); return marker; } - private GameObject CreateMarker() + private GameObject RentTarget() { - GameObject marker = GroundMarkerVisuals.CreateFlatQuad("SelectionMarker", transform); + GameObject marker = _targetPool.Count > 0 ? _targetPool.Pop() : CreateMarker("AttackTargetMarker", _targetColor); + marker.SetActive(true); + _activeTargets.Add(marker); + return marker; + } + + private GameObject CreateMarker(string markerName, Color color) + { + GameObject marker = GroundMarkerVisuals.CreateFlatQuad(markerName, transform); // The tint never changes per marker, so the property block is // uploaded once at creation — the per-frame path stays free of // SetPropertyBlock calls. if (_tintBlock == null) _tintBlock = new MaterialPropertyBlock(); _tintBlock.Clear(); - FactionTint.ApplyToPropertyBlock(_tintBlock, _selectionColor); + FactionTint.ApplyToPropertyBlock(_tintBlock, color); marker.GetComponent().SetPropertyBlock(_tintBlock); return marker; } @@ -129,6 +267,13 @@ private void ReturnAllToPool() _pool.Push(_active[i]); } _active.Clear(); + + for (int i = 0; i < _activeTargets.Count; i++) + { + _activeTargets[i].SetActive(false); + _targetPool.Push(_activeTargets[i]); + } + _activeTargets.Clear(); } private void OnDestroy() @@ -138,6 +283,19 @@ private void OnDestroy() { Destroy(_pool.Pop()); } + while (_targetPool.Count > 0) + { + Destroy(_targetPool.Pop()); + } + while (_clickPingPool.Count > 0) + { + Destroy(_clickPingPool.Pop()); + } + for (int i = 0; i < _activeClickPings.Count; i++) + { + if (_activeClickPings[i].Object != null) Destroy(_activeClickPings[i].Object); + } + _activeClickPings.Clear(); GroundMarkerVisuals.DestroyShared(); } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1433b138..ff9b7d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,29 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **RTS-Gefühl, Steuerung & Audio: Angriffsverfolgung, Kontext-Rechtsklick, dynamische Cursor, 3D-Welt und Sound-Cues (#51).** + Schließt die offenen Bedien-, Audio- und Darstellungslücken vollständig ab, ohne in + die deterministische Simulationslogik einzugreifen: + **(1) Kampf & Steuerung:** Kontextsensitiver Rechtsklick (`DispatchContextRightClick` in `RtsDeviceInput`): + Rechtsklick auf sichtbare Feinde löst gezielten Angriff mit Zwei-Intent-Annäherung (`MoveTo` in Waffenreichweite + `Attack`) + und Verfolgungsschleife (`UpdateAttackPursuit`) aus; Rechtsklick auf Aetherium-Vorkommen mit Sammlern schickt sie + zur Ernte (`ResolveHarvestAt`); Rechtsklick auf beschädigte eigene Gebäude mit Baupionieren repariert; + Rechtsklick auf freien Boden bewegt Einheiten oder setzt Sammelpunkte für Produktionsgebäude. + Feinde im Fog of War sind nicht blind anklickbar. + **(2) Visuelles Feedback & Cursors:** Prozedural generierte taktische 32x32-Hardware-Cursor (`RtsCursorController`) + mit dynamischem Hover-Switch (`Default`, `Move`, `Attack`, `Harvest`, `Repair`); + farbcodierte, animierte Boden-Klick-Pings (`SelectionMarkerView.SpawnClickFeedback` in Grün, Rot, Cyan, Gelb, Orange); + sichtbare Zielringe unter anvisierten Feinden; aufgewertete Lebensbalken (`HealthBarHud`) mit 1px Kontrastrahmen, + Glanzkante, breiteren Abmessungen (36px Einheiten / 54px Gebäude) und Sofortanzeige bei Einheiten-Hover. + **(3) 3D-Darstellung:** Volle Farberhaltung für texturierte 3D-Modelle (`FactionTint.PrefabBaseColor`); + Aetherium-Felder als facettierte, schimmernde 3D-Kristallcluster auf Erzbett (`GlutrinneBlockoutView`); + mitwachsende 3D-Gerüstkonstruktion für Baustellen und Hologramm-Vorschau beim Platzieren. + **(4) In-Game-Audio & Alarmtöne:** Akustische Signale für fertiggestellte Bauwerke (`PRD_UnitReady`), + Basis-unter-Beschuss-Warnung (`UI_Deny` mit 20s Abklingzeit bei Treffern auf eigene Gebäude), + Stromausfall-Alarm (`UI_Deny` bei Übergang in Defizit in `ResourceBarHud`), sowie Rundenabschluss-Audio + (Siegesfanfare vs. Niederlage-Explosion in `MatchFrameHud`). + **(5) In-Game-HUD:** `HudChrome`, `BuildMenuHud`, `CommandCardHud` und `ResourceBarHud` mit taktischen + Sci-Fi-Buttons, Rollen-Badges (`[HQ]`, `[RAF]`, `[NRG]` usw.), Hotkey-Pills und Fraktionsauswahl im Hauptmenü. - **Fahrzeugfabrik und Kaserne reparieren jetzt passiv, was sie bauen können (#55).** Der Wunsch stammt aus dem Betatest vom 09.08.2026 und wurde am 31.08.2026 erneut vermisst: beschädigte Panzerung war bis hierher faktisch dauerhaft — diff --git a/tools/art/rig_tank_hierarchy.py b/tools/art/rig_tank_hierarchy.py new file mode 100644 index 00000000..d8f6b234 --- /dev/null +++ b/tools/art/rig_tank_hierarchy.py @@ -0,0 +1,290 @@ +"""Segmentiert ein Panzer-FBX in Hull / Turret / Gun und baut die Pivot-Hierarchie. + +Blender-Weltachsen nach FBX-Import (Unity-Konvention der Repo-Pipeline): + +Z hoch, -Y = Fahrzeug-Vorwaerts (== Unity +Z) +""" +import bpy, bmesh, sys, os, json, math +from mathutils import Vector + +def parse(): + """rig_tank_hierarchy.py -- [--preview] [--ring-z Z] [--gun-y Y] + + --ring-z / --gun-y ueberschreiben die automatische Erkennung. Die Heuristik + sitzt bei den beiden BattleTanks, aber sie misst Geometrie und keine Absicht; + bei einem neuen Fahrzeug erst --preview fahren und das Ergebnis ansehen. + """ + a = sys.argv[sys.argv.index("--")+1:] + d = {"fbx": a[0], "out": a[1], "tag": a[2], "preview": "--preview" in a, + "ring_z": None, "gun_y": None} + for key, name in (("ring_z", "--ring-z"), ("gun_y", "--gun-y")): + if name in a: + d[key] = float(a[a.index(name) + 1]) + return d + +def world_polys(o): + mw = o.matrix_world + return [(mw @ p.center, p.index) for p in o.data.polygons] + +def find_ring_z(o, lo_frac=0.30, hi_frac=0.80, slices=160, + min_width_frac=0.35, min_share=0.05): + """Turmring = groesster Breitensprung, der oberhalb noch einen echten Turm + stehen laesst. Ohne diese Bedingung schnappt die Erkennung auf Kuppel oder + Antenne (schmal, aber ein noch groesserer Sprung).""" + pts = [o.matrix_world @ v.co for v in o.data.vertices] + zmin, zmax = min(p.z for p in pts), max(p.z for p in pts) + h = zmax - zmin + w_max = max(p.x for p in pts) - min(p.x for p in pts) + n = len(pts) + band = h * 0.12 + cands = [] + for i in range(slices): + z = zmin + h * i / slices + if not (zmin + h*lo_frac <= z <= zmin + h*hi_frac): + continue + above = [p for p in pts if z <= p.z < z + band] + below = [p for p in pts if z - band <= p.z < z] + if not above or not below: + continue + w_above = max(p.x for p in above) - min(p.x for p in above) + w_below = max(p.x for p in below) - min(p.x for p in below) + share = sum(1 for p in pts if p.z > z) / n + if w_above < w_max * min_width_frac or share < min_share: + continue # Kuppel/Antenne, kein Turm + cands.append((w_below - w_above, z)) + if not cands: + return None, 0.0 + top = max(d for d, _ in cands) + # Der Turmring ist die UNTERSTE Stufe dieser Groessenordnung. Ohne diese + # Regel gewinnt bei Legion der Aufbau darueber (Sprung 1.75 vs 1.70 m). + near = [(d, z) for d, z in cands if d >= top * 0.60] + d, z = min(near, key=lambda t: t[1]) + return z, d + +def gun_axis(turret_obj, tip_depth=0.30): + """Rohrachse aus den vordersten Faces des Turms (Fahrzeug-Vorwaerts = -Y).""" + mw = turret_obj.matrix_world + V = turret_obj.data.vertices + faces = [((mw @ p.center), [mw @ V[i].co for i in p.vertices]) + for p in turret_obj.data.polygons] + if not faces: + return None + ymin = min(c.y for c, _ in faces) + tip = [(c, vs) for c, vs in faces if c.y < ymin + tip_depth] + ax = sum(c.x for c, _ in tip) / len(tip) + az = sum(c.z for c, _ in tip) / len(tip) + r0 = max(math.hypot(v.x - ax, v.z - az) for _, vs in tip for v in vs) + return {"faces": faces, "ymin": ymin, "ax": ax, "az": az, "r0": r0} + +def find_gun_y(turret_obj, grow=1.6, step=0.10, max_len=3.0): + """Rohrende = erste Y-Scheibe, in der der Radius um die Rohrachse aufgeht. + + Radial statt per Bounding-Box: eine Y-Scheibe erfasst sonst auch Geometrie + an den Turmflanken (bei Legion Griffe und Kaesten), und die Breite springt + dann schon an der Muendung. Radial bleibt der Wert ueber das ganze Rohr + konstant und geht erst an der Blende auf. + """ + g = gun_axis(turret_obj) + if g is None: + return None + thr = g["r0"] * grow + y = g["ymin"] + while y < g["ymin"] + max_len: + near = [vs for c, vs in g["faces"] + if y <= c.y < y + step + and math.hypot(c.x - g["ax"], c.z - g["az"]) <= 2.5 * g["r0"]] + mr = max((math.hypot(v.x - g["ax"], v.z - g["az"]) for vs in near for v in vs), + default=0.0) + if mr > thr: + return None if y <= g["ymin"] + step else y + y += step + return None + +def split_by(obj, pred, newname): + """Trennt Faces, fuer die pred(world_center) True ist, in ein neues Objekt.""" + me = obj.data + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='EDIT') + bm = bmesh.from_edit_mesh(me) + bm.faces.ensure_lookup_table() + mw = obj.matrix_world + for f in bm.faces: + f.select = pred(mw @ f.calc_center_median()) + bmesh.update_edit_mesh(me) + n_before = len(bpy.data.objects) + if any(f.select for f in bm.faces): + bpy.ops.mesh.separate(type='SELECTED') + bpy.ops.object.mode_set(mode='OBJECT') + if len(bpy.data.objects) == n_before: + return None + new = [o for o in bpy.context.selected_objects if o is not obj][-1] + new.name = newname + return new + +def main(): + A = parse() + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=A["fbx"]) + lods = sorted([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: o.name) + base = lods[0] + + if A["ring_z"] is not None: + ring_z, drop = A["ring_z"], 0.0 + else: + ring_z, drop = find_ring_z(base) + if ring_z is None: + print(f"[{A['tag']}] ABBRUCH: kein Turmring gefunden, --ring-z setzen") + return + report = {"tag": A["tag"], "ring_z": round(ring_z, 4), "ring_drop_m": round(drop, 3), "lods": {}} + print(f"[{A['tag']}] Turmring bei z={ring_z:.3f} m (Breitensprung {drop:.2f} m)") + + parts = {} + gun_ref = None # Schnittwerte einmal aus LOD0, sonst schneidet jede + # LOD-Stufe an anderer Stelle und die Silhouetten driften + for lod in lods: + bpy.ops.object.select_all(action='DESELECT') + turret = split_by(lod, lambda c: c.z > ring_z, f"__turret_{lod.name}") + if turret is None: + print(f" {lod.name}: kein Turm abtrennbar"); continue + if gun_ref is None: + gun_y = A["gun_y"] if A["gun_y"] is not None else find_gun_y(turret) + gun_ref = (gun_y, gun_axis(turret)) if gun_y is not None else (None, None) + gun_y, g = gun_ref + gun = None + if gun_y is not None: + rmax = g["r0"] * 2.0 + bpy.ops.object.select_all(action='DESELECT') + gun = split_by(turret, lambda c, gy=gun_y, g=g, rmax=rmax: + c.y < gy and math.hypot(c.x - g["ax"], c.z - g["az"]) <= rmax, + f"__gun_{lod.name}") + lodname = lod.name.split("_")[-1] + parts[lodname] = {"hull": lod, "turret": turret, "gun": gun, "gun_y": gun_y} + report["lods"][lodname] = { + "hull_tris": len(lod.data.polygons), + "turret_tris": len(turret.data.polygons), + "gun_tris": len(gun.data.polygons) if gun else 0, + "gun_cut_y": round(gun_y, 4) if gun_y is not None else None, + } + print(f" {lodname}: Hull={len(lod.data.polygons)} Turm={len(turret.data.polygons)}" + f" Rohr={len(gun.data.polygons) if gun else 0} (gun_y={gun_y})") + + # ---- Pivots aus LOD0 ableiten + p0 = parts.get("LOD0") + tp = [p0["turret"].matrix_world @ v.co for v in p0["turret"].data.vertices] + # Drehachse = Mitte des Turmrings, nicht Mitte der Turm-Bounding-Box: + # letztere wird vom nach vorn stehenden Rohr aus der Mitte gezogen. + band = [p for p in tp if p.z < ring_z + 0.35] + ref = band if len(band) > 20 else tp + turret_pivot = Vector(((min(p.x for p in ref) + max(p.x for p in ref)) / 2, + (min(p.y for p in ref) + max(p.y for p in ref)) / 2, + ring_z)) + gun_pivot = None + if p0["gun"] and gun_ref[1] is not None: + g = gun_ref[1] + gp = [p0["gun"].matrix_world @ v.co for v in p0["gun"].data.vertices] + # Schildzapfen: hinteres Ende des Rohrstuecks, auf der Rohrachse + gun_pivot = Vector((g["ax"], max(p.y for p in gp), g["az"])) + report["turret_pivot_blender"] = [round(v, 4) for v in turret_pivot] + report["turret_pivot_unity"] = [round(turret_pivot.x, 4), round(turret_pivot.z, 4), + round(-turret_pivot.y, 4)] + if gun_pivot: + report["gun_pivot_blender"] = [round(v, 4) for v in gun_pivot] + report["gun_pivot_unity"] = [round(gun_pivot.x, 4), round(gun_pivot.z, 4), + round(-gun_pivot.y, 4)] + + if A["preview"]: + # Preview: Hull grau, Turm orange, Rohr rot + cols = {"hull": (0.55, 0.57, 0.60, 1), "turret": (0.90, 0.45, 0.10, 1), "gun": (0.85, 0.10, 0.10, 1)} + for role, rgba in cols.items(): + m = bpy.data.materials.new(f"col_{role}"); m.use_nodes = False; m.diffuse_color = rgba + for lodname, d in parts.items(): + if lodname != "LOD0" or d[role] is None: continue + d[role].data.materials.clear(); d[role].data.materials.append(m) + for lodname, d in parts.items(): + if lodname == "LOD0": continue + for role in ("hull", "turret", "gun"): + if d[role]: bpy.data.objects.remove(d[role], do_unlink=True) + render_preview(A["out"], A["tag"], turret_pivot, gun_pivot) + else: + build_hierarchy(A, parts, turret_pivot, gun_pivot) + + with open(os.path.join(A["out"], f"{A['tag']}_rig.json"), "w") as f: + json.dump(report, f, indent=2) + print("REPORT " + json.dumps(report)) + +def render_preview(out, tag, tpivot, gpivot): + import mathutils + sc = bpy.context.scene + pts = [o.matrix_world @ v.co for o in bpy.data.objects if o.type == 'MESH' for v in o.data.vertices] + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + ctr = (mn+mx)/2; size = max(mx-mn) + sc.render.engine = 'BLENDER_WORKBENCH' + sc.render.resolution_x = sc.render.resolution_y = 640 + sc.display.shading.light = 'STUDIO'; sc.display.shading.color_type = 'MATERIAL' + cd = bpy.data.cameras.new("c"); cd.type = 'ORTHO'; cd.ortho_scale = size*1.2 + cam = bpy.data.objects.new("c", cd); sc.collection.objects.link(cam); sc.camera = cam + for name, d, rot in (("side", Vector((1,0,0)), (math.radians(90),0,math.radians(90))), + ("iso", Vector((1,-1,0.6)).normalized(), (math.radians(66),0,math.radians(48)))): + cam.location = ctr + d*size*3; cam.rotation_euler = rot + sc.render.filepath = os.path.join(out, f"{tag}_split_{name}.png") + bpy.ops.render.render(write_still=True) + print("RENDERED", sc.render.filepath) + +def build_hierarchy(A, parts, tpivot, gpivot): + from mathutils import Matrix + sc = bpy.context.scene + + def empty(name, world): + e = bpy.data.objects.new(name, None) + sc.collection.objects.link(e) + e.location = world + e.empty_display_size = 0.5 + return e + + def attach(child, parent, parent_world): + """Lokale Transform explizit setzen. + + matrix_parent_inverse ueberlebt den FBX-Export nicht: Blender behaelt die + Weltposition, der Exporter schreibt aber die rohe location als lokale + Transform. Kinder wandern dann um den Pivot-Offset nach oben. + """ + child_world = child.location.copy() # noch ohne Parent == Weltlage + child.parent = parent + child.matrix_parent_inverse = Matrix.Identity(4) + child.location = child_world - parent_world + + zero = Vector((0.0, 0.0, 0.0)) + root = empty("Tank_Root", zero) + hull_p = empty("Hull", zero) + turret_p = empty("Turret", tpivot) + gun_p = empty("Gun", gpivot) if gpivot is not None else None + + attach(hull_p, root, zero) + attach(turret_p, root, zero) + if gun_p: + attach(gun_p, turret_p, tpivot) + + base = os.path.basename(A["fbx"]).replace(".fbx", "") + for lodname, d in sorted(parts.items()): + d["hull"].name = f"{base}_Hull_{lodname}" + attach(d["hull"], hull_p, zero) + d["turret"].name = f"{base}_Turret_{lodname}" + attach(d["turret"], turret_p, tpivot) + if d["gun"]: + d["gun"].name = f"{base}_Gun_{lodname}" + attach(d["gun"], gun_p or turret_p, gpivot if gun_p else tpivot) + + os.makedirs(A["out"], exist_ok=True) + fbx_out = os.path.join(A["out"], f"{base}_Rigged.fbx") + bpy.ops.object.select_all(action='SELECT') + bpy.ops.export_scene.fbx(filepath=fbx_out, use_selection=True, apply_unit_scale=True, + apply_scale_options='FBX_SCALE_NONE', object_types={'EMPTY', 'MESH'}, + global_scale=1.0, use_mesh_modifiers=True, + mesh_smooth_type='FACE', axis_forward='-Z', axis_up='Y', + bake_space_transform=False, path_mode='STRIP') + print("EXPORTED", fbx_out) + glb_out = os.path.join(A["out"], f"{base}_Rigged.glb") + bpy.ops.export_scene.gltf(filepath=glb_out, export_format='GLB', use_selection=True) + print("EXPORTED", glb_out) + +main() From 8225c619b311e5bca5819913d569764628931066 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 12:25:29 +0200 Subject: [PATCH 02/13] fix(presentation): face units along their direction of travel The simulation measures headings mathematically (SimTrig.Atan2: 0 = +X, counter-clockwise), Unity's yaw starts at +Z and turns clockwise. The view fed the sim angle straight into Quaternion.Euler, which mirrored every unit across the north-east diagonal: a tank driving east looked north. ViewHeading is now the single place that maps a sim heading to a Unity yaw (90 deg minus sim degrees). EditMode tests pin the mapping for eight compass directions and an oblique one. A PlayMode test drives six real prefabs in six directions through pathfinding and asserts every moving view faces its measured displacement; with the old mapping it fails (up to 180 deg off). Refs D-110. Co-Authored-By: Claude Opus 5.5 --- .../EditMode/Gameplay/ViewHeadingTests.cs | 65 +++++ .../Gameplay/ViewHeadingTests.cs.meta | 11 + .../PlayMode/UnitMotionPresentationTests.cs | 242 ++++++++++++++++++ .../UnitMotionPresentationTests.cs.meta | 11 + .../Scripts/Gameplay/Match/UnitViewManager.cs | 4 +- .../Scripts/Gameplay/Match/ViewHeading.cs | 53 ++++ .../Gameplay/Match/ViewHeading.cs.meta | 11 + 7 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta create mode 100644 Assets/Tests/PlayMode/UnitMotionPresentationTests.cs create mode 100644 Assets/Tests/PlayMode/UnitMotionPresentationTests.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs.meta diff --git a/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs new file mode 100644 index 00000000..04b51481 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs @@ -0,0 +1,65 @@ +using NUnit.Framework; +using UnityEngine; +using Nova.Core; +using Nova.Gameplay.Match; + +namespace Nova.Gameplay.Tests +{ + /// + /// Sim heading -> Unity yaw (EditMode lane). The simulation measures + /// counter-clockwise from sim +X (), Unity + /// clockwise from world +Z; a model rotated by the view yaw must look + /// along the direction the simulation actually moves it. The regression + /// these pin down: the raw sim angle as yaw mirrored every heading + /// across the north-east diagonal. + /// + [TestFixture] + public class ViewHeadingTests + { + private static SimAngle HeadingOf(int dx, int dy) + => SimTrig.Atan2(SimFixed.FromInt(dy), SimFixed.FromInt(dx)); + + private static Vector3 WorldMove(int dx, int dy) => new Vector3(dx, 0f, dy).normalized; + + [TestCase(1, 0)] + [TestCase(0, 1)] + [TestCase(-1, 0)] + [TestCase(0, -1)] + [TestCase(1, 1)] + [TestCase(-1, 1)] + [TestCase(-1, -1)] + [TestCase(1, -1)] + [TestCase(3, -7)] + public void ModelForward_PointsAlongTheSimulatedMove(int dx, int dy) + { + Vector3 forward = ViewHeading.Rotation(HeadingOf(dx, dy)) * Vector3.forward; + + Assert.That(Vector3.Angle(forward, WorldMove(dx, dy)), Is.LessThan(0.5f), + $"a unit moving ({dx},{dy}) in sim space must face world ({dx},0,{dy})"); + } + + [Test] + public void YawDegrees_EastIsNinety_NorthIsZero() + { + Assert.That(ViewHeading.YawDegrees(HeadingOf(1, 0)), Is.EqualTo(90f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(0, 1)), Is.EqualTo(0f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(0, -1)), Is.EqualTo(180f).Within(0.01f)); + Assert.That(ViewHeading.YawDegrees(HeadingOf(-1, 0)), Is.EqualTo(270f).Within(0.01f)); + } + + [Test] + public void YawToward_MatchesTheSimHeadingOfTheSameOffset() + { + float toward = ViewHeading.YawToward(new Vector3(2f, 0f, 5f), new Vector3(6f, 3f, 2f), 0f); + + Assert.That(Mathf.DeltaAngle(toward, ViewHeading.YawDegrees(HeadingOf(4, -3))), + Is.EqualTo(0f).Within(0.05f)); + } + + [Test] + public void YawToward_CoincidentPoints_KeepsTheFallback() + { + Assert.That(ViewHeading.YawToward(Vector3.one, Vector3.one, 123f), Is.EqualTo(123f)); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta new file mode 100644 index 00000000..b3a12071 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ViewHeadingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e057d5f41bb4314ab02e06c40ae874b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs new file mode 100644 index 00000000..e29637a8 --- /dev/null +++ b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs @@ -0,0 +1,242 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.Definitions; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + /// + /// Proof in the running match that a unit looks where it drives. Before + /// , the view used the sim heading as a raw Unity + /// yaw: the simulation measures counter-clockwise from +X, Unity clockwise + /// from +Z, so every unit was mirrored across the north-east diagonal (a + /// tank driving east faced north). The EditMode suite pins the mapping; + /// this test proves the whole chain with real prefabs, real pathfinding + /// and the real interpolating view: every moving view's forward axis must + /// lie along its own measured displacement. + /// + /// It also renders evidence shots from the RTS camera's pitch into + /// output/demo/ (headless with graphics; never run with -quit, see + /// GrayboxDemoProofTests). + /// + /// + public sealed class UnitMotionPresentationTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + private const byte Slot = 0; + + private static readonly (UnitRole Role, int Dx, int Dy)[] Plan = + { + (UnitRole.LightTank, 1, 0), // east + (UnitRole.BattleTank, 0, 1), // north + (UnitRole.ScoutVehicle, -1, 0), // west + (UnitRole.Harvester, 0, -1), // south + (UnitRole.BasicInfantry, 1, 1), // north-east + (UnitRole.Artillery, -1, 1), // north-west + }; + + [UnityTest] + public IEnumerator MovingViews_FaceTheirDirectionOfTravel() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady, "the canonical opening position was not built"); + + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views, "Bootstrap scene contains no UnitViewManager"); + CostField cost = runner.Pathfinding.CostField; + FactionId faction = runner.Economy.GetSlotFaction(Slot); + + // Test-setup spawns (the same legal direct write MatchBootstrap + // uses for the opening position), each on a free straight lane + // so the path is a line and the expected heading is unambiguous. + const int Lane = 12; + var ids = new List(); + var expected = new List(); + int seedX = 26, seedY = 22; + foreach ((UnitRole role, int dx, int dy) in Plan) + { + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, role, out SimUnitDefinition def), + $"setup: no {faction} {role} definition"); + Assert.IsTrue(FindOpenLane(cost, seedX, seedY, dx, dy, Lane, out int sx, out int sy), + $"setup: no free {Lane}-cell lane for {role} near ({seedX},{seedY})"); + EntityId id = runner.Entities.SpawnUnit(Slot, + new Transform2D(SimFixed.FromInt(sx), SimFixed.FromInt(sy)), def.MoveSpeed, role: def.Role); + Assert.IsTrue(id.IsValid, $"setup: spawn of {role} failed"); + + uint raw = UnitCommandStateView.ToRawEntityId(id); + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new MovePayload(new[] { raw }, + SimFixed.FromInt(sx + dx * Lane), SimFixed.FromInt(sy + dy * Lane))), out _), + $"the move intent for {role} must enter the sealed stream"); + ids.Add(id); + expected.Add(new Vector3(dx, 0f, dy).normalized); + seedY += 7; + } + + // Let the order land and the views get up to speed. + yield return new WaitForSeconds(1.2f); + + var samples = new int[ids.Count]; + var aligned = new int[ids.Count]; + var worst = new float[ids.Count]; + var lastPos = new Vector3[ids.Count]; + for (int i = 0; i < ids.Count; i++) + { + Assert.IsTrue(views.TryGetView(ids[i], out GameObject v) && v != null, + $"PRESENTATION: {Plan[i].Role} owns no view — prefab resolution or fog feed broke"); + lastPos[i] = v.transform.position; + } + + bool shot = false; + float until = Time.time + 1.6f; + while (Time.time < until) + { + yield return null; + for (int i = 0; i < ids.Count; i++) + { + if (!views.TryGetView(ids[i], out GameObject v) || v == null) continue; + Vector3 p = v.transform.position; + Vector3 d = p - lastPos[i]; + d.y = 0f; + lastPos[i] = p; + if (d.magnitude < 0.3f * Time.deltaTime) continue; // standing: nothing to check + samples[i]++; + float angle = Vector3.Angle(Flat(v.transform.forward), d); + if (angle < 25f) aligned[i]++; + worst[i] = Mathf.Max(worst[i], angle); + } + + if (!shot && Time.time > until - 0.8f) + { + shot = true; + Vector3 c = Centroid(views, ids); + CaptureRts($"{ShotDir}/motion_01_rts_close.png", c, 26f); + CaptureRts($"{ShotDir}/motion_02_rts_default.png", c, 42f); + } + } + + for (int i = 0; i < ids.Count; i++) + { + Assert.Greater(samples[i], 5, $"{Plan[i].Role} never moved — no evidence for its heading"); + Assert.GreaterOrEqual(aligned[i], (int)(samples[i] * 0.8f), + $"{Plan[i].Role}: the view faced its direction of travel in only {aligned[i]}/{samples[i]} " + + $"moving frames (worst {worst[i]:F0} deg) — the sim-to-yaw mapping or the prefab's forward axis is off"); + Debug.Log($"[UnitMotionPresentation] {Plan[i].Role}: {aligned[i]}/{samples[i]} frames aligned, worst {worst[i]:F1} deg, expected {expected[i]}"); + } + } + + private static Vector3 Flat(Vector3 v) + { + v.y = 0f; + return v; + } + + /// + /// First walkable start cell near the seed whose whole lane (three + /// cells wide) in the move direction is walkable. + /// + private static bool FindOpenLane(CostField cost, int seedX, int seedY, int dx, int dy, int len, + out int sx, out int sy) + { + for (int r = 0; r < 30; r++) + { + for (int oy = -r; oy <= r; oy++) + { + for (int ox = -r; ox <= r; ox++) + { + if (Mathf.Max(Mathf.Abs(ox), Mathf.Abs(oy)) != r) continue; + int x = seedX + ox, y = seedY + oy; + if (LaneIsOpen(cost, x, y, dx, dy, len)) + { + sx = x; + sy = y; + return true; + } + } + } + } + sx = sy = 0; + return false; + } + + private static bool LaneIsOpen(CostField cost, int x, int y, int dx, int dy, int len) + { + for (int k = -1; k <= len + 1; k++) + { + for (int side = -1; side <= 1; side++) + { + int cx = x + dx * k + (dy != 0 ? side : 0); + int cy = y + dy * k + (dx != 0 ? side : 0); + if (!cost.IsInBounds(cx, cy) || !cost.IsWalkable((ushort)cx, (ushort)cy)) return false; + } + } + return true; + } + + private static Vector3 Centroid(UnitViewManager views, List ids) + { + Vector3 sum = Vector3.zero; + int n = 0; + foreach (EntityId id in ids) + { + if (views.TryGetView(id, out GameObject v) && v != null) + { + sum += v.transform.position; + n++; + } + } + return n > 0 ? sum / n : Vector3.zero; + } + + /// The RTS camera's 55-degree pitch at a given height, looking at a point. + private static void CaptureRts(string path, Vector3 focus, float height) + { + var go = new GameObject("MotionProofCamera"); + try + { + var camera = go.AddComponent(); + camera.fieldOfView = 60f; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.13f, 0.16f, 0.22f, 1f); + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + float distance = height / Mathf.Sin(55f * Mathf.Deg2Rad); + camera.transform.SetPositionAndRotation(focus - rot * Vector3.forward * distance, rot); + + var rt = new RenderTexture(1600, 900, 24) { antiAliasing = 4 }; + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + var tex = new Texture2D(1600, 900, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0f, 0f, 1600f, 900f), 0, 0); + tex.Apply(); + File.WriteAllBytes(path, tex.EncodeToPNG()); + RenderTexture.active = null; + camera.targetTexture = null; + Object.DestroyImmediate(tex); + rt.Release(); + Object.DestroyImmediate(rt); + } + finally + { + Object.DestroyImmediate(go); + } + } + } +} diff --git a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs.meta b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs.meta new file mode 100644 index 00000000..2a8c7a1f --- /dev/null +++ b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5790afd157df485cab95a1ec7f3d7def +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index c075af8b..1f8b9b11 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -957,7 +957,7 @@ private void ApplyTransform(int slot, in UnitState unit, float blend) viewTransform.position = targetPos; if (!isBuilding) { - viewTransform.rotation = Quaternion.Euler(0f, unit.Transform.Rotation.ToDegrees().ToFloat(), 0f); + viewTransform.rotation = ViewHeading.Rotation(unit.Transform.Rotation); } return; } @@ -967,7 +967,7 @@ private void ApplyTransform(int slot, in UnitState unit, float blend) viewTransform.position = Vector3.Lerp(viewTransform.position, targetPos, blend); if (!isBuilding) { - Quaternion targetRot = Quaternion.Euler(0f, unit.Transform.Rotation.ToDegrees().ToFloat(), 0f); + Quaternion targetRot = ViewHeading.Rotation(unit.Transform.Rotation); viewTransform.rotation = Quaternion.Slerp(viewTransform.rotation, targetRot, blend); } } diff --git a/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs b/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs new file mode 100644 index 00000000..170b4336 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs @@ -0,0 +1,53 @@ +using UnityEngine; +using Nova.Core; + +namespace Nova.Gameplay.Match +{ + /// + /// The one place where a simulation heading becomes a Unity yaw. + /// + /// The two conventions disagree on both origin and sense of rotation. + /// The simulation measures mathematically (): + /// 0 points along sim +X and angles grow toward sim +Y, i.e. + /// counter-clockwise seen from above. Unity's yaw is 0 along world +Z and + /// grows clockwise seen from above (toward +X). With sim X mapped to + /// world X and sim Y to world Z, the yaw is therefore + /// 90° − simDegrees. Feeding the sim angle straight into + /// mirrored every + /// heading across the north-east diagonal: a unit driving east looked + /// north, one driving north looked east, and only the diagonal itself + /// came out right. + /// + /// + /// Every art prefab faces world +Z at yaw 0 (the -90° Tripo correction in + /// tools/art/glb_to_unity_fbx.py), so a model rotated by this yaw + /// looks where the simulation drives it. + /// + /// + public static class ViewHeading + { + /// Unity yaw in degrees, normalised to [0, 360). + public static float YawDegrees(SimAngle simHeading) + { + return Mathf.Repeat(90f - simHeading.ToDegrees().ToFloat(), 360f); + } + + /// The rotation a unit view takes for a simulation heading. + public static Quaternion Rotation(SimAngle simHeading) + { + return Quaternion.Euler(0f, YawDegrees(simHeading), 0f); + } + + /// + /// Unity yaw that looks from one world position toward another on the + /// ground plane; when they coincide. + /// + public static float YawToward(Vector3 from, Vector3 to, float fallbackYaw) + { + float dx = to.x - from.x; + float dz = to.z - from.z; + if (dx * dx + dz * dz < 1e-6f) return fallbackYaw; + return Mathf.Repeat(Mathf.Atan2(dx, dz) * Mathf.Rad2Deg, 360f); + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs.meta new file mode 100644 index 00000000..c142faf7 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ViewHeading.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e94c42a942c9455bbb1b7b6a21eefa52 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From e7855eefb6a7e5bd56c97c0830823dfc45dafa6c Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 12:25:40 +0200 Subject: [PATCH 03/13] feat(art): rig, animate and regrade the 34 Tripo assets A recipe-driven Blender pass (tools/art/asset_pass.py, headless) splits every model into named, pivoted nodes (Body, Turret, Gun, Wheel_*, Arm_*, Spin_*, Scan_*), removes needles and floating debris, smooths the faceted Tripo normals and lifts the far too dark base colour onto the D-072 palettes; it also writes the _MSK mask and a new _EM emission map. The source is always the untouched conversion outside the repo, so a rerun is reproducible. UnitRigAnimator drives those nodes from view-only input: turrets yaw toward the visible attack target, guns recoil on visible shots, wheels roll, the suspension pitches and leans, infantry bob in step, harvester and builder arms work, dishes spin or sweep, and buildings breathe through their light strips, faster while producing (own buildings only, so the glow cannot leak production through the fog). Import keeps the FBX hierarchy (a collapsed single root drops its axis rotation) and forces bakeAxisConversion off (it turns the older FBX by 180 deg and survives in .meta otherwise). Art materials render double sided because the Tripo shells have real gaps. ArtPreviewCapture photographs every prefab through URP headless for review. Verified locally: headless 768/768, EditMode 676/676, PlayMode 16/16; all 34 prefabs inspected at rest and in a drive/fight pose. Not played. Decision: D-110. Protocol and limits: docs/assets/AssetPass_2026-10-17.md Co-Authored-By: Claude Opus 5.5 --- Assets/_Project/Editor/ArtAssetAutoSync.cs | 13 + .../_Project/Editor/ArtAssetPrefabBuilder.cs | 88 ++- Assets/_Project/Editor/ArtPreviewCapture.cs | 274 +++++++ .../_Project/Editor/ArtPreviewCapture.cs.meta | 11 + .../Scripts/Gameplay/Match/UnitRigAnimator.cs | 456 +++++++++++ .../Gameplay/Match/UnitRigAnimator.cs.meta | 11 + .../Scripts/Gameplay/Match/UnitViewManager.cs | 166 +++- CHANGELOG.md | 28 + docs/README.md | 2 + docs/assets/ArtAssetStandard.md | 10 +- docs/assets/AssetPass_2026-10-17.md | 161 ++++ docs/production/DecisionLog.md | 58 +- docs/tech/AnimationSystem.md | 24 +- tools/art/asset_layout.py | 87 ++ tools/art/asset_pass.py | 724 +++++++++++++++++ tools/art/asset_recipes.json | 745 ++++++++++++++++++ tools/art/layout_grid.py | 38 + tools/art/rig_tank_hierarchy.py | 3 +- 18 files changed, 2868 insertions(+), 31 deletions(-) create mode 100644 Assets/_Project/Editor/ArtPreviewCapture.cs create mode 100644 Assets/_Project/Editor/ArtPreviewCapture.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs.meta create mode 100644 docs/assets/AssetPass_2026-10-17.md create mode 100644 tools/art/asset_layout.py create mode 100644 tools/art/asset_pass.py create mode 100644 tools/art/asset_recipes.json create mode 100644 tools/art/layout_grid.py diff --git a/Assets/_Project/Editor/ArtAssetAutoSync.cs b/Assets/_Project/Editor/ArtAssetAutoSync.cs index 7049f18d..bb0b2e2e 100644 --- a/Assets/_Project/Editor/ArtAssetAutoSync.cs +++ b/Assets/_Project/Editor/ArtAssetAutoSync.cs @@ -196,8 +196,21 @@ private void OnPreprocessModel() importer.importTangents = ModelImporterTangents.CalculateMikk; // Mikktspace importer.weldVertices = true; importer.importAnimation = false; // animated assets opt in per asset + // Rigged meshes (tools/art/asset_pass.py) arrive as a node + // hierarchy whose Z-up -> Y-up conversion sits on the single + // "Root" node. Unity would collapse that root into the model + // object and drop its rotation with it - the model then lies + // on its side. Keeping the hierarchy keeps the rotation. + // (bakeAxisConversion is NOT an option: it turns the older + // single-mesh FBX by 180 degrees, so they drive backwards.) + importer.preserveHierarchy = true; + importer.bakeAxisConversion = false; // set explicitly: a stale .meta value survives otherwise } + // Bumped whenever the presets above change, so Unity re-imports + // the art with them instead of keeping stale import results. + public override uint GetVersion() => 4; + // docs/assets/ArtAssetStandard.md §4.2 — texture import preset. private void OnPreprocessTexture() { diff --git a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs index 3363c71d..9b0e74d7 100644 --- a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs +++ b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs @@ -35,6 +35,9 @@ public static class ArtAssetPrefabBuilder private const string UrpLitShader = "Universal Render Pipeline/Lit"; + /// HDR gain on the _EM map: accents must read under the full desert sun. + private const float EmissionIntensity = 1.6f; + [MenuItem("Tools/Project Nova/Build Art Prefabs From FBX")] public static void BuildMenu() { @@ -123,12 +126,55 @@ private static Material CreateOrUpdateMaterial(string dir, string suffix) Debug.LogWarning($"[ArtAssetPrefabBuilder] no base-colour texture at '{texPath}'; material '{matPath}' stays untextured."); } - // The Tripo-sourced meshes ship base colour only. Until the _MSK - // mask of ArtAssetStandard.md section 5.1 is authored, keep the - // surface fully dielectric and mid-rough rather than inheriting + // _MSK (ArtAssetStandard.md section 5.1, written by + // tools/art/asset_pass.py): R metallic and A smoothness drive URP + // Lit's metallic map, G its occlusion map. Without one the surface + // stays fully dielectric and mid-rough rather than inheriting // URP's shiny default, which reads as wet plastic under the RTS sun. - material.SetFloat("_Metallic", 0f); - material.SetFloat("_Smoothness", 0.25f); + var mask = AssetDatabase.LoadAssetAtPath($"{dir}/T_{suffix}_MSK.png"); + if (mask != null) + { + material.SetTexture("_MetallicGlossMap", mask); + material.EnableKeyword("_METALLICSPECGLOSSMAP"); + material.SetFloat("_Metallic", 1f); + material.SetFloat("_Smoothness", 1f); // multiplies mask.a + material.SetTexture("_OcclusionMap", mask); + material.EnableKeyword("_OCCLUSIONMAP"); + material.SetFloat("_OcclusionStrength", 1f); + } + else + { + material.SetTexture("_MetallicGlossMap", null); + material.DisableKeyword("_METALLICSPECGLOSSMAP"); + material.SetTexture("_OcclusionMap", null); + material.DisableKeyword("_OCCLUSIONMAP"); + material.SetFloat("_Metallic", 0f); + material.SetFloat("_Smoothness", 0.25f); + } + + // _EM: the light strips and glow panels cut out by the art pass. + var emission = AssetDatabase.LoadAssetAtPath($"{dir}/T_{suffix}_EM.png"); + if (emission != null) + { + material.SetTexture("_EmissionMap", emission); + material.SetColor("_EmissionColor", Color.white * EmissionIntensity); + material.EnableKeyword("_EMISSION"); + material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None; + } + else + { + material.SetTexture("_EmissionMap", null); + material.SetColor("_EmissionColor", Color.black); + material.DisableKeyword("_EMISSION"); + } + + // Tripo meshes are ~1000 loose shells with real gaps between them. + // With back faces culled the desert floor shows through every + // gap as a bright crack; rendering both faces shows the dark + // inside instead, which reads as a panel line. + material.SetFloat("_Cull", (float)CullMode.Off); + material.doubleSidedGI = true; + EditorUtility.SetDirty(material); return material; } @@ -157,18 +203,22 @@ private static bool BuildPrefab(string fbxPath, string dir, string prefabName, M var levels = new List(); var thresholds = new[] { Lod0ScreenHeight, Lod1ScreenHeight, 0.0f }; + // A rigged mesh (tools/art/asset_pass.py) carries one renderer + // per animated part and LOD — Hull, Turret, Gun, Wheel_* ... — + // so a level collects EVERY renderer of its suffix, not the first. for (int lod = 0; lod < 3; lod++) { - Transform child = FindChildWithSuffix(instance.transform, $"_LOD{lod}"); - if (child == null) continue; - - var renderer = child.GetComponent(); - if (renderer == null) continue; - - if (material != null) renderer.sharedMaterial = material; - renderer.shadowCastingMode = lod == 2 ? ShadowCastingMode.Off : ShadowCastingMode.On; + var renderers = new List(); + foreach (Renderer renderer in instance.GetComponentsInChildren(true)) + { + if (!renderer.name.EndsWith($"_LOD{lod}", System.StringComparison.Ordinal)) continue; + if (material != null) renderer.sharedMaterial = material; + renderer.shadowCastingMode = lod == 2 ? ShadowCastingMode.Off : ShadowCastingMode.On; + renderers.Add(renderer); + } + if (renderers.Count == 0) continue; - levels.Add(new LOD(thresholds[lod], new[] { renderer })); + levels.Add(new LOD(thresholds[lod], renderers.ToArray())); } if (levels.Count == 0) @@ -195,15 +245,5 @@ private static bool BuildPrefab(string fbxPath, string dir, string prefabName, M Object.DestroyImmediate(instance); } } - - private static Transform FindChildWithSuffix(Transform root, string suffix) - { - foreach (Transform t in root.GetComponentsInChildren(true)) - { - if (t != root && t.name.EndsWith(suffix, System.StringComparison.Ordinal)) return t; - } - - return null; - } } } diff --git a/Assets/_Project/Editor/ArtPreviewCapture.cs b/Assets/_Project/Editor/ArtPreviewCapture.cs new file mode 100644 index 00000000..9d5d21ca --- /dev/null +++ b/Assets/_Project/Editor/ArtPreviewCapture.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using Nova.Core; +using Nova.Gameplay.Match; + +namespace Nova.Editor +{ + /// + /// Headless photo booth for the art prefabs: renders every PF_* + /// under the art root through the real URP pipeline, under the in-game + /// sun and ambient (BootstrapSceneGenerator), and writes PNGs. It exists + /// so an asset pass can be judged as the game draws it — not as Blender + /// draws it — without anybody opening the editor. + /// + /// Unity -batchmode -projectPath . -executeMethod Nova.Editor.ArtPreviewCapture.CaptureFromCommandLine \ + /// -captureOut /abs/dir [-captureFilter BattleTank] [-captureHeading 0] [-captureTime 0.0] + /// [-captureBuildPrefabs] -logFile log -quit + /// + /// Per prefab it writes <name>_top.png (orthographic, from + /// above, with a red bar pointing along the simulated move direction) and + /// <name>_rts.png (the RTS camera's 55° pitch, framed close). + /// The prefab is rotated by for a unit moving + /// at -captureHeading sim degrees (0 = sim +X = world east), so + /// the top shot proves the whole chain: FBX axes, import, and the + /// sim-to-yaw mapping — the nose must sit on the red bar. + /// + public static class ArtPreviewCapture + { + private const int Size = 512; + + public static void CaptureFromCommandLine() + { + string outDir = Arg("-captureOut", Path.Combine(Directory.GetCurrentDirectory(), "output", "art-preview")); + string filter = Arg("-captureFilter", ""); + float heading = float.Parse(Arg("-captureHeading", "0"), System.Globalization.CultureInfo.InvariantCulture); + float time = float.Parse(Arg("-captureTime", "0"), System.Globalization.CultureInfo.InvariantCulture); + int written = 0; + try + { + if (HasFlag("-captureBuildPrefabs")) + { + // Fresh FBX/texture drops need their materials and prefabs + // rebuilt before they can be photographed. + ArtAssetPrefabBuilder.Build(); + ArtAssetAutoSync.SyncRegistry(); + } + written = Capture(outDir, filter, heading, time); + } + catch (Exception e) + { + Debug.LogException(e); + EditorApplication.Exit(1); + return; + } + Debug.Log($"[ArtPreviewCapture] {written} prefab(s) captured into {outDir}"); + } + + public static int Capture(string outDir, string filter, float simHeadingDegrees, float animationTime) + { + Directory.CreateDirectory(outDir); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SetUpStage(); + + string[] guids = AssetDatabase.FindAssets("t:Prefab PF_", new[] { "Assets/_Project/Art" }); + var paths = new List(); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (!Path.GetFileName(path).StartsWith("PF_", StringComparison.Ordinal)) continue; + if (filter.Length > 0 && path.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) continue; + paths.Add(path); + } + paths.Sort(StringComparer.Ordinal); + + SimAngle heading = SimAngle.FromDegrees(SimFixed.FromFloat(simHeadingDegrees)); + Quaternion rotation = ViewHeading.Rotation(heading); + Vector3 moveDir = rotation * Vector3.forward; + + foreach (string path in paths) + { + var prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) continue; + GameObject instance = UnityEngine.Object.Instantiate(prefab); + try + { + instance.transform.SetPositionAndRotation(Vector3.zero, rotation); + if (HasFlag("-captureDump")) Dump(instance.transform, ""); + ApplyFactionTint(instance, path); + PoseAnimation(instance, path, animationTime); + + Bounds b = WorldBounds(instance); + float extent = Mathf.Max(b.extents.x, b.extents.z, b.extents.y * 0.7f, 0.5f); + + GameObject marker = MoveMarker(b, moveDir, extent); + string stem = Path.GetFileNameWithoutExtension(path); + Shoot(TopCamera(b, extent), Path.Combine(outDir, stem + "_top.png")); + UnityEngine.Object.DestroyImmediate(marker); + Shoot(RtsCamera(b, extent), Path.Combine(outDir, stem + "_rts.png")); + } + finally + { + UnityEngine.Object.DestroyImmediate(instance); + } + } + return paths.Count; + } + + /// Same sun, ambient and sand ground as the generated Bootstrap scene. + private static void SetUpStage() + { + var lightObject = new GameObject("Sun"); + Light light = lightObject.AddComponent(); + light.type = LightType.Directional; + lightObject.transform.rotation = Quaternion.Euler(33f, -128f, 0f); + light.color = new Color(1f, 0.87f, 0.70f); + light.intensity = 1.15f; + light.shadows = LightShadows.Soft; + light.shadowStrength = 0.85f; + + RenderSettings.fog = false; + RenderSettings.ambientMode = AmbientMode.Trilight; + RenderSettings.ambientSkyColor = new Color(0.52f, 0.50f, 0.46f); + RenderSettings.ambientEquatorColor = new Color(0.76f, 0.66f, 0.50f); + RenderSettings.ambientGroundColor = new Color(0.30f, 0.26f, 0.21f); + + GameObject ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + ground.transform.localScale = new Vector3(10f, 1f, 10f); + var sand = new Material(Shader.Find("Universal Render Pipeline/Lit")); + sand.SetColor("_BaseColor", new Color(0.66f, 0.56f, 0.42f)); + sand.SetFloat("_Smoothness", 0.1f); + ground.GetComponent().sharedMaterial = sand; + } + + private static void ApplyFactionTint(GameObject instance, string path) + { + Color tint = path.Contains("_Legion_") ? FactionTint.LegionPrefabTint : FactionTint.AlliancePrefabTint; + var block = new MaterialPropertyBlock(); + FactionTint.ApplyToPropertyBlock(block, tint); + foreach (Renderer r in instance.GetComponentsInChildren(true)) + { + r.SetPropertyBlock(block); + } + } + + /// + /// Puts the rig into a mid-drive, mid-fight pose after a fixed time + /// (turret swung toward a target front-right, gun recoiling), exactly + /// as UnitViewManager would attach it at runtime. + /// + private static void PoseAnimation(GameObject instance, string path, float seconds) + { + if (seconds <= 0f) return; + UnitRigAnimator rig = instance.GetComponent(); + if (rig == null) rig = instance.AddComponent(); + rig.InfantryGait = path.Contains("Infantry"); + rig.PreviewPose(seconds); + } + + private static GameObject MoveMarker(Bounds b, Vector3 dir, float extent) + { + GameObject bar = GameObject.CreatePrimitive(PrimitiveType.Cube); + var red = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + red.SetColor("_BaseColor", new Color(1f, 0.1f, 0.1f)); + bar.GetComponent().sharedMaterial = red; + float len = extent * 0.9f; + bar.transform.localScale = new Vector3(extent * 0.12f, 0.05f, len); + bar.transform.rotation = Quaternion.LookRotation(dir, Vector3.up); + bar.transform.position = new Vector3(b.center.x, 0.03f, b.center.z) + dir * (extent * 1.05f + len * 0.5f); + return bar; + } + + private static Camera TopCamera(Bounds b, float extent) + { + Camera cam = NewCamera(); + cam.orthographic = true; + cam.orthographicSize = extent * 2.3f; + cam.transform.SetPositionAndRotation(new Vector3(b.center.x, b.max.y + 50f, b.center.z), + Quaternion.Euler(90f, 0f, 0f)); + return cam; + } + + private static Camera RtsCamera(Bounds b, float extent) + { + Camera cam = NewCamera(); + cam.fieldOfView = 30f; + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + float dist = extent * 4.6f; + cam.transform.SetPositionAndRotation(b.center - rot * Vector3.forward * dist, rot); + return cam; + } + + private static Camera NewCamera() + { + var go = new GameObject("PreviewCamera"); + Camera cam = go.AddComponent(); + cam.clearFlags = CameraClearFlags.SolidColor; + cam.backgroundColor = new Color(0.16f, 0.17f, 0.19f); + cam.nearClipPlane = 0.1f; + cam.farClipPlane = 500f; + return cam; + } + + private static void Shoot(Camera cam, string file) + { + var rt = new RenderTexture(Size, Size, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; + try + { + var request = new RenderPipeline.StandardRequest { destination = rt }; + if (RenderPipeline.SupportsRenderRequest(cam, request)) + { + RenderPipeline.SubmitRenderRequest(cam, request); + } + else + { + cam.targetTexture = rt; + cam.Render(); + cam.targetTexture = null; + } + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = rt; + var tex = new Texture2D(Size, Size, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, Size, Size), 0, 0); + tex.Apply(); + RenderTexture.active = previous; + File.WriteAllBytes(file, tex.EncodeToPNG()); + UnityEngine.Object.DestroyImmediate(tex); + } + finally + { + rt.Release(); + UnityEngine.Object.DestroyImmediate(rt); + UnityEngine.Object.DestroyImmediate(cam.gameObject); + } + } + + private static Bounds WorldBounds(GameObject go) + { + Renderer[] renderers = go.GetComponentsInChildren(); + if (renderers.Length == 0) return new Bounds(go.transform.position, Vector3.one); + Bounds b = renderers[0].bounds; + for (int i = 1; i < renderers.Length; i++) b.Encapsulate(renderers[i].bounds); + return b; + } + + private static void Dump(Transform t, string indent) + { + var r = t.GetComponent(); + Debug.Log($"[ArtPreviewCapture:dump] {indent}{t.name} pos={t.localPosition:F3} rot={t.localEulerAngles:F1} scl={t.localScale:F3}" + + (r != null ? $" bounds={r.bounds.center:F2}/{r.bounds.size:F2}" : "")); + foreach (Transform c in t) Dump(c, indent + " "); + } + + private static bool HasFlag(string name) + { + return Array.IndexOf(Environment.GetCommandLineArgs(), name) >= 0; + } + + private static string Arg(string name, string fallback) + { + string[] args = Environment.GetCommandLineArgs(); + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i] == name) return args[i + 1]; + } + return fallback; + } + } +} diff --git a/Assets/_Project/Editor/ArtPreviewCapture.cs.meta b/Assets/_Project/Editor/ArtPreviewCapture.cs.meta new file mode 100644 index 00000000..326042f7 --- /dev/null +++ b/Assets/_Project/Editor/ArtPreviewCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf76703a7c2941d99ed4aa88baddc2e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs new file mode 100644 index 00000000..bf9138cb --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs @@ -0,0 +1,456 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Nova.Gameplay.Match +{ + /// + /// What the view knows about one entity this frame — everything the rig + /// animation reads. Pure view data: nothing here flows back into the + /// simulation (docs/tech/AnimationSystem.md, Architektur-Grundsatz 1). + /// + public struct UnitRigInput + { + /// Ground speed of the VIEW in metres per second (measured, not the sim's nominal speed). + public float Speed; + + /// Yaw rate of the view in degrees per second, positive clockwise from above. + public float TurnRate; + + public bool HasTarget; + public Vector3 TargetPosition; + + /// True on the frame a shot of this entity became visible. + public bool Fired; + + /// Harvesting, building, producing: the role's "busy" motion runs. + public bool Working; + } + + /// + /// Rig-less code animation for vehicles, infantry and buildings + /// (docs/tech/AnimationSystem.md §Rig-lose Code-/Transform-Animation). + /// + /// The art pass (tools/art/asset_pass.py) splits a model into + /// pivoted nodes and names them; this component finds them by name and + /// drives them from : + /// + /// + /// Body — suspension: nods on acceleration, leans into turns, + /// bobs while driving (infantry: steps). + /// Turret — yaws toward the attack target with a capped rate, + /// returns to the bow when idle. + /// Gun — recoils on every visible shot. + /// Wheel_* — roll with the distance driven. + /// Spin_* — turn continuously (radar dishes, rotors). + /// Scan_* — sweep back and forth (dishes mounted off-axis). + /// Arm_* — swing while the unit works. + /// + /// + /// Axes are measured once in each node's parent space from the root's + /// up/right/forward, so the rig stays correct whatever axis baking the + /// FBX import applied. A prefab without a Body node gets one + /// inserted at bind time, so old single-mesh prefabs still move. + /// + /// + [DisallowMultipleComponent] + public sealed class UnitRigAnimator : MonoBehaviour + { + public const string BodyNode = "Body"; + public const string TurretNode = "Turret"; + public const string GunNode = "Gun"; + public const string WheelPrefix = "Wheel_"; + public const string SpinPrefix = "Spin_"; + public const string ArmPrefix = "Arm_"; + public const string ScanPrefix = "Scan_"; + + [SerializeField] private float _turretTurnRate = 140f; + [SerializeField] private float _spinRate = 55f; + [SerializeField] private float _recoilDistance = 0.35f; + [SerializeField] private float _recoilRecovery = 0.35f; + [SerializeField] private float _armSwingDegrees = 14f; + [SerializeField] private bool _infantryGait; + [SerializeField] private bool _emissivePulse; + + private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor"); + private static MaterialPropertyBlock _glowBlock; + + private struct Node + { + public Transform T; + public Vector3 RestPos; + public Quaternion RestRot; + public Vector3 Up; // root up, in parent space + public Vector3 Right; // root right, in parent space + public Vector3 Forward; // root forward, in parent space + public float Scale; // one root-local unit, measured in parent-space units + public float Radius; // wheels only + public float Phase; + } + + private bool _bound; + private Node _body; + private bool _hasBody; + private Node _turret; + private bool _hasTurret; + private Node _gun; + private bool _hasGun; + private readonly List _wheels = new List(4); + private readonly List _spins = new List(2); + private readonly List _arms = new List(2); + private readonly List _scans = new List(1); + + private Renderer[] _glowRenderers = System.Array.Empty(); + private Color[] _glowBase = System.Array.Empty(); + private float _glowPhase; + private float _glowBusy; + + private float _size = 1f; + private float _turretYaw; + private float _recoil; + private float _bobPhase; + private float _wheelAngle; + private float _spinAngle; + private float _armPhase; + private float _armBlend; + private float _lastSpeed; + private float _pitch; + private float _roll; + private float _moveBlend; + + /// True when the model carries a turret node: the hull then keeps its heading while fighting. + public bool HasTurret + { + get { EnsureBound(); return _hasTurret; } + } + + /// Walks like a soldier (step bob and sway) instead of riding a suspension. + public bool InfantryGait + { + get => _infantryGait; + set => _infantryGait = value; + } + + /// + /// Buildings breathe through their light strips (the _EM map of the art + /// pass) and pulse faster while they work. Units keep a steady glow. + /// + public bool EmissivePulse + { + get => _emissivePulse; + set => _emissivePulse = value; + } + + /// Back to the authored pose; called when the view returns to its pool. + public void ResetPose() + { + EnsureBound(); + _turretYaw = 0f; _recoil = 0f; _bobPhase = 0f; _wheelAngle = 0f; + _armPhase = 0f; _armBlend = 0f; _lastSpeed = 0f; _pitch = 0f; _roll = 0f; _moveBlend = 0f; + Restore(_body, _hasBody); + Restore(_turret, _hasTurret); + Restore(_gun, _hasGun); + for (int i = 0; i < _wheels.Count; i++) Restore(_wheels[i], true); + for (int i = 0; i < _spins.Count; i++) Restore(_spins[i], true); + for (int i = 0; i < _arms.Count; i++) Restore(_arms[i], true); + for (int i = 0; i < _scans.Count; i++) Restore(_scans[i], true); + } + + /// Advances every animated node by one rendered frame. + public void Tick(in UnitRigInput input, float dt) + { + EnsureBound(); + if (dt <= 0f) return; + + float speed = input.Speed; + float moving = speed > 0.15f ? 1f : 0f; + _moveBlend = Mathf.MoveTowards(_moveBlend, moving, dt * 4f); + + TickBody(input, speed, dt); + TickTurret(input, dt); + TickGun(input, dt); + + _wheelAngle += speed * dt; + for (int i = 0; i < _wheels.Count; i++) + { + Node w = _wheels[i]; + float deg = _wheelAngle / Mathf.Max(0.05f, w.Radius) * Mathf.Rad2Deg; + w.T.localRotation = Quaternion.AngleAxis(deg, w.Right) * w.RestRot; + } + + _spinAngle = Mathf.Repeat(_spinAngle + _spinRate * dt, 360f); + for (int i = 0; i < _spins.Count; i++) + { + Node s = _spins[i]; + s.T.localRotation = Quaternion.AngleAxis(_spinAngle + s.Phase, s.Up) * s.RestRot; + } + + for (int i = 0; i < _scans.Count; i++) + { + Node s = _scans[i]; + float sweep = Mathf.Sin((_spinAngle + s.Phase) * Mathf.Deg2Rad * 0.6f) * 50f; + s.T.localRotation = Quaternion.AngleAxis(sweep, s.Up) * s.RestRot; + } + + _armBlend = Mathf.MoveTowards(_armBlend, input.Working ? 1f : 0f, dt * 2f); + _armPhase += dt * 2.6f; + for (int i = 0; i < _arms.Count; i++) + { + Node a = _arms[i]; + float swing = Mathf.Sin(_armPhase + a.Phase) * _armSwingDegrees * _armBlend; + a.T.localRotation = Quaternion.AngleAxis(swing, a.Right) * a.RestRot; + } + + TickGlow(input.Working, dt); + _lastSpeed = speed; + } + + private void TickGlow(bool working, float dt) + { + if (!_emissivePulse || _glowRenderers.Length == 0) return; + _glowBusy = Mathf.MoveTowards(_glowBusy, working ? 1f : 0f, dt * 1.5f); + _glowPhase += dt * Mathf.Lerp(1.4f, 5.5f, _glowBusy); + float k = 1f + Mathf.Sin(_glowPhase) * Mathf.Lerp(0.22f, 0.55f, _glowBusy) + 0.25f * _glowBusy; + if (_glowBlock == null) _glowBlock = new MaterialPropertyBlock(); + for (int i = 0; i < _glowRenderers.Length; i++) + { + Renderer r = _glowRenderers[i]; + if (r == null || !r.isVisible) continue; + // Read-modify-write keeps the faction tint UnitViewManager put + // into the same block. + r.GetPropertyBlock(_glowBlock); + _glowBlock.SetColor(EmissionColorId, _glowBase[i] * k); + r.SetPropertyBlock(_glowBlock); + } + } + + /// + /// Editor/preview hook (ArtPreviewCapture): a representative mid-drive, + /// mid-fight pose after of simulated time. + /// + public void PreviewPose(float seconds) + { + ResetPose(); + var input = new UnitRigInput + { + Speed = 4f, + TurnRate = 25f, + HasTarget = true, + TargetPosition = transform.position + transform.rotation * new Vector3(20f, 0f, 12f), + Working = true, + }; + const float step = 1f / 30f; + for (float t = 0f; t < seconds; t += step) + { + input.Fired = t + step >= seconds - 0.05f && t < seconds - 0.05f; + Tick(input, step); + } + } + + private void TickBody(in UnitRigInput input, float speed, float dt) + { + if (!_hasBody) return; + + float accel = (speed - _lastSpeed) / dt; + Vector3 offset = Vector3.zero; + float pitchTarget, rollTarget; + + if (_infantryGait) + { + // Two steps per ~1.4 m: a vertical bob at step rate, a sway at + // stride rate and a slight forward lean while moving. + _bobPhase += speed * dt / 0.7f * Mathf.PI; + float bob = Mathf.Abs(Mathf.Sin(_bobPhase)) * 0.06f * _size * _moveBlend; + offset = _body.Up * bob; + pitchTarget = 6f * _moveBlend; + rollTarget = Mathf.Sin(_bobPhase) * 4f * _moveBlend; + } + else + { + // Suspension: nose dips when braking and lifts on throttle, + // the hull leans out of turns, and a small road-noise bob. + _bobPhase += dt * (6f + speed * 1.5f); + float bob = (Mathf.Sin(_bobPhase) * 0.6f + Mathf.Sin(_bobPhase * 2.3f) * 0.4f) * 0.012f * _size * _moveBlend; + offset = _body.Up * bob; + pitchTarget = Mathf.Clamp(-accel * 0.9f, -3.5f, 3.5f); + rollTarget = Mathf.Clamp(-input.TurnRate * 0.05f, -3f, 3f) * _moveBlend; + } + + float k = 1f - Mathf.Exp(-dt * 8f); + _pitch = Mathf.Lerp(_pitch, pitchTarget, k); + _roll = Mathf.Lerp(_roll, rollTarget, k); + + _body.T.localPosition = _body.RestPos + offset * _body.Scale; + _body.T.localRotation = Quaternion.AngleAxis(_pitch, _body.Right) + * Quaternion.AngleAxis(_roll, _body.Forward) + * _body.RestRot; + } + + private void TickTurret(in UnitRigInput input, float dt) + { + if (!_hasTurret) return; + + float desired = 0f; + if (input.HasTarget) + { + Vector3 local = transform.InverseTransformPoint(input.TargetPosition); + if (local.x * local.x + local.z * local.z > 0.01f) + { + desired = Mathf.Atan2(local.x, local.z) * Mathf.Rad2Deg; + } + } + float delta = Mathf.DeltaAngle(_turretYaw, desired); + _turretYaw += Mathf.Clamp(delta, -_turretTurnRate * dt, _turretTurnRate * dt); + _turret.T.localRotation = Quaternion.AngleAxis(_turretYaw, _turret.Up) * _turret.RestRot; + } + + private void TickGun(in UnitRigInput input, float dt) + { + if (!_hasGun) return; + if (input.Fired) _recoil = 1f; + _recoil = Mathf.MoveTowards(_recoil, 0f, dt / Mathf.Max(0.05f, _recoilRecovery)); + // Fast kick, eased return. + float back = _recoil * _recoil * _recoilDistance * _size * 0.25f; + _gun.T.localPosition = _gun.RestPos - _gun.Forward * (back * _gun.Scale); + } + + private void EnsureBound() + { + if (_bound) return; + _bound = true; + + // Offsets are written in the root's LOCAL space, so the footprint + // is measured there too: UnitViewManager scales the instance root + // to the role's view size, and a world-space size would apply + // that scale twice. + Bounds b = RendererBounds(transform); + float rootScale = Mathf.Max(1e-4f, transform.lossyScale.x); + _size = Mathf.Max(0.5f, Mathf.Max(b.size.x, b.size.z) / rootScale); + + Transform body = FindDeep(transform, BodyNode); + if (body == null) + { + body = InsertBody(); + } + _hasBody = body != null; + if (_hasBody) _body = Capture(body); + + Transform turret = FindDeep(transform, TurretNode); + _hasTurret = turret != null; + if (_hasTurret) _turret = Capture(turret); + + Transform gun = FindDeep(transform, GunNode); + _hasGun = gun != null; + if (_hasGun) _gun = Capture(gun); + + var glowing = new List(); + var glowBase = new List(); + foreach (Renderer r in GetComponentsInChildren(true)) + { + Material m = r.sharedMaterial; + if (m == null || !m.IsKeywordEnabled("_EMISSION") || !m.HasProperty(EmissionColorId)) continue; + glowing.Add(r); + glowBase.Add(m.GetColor(EmissionColorId)); + } + _glowRenderers = glowing.ToArray(); + _glowBase = glowBase.ToArray(); + _glowPhase = Random.value * 6.28f; + + var all = GetComponentsInChildren(true); + for (int i = 0; i < all.Length; i++) + { + Transform t = all[i]; + string n = t.name; + if (n.StartsWith(WheelPrefix, System.StringComparison.Ordinal)) + { + Node w = Capture(t); + w.Radius = Mathf.Max(0.05f, RendererBounds(t).extents.y); + _wheels.Add(w); + } + else if (n.StartsWith(SpinPrefix, System.StringComparison.Ordinal)) + { + Node s = Capture(t); + s.Phase = _spins.Count * 47f; + _spins.Add(s); + } + else if (n.StartsWith(ScanPrefix, System.StringComparison.Ordinal)) + { + _scans.Add(Capture(t)); + } + else if (n.StartsWith(ArmPrefix, System.StringComparison.Ordinal)) + { + Node a = Capture(t); + a.Phase = _arms.Count * 1.7f; + _arms.Add(a); + } + } + } + + /// + /// Single-mesh prefabs have no suspension node: wrap every direct child + /// in a fresh Body so the bob/lean has something to move without + /// fighting the root transform that UnitViewManager writes. + /// + private Transform InsertBody() + { + if (transform.childCount == 0) return null; + var go = new GameObject(BodyNode); + Transform body = go.transform; + body.SetParent(transform, false); + var children = new List(transform.childCount); + foreach (Transform child in transform) + { + if (child != body) children.Add(child); + } + for (int i = 0; i < children.Count; i++) + { + children[i].SetParent(body, true); + } + return body; + } + + private Node Capture(Transform t) + { + Transform parent = t.parent != null ? t.parent : transform; + return new Node + { + T = t, + RestPos = t.localPosition, + RestRot = t.localRotation, + Up = parent.InverseTransformDirection(transform.up).normalized, + Right = parent.InverseTransformDirection(transform.right).normalized, + Forward = parent.InverseTransformDirection(transform.forward).normalized, + // Blender FBX hierarchies import with a x100 "Root" node: a + // translation must be converted into the parent's units, a + // rotation axis need not. + Scale = parent.InverseTransformVector(transform.TransformVector(Vector3.up)).magnitude, + }; + } + + private static void Restore(Node n, bool has) + { + if (!has || n.T == null) return; + n.T.localPosition = n.RestPos; + n.T.localRotation = n.RestRot; + } + + private static Transform FindDeep(Transform root, string name) + { + if (root.name == name) return root; + foreach (Transform child in root) + { + Transform hit = FindDeep(child, name); + if (hit != null) return hit; + } + return null; + } + + private static Bounds RendererBounds(Transform t) + { + Renderer[] rs = t.GetComponentsInChildren(true); + if (rs.Length == 0) return new Bounds(t.position, Vector3.one); + Bounds b = rs[0].bounds; + for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds); + return b; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs.meta new file mode 100644 index 00000000..2b010664 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e4a2dbf45bf4cc79ef5b9ef82018e29 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index 1f8b9b11..98a57d8b 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -132,6 +132,16 @@ public class UnitViewManager : MonoBehaviour private int[] _lastSeenFrame; private bool[] _tracked; + // Rig animation (UnitRigAnimator): one per prefab view, plus the last + // rendered position/yaw so speed and turn rate are measured on the + // VIEW — the smoothed motion the player sees, not the 10-Hz sim steps. + private UnitRigAnimator[] _viewRigs; + private Vector3[] _lastViewPositions; + private float[] _lastViewYaws; + private bool[] _firedThisFrame; + private bool[] _buildingThisFrame; + private byte _lastViewerTeam; + // Compact list of slots that currently own a view, so the per-frame // sweep is O(visible) instead of O(entity capacity). private readonly List _activeIndices = new List(256); @@ -275,6 +285,7 @@ private void LateUpdate() EnsureBuffers(); byte viewerTeam = ResolveViewerTeam(fog); + _lastViewerTeam = viewerTeam; _frameStamp++; // 1) The committed team view is the ONLY source of renderable @@ -351,6 +362,16 @@ private void LateUpdate() } _combatEffects?.Present(_combatEvents); + MarkWorkingBuilders(entities); + System.Array.Clear(_firedThisFrame, 0, _firedThisFrame.Length); + for (int i = 0; i < _combatEvents.Count; i++) + { + CombatFeedbackEvent feedback = _combatEvents[i]; + if (feedback.Kind != CombatFeedbackKind.Shot) continue; + int sourceSlot = feedback.SourceId.Index; + if (sourceSlot >= 0 && sourceSlot < _firedThisFrame.Length) _firedThisFrame[sourceSlot] = true; + } + float blend = 1f - Mathf.Exp(-Mathf.Max(0f, _interpolationSpeed) * Time.deltaTime); for (int i = 0; i < _visibleScratch.Count; i++) { @@ -392,6 +413,7 @@ private void LateUpdate() _lastSeenFrame[slot] = _frameStamp; ApplyTransform(slot, in unit, spawned ? 1f : blend); + AnimateRig(slot, in unit, spawned); } // 2) Everything not visible now is either already detached into a @@ -465,6 +487,11 @@ private void EnsureBuffers() _viewHealthSteps = new int[capacity]; _lastSeenFrame = new int[capacity]; _tracked = new bool[capacity]; + _viewRigs = new UnitRigAnimator[capacity]; + _lastViewPositions = new Vector3[capacity]; + _lastViewYaws = new float[capacity]; + _firedThisFrame = new bool[capacity]; + _buildingThisFrame = new bool[capacity]; for (int i = 0; i < capacity; i++) { @@ -581,6 +608,18 @@ private void AcquireView(int slot, in UnitState unit) _viewHealthSteps[slot] = -1; // ... including the health bucket of the recycled instance _viewObjectToSlot[instance] = slot; + // Art prefabs get the rig animation; graybox primitives stay still. + UnitRigAnimator rig = null; + if (sourcePrefab != null) + { + rig = instance.GetComponent(); + if (rig == null) rig = instance.AddComponent(); + rig.InfantryGait = IsInfantryRole(unit.Role); + rig.EmissivePulse = SimDefinitions.IsBuildingRole(unit.Role); + rig.ResetPose(); + } + _viewRigs[slot] = rig; + if (!_tracked[slot]) { _tracked[slot] = true; @@ -703,6 +742,11 @@ private void ReleaseView(int slot) } _viewObjectToSlot.Remove(instance); + if (_viewRigs[slot] != null) + { + _viewRigs[slot].ResetPose(); + _viewRigs[slot] = null; + } int shapeKey = _viewShapeKeys[slot]; GameObject sourcePrefab = _viewSourcePrefabs[slot]; ReturnInstanceToPool(instance, shapeKey, sourcePrefab); @@ -777,7 +821,9 @@ private bool BeginDeathHold(EntityId id) SimDefinitions.IsBuildingRole(_viewRoles[slot]))); // Detach only. The active-slot sweep owns _tracked/_activeIndices - // and removes this slot later in the same LateUpdate. + // and removes this slot later in the same LateUpdate. The corpse + // keeps its last rig pose; the pool resets it on reuse. + _viewRigs[slot] = null; _viewInstances[slot] = null; _viewRenderers[slot] = null; _boundIds[slot] = EntityId.Invalid; @@ -952,12 +998,26 @@ private void ApplyTransform(int slot, in UnitState unit, float blend) // receive position writes only. bool isBuilding = SimDefinitions.IsBuildingRole(_viewRoles[slot]); + Quaternion targetRot = ViewHeading.Rotation(unit.Transform.Rotation); + if (!isBuilding && !unit.IsMoving && TryGetTargetViewPosition(in unit, out Vector3 aim)) + { + // A standing unit without a turret turns its whole body onto + // the target; a turreted one keeps the hull and lets the rig + // swing the turret. View-only: the sim heading is untouched. + UnitRigAnimator rig = _viewRigs[slot]; + if (rig == null || !rig.HasTurret) + { + float yaw = ViewHeading.YawToward(viewTransform.position, aim, targetRot.eulerAngles.y); + targetRot = Quaternion.Euler(0f, yaw, 0f); + } + } + if (blend >= 1f) { viewTransform.position = targetPos; if (!isBuilding) { - viewTransform.rotation = ViewHeading.Rotation(unit.Transform.Rotation); + viewTransform.rotation = targetRot; } return; } @@ -967,11 +1027,111 @@ private void ApplyTransform(int slot, in UnitState unit, float blend) viewTransform.position = Vector3.Lerp(viewTransform.position, targetPos, blend); if (!isBuilding) { - Quaternion targetRot = ViewHeading.Rotation(unit.Transform.Rotation); viewTransform.rotation = Quaternion.Slerp(viewTransform.rotation, targetRot, blend); } } + /// + /// Feeds the rig animation of a prefab view with this frame's view + /// motion, the visible attack target and the shot flag. + /// + private void AnimateRig(int slot, in UnitState unit, bool spawned) + { + UnitRigAnimator rig = _viewRigs[slot]; + Transform t = _viewInstances[slot].transform; + Vector3 pos = t.position; + float yaw = t.eulerAngles.y; + if (rig == null || spawned) + { + _lastViewPositions[slot] = pos; + _lastViewYaws[slot] = yaw; + return; + } + + float dt = Time.deltaTime; + float speed = 0f, turnRate = 0f; + if (dt > 1e-5f) + { + Vector3 d = pos - _lastViewPositions[slot]; + d.y = 0f; + speed = d.magnitude / dt; + turnRate = Mathf.DeltaAngle(_lastViewYaws[slot], yaw) / dt; + } + _lastViewPositions[slot] = pos; + _lastViewYaws[slot] = yaw; + + var input = new UnitRigInput + { + Speed = speed, + TurnRate = turnRate, + Fired = _firedThisFrame[slot], + Working = !unit.IsMoving + && ((unit.Role == UnitRole.Harvester && unit.HarvestFieldId != 0 && !unit.IsReturningCargo) + || (unit.Role == UnitRole.Builder && _buildingThisFrame[slot]) + || IsProducingForViewer(in unit)), + }; + input.HasTarget = TryGetTargetViewPosition(in unit, out input.TargetPosition); + rig.Tick(in input, dt); + } + + /// + /// World position of the unit's attack target, read from the target's + /// VIEW — a target without a bound view is not visible to the viewer, + /// and aiming at it would leak its position through the fog. + /// + private bool TryGetTargetViewPosition(in UnitState unit, out Vector3 position) + { + position = default; + EntityId target = unit.AttackTarget; + if (!target.IsValid) return false; + int targetSlot = target.Index; + if (targetSlot < 0 || targetSlot >= _viewInstances.Length) return false; + if (_boundIds[targetSlot] != target || _viewInstances[targetSlot] == null) return false; + position = _viewInstances[targetSlot].transform.position; + return true; + } + + /// + /// Flags every builder assigned to a VISIBLE construction site, read + /// from the site side (the simulation keeps the assignment there), so + /// its arms can work while it stands at the site. + /// + private void MarkWorkingBuilders(EntityManager entities) + { + System.Array.Clear(_buildingThisFrame, 0, _buildingThisFrame.Length); + ConstructionSystem construction = _matchRunner.Construction; + if (construction == null) return; + for (int i = 0; i < _visibleScratch.Count; i++) + { + EntityId id = _visibleScratch[i]; + if (!construction.IsActiveSite(id)) continue; + if (!construction.TryGetSite(UnitCommandStateView.ToRawEntityId(id), out _, out _, out uint builderRaw)) continue; + if (builderRaw == 0u) continue; + EntityId builder = UnitCommandStateView.ToEntityId(builderRaw); + if (builder.IsValid && builder.Index < _buildingThisFrame.Length) _buildingThisFrame[builder.Index] = true; + } + } + + /// + /// A production building with a non-empty queue — for the viewer's + /// OWN buildings only: a busy glow on a foreign factory would leak its + /// production through the fog. + /// + private bool IsProducingForViewer(in UnitState unit) + { + if (!SimDefinitions.IsBuildingRole(unit.Role)) return false; + if (unit.PlayerId != _lastViewerTeam) return false; + var production = _matchRunner.Production; + if (production == null) return false; + return production.TryGetProducer(UnitCommandStateView.ToRawEntityId(unit.Id), out int entries, out _, out _) + && entries > 0; + } + + private static bool IsInfantryRole(UnitRole role) + { + return role == UnitRole.BasicInfantry || role == UnitRole.AntiArmorInfantry; + } + private void ApplyTint(int slot, byte playerId, int healthStep) { GameObject instance = _viewInstances[slot]; diff --git a/CHANGELOG.md b/CHANGELOG.md index ff9b7d08..4766a61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,26 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Asset-Pass über alle 34 Tripo-Assets: bewegliche Teile, Animation und Optik (D-110).** + Ein rezeptgesteuerter Blender-Pass (`tools/art/asset_pass.py`, + `asset_recipes.json`, headless) trennt die Modelle in benannte, gepivotete Teile, + repariert Einzelfehler (schwebendes Doppelrohr der Allianz-Verteidigung, lose + Platte der Legion, 6-m-Splitter am Legion-Bauer, der die Einheit im Spiel + schrumpfen ließ), glättet die facettierten Normalen und hebt die viel zu dunklen + Texturen auf die D-072-Paletten (Allianz Stahlgrau mit Cyan-Akzenten, Legion + Rost); Aetherium-Kristalle und Warnlichter behalten ihre Farbe. Neu: Leuchttextur + `T_*_EM` und die `_MSK` nach Standard §5.1. In Unity bewegt `UnitRigAnimator` die + Teile rig-los aus dem Spielzustand: Türme schwenken aufs sichtbare Ziel, Rohre + stoßen beim Schuss zurück, Räder rollen, die Federung nickt und neigt sich, + Infanterie wippt im Schritt, Sammler- und Bauarme arbeiten, Radarschüsseln, + Raffinerie-Kristall und HQ-Antenne drehen sich, Gebäude atmen über ihre + Leuchtstreifen und pulsieren beim Produzieren. Art-Materialien rendern + doppelseitig, damit man durch die Lücken der Tripo-Schalen nicht mehr den Sand + sieht. Prüfwerkzeuge: `ArtPreviewCapture` (Unity-Fotobox im echten Renderer), + `asset_layout.py` (Raster-Ansichten für Rezepte). Protokoll und ehrliche Grenzen: + `docs/assets/AssetPass_2026-10-17.md` — Geometrie bleibt Tripo-Qualität, + Legion-Kraftwerk und -Forschungslabor sind als Panzer generiert, Infanterie hat + kein Skelett. Automatisiert und per Kamera geprüft, **nicht gespielt** - **RTS-Gefühl, Steuerung & Audio: Angriffsverfolgung, Kontext-Rechtsklick, dynamische Cursor, 3D-Welt und Sound-Cues (#51).** Schließt die offenen Bedien-, Audio- und Darstellungslücken vollständig ab, ohne in die deterministische Simulationslogik einzugreifen: @@ -426,6 +446,14 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Einheiten schauen in ihre Fahrtrichtung.** Die Ansicht setzte den + mathematischen Sim-Kurs (0° = Osten, gegen den Uhrzeigersinn) direkt als + Unity-Gier (0° = Norden, im Uhrzeigersinn) ein und spiegelte jede Einheit an der + Nordost-Diagonale: Wer nach Osten fuhr, schaute nach Norden. Die Abbildung liegt + jetzt an einer Stelle (`ViewHeading`), abgesichert durch EditMode-Tests und einen + PlayMode-Nachweis in der laufenden Partie (sechs Richtungen, Gegenprobe mit der + alten Abbildung schlägt fehl). Stehende Einheiten ohne Turm drehen sich zum + sichtbaren Angriffsziel (D-110) - **Die Bauverweigerung nennt jetzt den Grund (#135).** Aus der Proberunde vom 31.08.2026 kamen zwei Beschwerden, die sich als **dieselbe** erwiesen: „Der zweite Atlas kann keine Gebäude bauen" und „Ich konnte in der Kartenmitte kein diff --git a/docs/README.md b/docs/README.md index d036d487..7c0216b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,6 +162,8 @@ gelisteten Kernverträge. Paket ausserhalb des Repos liegen, Inhalt und Installationsablauf - [AssetImport_Tripo_2026-08-06](assets/AssetImport_Tripo_2026-08-06.md) – Importprotokoll der 34 Tripo-Assets (GLB → FBX, LODs, Texturen) +- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.1.0, Entwurf) – + Blickrichtung, Teile-Animation und Optik der 34 Tripo-Assets, Werkzeuge und Grenzen - [VerticalSlice_MS1](assets/VerticalSlice_MS1.md) (0.2.0, Entwurf) – Vertical-Slice-Spezifikation der vier Erst-Assets - [ConceptArtStyleGuide](assets/ConceptArtStyleGuide.md) (0.1.0, Entwurf) – diff --git a/docs/assets/ArtAssetStandard.md b/docs/assets/ArtAssetStandard.md index b68c963d..b87e46b3 100644 --- a/docs/assets/ArtAssetStandard.md +++ b/docs/assets/ArtAssetStandard.md @@ -1,6 +1,6 @@ # Art Asset Standard -**Version:** 0.2.0 | **Status:** Entwurf – MS-1 Art-Strang verbindlich, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art / Producer | **Sprint:** 7 +**Version:** 0.3.0 | **Status:** Entwurf – MS-1 Art-Strang verbindlich, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art / Producer | **Sprint:** 7 ## Zweck @@ -110,6 +110,7 @@ Zellzahl zurückgerechnet wird. | Textur BaseColor | `T_BLDG___BC.png` bzw. `T_UNIT___BC.png` | `T_UNIT_Alliance_Builder_BC.png` | | Textur Normal | `..._N.png` | `T_BLDG_Legion_Barracks_N.png` | | Textur Mask (Metallic/AO/Team/Smoothness, §5) | `..._MSK.png` | `T_UNIT_Alliance_LightTank_MSK.png` | +| Textur Emission (Leuchtakzente, D-110) | `..._EM.png` | `T_BLDG_Alliance_Refinery_EM.png` | | Material | `M_BLDG__.mat` bzw. `M_UNIT__.mat` | `M_BLDG_Alliance_Refinery.mat` | | Prefab | `PF_BLDG__.prefab` bzw. `PF_UNIT__.prefab` | `PF_UNIT_Legion_BattleTank.prefab` | @@ -119,6 +120,12 @@ Submesh-/Objektnamen mit Suffix `_LOD0` / `_LOD1` / `_LOD2`, z. B. `SM_UNIT_Alliance_LightTank_LOD0`, `..._LOD1`, `..._LOD2` als drei Objekte im selben FBX-Export. Der Root-Dateiname trägt kein LOD-Suffix. +**Animierbare Assets (D-110)** tragen statt dreier flacher Objekte eine +Knoten-Hierarchie (`Root` → `Body` → `Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*`); +jedes Teil liegt je LOD-Stufe als eigenes Objekt mit Suffix `_LOD0..2` darunter. +Der Prefab-Bau sammelt alle Renderer einer Stufe in deren `LODGroup`-Eintrag. +Vertrag und Werkzeuge: [AssetPass_2026-10-17.md](AssetPass_2026-10-17.md). + **Verhältnis zur Daten-Ebene:** [../tech/NamingConvention.md](../tech/NamingConvention.md) §4 definiert bereits die Präfixe `UNIT_` und `BLDG_` für ScriptableObject-Dateien (`UNIT_Allianz_Rifleman.asset`, @@ -356,3 +363,4 @@ möglich, aber im PR zu begründen. |---|---|---|---| | 0.1.0 | 2026-07-25 | Erstfassung: Ordnerstruktur, Dateinamenskonvention, LOD-Konvention, Import-Settings, Material-Standard und Team-Mask-Kanalbelegung spezifiziert | Technical Art | | 0.2.0 | 2026-07-25 | Team-Mask-Kanalbelegung (§5.1) von Vorschlag auf für MS-1 verbindlich gehoben, Alternativenabwägung als Begründung in den Fachabschnitt übernommen; Grid-Zellgröße 3,0 m als art-seitige Arbeitsannahme samt Footprint-Maßtabelle ergänzt (§1); Texel-Density bleibt Vorschlag; Hinweis auf ausstehenden `FolderStructure.md`-Nachtrag nach G0-B ergänzt | Technical Art | +| 0.3.0 | 2026-10-17 | Emissionstextur `_EM` und Knoten-Hierarchie animierbarer Assets ergänzt (D-110) | Technical Art | diff --git a/docs/assets/AssetPass_2026-10-17.md b/docs/assets/AssetPass_2026-10-17.md new file mode 100644 index 00000000..e4fef651 --- /dev/null +++ b/docs/assets/AssetPass_2026-10-17.md @@ -0,0 +1,161 @@ +# Asset-Pass: Blickrichtung, Teile-Animation, Optik + +**Version:** 0.1.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 + +## Zweck + +Protokolliert den zweiten Durchgang über alle 34 Tripo-Assets aus +[AssetImport_Tripo_2026-08-06.md](AssetImport_Tripo_2026-08-06.md): Einheiten +schauen jetzt in ihre Fahrtrichtung, Fahrzeuge und einige Gebäude haben +bewegliche Teile, und die Texturen sind auf die D-072-Paletten gehoben. Das +Dokument beschreibt, wie der Durchgang reproduziert wird, wie er geprüft wurde +und was er **nicht** behebt. Die Entscheidung dahinter ist +[D-110](../production/DecisionLog.md). + +## Abhängigkeiten + +- [AssetImport_Tripo_2026-08-06.md](AssetImport_Tripo_2026-08-06.md) – das Konvertat, von dem dieser Pass ausgeht +- [ArtAssetStandard.md](ArtAssetStandard.md) – Namen, LODs, `_MSK`-Kanäle +- [../tech/AnimationSystem.md](../tech/AnimationSystem.md) – rig-lose Code-Animation für Fahrzeuge und Gebäude +- [AssetPackage.md](AssetPackage.md) – warum die Binärdaten nicht im Repo liegen + +## 1. Was falsch war + +1. **Bewegungsrichtung (Code, nicht Asset).** Die Simulation misst den Kurs + mathematisch (`SimTrig.Atan2`: 0 = Sim-+X, gegen den Uhrzeigersinn), Unity + den Gierwinkel ab +Z im Uhrzeigersinn. `UnitViewManager` setzte den Sim-Winkel + direkt als Unity-Gier ein. Jede Einheit war dadurch an der Nordost-Diagonale + gespiegelt: Wer nach Osten fuhr, schaute nach Norden. Die Modelle selbst + zeigen korrekt nach +Z; geprüft per Draufsicht aller 34 Prefabs. +2. **Risse.** Jedes Tripo-Mesh besteht aus rund 1000 losen Schalen (eine je + UV-Insel), die teils zentimeterweit auseinanderliegen. Mit Backface-Culling + sah man durch jede Lücke den Sandboden. +3. **Zu dunkle Texturen.** Tripo backt Licht in die Basisfarbe; die mittlere + Helligkeit lag zwischen 5 und 21 %. Die Allianz war einheitlich Petrol statt + Stahlgrau (D-072). +4. **Keine beweglichen Teile.** Jedes Modell war ein einziges Mesh. +5. **Einzelfehler:** Das Doppelrohr der Allianz-Verteidigungsplattform schwebte + 2,5 m vor dem Turm, bei der Legion lag eine lose Platte davor, und am + Legion-Bauer hing ein 6 m langer Splitter, der über die Renderer-Bounds die + ganze Einheit im Spiel schrumpfen ließ. + +## 2. Werkzeuge + +| Werkzeug | Läuft in | Aufgabe | +|---|---|---| +| `tools/art/asset_pass.py` | Blender 5.2 headless | Ein Asset nach Rezept bearbeiten und exportieren | +| `tools/art/asset_recipes.json` | – | Rezepte aller 34 Assets: Teile, Reparaturen, Farbparameter | +| `tools/art/asset_layout.py` + `layout_grid.py` | Blender + System-Python | Orthografische Ansichten mit normiertem Raster als Vorlage für Schnittboxen | +| `Nova.Editor.ArtPreviewCapture` | Unity batchmode | Jedes `PF_*` im echten URP-Renderer fotografieren, wahlweise in Fahrt-/Kampfpose | +| `UnitMotionPresentationTests` | Unity PlayMode | Nachweis in der laufenden Partie, dass jede fahrende Ansicht in Fahrtrichtung schaut | + +Quelle ist immer das unveränderte Konvertat unter +`Hashkrieg_Assets/3d/unity_ready`; geschrieben wird nur nach +`Assets/_Project/Art`. Ein Lauf ist also wiederholbar und überschreibt keine +Eingabe. Der Zustand vor diesem Pass liegt zusätzlich als +`Hashkrieg_Assets/backup/Art_vor_AssetPass_2026-10-17.tar.gz`. + +```bash +# ein Asset (Blender headless) +/Applications/Blender.app/Contents/MacOS/Blender -b --python tools/art/asset_pass.py -- \ + --asset UNIT_Alliance_BattleTank --src ../Hashkrieg_Assets/3d/unity_ready --dst Assets/_Project/Art +# Prefabs neu bauen und alles fotografieren (Unity batchmode, Editor geschlossen) +Unity -batchmode -projectPath . -executeMethod Nova.Editor.ArtPreviewCapture.CaptureFromCommandLine \ + -captureBuildPrefabs -captureOut "$PWD/output/art-preview" -captureTime 1.3 -logFile cap.log -quit +``` + +## 3. Was der Pass je Asset tut + +1. **Aufräumen:** Splitterinseln (≤ 6 Faces, lang und dünn) entfernen, Rezept-Boxen + löschen (`drop_boxes`) oder verschieben (`move_boxes`). +2. **Normalen:** UV-Nähte verschweißen (0,2 % der Diagonale), nach Kantenwinkel + glätten, gewichtete Normalen. Mildert den Knitterlook; die Geometrie selbst + bleibt Tripo. +3. **Textur:** Belichtung auf eine Zielhelligkeit mit weicher Lichterschulter, + Sättigung je Fraktion, Gradient-Map auf die D-072-Palette, fremde Farbtöne + (Aetherium-Kristalle, Warnlichter) bleiben erhalten. Leuchtakzente werden als + `T_*_EM` ausgeschnitten, dazu `T_*_MSK` nach §5.1 des Standards (B = TeamMask + bleibt vorerst 0). +4. **Teile:** Trennen nach Rezept und Export als Knoten-Hierarchie: + + | Knoten | Bewegung im Spiel | Assets | + |---|---|---| + | `Body` | Federung: Nicken beim Anfahren/Bremsen, Neigen in Kurven, Wippen; Infanterie: Schritt-Wippen | alle | + | `Turret` | schwenkt mit begrenzter Rate aufs sichtbare Angriffsziel | Kampf-/Leichtpanzer, Artillerie, Verteidigungsplattformen, Legion-Scout (MG), Legion-Bauer (Oberwagen) | + | `Gun` | Rückstoß bei jedem sichtbaren Schuss | Panzer, Artillerie, Verteidigungsplattformen | + | `Wheel_*` | rollt mit der gefahrenen Strecke | beide Scouts, Legion-Sammler | + | `Arm_*` | pendelt beim Arbeiten (Abbauen, Bauen) | Allianz-Sammler, beide Bauer | + | `Spin_*` | dreht dauernd | Allianz-Radar, Allianz-Raffinerie (Kristall), Allianz-HQ (Antenne) | + | `Scan_*` | schwenkt hin und her | Legion-Radar | + + Gebäude „atmen“ zusätzlich über ihre Leuchtakzente und pulsieren schneller, + solange sie produzieren (nur eigene Gebäude, sonst verriete der Puls fremde + Produktion durch den Nebel). + +Die Unity-Seite (`UnitRigAnimator`) findet die Knoten über den Namen und misst +ihre Achsen einmal im Elternraum. Ein Modell ohne Knoten bekommt einen +`Body` eingefügt, damit auch Infanterie und Gebäude ohne Teile sich bewegen. + +## 4. Unity-Import: zwei Fallen + +- **Ein FBX mit genau einem Wurzelknoten verliert seine Achsen-Drehung**, wenn + Unity den Knoten in das Modellobjekt zusammenlegt; das Modell liegt dann auf + der Seite. Der Import setzt deshalb `preserveHierarchy`. +- **`bakeAxisConversion` ist keine Lösung:** Es dreht die älteren Einzelmesh-FBX + um 180°, sie fahren dann rückwärts. Der Import setzt es ausdrücklich auf + `false`, weil ein einmal gesetzter Wert sonst in der `.meta` überlebt. + +Der Wurzelknoten trägt in Unity Skalierung 100 (Zentimeter-Umrechnung). +Translationen der Animation werden deshalb in den Maßstab des jeweiligen +Elternknotens umgerechnet. + +## 5. Nachweis + +- EditMode `ViewHeadingTests`: Abbildung Sim-Kurs → Unity-Gier für acht + Richtungen und eine schiefe. +- PlayMode `UnitMotionPresentationTests`: sechs Einheiten fahren in sechs + Richtungen über echte Wegfindung; in 90–100 % der Fahrframes liegt die + Blickrichtung unter 25° zur gemessenen Bewegung (Ausreißer beim Anfahren + durch Ausweichsteuerung). **Gegenprobe:** Mit der alten Abbildung schlägt der + Test fehl (bis 180° Abweichung). +- Läufe vom 2026-10-17 auf diesem Stand: headless 768/768, EditMode 676/676, + PlayMode 16/16. +- Draufsicht und Fahrt-/Kampfpose aller 34 Prefabs mit `ArtPreviewCapture` + gesichtet. + +## 6. Was dieser Pass nicht löst + +- **Die Geometrie bleibt Tripo-Qualität.** Flächen sind knubbelig; Glätten und + doppelseitiges Rendern kaschieren, sie reparieren nicht. Eine + Geometrie-AO war ein Fehlschlag: Auf überlappenden Schalen backen ganze Flächen + schwarz. Sie ist deshalb im Rezept abgeschaltet. +- **Legion-Kraftwerk und Legion-Forschungslabor sind als Panzer generiert** + (Wanne, Kettenlaufwerk, Turm). Das ist nur durch Neugenerierung zu beheben. +- **Infanterie hat kein Skelett.** Sie wippt und neigt sich beim Gehen, die Beine + bewegen sich nicht. Ein Humanoid-Rig ist ein eigener Schritt. +- **Ketten laufen nicht.** Die Kettentextur liegt verstreut im Tripo-Atlas, ein + UV-Scrollen ist nicht möglich. +- **Kein Bloom.** Die Emission leuchtet, glüht aber nicht über; das bräuchte ein + Post-Processing-Volume in der Bootstrap-Szene. +- **Leuchtpuls über `MaterialPropertyBlock`.** Wie schon die Fraktionstönung; + ArtAssetStandard §5 untersagt das für die 500-Objekt-Last. Bis der + `NovaUnit`-Shader mit Instanzdaten kommt, ist das bewusst so. +- **TeamMask (B-Kanal) ist leer.** Spiegelpartien (Allianz gegen Allianz) sind + farblich nicht unterscheidbar. +- Die Allianz-Gebäude wirken neben den Einheiten noch dunkel; die Farbregeln + sind fraktions-, nicht rollenweise eingestellt. + +## Offene Punkte + +- Neugenerierung von Legion-Kraftwerk und Legion-Forschungslabor (Kosten pro + Generierung, Inhaberentscheidung). +- Spielerische Abnahme in einer gespielten Runde steht aus; dieser Pass ist + automatisiert und per Kamera geprüft, nicht gespielt. +- Das Asset-Paket ([AssetPackage.md](AssetPackage.md)) muss neu gebaut werden, + damit andere den Stand bekommen. + +## Änderungsverlauf + +| Version | Datum | Änderung | Autor | +|---|---|---|---| +| 0.1.0 | 2026-10-17 | Erstfassung: Blickrichtungsfehler, Asset-Pass mit Rezepten, Teile-Animation, Prüfwerkzeuge und Grenzen | Technical Art | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 6a31b3ce..4c00971e 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.41.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 +**Version:** 1.42.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 ## Zweck @@ -3583,6 +3583,61 @@ sie braucht deshalb ein eigenes, mit dem Einheitenstrang abgestimmtes Merge-Fenster. Verteidigungsbalance an den Zufahrten und KI-Verhalten in der Mitte sind ausdrücklich nicht Teil dieser Entscheidung. +### D-110 | verbindlich | Asset-Pass 2026-10-17 (Blickrichtung, Teile-Animation, Optik der Tripo-Assets) + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („alle Assets Stück für Stück +verbessern, korrekte Bewegungsrichtung, Animation der Fahrzeuge und Gebäude, mit +Blender“). Neugenerierung einzelner Modelle bleibt Inhaberentscheidung. + +**Kontext:** Einheiten fuhren sichtbar seitwärts oder rückwärts, die 34 +Tripo-Modelle waren starre Einzelmeshes mit Rissen und sehr dunklen Texturen. +Am Code geprüft: Die Blickrichtung war kein Asset-, sondern ein Abbildungsfehler +– `UnitViewManager` setzte den mathematischen Sim-Kurs (`SimTrig.Atan2`, 0 = +X, +gegen den Uhrzeigersinn) direkt als Unity-Gier (0 = +Z, im Uhrzeigersinn) ein und +spiegelte so jede Einheit an der Nordost-Diagonale. Die Modelle zeigen korrekt +nach +Z. [AnimationSystem.md](../tech/AnimationSystem.md) sieht für Fahrzeuge und +Gebäude bereits rig-lose Code-Animation vor, nur gab es nichts zu bewegen. + +**Alternativen:** + +1. Die vorhandenen Tripo-Modelle in Blender per Skript aufbereiten: in + benannte, gepivotete Teile trennen, reparieren, Texturen auf die + D-072-Paletten heben; Unity animiert die Teile im Code. +2. Alle Modelle mit höherer Generator-Qualität neu erzeugen. +3. Alle Modelle in Blender von Hand neu bauen. + +**Entscheidung:** Alternative 1, mit diesen verbindlichen Festlegungen: + +1. **Eine Stelle für Sim-Kurs → Unity-Gier:** `ViewHeading` (Gier = + 90° − Sim-Grad). Kein anderer Code rechnet die Abbildung selbst. +2. **Knoten-Vertrag zwischen Art und Code:** `Body`, `Turret`, `Gun`, `Wheel_*`, + `Arm_*`, `Spin_*`, `Scan_*`; Meshes darunter enden auf `_LOD0..2`. + `UnitRigAnimator` findet sie über den Namen. Animation bleibt reine View und + liest nur, was der Betrachter sehen darf (Ziel nur mit sichtbarer Ansicht, + Produktionspuls nur an eigenen Gebäuden). +3. **Art-Materialien rendern doppelseitig.** Die Tripo-Schalen haben echte + Lücken; mit Culling sieht man den Boden hindurch. +4. **Neue Textur `T_*_EM`** (Leuchtakzente) neben `_BC` und `_MSK`. +5. **Reproduzierbar:** Quelle ist immer das unveränderte Konvertat außerhalb des + Repos, die Rezepte (`tools/art/asset_recipes.json`) und Werkzeuge liegen im + Repo. + +**Begründung:** Alternative 1 liefert alle drei Wünsche sofort für alle 34 +Assets, kostet keine Generator-Guthaben und hält die vom Inhaber gewählte +Art-Richtung. Alternative 2 kostet je Modell, liefert wieder Einzelmeshes ohne +Teile und müsste trotzdem durch dieselbe Teile-Trennung. Alternative 3 ist der +größte Aufwand und verwirft die vorhandene Formensprache. Die Grenzen von +Alternative 1 sind bewusst in Kauf genommen und benannt: Die Geometrie bleibt +Tripo-Qualität, Legion-Kraftwerk und Legion-Forschungslabor sind als Panzer +generiert und nur durch Neugenerierung zu retten. + +**Konsequenzen:** Prüfung und Grenzen stehen in +[AssetPass_2026-10-17.md](../assets/AssetPass_2026-10-17.md). Der FBX-Import +setzt `preserveHierarchy` und ausdrücklich `bakeAxisConversion = false` (Letzteres +dreht die älteren FBX um 180°). Das Asset-Paket muss neu gebaut werden, sonst +sehen andere den alten Stand. Keine Änderung an der Simulation, keine Bewegung +der Determinismus-Baselines. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3663,6 +3718,7 @@ Mitte sind ausdrücklich nicht Teil dieser Entscheidung. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.42.0 | 2026-10-17 | D-110 aufgenommen: Blickrichtung über **eine** Abbildung Sim-Kurs → Unity-Gier (`ViewHeading`), Knoten-Vertrag `Body`/`Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*` zwischen Blender-Pass und `UnitRigAnimator`, doppelseitige Art-Materialien, neue Leuchttextur `T_*_EM`; Tripo-Modelle werden aufbereitet statt neu erzeugt | Dennis Westermann / Orchestrator | | 1.41.0 | 2026-08-18 | **D-108 in der Prämisse berichtigt und in der Sache neu gefasst.** Die Erstfassung behauptete, der Code lasse bereits jedes Gebäude als Bauanker gelten, und wollte das nur aussprechen — tatsächlich prüft `IsInsideBuildInfluence` seit D-104 auf HQ, Lager und Kraftwerk. Der ausführende Agent hat den Widerspruch beim Umsetzen von Paket 21.1 gefunden und angehalten. Der Inhaber hat daraufhin in Kenntnis der Lage neu entschieden: die Ankerliste wird bewusst geöffnet, **jedes eigene fertiggestellte Gebäude wird Anker**. Damit ist D-108 eine Verhaltensänderung (`RulesHash64`, Baselines, kanonischer KI-Pin), keine Festschreibung. `MinimumBuildingDistanceCells` bleibt bei 2 — die Messung aus 21.1 (15/23/23) weist die gemeldete Enge nicht als Abstandsproblem aus | Dennis Westermann / Orchestrator | | 1.40.0 | 2026-08-17 | D-108 und D-109 aufgenommen (Sprint 21, aus Testbericht T-01): Territorium wächst **kriechend an jedem eigenen Bauanker** — die bestehende Regel wird festgeschrieben statt geändert, und die gemeldete Enge wird an `MinimumBuildingDistanceCells` gemessen, bevor ein Wert fällt. Die Kartenmitte wird ein Gebiet mit schmalen Zufahrten, mit der verbindlichen Auflage, dass Begehbarkeit und Optik aus **einer** Struktur stammen — heute streut `GlutrinneBlockoutView` begehbare Deko-Felsen, während niemand ausser `ConstructionSystem` in das `CostField` schreibt | Dennis Westermann / Orchestrator | | 1.39.0 | 2026-08-10 | D-107 aufgenommen: Die bestehende Glutrinne-Layoutachse spiegelt Punkte um 124 und 3×3-Footprint-Ursprünge abgeleitet um 122; der zweite HQ-Ursprung wird von `(120,120)` auf `(118,118)` korrigiert | Agent (unter Delegation) / Dennis Westermann | diff --git a/docs/tech/AnimationSystem.md b/docs/tech/AnimationSystem.md index 5c8d6829..fd255278 100644 --- a/docs/tech/AnimationSystem.md +++ b/docs/tech/AnimationSystem.md @@ -1,6 +1,6 @@ # Animation System -**Version:** 0.2.1 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 +**Version:** 0.3.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 ## Zweck @@ -55,6 +55,27 @@ Fahrzeuge, Mechs ohne Humanoid-Rig, Gebäude und Lufteinheiten erhalten **kein S - **Luft:** Banking/Pitch aus Steuerkurve der Luft-Steering-Schicht (D-034), Schwebetrudeln im Idle; kein Rig. - **Evolvierte-Gebäude (D-011):** Keim → Reifung über Wachstums-Shader (Vertex-Displacement/Blend-Shapes, Wachstumsgrad 0–1 aus Sim-Reifestufe) statt Konstruktions-Stufen; Regenerations-Visualisierung als Pulsieren statt Reparatur-Funken. +### Umsetzungsstand (2026-10-17, D-110) + +Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne +`AnimationSetSO` und ohne Animations-LOD; beides bleibt Ziel dieses Dokuments. + +- **`UnitRigAnimator`** (`Nova.Gameplay.Match`) hängt an jeder Art-Ansicht und + bewegt die vom Blender-Pass benannten Knoten: `Body` (Federung, bei Infanterie + Schritt-Wippen), `Turret` (Gier zum sichtbaren Angriffsziel, gedeckelte Rate), + `Gun` (Rückstoß), `Wheel_*`, `Arm_*`, `Spin_*`, `Scan_*`; Gebäude pulsieren über + ihre Emission, schneller beim Produzieren. Knoten-Vertrag und Werkzeuge: + [../assets/AssetPass_2026-10-17.md](../assets/AssetPass_2026-10-17.md). +- **Eingaben** kommen aus `UnitViewManager` und sind reine View-Daten: gemessene + Geschwindigkeit und Drehrate der Ansicht, Zielposition nur aus einer + gebundenen (also sichtbaren) Zielansicht, Schuss aus dem `Shot`-Ereignis des + `VisibleCombatFrameDiffer`, Arbeitszustand aus Ernte, Baustellen-Zuweisung und + eigener Produktionsschlange. +- **Abweichung von diesem Entwurf:** Die Turmrate ist ein View-Wert, nicht aus + der Sim gespiegelt — die Sim kennt keinen Turm. Infanterie hat noch kein + Mecanim-Rig. Der Emissionspuls schreibt wie die Fraktionstönung über + `MaterialPropertyBlock`. + ## Animations-LOD (3 Stufen, Pflicht) Konfigurierbar pro Einheitentyp im `AnimationSetSO` (Schwellen in Kamerahöhe/Metern und Einheitenanzahl): @@ -147,3 +168,4 @@ Der `RenderSnapshot` wird vom View-Adapter aus dem serialisierbaren Sim-State (D | 0.1.0 | 2026-07-21 | Erstfassung | Lead Graphics Engineer | | 0.2.0 | 2026-07-24 | MS-1-Animationsumfang, synthetischen 500er-Lasttest, CPU-P95-Budget und Unity-Pin gemäß D-056/D-058/D-060/D-061 festgelegt | Lead Graphics Engineer | | 0.2.1 | 2026-07-24 | MG/Rocket-Module als MS-1-Pflicht klargestellt und nur Flak zurückgestellt | Lead Graphics Engineer | +| 0.3.0 | 2026-10-17 | Umsetzungsstand der rig-losen Sparte (D-110): `UnitRigAnimator`, Knoten-Vertrag, View-Eingaben und Abweichungen vom Entwurf | Technical Art | diff --git a/tools/art/asset_layout.py b/tools/art/asset_layout.py new file mode 100644 index 00000000..ed2dd993 --- /dev/null +++ b/tools/art/asset_layout.py @@ -0,0 +1,87 @@ +"""Orthografische Layout-Ansichten eines Konvertats, als Vorlage fuer die +Schnittboxen in ``asset_recipes.json``. + + Blender -b --python tools/art/asset_layout.py -- [texture.png] + +Schreibt ``_top.png``, ``_side.png``, ``_front.png`` und ``_layout.json``. +Jede Ansicht ist exakt auf die LOD0-Bounding-Box beschnitten, das Bild bildet die +normierten Rezept-Koordinaten also linear ab: + + top x -> rechts, y -> oben (unten im Bild = vorne, y 0) + side y -> rechts, z -> oben (Blick von +X, links im Bild = vorne) + front x -> rechts, z -> oben (Blick von vorne) + +``tools/art/layout_grid.py`` legt das Raster darueber. +""" +import json +import math +import os +import sys + +import bpy +from mathutils import Vector + +a = sys.argv[sys.argv.index("--") + 1:] +fbx, out = a[0], a[1] +tex = a[2] if len(a) > 2 else "" +os.makedirs(out, exist_ok=True) +stem = os.path.basename(fbx)[3:-4] + +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.fbx(filepath=fbx) +meshes = [o for o in bpy.data.objects if o.type == "MESH"] +lod0 = [o for o in meshes if o.name.endswith("LOD0")] or meshes[:1] +for o in meshes: + if o not in lod0: + bpy.data.objects.remove(o, do_unlink=True) + +mat = bpy.data.materials.new("m") +if tex and os.path.exists(tex): + tree = mat.node_tree + bsdf = next(n for n in tree.nodes if n.type == "BSDF_PRINCIPLED") + tn = tree.nodes.new("ShaderNodeTexImage") + tn.image = bpy.data.images.load(tex) + tree.links.new(tn.outputs[0], bsdf.inputs[0]) +for o in lod0: + o.data.materials.clear() + o.data.materials.append(mat) + +pts = [o.matrix_world @ v.co for o in lod0 for v in o.data.vertices] +mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) +mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) +ext = mx - mn +ctr = (mn + mx) / 2 + +sc = bpy.context.scene +sc.render.engine = "BLENDER_WORKBENCH" +sc.display.shading.light = "STUDIO" +sc.display.shading.color_type = "TEXTURE" if tex else "OBJECT" +sc.display.shading.show_cavity = True +sc.render.film_transparent = False + +cd = bpy.data.cameras.new("c") +cd.type = "ORTHO" +cam = bpy.data.objects.new("c", cd) +sc.collection.objects.link(cam) +sc.camera = cam + +LONG = 900 +views = { + # name: (horizontal extent, vertical extent, location, euler) + "top": (ext.x, ext.y, ctr + Vector((0, 0, ext.z * 3 + 1)), (0, 0, 0)), + "side": (ext.y, ext.z, ctr + Vector((ext.x * 3 + 1, 0, 0)), (math.pi / 2, 0, math.pi / 2)), + "front": (ext.x, ext.z, ctr + Vector((0, -(ext.y * 3 + 1), 0)), (math.pi / 2, 0, 0)), +} +for name, (h, v, loc, rot) in views.items(): + scale = LONG / max(h, v) + sc.render.resolution_x = max(16, round(h * scale)) + sc.render.resolution_y = max(16, round(v * scale)) + cd.ortho_scale = max(h, v) + cam.location = loc + cam.rotation_euler = rot + sc.render.filepath = os.path.join(out, f"{stem}_{name}.png") + bpy.ops.render.render(write_still=True) + +with open(os.path.join(out, f"{stem}_layout.json"), "w") as f: + json.dump({"min": list(mn), "max": list(mx), "extent": list(ext)}, f, indent=1) +print("LAYOUT", stem, [round(x, 3) for x in ext]) diff --git a/tools/art/asset_pass.py b/tools/art/asset_pass.py new file mode 100644 index 00000000..a5c875ee --- /dev/null +++ b/tools/art/asset_pass.py @@ -0,0 +1,724 @@ +"""Asset-Pass fuer den Hashkrieg-Art-Strang: macht aus einem konvertierten Tripo-FBX +ein spielfertiges, animierbares Asset. + +Aufruf (Blender 5.2 LTS headless), ein Asset pro Lauf: + + /Applications/Blender.app/Contents/MacOS/Blender --background --python \\ + tools/art/asset_pass.py -- --asset UNIT_Alliance_BattleTank \\ + --src ../Hashkrieg_Assets/3d/unity_ready --dst Assets/_Project/Art \\ + [--preview ] [--no-export] + +Quelle ist IMMER das unveraenderte Konvertat unter ``--src`` (Hashkrieg_Assets, +ausserhalb von Git); geschrieben wird nur nach ``--dst``. Ein zweiter Lauf +ergibt also dasselbe Ergebnis und ueberschreibt keine Eingabe. + +Was je Asset passiert, steht in ``tools/art/asset_recipes.json``: + +1. ``yaw_fix`` Feinkorrektur der Blickrichtung (Grad, um Blender-Z). +2. ``drop_boxes`` Faces in normierten Boxen loeschen (schwebende Splitter). +3. ``smooth_angle`` Normalen nach Kantenwinkel glaetten + gewichtete Normalen: + Tripo liefert facettierte Flaechen, die wie zerknittertes + Papier schattieren. +4. Textur AO und konvexe Kanten mit Cycles backen, Basisfarbe auf die + D-072-Fraktionspalette heben, Leuchtakzente als Emission + ausschneiden. Schreibt ``T_*_BC``, ``T_*_MSK`` (R Metallic, + G AO, B TeamMask, A Smoothness, ArtAssetStandard 5.1) und + ``T_*_EM``. +5. ``parts`` Mesh in animierbare Teile trennen, Pivots setzen und als + Knoten-Hierarchie exportieren. Unity findet die Knoten ueber + ihre Namen (UnitRigAnimator): + + Root + +- Body Federung (Nicken/Wippen) + +- _Hull_LODn + +- Turret Gier zum Ziel (kind turret) + | +- Gun Rueckstoss/Elevation (kind gun) + +- Spin_* Dauerrotation um Hoch (kind spin) + +- Wheel_* Rollen um Querachse (kind wheel) + +- Arm_* Pendeln um Querachse (kind arm) + +Koordinaten in den Rezepten sind auf die LOD0-Bounding-Box normiert: +x 0..1 links->rechts, y 0..1 vorne->hinten (vorne = Blender -Y = Unity +Z), +z 0..1 unten->oben. Pivots ebenso, oder ``auto``. +""" + +import argparse +import json +import math +import os +import sys + +import bmesh +import bpy +import numpy as np +from mathutils import Matrix, Vector + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import rig_tank_hierarchy as tank # noqa: E402 (find_ring_z, find_gun_y, gun_axis) + +RECIPES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "asset_recipes.json") + +# D-072-Paletten (sRGB). Rampen fuer die Gradient-Map, dunkel -> hell. +PALETTES = { + "Alliance": [(0.00, "#0b1016"), (0.28, "#24384e"), (0.55, "#6f7d8a"), (0.78, "#a9b3bc"), (1.00, "#e6ecf1")], + "Legion": [(0.00, "#140c08"), (0.30, "#3e1b12"), (0.55, "#7A3524"), (0.78, "#a0612c"), (1.00, "#e2c08a")], +} +ACCENT = {"Alliance": "#4FD8FF", "Legion": "#ff8a2a"} + + +# ---------------------------------------------------------------- Hilfen + +def hex_rgb(h): + h = h.lstrip("#") + return np.array([int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)], dtype=np.float32) + + +def parse_args(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser(prog="asset_pass") + p.add_argument("--asset", required=True, help="z. B. UNIT_Alliance_BattleTank") + p.add_argument("--src", required=True, help="Wurzel der unveraenderten Konvertate (unity_ready)") + p.add_argument("--dst", required=True, help="Wurzel der Spiel-Art (Assets/_Project/Art)") + p.add_argument("--preview", default="", help="Ordner fuer Kontrollbilder") + p.add_argument("--no-export", action="store_true") + return p.parse_args(argv) + + +def load_recipe(asset): + with open(RECIPES) as f: + all_recipes = json.load(f) + defaults = all_recipes.get("_defaults", {}) + if asset not in all_recipes: + raise SystemExit(f"[asset_pass] kein Rezept fuer {asset} in {RECIPES}") + r = dict(defaults) + r.update(all_recipes[asset]) + r["texture"] = {**defaults.get("texture", {}), **all_recipes[asset].get("texture", {})} + return r + + +def lod_objects(): + meshes = [o for o in bpy.data.objects if o.type == "MESH"] + return sorted(meshes, key=lambda o: o.name[-1] if o.name[-4:-1] == "LOD" else "9") + + +def world_bbox(objs): + pts = [o.matrix_world @ Vector(c) for o in objs for c in o.bound_box] + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + return mn, mx + + +def denorm(bb, n): + mn, mx = bb + return Vector((mn.x + (mx.x - mn.x) * n[0], mn.y + (mx.y - mn.y) * n[1], mn.z + (mx.z - mn.z) * n[2])) + + +def in_box(bb, box): + if box and isinstance(box[0], (list, tuple)): # Liste von Boxen = Vereinigung + preds = [in_box(bb, b) for b in box] + return lambda c: any(p(c) for p in preds) + lo = denorm(bb, (box[0], box[2], box[4])) + hi = denorm(bb, (box[1], box[3], box[5])) + return lambda c: lo.x <= c.x <= hi.x and lo.y <= c.y <= hi.y and lo.z <= c.z <= hi.z + + +def activate(o): + bpy.ops.object.select_all(action="DESELECT") + o.select_set(True) + bpy.context.view_layer.objects.active = o + + +def edit_faces(obj): + """Edit-Mode im Face-Modus mit leerer Auswahl - sonst wandern beim Trennen + noch markierte Vertices aus frueheren Operationen mit.""" + activate(obj) + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_mode(type="FACE") + bpy.ops.mesh.select_all(action="DESELECT") + return bmesh.from_edit_mesh(obj.data) + + +def face_islands(bm): + seen, out = set(), [] + for f in bm.faces: + if f.index in seen: + continue + isl, stack = [], [f] + while stack: + x = stack.pop() + if x.index in seen: + continue + seen.add(x.index) + isl.append(x) + for e in x.edges: + stack.extend(l for l in e.link_faces if l.index not in seen) + out.append(isl) + return out + + +def split_faces(obj, pred, newname, islands=0.0): + """Faces mit pred(Weltzentrum) in ein neues Objekt trennen; None wenn keine. + + ``islands`` > 0 waehlt ganze zusammenhaengende Inseln, von deren Faces + mindestens dieser Anteil pred erfuellt - fuer Raeder, deren Radkasten- + Kante geometrisch im Radkreis liegt, aber zur Karosserie-Insel gehoert. + """ + bm = edit_faces(obj) + mw = obj.matrix_world + hit = 0 + if islands > 0: + bm.faces.ensure_lookup_table() + for isl in face_islands(bm): + inside = sum(1 for f in isl if pred(mw @ f.calc_center_median())) + take = inside >= islands * len(isl) + for f in isl: + f.select = take + hit += len(isl) if take else 0 + else: + for f in bm.faces: + f.select = bool(pred(mw @ f.calc_center_median())) + hit += f.select + bm.select_flush_mode() + bmesh.update_edit_mesh(obj.data) + new = None + if hit and hit < len(bm.faces): + before = set(bpy.data.objects) + bpy.ops.mesh.separate(type="SELECTED") + new = next(iter(set(bpy.data.objects) - before)) + new.name = newname + bpy.ops.object.mode_set(mode="OBJECT") + return new + + +def delete_faces(obj, pred): + bm = edit_faces(obj) + mw = obj.matrix_world + doomed = [f for f in bm.faces if pred(mw @ f.calc_center_median())] + bmesh.ops.delete(bm, geom=doomed, context="FACES") + bmesh.update_edit_mesh(obj.data) + bpy.ops.object.mode_set(mode="OBJECT") + return len(doomed) + + +# ---------------------------------------------------------------- Geometrie + +def drop_needles(o, diag, max_faces=6, min_len=0.22, max_width=0.04): + """Loose Splitter: Inseln mit hoechstens ``max_faces`` Faces, die laenger als + ``min_len`` x Diagonale und duenner als ``max_width`` x Laenge sind. + + Der Konverter filtert nur Inseln bis acht Dreiecke ueber 15 % Diagonale; + einzelne Nadeln ueberleben, wenn sie an Nachbarn haengen. Sie kosten mehr als + Optik: UnitViewManager skaliert ueber die Renderer-Bounds, ein 6-m-Spiess + laesst die ganze Einheit schrumpfen. + """ + bm = bmesh.new() + bm.from_mesh(o.data) + bm.faces.ensure_lookup_table() + seen, doomed = set(), [] + for f in bm.faces: + if f.index in seen: + continue + isl, stack = [], [f] + while stack: + x = stack.pop() + if x.index in seen: + continue + seen.add(x.index) + isl.append(x) + for e in x.edges: + stack.extend(l for l in e.link_faces if l.index not in seen) + if len(isl) > max_faces: + continue + pts = [v.co for g in isl for v in g.verts] + d = sorted((max(p[i] for p in pts) - min(p[i] for p in pts)) for i in range(3)) + if d[2] > min_len * diag and d[1] < max_width * d[2]: + doomed.extend(isl) + if doomed: + bmesh.ops.delete(bm, geom=list({g.index: g for g in doomed}.values()), context="FACES") + bm.to_mesh(o.data) + bm.free() + return len(doomed) + + +def move_faces(obj, pred, offset): + bm = edit_faces(obj) + mw = obj.matrix_world + verts = {v for f in bm.faces if pred(mw @ f.calc_center_median()) for v in f.verts} + for v in verts: + v.co += offset + bmesh.update_edit_mesh(obj.data) + bpy.ops.object.mode_set(mode="OBJECT") + return len(verts) + +def apply_yaw(objs, deg): + if not deg: + return + rot = Matrix.Rotation(math.radians(deg), 4, "Z") + for o in objs: + o.matrix_world = rot @ o.matrix_world + activate(o) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + + +def smooth_normals(o, angle_deg, merge_dist): + """Facettierte Tripo-Flaechen glaetten, harte Kanten behalten. + + Merge-by-distance verbindet die an UV-Naehten doppelten Vertices, damit die + Glaettung ueber die Naht reicht (UVs liegen an den Loops und bleiben). Die + gewichteten Normalen halten grosse Flaechen plan - der Hard-Surface-Look, + den ein RTS aus 40 m Kamerahoehe noch lesen kann. + """ + if not angle_deg: + return + activate(o) + bpy.ops.object.mode_set(mode="EDIT") + bpy.ops.mesh.select_all(action="SELECT") + bpy.ops.mesh.remove_doubles(threshold=merge_dist) + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg), keep_sharp_edges=True) + m = o.modifiers.new("wn", "WEIGHTED_NORMAL") + m.keep_sharp = True + m.weight = 50 + # Smooth-by-Angle ist in 5.x ein Geometry-Nodes-Modifier; beide anwenden, + # damit LOD-Splits und Export dieselben Normalen sehen. + for mod in list(o.modifiers): + bpy.ops.object.modifier_apply(modifier=mod.name) + + +# ---------------------------------------------------------------- Textur + +def ensure_cycles(samples): + sc = bpy.context.scene + sc.render.engine = "CYCLES" + sc.cycles.device = "CPU" + sc.cycles.samples = samples + sc.render.bake.margin = 16 + + +def image_np(img): + a = np.empty(img.size[0] * img.size[1] * 4, dtype=np.float32) + img.pixels.foreach_get(a) + return a.reshape(img.size[1], img.size[0], 4) + + +def np_image(name, arr, colorspace): + h, w = arr.shape[:2] + img = bpy.data.images.new(name, w, h, alpha=True) + img.colorspace_settings.name = colorspace + img.pixels.foreach_set(np.ascontiguousarray(arr, dtype=np.float32).ravel()) + return img + + +def bake(o, kind, size, build_nodes): + """Backt in ein neues Bild; build_nodes(tree) verdrahtet das Material.""" + mat = bpy.data.materials.new(f"bake_{kind}") + tree = mat.node_tree + for n in list(tree.nodes): + if n.type != "OUTPUT_MATERIAL": + tree.nodes.remove(n) + build_nodes(tree) + img = bpy.data.images.new(f"bake_{kind}", size, size, alpha=False) + img.colorspace_settings.name = "Non-Color" + tn = tree.nodes.new("ShaderNodeTexImage") + tn.image = img + tree.nodes.active = tn + o.data.materials.clear() + o.data.materials.append(mat) + activate(o) + bpy.ops.object.bake(type=kind) + return image_np(img)[..., 0].copy() + + +def bake_ao(o, size, distance): + def nodes(t): + out = next(n for n in t.nodes if n.type == "OUTPUT_MATERIAL") + bs = t.nodes.new("ShaderNodeBsdfDiffuse") + t.links.new(bs.outputs[0], out.inputs[0]) + bpy.context.scene.world = bpy.context.scene.world or bpy.data.worlds.new("w") + bpy.context.scene.world.light_settings.distance = distance + return bake(o, "AO", size, nodes) + + +def bake_edges(o, size, radius): + """Konvexe Kanten: Abweichung der Bevel-Normale von der wahren Normale, + nur wo Pointiness > 0.5 (konvex). Konkave Kanten macht schon die AO dunkel.""" + def nodes(t): + out = next(n for n in t.nodes if n.type == "OUTPUT_MATERIAL") + bev = t.nodes.new("ShaderNodeBevel"); bev.inputs["Radius"].default_value = radius + bev.samples = 12 + geo = t.nodes.new("ShaderNodeNewGeometry") + dot = t.nodes.new("ShaderNodeVectorMath"); dot.operation = "DOT_PRODUCT" + t.links.new(bev.outputs[0], dot.inputs[0]); t.links.new(geo.outputs["True Normal"], dot.inputs[1]) + inv = t.nodes.new("ShaderNodeMath"); inv.operation = "SUBTRACT"; inv.inputs[0].default_value = 1.0 + t.links.new(dot.outputs["Value"], inv.inputs[1]) + gain = t.nodes.new("ShaderNodeMath"); gain.operation = "MULTIPLY"; gain.inputs[1].default_value = 6.0 + gain.use_clamp = True + t.links.new(inv.outputs[0], gain.inputs[0]) + cvx = t.nodes.new("ShaderNodeMath"); cvx.operation = "GREATER_THAN"; cvx.inputs[1].default_value = 0.5 + t.links.new(geo.outputs["Pointiness"], cvx.inputs[0]) + m = t.nodes.new("ShaderNodeMath"); m.operation = "MULTIPLY" + t.links.new(gain.outputs[0], m.inputs[0]); t.links.new(cvx.outputs[0], m.inputs[1]) + em = t.nodes.new("ShaderNodeEmission") + t.links.new(m.outputs[0], em.inputs["Color"]) + t.links.new(em.outputs[0], out.inputs[0]) + return bake(o, "EMIT", size, nodes) + + +def gradient_map(lum, stops): + xs = np.array([s for s, _ in stops], dtype=np.float32) + cols = np.stack([hex_rgb(c) for _, c in stops]) + return np.stack([np.interp(lum, xs, cols[:, i]) for i in range(3)], axis=-1) + + +def rgb_to_hsv(rgb): + r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2] + mx = rgb.max(-1); mn = rgb.min(-1); d = mx - mn + h = np.zeros_like(mx) + m = d > 1e-5 + rm = m & (mx == r); gm = m & (mx == g) & ~rm; bm = m & ~rm & ~gm + h[rm] = ((g - b)[rm] / d[rm]) % 6 + h[gm] = (b - r)[gm] / d[gm] + 2 + h[bm] = (r - g)[bm] / d[bm] + 4 + s = np.where(mx > 1e-5, d / np.maximum(mx, 1e-5), 0) + return h * 60.0, s, mx + + +def grade(bc, ao, edge, faction, t): + """Neue Basisfarbe + Masken. bc in sRGB-Werten (wie gespeichert). + + 1. Belichtung: Luminanz auf ``target_mean`` anheben (Gain gedeckelt), mit + weicher Reinhard-Schulter statt harter Perzentil-Spreizung - letztere + trieb sehr dunkle Texturen ins Lachsrosa. + 2. Saettigung je Fraktion: die Allianz zieht von Petrol Richtung Stahlgrau. + 3. Gradient-Map auf die D-072-Palette, gemischt mit dem angehobenen Original + (Tripo-Details wie Kabel, Rost, Leuchten leben von der Eigenfarbe). + 4. Fremde Farbtoene (gesaettigt, ausserhalb des Fraktionsbands: Aetherium- + Kristalle, Warnlichter) bleiben im Original-Farbton. + 5. Leuchtakzente als Emission ausschneiden. + """ + W = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32) + rgb = bc[..., :3] + lum = rgb @ W + mean = float(lum.mean()) + gain = min(t["max_gain"], max(1.0, t["target_mean"] / max(mean, 1e-3))) + L = lum * gain + wp = t["white_point"] + Ls = L * (1 + L / (wp * wp)) / (1 + L) + scale = np.where(lum > 1e-4, Ls / np.maximum(lum, 1e-4), 0)[..., None] + raw_lift = np.clip(rgb * scale, 0, 1) + g = (raw_lift @ W)[..., None] + lifted = np.clip(g + (raw_lift - g) * t["saturation_by_faction"][faction], 0, 1) + + h, s, v = rgb_to_hsv(rgb) + lo, hi = t["hue_band_by_faction"][faction] + in_band = ((h - lo) % 360) <= ((hi - lo) % 360) + foreign = ((~in_band) & (s > 0.35) & (v > 0.15)).astype(np.float32)[..., None] + + acc = t.get("accent") or t["accent_by_faction"][faction] + hue_ok = ((h - acc["hue"][0]) % 360) <= ((acc["hue"][1] - acc["hue"][0]) % 360) + accent = (hue_ok & (s >= acc["sat"]) & (v >= acc["val"])).astype(np.float32) + # Akzente sauber aufweiten, damit die Emission an UV-Raendern nicht franst + accent = np.maximum(accent, np.roll(accent, 1, 0)); accent = np.maximum(accent, np.roll(accent, 1, 1)) + + painted = gradient_map(np.clip(Ls, 0, 1), PALETTES[faction]) + mix = t["palette_mix_by_faction"][faction] + base = painted * mix + lifted * (1 - mix) + base = base * (1 - foreign) + raw_lift * foreign + + occl = 1.0 - t["ao_strength"] * (1.0 - ao) + base = base * occl[..., None] + base = base + edge[..., None] * t["edge_highlight"] * (1.0 - base) + + acc_col = hex_rgb(ACCENT[faction]) + base = base * (1 - accent[..., None]) + (acc_col * 0.85) * accent[..., None] + base = np.clip(base, 0, 1) + + em = np.zeros_like(bc) + em[..., :3] = acc_col * accent[..., None] + em[..., 3] = 1 + + msk = np.zeros_like(bc) + msk[..., 0] = t["metallic"] * (1 - accent) # R Metallic + msk[..., 1] = 1.0 - 0.4 * (1.0 - ao) # G AO (gedaempft: Basisfarbe traegt sie schon) + msk[..., 2] = 0.0 # B TeamMask (folgt mit NovaUnit) + msk[..., 3] = np.clip(t["smoothness"] + 0.25 * edge, 0, 1) # A Smoothness + + out = np.empty_like(bc) + out[..., :3] = base + out[..., 3] = 1 + stats = {"lum_in_mean": round(mean, 4), "gain": round(gain, 3), + "lum_out_mean": round(float((base @ W).mean()), 4), + "accent_share": round(float(accent.mean()), 4), "foreign_share": round(float(foreign.mean()), 4)} + return out, msk, em, stats + + +def texture_pass(lod0, tex_path, faction, t, out_dir, stem): + src = bpy.data.images.load(tex_path) + size = src.size[0] + bc = image_np(src).copy() + # Geometrie-AO und Kanten nur auf Wunsch: Auf Tripo-Meshes ueberlappen + # sich ~1000 Scherben, jeder AO-Strahl trifft die Nachbarschale, und ganze + # Flaechen backen schwarz. Die gemalte Textur traegt ihre AO schon. + ao = np.ones(bc.shape[:2], dtype=np.float32) + edge = np.zeros(bc.shape[:2], dtype=np.float32) + if t["ao_strength"] > 0 or t["edge_highlight"] > 0: + ensure_cycles(t["bake_samples"]) + diag = (Vector(world_bbox([lod0])[1]) - Vector(world_bbox([lod0])[0])).length + if t["ao_strength"] > 0: + ao = bake_ao(lod0, size, diag * t["ao_distance"]) + if t["edge_highlight"] > 0: + edge = bake_edges(lod0, size, diag * t["edge_radius"]) + new_bc, msk, em, stats = grade(bc, ao, edge, faction, t) + paths = {} + for suffix, arr, cs in (("BC", new_bc, "sRGB"), ("MSK", msk, "Non-Color"), ("EM", em, "sRGB")): + img = np_image(f"{stem}_{suffix}", arr, cs) + p = os.path.join(out_dir, f"T_{stem}_{suffix}.png") + img.filepath_raw = p + img.file_format = "PNG" + img.save() + paths[suffix] = p + lod0.data.materials.clear() + return paths, stats + + +# ---------------------------------------------------------------- Teile + +def split_parts(lods, recipe, bb): + """Liefert {lodname: {partname: obj}} plus Pivots {partname: Vector}.""" + parts_cfg = recipe.get("parts", []) + result = {o.name[-4:]: {"Hull": o} for o in lods} + pivots, parents, kinds = {"Body": Vector((0, 0, 0))}, {}, {} + lod0 = lods[0] + + for cfg in parts_cfg: + name, kind = cfg["name"], cfg["kind"] + src = cfg.get("from", "Hull") + parents[name] = cfg.get("parent", "Body") + kinds[name] = kind + if kind == "turret": + ring_z = cfg.get("ring_z") + if ring_z is None: + ring_z, _ = tank.find_ring_z(result["LOD0"][src]) + elif cfg.get("ring_z_norm"): + ring_z = denorm(bb, (0, 0, ring_z)).z + box = in_box(bb, cfg["box"]) if "box" in cfg else (lambda c: True) + pred = lambda c, rz=ring_z, bx=box: c.z > rz and bx(c) + elif kind == "wheel": + ext = bb[1] - bb[0] + ay = bb[0].y + ext.y * cfg["axle"][0] + az = bb[0].z + ext.z * cfg["axle"][1] + rw = ext.z * cfg["r"] + x0 = bb[0].x + ext.x * cfg["x"][0] + x1 = bb[0].x + ext.x * cfg["x"][1] + pred = lambda c, ay=ay, az=az, rw=rw, x0=x0, x1=x1: x0 <= c.x <= x1 and math.hypot(c.y - ay, c.z - az) <= rw + cfg = dict(cfg, _axle=(ay, az)) + elif kind == "gun" and "box" in cfg: + pred = in_box(bb, cfg["box"]) + elif kind == "gun": + turret0 = result["LOD0"][src] + g = tank.gun_axis(turret0) + gun_y = cfg.get("gun_y") or tank.find_gun_y(turret0) + if gun_y is None or g is None: + print(f" {name}: kein Rohr erkannt, uebersprungen"); continue + rmax = g["r0"] * cfg.get("radius_factor", 2.0) + pred = lambda c, gy=gun_y, g=g, r=rmax: c.y < gy and math.hypot(c.x - g["ax"], c.z - g["az"]) <= r + cfg = dict(cfg, _gun=(gun_y, g)) + else: + pred = in_box(bb, cfg["box"]) + + for lodname, d in result.items(): + if src not in d: + continue + piece = split_faces(d[src], pred, f"__{name}_{lodname}", cfg.get("islands", 0.0)) + if piece is not None: + d[name] = piece + + if name not in result["LOD0"]: + print(f" {name}: keine Faces getroffen") + continue + pivots[name] = part_pivot(result["LOD0"][name], cfg, bb) + print(f" {name} ({kind}): LOD0 {len(result['LOD0'][name].data.polygons)} Faces, Pivot {tuple(round(v, 3) for v in pivots[name])}") + return result, pivots, parents, kinds + + +def part_pivot(obj, cfg, bb): + pts = [obj.matrix_world @ v.co for v in obj.data.vertices] + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + piv = cfg.get("pivot", "auto") + if isinstance(piv, list): + return denorm(bb, piv) + kind = cfg["kind"] + if kind == "turret": + band = [p for p in pts if p.z < mn.z + 0.35] + ref = band if len(band) > 20 else pts + return Vector(((min(p.x for p in ref) + max(p.x for p in ref)) / 2, + (min(p.y for p in ref) + max(p.y for p in ref)) / 2, mn.z)) + if kind == "gun" and "_gun" in cfg: + gun_y, g = cfg["_gun"] + return Vector((g["ax"], mx.y, g["az"])) + if kind == "gun": # per Box: Schildzapfen am hinteren Ende, mittig im Rohrquerschnitt + rear = [p for p in pts if p.y > mx.y - 0.25 * (mx.y - mn.y)] + return Vector((sum(p.x for p in rear) / len(rear), mx.y, sum(p.z for p in rear) / len(rear))) + if kind == "wheel": + ay, az = cfg["_axle"] + return Vector(((mn.x + mx.x) / 2, ay, az)) + if kind == "spin": + return Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z)) + if kind == "arm": + return Vector(((mn.x + mx.x) / 2, mx.y, mn.z)) + return (mn + mx) / 2 # wheel und Rest: Mittelpunkt + + +# ---------------------------------------------------------------- Export + +def build_and_export(result, pivots, parents, kinds, stem, fbx_out): + sc = bpy.context.scene + nodes = {} + + def empty(name, loc): + e = bpy.data.objects.new(name, None) + sc.collection.objects.link(e) + e.location = loc + e.empty_display_size = 0.4 + nodes[name] = e + bpy.context.view_layer.update() # sonst ist matrix_world noch Identitaet + return e + + def attach(child, parent): + bpy.context.view_layer.update() + world = child.matrix_world.copy() + child.parent = parent + child.matrix_parent_inverse = Matrix.Identity(4) + child.matrix_world = world + + root = empty("Root", Vector((0, 0, 0))) + body = empty("Body", Vector((0, 0, 0))) + attach(body, root) + pending = [n for n in pivots if n != "Body"] + while pending: # Eltern vor Kindern anlegen + for n in list(pending): + par = parents.get(n, "Body") + if par in nodes: + attach(empty(n, pivots[n]), nodes[par]) + pending.remove(n) + elif par not in pivots: + parents[n] = "Body" + + for lodname, d in sorted(result.items()): + for part, obj in d.items(): + target = "Body" if part == "Hull" else part + if target not in nodes: + target = "Body" + obj.name = f"SM_{stem}_{part}_{lodname}" + obj.data.name = obj.name + # Mesh-Ursprung auf den Knoten legen, damit die lokale Transform + # des Meshes Null ist und nur der Knoten animiert. + pivot_world = nodes[target].matrix_world.translation + activate(obj) + sc.cursor.location = pivot_world + bpy.ops.object.origin_set(type="ORIGIN_CURSOR") + attach(obj, nodes[target]) + + os.makedirs(os.path.dirname(fbx_out), exist_ok=True) + bpy.ops.object.select_all(action="SELECT") + bpy.ops.export_scene.fbx( + filepath=fbx_out, use_selection=True, object_types={"EMPTY", "MESH"}, + apply_unit_scale=True, global_scale=1.0, apply_scale_options="FBX_SCALE_NONE", + axis_forward="-Z", axis_up="Y", bake_space_transform=False, + use_mesh_modifiers=True, mesh_smooth_type="OFF", path_mode="STRIP", + add_leaf_bones=False) + print("EXPORTED", fbx_out) + + +def render_preview(result, out_png, tex_paths): + sc = bpy.context.scene + sc.render.engine = "BLENDER_WORKBENCH" + sc.display.shading.light = "STUDIO" + sc.display.shading.color_type = "MATERIAL" + sc.render.resolution_x, sc.render.resolution_y = 1200, 600 + objs = list(result["LOD0"].values()) + for o in bpy.data.objects: + if o.type == "MESH" and o not in objs: + o.hide_render = True + cols = [(0.6, 0.62, 0.65, 1), (0.95, 0.5, 0.1, 1), (0.9, 0.1, 0.1, 1), (0.2, 0.7, 0.3, 1), + (0.2, 0.45, 0.95, 1), (0.85, 0.2, 0.85, 1), (0.95, 0.9, 0.2, 1)] + for i, (part, o) in enumerate(result["LOD0"].items()): + m = bpy.data.materials.new(f"prev_{part}") + m.diffuse_color = cols[i % len(cols)] + o.data.materials.clear(); o.data.materials.append(m) + mn, mx = world_bbox(objs) + ctr, size = (mn + mx) / 2, max(mx - mn) + cd = bpy.data.cameras.new("pc"); cd.type = "ORTHO"; cd.ortho_scale = size * 2.4 + cam = bpy.data.objects.new("pc", cd); sc.collection.objects.link(cam); sc.camera = cam + loc = ctr + Vector((size * 2.2, -size * 2.2, size * 1.8)) + cam.location = loc + cam.rotation_euler = (ctr - loc).to_track_quat("-Z", "Y").to_euler() + sc.render.filepath = out_png + bpy.ops.render.render(write_still=True) + + +# ---------------------------------------------------------------- main + +def main(): + a = parse_args() + r = load_recipe(a.asset) + stem = a.asset # z. B. UNIT_Alliance_BattleTank + rel = r["path"] + src_fbx = os.path.join(a.src, rel, f"SM_{stem}.fbx") + src_tex = os.path.join(a.src, rel, f"T_{stem}_BC.png") + out_dir = os.path.join(a.dst, rel) + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.fbx(filepath=src_fbx) + lods = lod_objects() + for o in lods: # FBX-Import liefert 90 Grad X und Skala 0.01; alles in Weltmass backen + activate(o) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + o.data.materials.clear() + print(f"[{stem}] {len(lods)} LODs: {[o.name for o in lods]}") + + apply_yaw(lods, r.get("yaw_fix", 0)) + # Rezept-Koordinaten beziehen sich auf die Bounding-Box VOR jeder + # Reparatur - genau die, die asset_layout.py rendert. + bb = world_bbox([lods[0]]) + diag = (bb[1] - bb[0]).length + if r.get("drop_needles", True): + n = sum(drop_needles(o, diag) for o in lods) + if n: + print(f" Splitter: {n} Faces entfernt") + for box in r.get("drop_boxes", []): + n = sum(delete_faces(o, in_box(bb, box)) for o in lods) + print(f" drop_box {box}: {n} Faces entfernt") + ext = bb[1] - bb[0] + for mv in r.get("move_boxes", []): + off = Vector((ext.x * mv["offset"][0], ext.y * mv["offset"][1], ext.z * mv["offset"][2])) + n = sum(move_faces(o, in_box(bb, mv["box"]), off) for o in lods) + print(f" move_box {mv['box']} um {tuple(round(v, 2) for v in off)}: {n} Vertices") + # Tripo trennt an jeder UV-Naht (~1000 Scherben je Modell, oft mm bis cm + # auseinander); 0,2 % der Diagonale schliesst die meisten Naehte, ohne + # Details zu verschmelzen. + merge = (bb[1] - bb[0]).length * r.get("merge_frac", 0.002) + for o in lods: + smooth_normals(o, r.get("smooth_angle", 0), merge) + + tex_paths, stats = {}, {} + if r["texture"].get("enabled", True) and not a.no_export: + os.makedirs(out_dir, exist_ok=True) + tex_paths, stats = texture_pass(lods[0], src_tex, r["faction"], r["texture"], out_dir, stem) + print(f" Textur: {json.dumps(stats)}") + + result, pivots, parents, kinds = split_parts(lods, r, bb) + + if a.preview: + os.makedirs(a.preview, exist_ok=True) + render_preview(result, os.path.join(a.preview, f"{stem}_parts.png"), tex_paths) + if not a.no_export: + build_and_export(result, pivots, parents, kinds, stem, os.path.join(out_dir, f"SM_{stem}.fbx")) + report = {"asset": stem, "texture": stats, + "parts": {n: {"kind": kinds.get(n, "body"), "pivot": [round(v, 4) for v in p]} for n, p in pivots.items()}} + print("REPORT " + json.dumps(report)) + + +if __name__ == "__main__": + main() diff --git a/tools/art/asset_recipes.json b/tools/art/asset_recipes.json new file mode 100644 index 00000000..8c31a2f4 --- /dev/null +++ b/tools/art/asset_recipes.json @@ -0,0 +1,745 @@ +{ + "_doc": "Rezepte fuer tools/art/asset_pass.py. Koordinaten normiert auf die LOD0-Bounding-Box: x links->rechts, y vorne->hinten (vorne = Unity +Z), z unten->oben. Felder siehe Docstring von asset_pass.py. Geometrie-AO/Kanten (ao_strength, edge_highlight) sind standardmaessig aus: auf Tripo-Mehrschalen-Meshes backen sie ganze Flaechen schwarz.", + "_defaults": { + "smooth_angle": 38, + "texture": { + "enabled": true, + "bake_samples": 48, + "ao_strength": 0.0, + "ao_distance": 0.08, + "edge_radius": 0.006, + "edge_highlight": 0.0, + "metallic": 0.25, + "smoothness": 0.3, + "accent_by_faction": { + "Alliance": { + "hue": [ + 165, + 215 + ], + "sat": 0.45, + "val": 0.45 + }, + "Legion": { + "hue": [ + 10, + 45 + ], + "sat": 0.6, + "val": 0.75 + } + }, + "palette_mix_by_faction": { + "Alliance": 0.55, + "Legion": 0.45 + }, + "target_mean": 0.34, + "max_gain": 4.0, + "white_point": 1.25, + "saturation_by_faction": { + "Alliance": 0.6, + "Legion": 0.85 + }, + "hue_band_by_faction": { + "Alliance": [ + 150, + 230 + ], + "Legion": [ + 0, + 50 + ] + } + } + }, + "BLDG_Alliance_Barracks": { + "path": "Buildings/Alliance/Barracks", + "faction": "Alliance" + }, + "BLDG_Alliance_DefensePlatform": { + "move_boxes": [ + { + "box": [ + 0.3, + 0.65, + 0, + 0.2, + 0.7, + 1.0 + ], + "offset": [ + 0, + 0.42, + 0 + ] + } + ], + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.62, + "ring_z_norm": true + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret", + "box": [ + 0.35, + 0.55, + 0.4, + 0.64, + 0.76, + 0.97 + ] + } + ], + "path": "Buildings/Alliance/DefensePlatform", + "faction": "Alliance" + }, + "BLDG_Alliance_HQ": { + "path": "Buildings/Alliance/HQ", + "faction": "Alliance", + "parts": [ + { + "name": "Spin_Spire", + "kind": "spin", + "box": [ + 0.4, + 0.6, + 0.4, + 0.62, + 0.82, + 1.0 + ], + "pivot": [ + 0.5, + 0.51, + 0.82 + ] + } + ] + }, + "BLDG_Alliance_Power": { + "path": "Buildings/Alliance/Power", + "faction": "Alliance" + }, + "BLDG_Alliance_Radar": { + "parts": [ + { + "name": "Spin_Dish", + "kind": "spin", + "box": [ + 0, + 1, + 0, + 1, + 0.84, + 1 + ], + "pivot": [ + 0.5, + 0.52, + 0.84 + ] + } + ], + "path": "Buildings/Alliance/Radar", + "faction": "Alliance" + }, + "BLDG_Alliance_Refinery": { + "path": "Buildings/Alliance/Refinery", + "faction": "Alliance", + "parts": [ + { + "name": "Spin_Crystal", + "kind": "spin", + "box": [ + 0.35, + 0.55, + 0.55, + 0.8, + 0.89, + 1.0 + ], + "pivot": [ + 0.45, + 0.67, + 0.88 + ], + "islands": 0.6 + } + ] + }, + "BLDG_Alliance_ResearchLab": { + "path": "Buildings/Alliance/ResearchLab", + "faction": "Alliance" + }, + "BLDG_Alliance_Storage": { + "path": "Buildings/Alliance/Storage", + "faction": "Alliance" + }, + "BLDG_Alliance_VehicleFactory": { + "path": "Buildings/Alliance/VehicleFactory", + "faction": "Alliance" + }, + "BLDG_Legion_Barracks": { + "path": "Buildings/Legion/Barracks", + "faction": "Legion" + }, + "BLDG_Legion_DefensePlatform": { + "drop_boxes": [ + [ + 0.3, + 0.58, + 0.1, + 0.28, + 0, + 0.05 + ] + ], + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.42, + "ring_z_norm": true + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret" + } + ], + "path": "Buildings/Legion/DefensePlatform", + "faction": "Legion" + }, + "BLDG_Legion_HQ": { + "path": "Buildings/Legion/HQ", + "faction": "Legion" + }, + "BLDG_Legion_Power": { + "path": "Buildings/Legion/Power", + "faction": "Legion" + }, + "BLDG_Legion_Radar": { + "parts": [ + { + "name": "Scan_Dish", + "kind": "spin", + "box": [ + 0, + 0.36, + 0, + 0.45, + 0.6, + 1.0 + ], + "pivot": [ + 0.2, + 0.3, + 0.62 + ] + } + ], + "path": "Buildings/Legion/Radar", + "faction": "Legion" + }, + "BLDG_Legion_Refinery": { + "path": "Buildings/Legion/Refinery", + "faction": "Legion" + }, + "BLDG_Legion_ResearchLab": { + "path": "Buildings/Legion/ResearchLab", + "faction": "Legion" + }, + "BLDG_Legion_Storage": { + "path": "Buildings/Legion/Storage", + "faction": "Legion" + }, + "BLDG_Legion_VehicleFactory": { + "path": "Buildings/Legion/VehicleFactory", + "faction": "Legion" + }, + "UNIT_Alliance_AntiArmorInfantry": { + "path": "Units/Alliance/AntiArmorInfantry", + "faction": "Alliance" + }, + "UNIT_Alliance_Artillery": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.57, + "ring_z_norm": true + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret", + "box": [ + 0.38, + 0.62, + 0.0, + 0.58, + 0.72, + 1.0 + ] + } + ], + "path": "Units/Alliance/Artillery", + "faction": "Alliance" + }, + "UNIT_Alliance_BasicInfantry": { + "path": "Units/Alliance/BasicInfantry", + "faction": "Alliance" + }, + "UNIT_Alliance_BattleTank": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 4.109 + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret" + } + ], + "path": "Units/Alliance/BattleTank", + "faction": "Alliance" + }, + "UNIT_Alliance_Builder": { + "parts": [ + { + "name": "Arm_L", + "kind": "arm", + "box": [ + [ + 0, + 0.18, + 0, + 1, + 0.2, + 0.62 + ], + [ + 0, + 0.3, + 0, + 1, + 0.6, + 0.8 + ] + ], + "pivot": [ + 0.27, + 0.5, + 0.7 + ] + }, + { + "name": "Arm_R", + "kind": "arm", + "box": [ + [ + 0.82, + 1, + 0, + 1, + 0.2, + 0.62 + ], + [ + 0.7, + 1, + 0, + 1, + 0.6, + 0.8 + ] + ], + "pivot": [ + 0.73, + 0.5, + 0.7 + ] + } + ], + "path": "Units/Alliance/Builder", + "faction": "Alliance" + }, + "UNIT_Alliance_Harvester": { + "parts": [ + { + "name": "Arm_R", + "kind": "arm", + "box": [ + 0.74, + 1.0, + 0.42, + 0.9, + 0.45, + 1.0 + ], + "pivot": [ + 0.82, + 0.6, + 0.92 + ] + } + ], + "path": "Units/Alliance/Harvester", + "faction": "Alliance" + }, + "UNIT_Alliance_LightTank": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.49, + "ring_z_norm": true + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret" + } + ], + "path": "Units/Alliance/LightTank", + "faction": "Alliance" + }, + "UNIT_Alliance_ScoutVehicle": { + "parts": [ + { + "name": "Wheel_FL", + "kind": "wheel", + "axle": [ + 0.205, + 0.205 + ], + "r": 0.21, + "x": [ + 0, + 0.16 + ], + "islands": 0.7 + }, + { + "name": "Wheel_FR", + "kind": "wheel", + "axle": [ + 0.205, + 0.205 + ], + "r": 0.21, + "x": [ + 0.84, + 1 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RL", + "kind": "wheel", + "axle": [ + 0.805, + 0.205 + ], + "r": 0.21, + "x": [ + 0, + 0.16 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RR", + "kind": "wheel", + "axle": [ + 0.805, + 0.205 + ], + "r": 0.21, + "x": [ + 0.84, + 1 + ], + "islands": 0.7 + } + ], + "path": "Units/Alliance/ScoutVehicle", + "faction": "Alliance" + }, + "UNIT_Legion_AntiArmorInfantry": { + "path": "Units/Legion/AntiArmorInfantry", + "faction": "Legion" + }, + "UNIT_Legion_Artillery": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.5, + "ring_z_norm": true, + "box": [ + 0.2, + 0.8, + 0, + 1, + 0, + 1 + ] + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret", + "box": [ + 0.25, + 0.75, + 0.0, + 0.5, + 0.74, + 1.0 + ] + } + ], + "path": "Units/Legion/Artillery", + "faction": "Legion" + }, + "UNIT_Legion_BasicInfantry": { + "path": "Units/Legion/BasicInfantry", + "faction": "Legion" + }, + "UNIT_Legion_BattleTank": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 3.9375 + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret" + } + ], + "path": "Units/Legion/BattleTank", + "faction": "Legion" + }, + "UNIT_Legion_Builder": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.23, + "ring_z_norm": true + }, + { + "name": "Arm_Boom", + "kind": "arm", + "from": "Turret", + "parent": "Turret", + "box": [ + [ + 0.6, + 0.85, + 0, + 0.2, + 0.4, + 0.8 + ], + [ + 0.6, + 0.85, + 0.17, + 0.62, + 0.62, + 1.0 + ], + [ + 0.6, + 0.85, + 0.62, + 0.82, + 0.42, + 1.0 + ] + ], + "pivot": [ + 0.7, + 0.74, + 0.47 + ] + } + ], + "path": "Units/Legion/Builder", + "faction": "Legion" + }, + "UNIT_Legion_Harvester": { + "parts": [ + { + "name": "Wheel_FL", + "kind": "wheel", + "axle": [ + 0.36, + 0.14 + ], + "r": 0.145, + "x": [ + 0, + 0.14 + ], + "islands": 0.7 + }, + { + "name": "Wheel_FR", + "kind": "wheel", + "axle": [ + 0.36, + 0.14 + ], + "r": 0.145, + "x": [ + 0.86, + 1 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RL", + "kind": "wheel", + "axle": [ + 0.79, + 0.14 + ], + "r": 0.145, + "x": [ + 0, + 0.14 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RR", + "kind": "wheel", + "axle": [ + 0.79, + 0.14 + ], + "r": 0.145, + "x": [ + 0.86, + 1 + ], + "islands": 0.7 + } + ], + "path": "Units/Legion/Harvester", + "faction": "Legion" + }, + "UNIT_Legion_LightTank": { + "parts": [ + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.655, + "ring_z_norm": true + }, + { + "name": "Gun", + "kind": "gun", + "from": "Turret", + "parent": "Turret" + } + ], + "path": "Units/Legion/LightTank", + "faction": "Legion" + }, + "UNIT_Legion_ScoutVehicle": { + "parts": [ + { + "name": "Wheel_FL", + "kind": "wheel", + "axle": [ + 0.15, + 0.215 + ], + "r": 0.215, + "x": [ + 0, + 0.2 + ], + "islands": 0.7 + }, + { + "name": "Wheel_FR", + "kind": "wheel", + "axle": [ + 0.15, + 0.215 + ], + "r": 0.215, + "x": [ + 0.8, + 1 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RL", + "kind": "wheel", + "axle": [ + 0.8, + 0.25 + ], + "r": 0.25, + "x": [ + 0, + 0.2 + ], + "islands": 0.7 + }, + { + "name": "Wheel_RR", + "kind": "wheel", + "axle": [ + 0.8, + 0.25 + ], + "r": 0.25, + "x": [ + 0.8, + 1 + ], + "islands": 0.7 + }, + { + "name": "Turret", + "kind": "turret", + "ring_z": 0.845, + "ring_z_norm": true, + "box": [ + 0.15, + 0.7, + 0.45, + 0.88, + 0.84, + 1.0 + ] + } + ], + "path": "Units/Legion/ScoutVehicle", + "faction": "Legion" + } +} \ No newline at end of file diff --git a/tools/art/layout_grid.py b/tools/art/layout_grid.py new file mode 100644 index 00000000..3a76809a --- /dev/null +++ b/tools/art/layout_grid.py @@ -0,0 +1,38 @@ +"""Legt das normierte Rezept-Raster (0..1, Schritt 0,1) ueber die Ansichten von +asset_layout.py und setzt sie zu einem Blatt zusammen. Laeuft mit System-Python + Pillow: + + python3 tools/art/layout_grid.py +""" +import sys +from PIL import Image, ImageDraw + +d, stem, out = sys.argv[1], sys.argv[2], sys.argv[3] +AXES = {"top": ("x", "y"), "side": ("y", "z"), "front": ("x", "z")} +tiles = [] +for view, (hx, vy) in AXES.items(): + im = Image.open(f"{d}/{stem}_{view}.png").convert("RGB") + w, h = im.size + pad = 34 + canvas = Image.new("RGB", (w + pad * 2, h + pad * 2), (24, 24, 28)) + canvas.paste(im, (pad, pad)) + g = ImageDraw.Draw(canvas) + for i in range(11): + t = i / 10 + x = pad + t * w + y = pad + (1 - t) * h # vertikal waechst nach oben + col = (255, 220, 0) if i in (0, 5, 10) else (255, 255, 255) + g.line([(x, pad), (x, pad + h)], fill=col, width=1) + g.line([(pad, y), (pad + w, y)], fill=col, width=1) + g.text((x - 8, pad + h + 6), f"{t:.1f}", fill=(255, 255, 0)) + g.text((4, y - 6), f"{t:.1f}", fill=(255, 255, 0)) + g.text((pad, 8), f"{view}: horizontal {hx}, vertikal {vy}", fill=(120, 220, 255)) + tiles.append(canvas) +W = sum(t.size[0] for t in tiles) +H = max(t.size[1] for t in tiles) +sheet = Image.new("RGB", (W, H), (24, 24, 28)) +x = 0 +for t in tiles: + sheet.paste(t, (x, 0)) + x += t.size[0] +sheet.save(out) +print(out) diff --git a/tools/art/rig_tank_hierarchy.py b/tools/art/rig_tank_hierarchy.py index d8f6b234..dabcd7e0 100644 --- a/tools/art/rig_tank_hierarchy.py +++ b/tools/art/rig_tank_hierarchy.py @@ -287,4 +287,5 @@ def attach(child, parent, parent_world): bpy.ops.export_scene.gltf(filepath=glb_out, export_format='GLB', use_selection=True) print("EXPORTED", glb_out) -main() +if __name__ == "__main__": + main() From b7b4fba03f663c6a300fd6c9e98ce02de7420d08 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 12:47:02 +0200 Subject: [PATCH 04/13] feat(presentation): glow via URP bloom, light from the camera side The emissive accents of the art pass never glowed: the URP renderer had no postProcessData since UrpProjectSetup created it, so URP rendered every camera without post-processing; there was no volume profile, the main camera had post-processing off, and URP's material validation stripped _EMISSION because the GI flags were None. UrpPostProcessingSetup (reflection only, D-061 allows no URP reference) wires the renderer data, a bloom profile as the pipeline asset's default volume (threshold 1.0, so only emission blooms) and the camera switch; the scene generator calls it and the Bootstrap scene is regenerated. The art pass now separates painted accent stripes from glowing ones so light panels no longer burn out white. The dark Alliance base was backlighting, not texture: the sun at Euler(33, -128) stood behind every facade the north-facing RTS camera shows. It now sits high on the camera side (50, 35) as Lighting.md asks for the desert. The Alliance radar column is squashed to building height. ArtShowcaseTests places every building and unit of both factions in the running match and photographs them through the main camera from 18, 30 and 42 m. Verified locally: headless 768/768, EditMode 676/676, PlayMode 17/17. Not played. Decision: D-111. Co-Authored-By: Claude Opus 5.5 --- Assets/Tests/PlayMode/ArtShowcaseTests.cs | 192 +++ .../Tests/PlayMode/ArtShowcaseTests.cs.meta | 11 + .../PlayMode/UnitMotionPresentationTests.cs | 31 +- .../_Project/Editor/ArtAssetPrefabBuilder.cs | 8 +- Assets/_Project/Editor/ArtPreviewCapture.cs | 4 +- .../Editor/BootstrapSceneGenerator.cs | 25 +- .../_Project/Editor/UrpPostProcessingSetup.cs | 177 +++ .../Editor/UrpPostProcessingSetup.cs.meta | 11 + Assets/_Project/Scenes/Bootstrap.unity | 1397 +++++++++-------- .../Settings/NovaPostProcessProfile.asset | 66 + .../NovaPostProcessProfile.asset.meta | 8 + Assets/_Project/Settings/NovaUrp.asset | 2 +- .../_Project/Settings/NovaUrpRenderer.asset | 2 +- CHANGELOG.md | 10 + docs/README.md | 2 +- docs/assets/AssetPass_2026-10-17.md | 27 +- docs/production/DecisionLog.md | 43 +- docs/tech/Lighting.md | 22 +- tools/art/asset_pass.py | 49 +- tools/art/asset_recipes.json | 89 +- 20 files changed, 1456 insertions(+), 720 deletions(-) create mode 100644 Assets/Tests/PlayMode/ArtShowcaseTests.cs create mode 100644 Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta create mode 100644 Assets/_Project/Editor/UrpPostProcessingSetup.cs create mode 100644 Assets/_Project/Editor/UrpPostProcessingSetup.cs.meta create mode 100644 Assets/_Project/Settings/NovaPostProcessProfile.asset create mode 100644 Assets/_Project/Settings/NovaPostProcessProfile.asset.meta diff --git a/Assets/Tests/PlayMode/ArtShowcaseTests.cs b/Assets/Tests/PlayMode/ArtShowcaseTests.cs new file mode 100644 index 00000000..9714ca29 --- /dev/null +++ b/Assets/Tests/PlayMode/ArtShowcaseTests.cs @@ -0,0 +1,192 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + /// + /// Evidence shots of the whole art set as the player sees it: every + /// building and unit of both factions, placed in the running match at + /// its real in-game scale, photographed through the main camera (and so + /// with the project's post-processing) from three RTS camera heights. + /// It exists because close-up previews (ArtPreviewCapture) say nothing + /// about readability at 30-40 m camera height, which is where an art + /// pass is won or lost. + /// + /// The assertions only guard that every placed entity owns a view; the + /// value is in output/demo/showcase_*.png. Headless with graphics, never + /// with -quit (see GrayboxDemoProofTests). + /// + /// + public sealed class ArtShowcaseTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + + private static readonly UnitRole[] Buildings = + { + UnitRole.HQ, UnitRole.Power, UnitRole.Refinery, UnitRole.Storage, UnitRole.Barracks, + UnitRole.VehicleFactory, UnitRole.ResearchLab, UnitRole.Radar, UnitRole.DefensePlatform, + }; + + private static readonly UnitRole[] Units = + { + UnitRole.Builder, UnitRole.Harvester, UnitRole.BasicInfantry, UnitRole.AntiArmorInfantry, + UnitRole.ScoutVehicle, UnitRole.LightTank, UnitRole.BattleTank, UnitRole.Artillery, + }; + + [UnityTest] + public IEnumerator Showcase_BothFactionsAtRtsDistance() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady, "the canonical opening position was not built"); + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views, "Bootstrap scene contains no UnitViewManager"); + + // Legion sits in the fog of the local Alliance player; the editor + // reveal is the same switch the lab uses to watch the AI play. + if (!FogRevealDebug.RevealAll) FogRevealDebug.Toggle(); + try + { + var placed = new List(); + Vector3 allianceCentre = Lay(runner, 0, 22, 6, +1, placed); + Vector3 legionCentre = Lay(runner, 1, 102, 118, -1, placed); + + // Let views spawn, rigs settle and the glow pulse run. + yield return new WaitForSeconds(2.5f); + + foreach (EntityId id in placed) + { + Assert.IsTrue(views.TryGetView(id, out GameObject view) && view != null, + $"entity {id} owns no view — prefab resolution or fog feed broke"); + } + + foreach ((string name, Vector3 centre) in new[] { ("alliance", allianceCentre), ("legion", legionCentre) }) + { + foreach (float height in new[] { 18f, 30f, 42f }) + { + CaptureRts($"{ShotDir}/showcase_{name}_{height:0}m.png", centre, height); + } + } + } + finally + { + if (FogRevealDebug.RevealAll) FogRevealDebug.Toggle(); + } + } + + /// + /// Places all nine buildings (completed) in a 5-wide row grid and the + /// eight units in a row in front of them, walking from the start cell + /// in direction (+1 east/north, -1 west/south). + /// Returns the world centre of the layout. + /// + private static Vector3 Lay(MatchRunner runner, byte slot, int x0, int y0, int dir, List placed) + { + FactionId faction = runner.Economy.GetSlotFaction(slot); + float minX = float.MaxValue, maxX = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; + + for (int i = 0; i < Buildings.Length; i++) + { + Assert.IsTrue(SimDefinitions.TryGetBuilding(faction, Buildings[i], out SimBuildingDefinition def), + $"setup: no {faction} {Buildings[i]} definition"); + int ox = x0 + dir * (i % 5) * 5; + int oy = y0 + dir * (i / 5) * 5; + EntityId id = PlaceNear(runner, slot, def.DefinitionId, ox, oy); + Assert.IsTrue(id.IsValid, $"setup: {faction} {Buildings[i]} could not be placed near ({ox},{oy})"); + placed.Add(id); + Grow(ref minX, ref maxX, ref minY, ref maxY, ox + 1.5f, oy + 1.5f); + } + + int uy = y0 + dir * 11; + for (int i = 0; i < Units.Length; i++) + { + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, Units[i], out SimUnitDefinition def), + $"setup: no {faction} {Units[i]} definition"); + int ux = x0 + dir * i * 3; + EntityId id = runner.Entities.SpawnUnit(slot, + new Transform2D(SimFixed.FromInt(ux), SimFixed.FromInt(uy)), def.MoveSpeed, role: def.Role); + Assert.IsTrue(id.IsValid, $"setup: spawn of {faction} {Units[i]} failed"); + placed.Add(id); + Grow(ref minX, ref maxX, ref minY, ref maxY, ux, uy); + } + + return new Vector3((minX + maxX) * 0.5f, 0f, (minY + maxY) * 0.5f); + } + + private static EntityId PlaceNear(MatchRunner runner, byte slot, ushort defId, int x, int y) + { + for (int r = 0; r < 4; r++) + { + for (int dy = -r; dy <= r; dy++) + { + for (int dx = -r; dx <= r; dx++) + { + if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; + EntityId id = runner.Construction.PlaceCompletedBuilding(slot, defId, x + dx, y + dy); + if (id.IsValid) return id; + } + } + } + return EntityId.Invalid; + } + + private static void Grow(ref float minX, ref float maxX, ref float minY, ref float maxY, float x, float y) + { + minX = Mathf.Min(minX, x); maxX = Mathf.Max(maxX, x); + minY = Mathf.Min(minY, y); maxY = Mathf.Max(maxY, y); + } + + /// + /// The RTS camera's 55-degree pitch at a given height, rendered through + /// the scene's main camera (moved, then put back) so post-processing + /// is included. + /// + private static void CaptureRts(string path, Vector3 focus, float height) + { + Camera camera = Camera.main; + Assert.NotNull(camera, "no main camera in the Bootstrap scene"); + Transform t = camera.transform; + Vector3 oldPos = t.position; + Quaternion oldRot = t.rotation; + var rt = new RenderTexture(1600, 900, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; + try + { + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + float distance = height / Mathf.Sin(55f * Mathf.Deg2Rad); + t.SetPositionAndRotation(focus - rot * Vector3.forward * distance, rot); + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + var tex = new Texture2D(1600, 900, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0f, 0f, 1600f, 900f), 0, 0); + tex.Apply(); + File.WriteAllBytes(path, tex.EncodeToPNG()); + RenderTexture.active = null; + Object.DestroyImmediate(tex); + } + finally + { + camera.targetTexture = null; + t.SetPositionAndRotation(oldPos, oldRot); + rt.Release(); + Object.DestroyImmediate(rt); + } + } + } +} diff --git a/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta b/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta new file mode 100644 index 00000000..d6307e60 --- /dev/null +++ b/Assets/Tests/PlayMode/ArtShowcaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 921b12a4edf84de09e8f513a5dfca76a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs index e29637a8..b7351689 100644 --- a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs +++ b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs @@ -205,21 +205,26 @@ private static Vector3 Centroid(UnitViewManager views, List ids) return n > 0 ? sum / n : Vector3.zero; } - /// The RTS camera's 55-degree pitch at a given height, looking at a point. + /// + /// The RTS camera's 55-degree pitch at a given height, looking at a + /// point. Renders through the scene's MAIN camera (moved, then put + /// back) so the shot carries what the player sees — post-processing + /// included, which a fresh test camera would not have opted into. + /// private static void CaptureRts(string path, Vector3 focus, float height) { - var go = new GameObject("MotionProofCamera"); + Camera camera = Camera.main; + Assert.NotNull(camera, "no main camera in the Bootstrap scene"); + Transform t = camera.transform; + Vector3 oldPos = t.position; + Quaternion oldRot = t.rotation; + // LDR target: URP renders HDR internally and resolves bloom into it. + var rt = new RenderTexture(1600, 900, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; try { - var camera = go.AddComponent(); - camera.fieldOfView = 60f; - camera.clearFlags = CameraClearFlags.SolidColor; - camera.backgroundColor = new Color(0.13f, 0.16f, 0.22f, 1f); Quaternion rot = Quaternion.Euler(55f, 0f, 0f); float distance = height / Mathf.Sin(55f * Mathf.Deg2Rad); - camera.transform.SetPositionAndRotation(focus - rot * Vector3.forward * distance, rot); - - var rt = new RenderTexture(1600, 900, 24) { antiAliasing = 4 }; + t.SetPositionAndRotation(focus - rot * Vector3.forward * distance, rot); camera.targetTexture = rt; camera.Render(); RenderTexture.active = rt; @@ -228,14 +233,14 @@ private static void CaptureRts(string path, Vector3 focus, float height) tex.Apply(); File.WriteAllBytes(path, tex.EncodeToPNG()); RenderTexture.active = null; - camera.targetTexture = null; Object.DestroyImmediate(tex); - rt.Release(); - Object.DestroyImmediate(rt); } finally { - Object.DestroyImmediate(go); + camera.targetTexture = null; + t.SetPositionAndRotation(oldPos, oldRot); + rt.Release(); + Object.DestroyImmediate(rt); } } } diff --git a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs index 9b0e74d7..a04ce124 100644 --- a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs +++ b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs @@ -36,7 +36,7 @@ public static class ArtAssetPrefabBuilder private const string UrpLitShader = "Universal Render Pipeline/Lit"; /// HDR gain on the _EM map: accents must read under the full desert sun. - private const float EmissionIntensity = 1.6f; + private const float EmissionIntensity = 2.0f; [MenuItem("Tools/Project Nova/Build Art Prefabs From FBX")] public static void BuildMenu() @@ -159,13 +159,17 @@ private static Material CreateOrUpdateMaterial(string dir, string suffix) material.SetTexture("_EmissionMap", emission); material.SetColor("_EmissionColor", Color.white * EmissionIntensity); material.EnableKeyword("_EMISSION"); - material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.None; + // URP's material validation keeps _EMISSION only while the GI + // flags say "emissive" (BaseShaderGUI.SetMaterialKeywords); + // with None it silently strips the keyword on the next import. + material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive; } else { material.SetTexture("_EmissionMap", null); material.SetColor("_EmissionColor", Color.black); material.DisableKeyword("_EMISSION"); + material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.EmissiveIsBlack; } // Tripo meshes are ~1000 loose shells with real gaps between them. diff --git a/Assets/_Project/Editor/ArtPreviewCapture.cs b/Assets/_Project/Editor/ArtPreviewCapture.cs index 9d5d21ca..d66d5d7f 100644 --- a/Assets/_Project/Editor/ArtPreviewCapture.cs +++ b/Assets/_Project/Editor/ArtPreviewCapture.cs @@ -116,7 +116,7 @@ private static void SetUpStage() var lightObject = new GameObject("Sun"); Light light = lightObject.AddComponent(); light.type = LightType.Directional; - lightObject.transform.rotation = Quaternion.Euler(33f, -128f, 0f); + lightObject.transform.rotation = Quaternion.Euler(BootstrapSceneGenerator.SunPitch, BootstrapSceneGenerator.SunYaw, 0f); light.color = new Color(1f, 0.87f, 0.70f); light.intensity = 1.15f; light.shadows = LightShadows.Soft; @@ -202,6 +202,8 @@ private static Camera NewCamera() cam.backgroundColor = new Color(0.16f, 0.17f, 0.19f); cam.nearClipPlane = 0.1f; cam.farClipPlane = 500f; + cam.allowHDR = true; + UrpPostProcessingSetup.EnablePostProcessing(cam); return cam; } diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs index 852883e0..1a885af4 100644 --- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs +++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs @@ -61,6 +61,10 @@ public static void CreateBootstrapScene() NewSceneSetup.EmptyScene, NewSceneMode.Single); + // Bloom for the emissive light strips: renderer post-process data, + // the default volume profile and the camera switch (see the class). + UrpPostProcessingSetup.Apply(); + Camera camera = CreateCamera(); CreateDirectionalLight(); ConfigureAtmosphere(); @@ -111,6 +115,10 @@ private static Camera CreateCamera() // edit cannot silently clip the terrain away. camera.farClipPlane = Mathf.Max(camera.farClipPlane, 400f); + // URP renders post-processing (the bloom on emissive accents) only + // on cameras that opt in; a plain AddComponent does not. + UrpPostProcessingSetup.EnablePostProcessing(camera); + // [RequireComponent(typeof(Camera))] — the Camera above satisfies it. cameraObject.AddComponent(); @@ -123,17 +131,28 @@ private static Camera CreateCamera() } /// - /// The Glutrinne sun (D-085): low and slanted for long shadows, warm - /// in colour temperature, with soft shadows. These are Light + /// The Glutrinne sun: high and from the camera side (Lighting.md, + /// desert biome; D-110), warm in colour temperature, with soft shadows. These are Light /// properties on the generated object, so regeneration keeps them — /// hand-tuning the scene YAML would be overwritten here anyway. /// + /// Sun elevation in degrees; shared with ArtPreviewCapture so previews match the game. + public const float SunPitch = 50f; + + /// Sun yaw: light travels toward the north-east, i.e. it comes from the camera's lower left. + public const float SunYaw = 35f; + private static void CreateDirectionalLight() { var lightObject = new GameObject("Directional Light"); Light light = lightObject.AddComponent(); light.type = LightType.Directional; - lightObject.transform.rotation = Quaternion.Euler(33f, -128f, 0f); + // From the camera's side (south-west) and high, as Lighting.md asks + // for the desert: the RTS camera looks north, and the former + // Euler(33, -128) lit the buildings from behind — every facade the + // player sees sat in its own shadow, and the shadows fell toward + // the camera. Now shadows fall away to the upper right (D-110). + lightObject.transform.rotation = Quaternion.Euler(SunPitch, SunYaw, 0f); light.color = new Color(1f, 0.87f, 0.70f); light.intensity = 1.15f; light.shadows = LightShadows.Soft; diff --git a/Assets/_Project/Editor/UrpPostProcessingSetup.cs b/Assets/_Project/Editor/UrpPostProcessingSetup.cs new file mode 100644 index 00000000..49e78859 --- /dev/null +++ b/Assets/_Project/Editor/UrpPostProcessingSetup.cs @@ -0,0 +1,177 @@ +using System; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace Nova.Editor +{ + /// + /// Post-processing for the whole project: the glow on emissive light + /// strips (the _EM maps of tools/art/asset_pass.py) needs URP bloom, and + /// bloom needed three things the project never had. + /// + /// Renderer post-process data. + /// created NovaUrpRenderer by CreateInstance, which leaves + /// postProcessData null — URP then silently renders every camera + /// without post-processing, whatever volumes exist. + /// A volume profile. Assigned as the pipeline asset's + /// volumeProfile, it is the default stack of every camera, so no + /// scene needs a Volume object and preview cameras get it too. + /// Per-camera switch. URP cameras render post-processing only + /// with renderPostProcessing on; + /// sets it on the serialized camera data. + /// + /// + /// Reflection and serialized properties only, like UrpProjectSetup: the + /// asmdef contract (D-061, quality/scripts/run_gate_check.py) allows no URP + /// assembly reference from Nova.Editor. Idempotent: re-running updates the + /// profile in place and keeps its GUID. + /// + /// + public static class UrpPostProcessingSetup + { + private const string RendererPath = "Assets/_Project/Settings/NovaUrpRenderer.asset"; + private const string PipelinePath = "Assets/_Project/Settings/NovaUrp.asset"; + public const string ProfilePath = "Assets/_Project/Settings/NovaPostProcessProfile.asset"; + private const string PostProcessDataPath = + "Packages/com.unity.render-pipelines.universal/Runtime/Data/PostProcessData.asset"; + + // Bloom only catches HDR values above the threshold: the emissive + // accents (EmissionIntensity in ArtAssetPrefabBuilder) and the sun on + // the brightest steel, not the desert floor. + private const float BloomThreshold = 1.0f; + private const float BloomIntensity = 1.0f; + private const float BloomScatter = 0.65f; + + [MenuItem("Tools/Project Nova/Setup URP Post-Processing (Bloom)")] + public static void Apply() + { + var renderer = AssetDatabase.LoadMainAssetAtPath(RendererPath); + var pipeline = AssetDatabase.LoadMainAssetAtPath(PipelinePath); + var postProcessData = AssetDatabase.LoadMainAssetAtPath(PostProcessDataPath); + if (renderer == null || pipeline == null) + { + Debug.LogError("[UrpPostProcessingSetup] URP assets missing — run Tools/Project Nova/Setup URP Pipeline first."); + return; + } + if (postProcessData == null) + { + Debug.LogError($"[UrpPostProcessingSetup] '{PostProcessDataPath}' not found — URP package layout changed?"); + return; + } + + SetReference(renderer, "postProcessData", postProcessData); + ScriptableObject profile = LoadOrCreateProfile(); + if (profile == null) return; + ConfigureBloom(profile); + SetReference(pipeline, "m_VolumeProfile", profile); + + AssetDatabase.SaveAssets(); + Debug.Log("[UrpPostProcessingSetup] post-process data, bloom profile and pipeline default volume set."); + } + + /// Turns URP post-processing on for one camera (adds its URP camera data if missing). + public static void EnablePostProcessing(Camera camera) + { + Type dataType = FindType("UnityEngine.Rendering.Universal.UniversalAdditionalCameraData"); + if (camera == null || dataType == null) return; + Component data = camera.GetComponent(dataType) ?? camera.gameObject.AddComponent(dataType); + var so = new SerializedObject(data); + SerializedProperty flag = so.FindProperty("m_RenderPostProcessing"); + if (flag == null) + { + Debug.LogError("[UrpPostProcessingSetup] m_RenderPostProcessing not found — URP camera data changed?"); + return; + } + flag.boolValue = true; + so.ApplyModifiedPropertiesWithoutUndo(); + } + + private static ScriptableObject LoadOrCreateProfile() + { + Type profileType = FindType("UnityEngine.Rendering.VolumeProfile"); + if (profileType == null) + { + Debug.LogError("[UrpPostProcessingSetup] VolumeProfile type not found."); + return null; + } + var profile = AssetDatabase.LoadAssetAtPath(ProfilePath, profileType) as ScriptableObject; + if (profile != null) return profile; + + profile = ScriptableObject.CreateInstance(profileType); + AssetDatabase.CreateAsset(profile, ProfilePath); + return profile; + } + + private static void ConfigureBloom(ScriptableObject profile) + { + Type bloomType = FindType("UnityEngine.Rendering.Universal.Bloom"); + if (bloomType == null) + { + Debug.LogError("[UrpPostProcessingSetup] URP Bloom type not found."); + return; + } + + // VolumeProfile.TryGet/Add are generic or overloaded; the component + // list is the stable surface. + var components = (System.Collections.IList)profile.GetType() + .GetField("components", BindingFlags.Public | BindingFlags.Instance).GetValue(profile); + ScriptableObject bloom = null; + foreach (object c in components) + { + if (c != null && c.GetType() == bloomType) bloom = (ScriptableObject)c; + } + if (bloom == null) + { + bloom = ScriptableObject.CreateInstance(bloomType); + bloom.name = "Bloom"; + bloom.hideFlags = HideFlags.HideInInspector | HideFlags.HideInHierarchy; + components.Add(bloom); + AssetDatabase.AddObjectToAsset(bloom, profile); + } + + Override(bloom, "threshold", BloomThreshold); + Override(bloom, "intensity", BloomIntensity); + Override(bloom, "scatter", BloomScatter); + Override(bloom, "highQualityFiltering", true); + EditorUtility.SetDirty(bloom); + EditorUtility.SetDirty(profile); + } + + /// VolumeParameter<T>.Override(value): sets the value and its override flag. + private static void Override(object component, string field, object value) + { + object parameter = component.GetType().GetField(field, BindingFlags.Public | BindingFlags.Instance)?.GetValue(component); + MethodInfo method = parameter?.GetType().GetMethod("Override", new[] { value.GetType() }); + if (method == null) + { + Debug.LogError($"[UrpPostProcessingSetup] cannot override '{field}' on {component.GetType().Name}."); + return; + } + method.Invoke(parameter, new[] { value }); + } + + private static void SetReference(UnityEngine.Object target, string property, UnityEngine.Object value) + { + var so = new SerializedObject(target); + SerializedProperty p = so.FindProperty(property); + if (p == null) + { + Debug.LogError($"[UrpPostProcessingSetup] '{property}' not found on {target.name}."); + return; + } + p.objectReferenceValue = value; + so.ApplyModifiedPropertiesWithoutUndo(); + } + + private static Type FindType(string fullName) + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + Type type = assembly.GetType(fullName); + if (type != null) return type; + } + return null; + } + } +} diff --git a/Assets/_Project/Editor/UrpPostProcessingSetup.cs.meta b/Assets/_Project/Editor/UrpPostProcessingSetup.cs.meta new file mode 100644 index 00000000..b3ca925d --- /dev/null +++ b/Assets/_Project/Editor/UrpPostProcessingSetup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ef94dcb5ee24a0a92237e1c2d6a73b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scenes/Bootstrap.unity b/Assets/_Project/Scenes/Bootstrap.unity index 82f21b29..d7a10798 100644 --- a/Assets/_Project/Scenes/Bootstrap.unity +++ b/Assets/_Project/Scenes/Bootstrap.unity @@ -119,7 +119,7 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} ---- !u!1 &105837768 +--- !u!1 &288301843 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -127,9 +127,9 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 105837771} - - component: {fileID: 105837770} - - component: {fileID: 105837769} + - component: {fileID: 288301846} + - component: {fileID: 288301845} + - component: {fileID: 288301844} m_Layer: 0 m_Name: IngameMusic m_TagString: Untagged @@ -137,35 +137,35 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &105837769 +--- !u!114 &288301844 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 105837768} + m_GameObject: {fileID: 288301843} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: cdcf36c4668804ae6a8c2889ba4c9d33, type: 3} m_Name: m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MusicDirector - _source: {fileID: 105837770} - _runner: {fileID: 966459987} - _bootstrap: {fileID: 966459986} - _menu: {fileID: 1530823775} + _source: {fileID: 288301845} + _runner: {fileID: 769591639} + _bootstrap: {fileID: 769591638} + _menu: {fileID: 1073198453} _playlist: - {fileID: 8300000, guid: 3c291cb887cd427f8fe213d0cc97e7ba, type: 3} - {fileID: 8300000, guid: 159885d03b3d4674a15ae77a1275fd85, type: 3} - {fileID: 8300000, guid: 8605bd0c749044cbaaa4a64ec990b5b5, type: 3} _fadeSeconds: 1.25 _trackGapSeconds: 2 ---- !u!82 &105837770 +--- !u!82 &288301845 AudioSource: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 105837768} + m_GameObject: {fileID: 288301843} m_Enabled: 1 serializedVersion: 4 OutputAudioMixerGroup: {fileID: -161172014317909320, guid: 7831b040d10e94eb9bddf12783bcee5e, type: 2} @@ -256,13 +256,13 @@ AudioSource: m_PreInfinity: 2 m_PostInfinity: 2 m_RotationOrder: 4 ---- !u!4 &105837771 +--- !u!4 &288301846 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 105837768} + m_GameObject: {fileID: 288301843} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -271,7 +271,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &250056992 +--- !u!1 &587992135 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -279,385 +279,253 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 250057008} - - component: {fileID: 250057007} - - component: {fileID: 250057006} - - component: {fileID: 250057005} - - component: {fileID: 250057004} - - component: {fileID: 250057003} - - component: {fileID: 250057002} - - component: {fileID: 250057001} - - component: {fileID: 250057000} - - component: {fileID: 250056999} - - component: {fileID: 250056998} - - component: {fileID: 250056997} - - component: {fileID: 250056996} - - component: {fileID: 250056995} - - component: {fileID: 250056994} - - component: {fileID: 250056993} + - component: {fileID: 587992139} + - component: {fileID: 587992138} + - component: {fileID: 587992137} + - component: {fileID: 587992136} m_Layer: 0 - m_Name: UI + m_Name: Ground m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 + m_StaticEditorFlags: 2147483647 m_IsActive: 1 ---- !u!114 &250056993 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: e920fc747d1c406fb1fa611b6934aa74, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.ResourceBarHud - _runner: {fileID: 966459987} - _uiScale: 1.5 - _fontSize: 13 - _margin: 8 - _topOffset: 31 - _barHeight: 22 - _warningColor: {r: 1, g: 0.72, b: 0.25, a: 1} - _criticalColor: {r: 1, g: 0.38, b: 0.32, a: 1} ---- !u!114 &250056994 -MonoBehaviour: +--- !u!23 &587992136 +MeshRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 587992135} m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 743caecd4c5e6831d67dc968613cbfc6, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.DebugHud - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _visible: 0 - _uiScale: 1.5 - _fontSize: 13 ---- !u!114 &250056995 -MonoBehaviour: + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!64 &587992137 +MeshCollider: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 587992135} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 4839e6caa28240839b51760036ea05f0, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.PauseMenuHud - _runner: {fileID: 966459987} - _menu: {fileID: 1530823775} - _matchFrame: {fileID: 250056996} - _input: {fileID: 250057007} - _uiScale: 1.5 - _panelWidth: 340 ---- !u!114 &250056996 -MonoBehaviour: + serializedVersion: 5 + m_Convex: 0 + m_CookingOptions: 30 + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!33 &587992138 +MeshFilter: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 9a8f5ae5c96d7491494f6d2974f32f3b, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MatchFrameHud - _runner: {fileID: 966459987} - _bootstrap: {fileID: 966459986} - _menu: {fileID: 1530823775} - _views: {fileID: 966459988} - _uiScale: 1.5 - _panelWidth: 340 ---- !u!114 &250056997 -MonoBehaviour: + m_GameObject: {fileID: 587992135} + m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &587992139 +Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 3fbdd2dfe068b4dc0a6f71442dc18c31, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.HealthBarHud - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _uiScale: 1.5 - _barWidth: 26 - _barHeight: 4 - _unitHeightOffset: 1.6 - _buildingHeightOffset: 3.4 ---- !u!114 &250056998 -MonoBehaviour: + m_GameObject: {fileID: 587992135} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 64, y: 0, z: 64} + m_LocalScale: {x: 12.8, y: 1, z: 12.8} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &769591637 +GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 885e2e9fac8724df08b326c82e983510, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MinimapHud - _runner: {fileID: 966459987} - _buildMenu: {fileID: 250057001} - _uiScale: 1.5 - _mapSize: 168 - _margin: 8 - _terrainColor: {r: 0.72, g: 0.6, b: 0.42, a: 1} - _exploredBrightness: 0.45 - _unexploredBrightness: 0.15 - _unitDotSize: 3 - _buildingDotSize: 4.5 - _radarPingColor: {r: 1, g: 0.55, b: 0.1, a: 1} ---- !u!114 &250056999 + serializedVersion: 6 + m_Component: + - component: {fileID: 769591642} + - component: {fileID: 769591639} + - component: {fileID: 769591638} + - component: {fileID: 769591641} + - component: {fileID: 769591640} + m_Layer: 0 + m_Name: Match + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &769591638 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 769591637} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 956e9df8ef76b491abc04e94045ee0d7, type: 3} + m_Script: {fileID: 11500000, guid: 0543a6c6c2d8cbd976ba484a58c2ddc5, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.FogOfWarOverlayView - _runner: {fileID: 966459987} - _unexploredAlpha: 0.85 - _exploredAlpha: 0.45 - _overlayHeight: 0.04 ---- !u!114 &250057000 + m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.MatchBootstrap + AutoStart: 0 + _seed: 16032251723485610609 + _mapWidth: 128 + _mapHeight: 128 + _entityCapacity: 1024 + UseDefinitionStats: 1 +--- !u!114 &769591639 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 769591637} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 79cf751f533e746e993958254d4d427e, type: 3} + m_Script: {fileID: 11500000, guid: 3b9301a799fe64f85afe9906dc15bc39, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.CommandCardHud - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _buildMenu: {fileID: 250057001} - _bootstrap: {fileID: 966459986} - _uiScale: 1.5 - _panelWidth: 236 - _panelMargin: 8 ---- !u!114 &250057001 + m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.MatchRunner + _seed: 12345 + _mapWidth: 128 + _mapHeight: 128 + _maxUnits: 2048 +--- !u!114 &769591640 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 769591637} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 22628296e159b4d5a8e9c701b125cee0, type: 3} + m_Script: {fileID: 11500000, guid: bad3b0760ec204c94bb15e3918bbc921, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.BuildMenuHud - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _uiScale: 1.5 - _buttonWidth: 108 - _buttonHeight: 62 - _barMargin: 8 - _hintMinSeconds: 6 - _hintMaxSeconds: 90 ---- !u!114 &250057002 + m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.UnitViewManager + _matchRunner: {fileID: 769591639} + _unitPrefab: {fileID: 0} + _combatEffects: {fileID: 769591641} + _assetMappings: {fileID: 11400000, guid: 54dfdf235acc54eecbef61df13a6b356, type: 2} + _interpolationSpeed: 25 + _viewerTeamOverride: -1 + _unknownPlayerColor: {r: 0.62, g: 0.62, b: 0.62, a: 1} + _damagedColor: {r: 0.42, g: 0.05, b: 0.05, a: 1} + _healthTintFloor: 0.25 +--- !u!114 &769591641 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 769591637} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: a2b97178f25d64f78897be61bbbb70dd, type: 3} + m_Script: {fileID: 11500000, guid: f4cba4e364582443899c3490f303f72f, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.ConstructionSiteMarkerView - _runner: {fileID: 966459987} - _markerHeight: 0.07 - _buildingColor: {r: 0.35, g: 0.9, b: 0.55, a: 0.55} - _pausedColor: {r: 1, g: 0.62, b: 0.15, a: 1} - _pulseSpeed: 4 ---- !u!114 &250057003 -MonoBehaviour: + m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.CombatFeedback.CombatEffectController +--- !u!4 &769591642 +Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 60b14ad0059d4ecbb3bb0f16e8f28aaf, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.BuildZoneOverlayView - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _buildableColor: - serializedVersion: 2 - rgba: 442164806 - _spacingBlockedColor: - serializedVersion: 2 - rgba: 740857599 - _overlayHeight: 0.05 - _repaintIntervalSeconds: 0.25 ---- !u!114 &250057004 -MonoBehaviour: + m_GameObject: {fileID: 769591637} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &788616591 +GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 2cef43f34213c4155a30156e42d58fa4, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.PlacementGhostView - _input: {fileID: 250057007} - _ghostHeight: 0.09 - _validColor: {r: 0.3, g: 1, b: 0.4, a: 1} - _invalidColor: {r: 1, g: 0.3, b: 0.25, a: 1} ---- !u!114 &250057005 + serializedVersion: 6 + m_Component: + - component: {fileID: 788616594} + - component: {fileID: 788616593} + - component: {fileID: 788616592} + m_Layer: 0 + m_Name: Audio + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &788616592 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 788616591} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: f602abebcfdbc4649af799cbeddc3fcc, type: 3} + m_Script: {fileID: 11500000, guid: 209209a86486d4b3a8bed38f9ae33c50, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.RallyFlagView - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _markerHeight: 0.07 - _flagSize: 1 - _lineWidth: 0.18 - _rallyColor: {r: 1, g: 0.82, b: 0.25, a: 1} ---- !u!114 &250057006 + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.SfxSettingsBridge +--- !u!114 &788616593 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} + m_GameObject: {fileID: 788616591} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: bc78ab4760d9d417bb2865e8edf60d3d, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.SelectionMarkerView - _runner: {fileID: 966459987} - _input: {fileID: 250057007} - _views: {fileID: 966459988} - _markerHeight: 0.06 - _unitMarkerSize: 2.4 - _buildingMarkerMargin: 0.4 - _selectionColor: {r: 0.35, g: 1, b: 0.4, a: 1} ---- !u!114 &250057007 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 39a0624d641e60a06323d84ab3edc209, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.RtsDeviceInput - _runner: {fileID: 966459987} - _camera: {fileID: 536714283} - _buildMenu: {fileID: 250057001} - _commandCard: {fileID: 250057000} - _minimap: {fileID: 0} - _menu: {fileID: 0} - _groundPlaneY: 0 - _dragThresholdPixels: 8 - _pickRadiusWorld: 1.5 - _fieldPickRadiusWorld: 2 - _doubleClickSeconds: 0.35 - _buildingDefId: 5 - _altBuildingDefId: 7 - _unitDefId: 2 - _altUnitDefId: 12 - _storageDefId: 6 - _vehicleFactoryDefId: 8 - _researchLabDefId: 9 - _radarDefId: 10 - _defensePlatformDefId: 11 - _refineryDefId: 4 - _builderDefId: 1 - _antiArmorDefId: 13 - _scoutDefId: 14 - _lightTankDefId: 15 - _battleTankDefId: 16 - _artilleryDefId: 17 ---- !u!4 &250057008 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 250056992} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &380136776 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 380136779} - - component: {fileID: 380136778} - - component: {fileID: 380136777} - m_Layer: 0 - m_Name: Audio - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &380136777 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 380136776} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 209209a86486d4b3a8bed38f9ae33c50, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.SfxSettingsBridge ---- !u!114 &380136778 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 380136776} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 5dc2508a98b444101ad6cef0e77cec67, type: 3} + m_Script: {fileID: 11500000, guid: 5dc2508a98b444101ad6cef0e77cec67, type: 3} m_Name: m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Audio.UnityAudioService _events: @@ -678,13 +546,13 @@ MonoBehaviour: _weaponsGroup: {fileID: -2573851237862643720, guid: 7831b040d10e94eb9bddf12783bcee5e, type: 2} _unitsGroup: {fileID: -3201287591632689540, guid: 7831b040d10e94eb9bddf12783bcee5e, type: 2} _uiGroup: {fileID: 2761139959417956413, guid: 7831b040d10e94eb9bddf12783bcee5e, type: 2} ---- !u!4 &380136779 +--- !u!4 &788616594 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 380136776} + m_GameObject: {fileID: 788616591} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -693,7 +561,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &536714281 +--- !u!1 &929546046 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -701,10 +569,11 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 536714285} - - component: {fileID: 536714283} - - component: {fileID: 536714282} - - component: {fileID: 536714284} + - component: {fileID: 929546051} + - component: {fileID: 929546048} + - component: {fileID: 929546050} + - component: {fileID: 929546047} + - component: {fileID: 929546049} m_Layer: 0 m_Name: Main Camera m_TagString: MainCamera @@ -712,13 +581,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &536714282 +--- !u!114 &929546047 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 536714281} + m_GameObject: {fileID: 929546046} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 7e207447d841f8c67cd26bb722acdaf5, type: 3} @@ -740,13 +609,13 @@ MonoBehaviour: _startFocusZ: 6 _startHeight: 42 _startYawDegrees: 45 ---- !u!20 &536714283 +--- !u!20 &929546048 Camera: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 536714281} + m_GameObject: {fileID: 929546046} m_Enabled: 1 serializedVersion: 2 m_ClearFlags: 2 @@ -791,143 +660,74 @@ Camera: m_OcclusionCulling: 1 m_StereoConvergence: 10 m_StereoSeparation: 0.022 ---- !u!81 &536714284 +--- !u!81 &929546049 AudioListener: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 536714281} + m_GameObject: {fileID: 929546046} m_Enabled: 1 ---- !u!4 &536714285 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 536714281} - serializedVersion: 2 - m_LocalRotation: {x: 0.5, y: 0, z: 0, w: 0.8660254} - m_LocalPosition: {x: 64, y: 60, z: -20} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &620007468 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 620007472} - - component: {fileID: 620007471} - - component: {fileID: 620007470} - - component: {fileID: 620007469} - m_Layer: 0 - m_Name: Ground - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 2147483647 - m_IsActive: 1 ---- !u!23 &620007469 -MeshRenderer: +--- !u!114 &929546050 +MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 620007468} + m_GameObject: {fileID: 929546046} m_Enabled: 1 - m_CastShadows: 1 - m_ReceiveShadows: 1 - m_DynamicOccludee: 1 - m_StaticShadowCaster: 0 - m_MotionVectors: 1 - m_LightProbeUsage: 1 - m_ReflectionProbeUsage: 1 - m_RayTracingMode: 2 - m_RayTraceProcedural: 0 - m_RayTracingAccelStructBuildFlagsOverride: 0 - m_RayTracingAccelStructBuildFlags: 1 - m_SmallMeshCulling: 1 - m_ForceMeshLod: -1 - m_MeshLodSelectionBias: 0 - m_RenderingLayerMask: 1 - m_RendererPriority: 0 - m_Materials: - - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2} - m_StaticBatchInfo: - firstSubMesh: 0 - subMeshCount: 0 - m_StaticBatchRoot: {fileID: 0} - m_ProbeAnchor: {fileID: 0} - m_LightProbeVolumeOverride: {fileID: 0} - m_ScaleInLightmap: 1 - m_ReceiveGI: 1 - m_PreserveUVs: 1 - m_IgnoreNormalsForChartDetection: 0 - m_ImportantGI: 0 - m_StitchLightmapSeams: 1 - m_SelectedEditorRenderState: 3 - m_MinimumChartSize: 4 - m_AutoUVMaxDistance: 0.5 - m_AutoUVMaxAngle: 89 - m_LightmapParameters: {fileID: 0} - m_GlobalIlluminationMeshLod: 0 - m_SortingLayerID: 0 - m_SortingLayer: 0 - m_SortingOrder: 0 - m_MaskInteraction: 0 - m_AdditionalVertexStreams: {fileID: 0} ---- !u!64 &620007470 -MeshCollider: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 620007468} - m_Material: {fileID: 0} - m_IncludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_ExcludeLayers: + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData + m_RenderShadows: 1 + m_RequiresDepthTextureOption: 2 + m_RequiresOpaqueTextureOption: 2 + m_CameraType: 0 + m_Cameras: [] + m_RendererIndex: -1 + m_VolumeLayerMask: serializedVersion: 2 - m_Bits: 0 - m_LayerOverridePriority: 0 - m_IsTrigger: 0 - m_ProvidesContacts: 0 - m_Enabled: 1 - serializedVersion: 5 - m_Convex: 0 - m_CookingOptions: 30 - m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!33 &620007471 -MeshFilter: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 620007468} - m_Mesh: {fileID: 10209, guid: 0000000000000000e000000000000000, type: 0} ---- !u!4 &620007472 + m_Bits: 1 + m_VolumeTrigger: {fileID: 0} + m_VolumeFrameworkUpdateModeOption: 2 + m_RenderPostProcessing: 1 + m_Antialiasing: 0 + m_AntialiasingQuality: 2 + m_StopNaN: 0 + m_Dithering: 0 + m_ClearDepth: 1 + m_AllowXRRendering: 1 + m_AllowHDROutput: 1 + m_UseScreenCoordOverride: 0 + m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0} + m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0} + m_RequiresDepthTexture: 0 + m_RequiresColorTexture: 0 + m_TaaSettings: + m_Quality: 3 + m_FrameInfluence: 0.1 + m_JitterScale: 1 + m_MipBias: 0 + m_VarianceClampScale: 0.9 + m_ContrastAdaptiveSharpening: 0 + m_Version: 2 +--- !u!4 &929546051 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 620007468} + m_GameObject: {fileID: 929546046} serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 64, y: 0, z: 64} - m_LocalScale: {x: 12.8, y: 1, z: 12.8} + m_LocalRotation: {x: 0.5, y: 0, z: 0, w: 0.8660254} + m_LocalPosition: {x: 64, y: 60, z: -20} + m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &966459985 +--- !u!1 &1073198452 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -935,236 +735,83 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 966459990} - - component: {fileID: 966459987} - - component: {fileID: 966459986} - - component: {fileID: 966459989} - - component: {fileID: 966459988} + - component: {fileID: 1073198457} + - component: {fileID: 1073198456} + - component: {fileID: 1073198455} + - component: {fileID: 1073198454} + - component: {fileID: 1073198453} m_Layer: 0 - m_Name: Match + m_Name: MainMenu m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &966459986 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 966459985} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 0543a6c6c2d8cbd976ba484a58c2ddc5, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.MatchBootstrap - AutoStart: 0 - _seed: 16032251723485610609 - _mapWidth: 128 - _mapHeight: 128 - _entityCapacity: 1024 - UseDefinitionStats: 1 ---- !u!114 &966459987 +--- !u!114 &1073198453 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 966459985} + m_GameObject: {fileID: 1073198452} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 3b9301a799fe64f85afe9906dc15bc39, type: 3} + m_Script: {fileID: 11500000, guid: e77b731ae61b8471192a9ee5a7727fdb, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.MatchRunner - _seed: 12345 - _mapWidth: 128 - _mapHeight: 128 - _maxUnits: 2048 ---- !u!114 &966459988 + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MainMenuController + _document: {fileID: 1073198456} + _bootstrap: {fileID: 769591638} + _music: {fileID: 1073198454} + _cameraRig: {fileID: 929546047} + _gameplayHudRoot: {fileID: 2133751285} + _keyArt: {fileID: 2800000, guid: 0f0988a5c327e451a990f97269d3ceee, type: 3} + _titleFont: {fileID: 12800000, guid: b95db3c2c8b9045b683866f321683fad, type: 3} + _bodyFont: {fileID: 12800000, guid: f028379ddea954641b86d6e9d5be7473, type: 3} + _title: HASHKRIEG + _titleFontSize: 92 + _titleLetterSpacing: 14 + _titleRuleWidth: 420 + _contentPadding: 120 + _buttonFontSize: 30 + _buttonWidth: 340 + _buttonHeight: 56 + _fieldFontSize: 17 + _fieldLabelWidth: 190 + _settingsWidth: 680 + _settingsScrollHeight: 480 + _titleColor: {r: 0.92, g: 0.96, b: 1, a: 1} + _accentColor: {r: 0.35, g: 0.78, b: 0.92, a: 1} + _bodyColor: {r: 0.88, g: 0.91, b: 0.95, a: 1} + _panelFill: {r: 0.05, g: 0.06, b: 0.08, a: 0.82} + _panelEdge: {r: 0.55, g: 0.62, b: 0.72, a: 0.5} + _buttonFill: {r: 0.08, g: 0.1, b: 0.13, a: 0.85} + _buttonHoverFill: {r: 0.16, g: 0.26, b: 0.32, a: 0.92} + _scrimColor: {r: 0.02, g: 0.03, b: 0.05, a: 0.42} + _disabledTextColor: {r: 0.52, g: 0.55, b: 0.6, a: 1} + _disabledEdgeColor: {r: 0.34, g: 0.38, b: 0.44, a: 0.45} + _saveIntervalSeconds: 0.35 +--- !u!114 &1073198454 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 966459985} + m_GameObject: {fileID: 1073198452} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: bad3b0760ec204c94bb15e3918bbc921, type: 3} + m_Script: {fileID: 11500000, guid: bccc1231bdd82465fa987e6ad5bb61e2, type: 3} m_Name: - m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.Match.UnitViewManager - _matchRunner: {fileID: 966459987} - _unitPrefab: {fileID: 0} - _combatEffects: {fileID: 966459989} - _assetMappings: {fileID: 11400000, guid: 54dfdf235acc54eecbef61df13a6b356, type: 2} - _interpolationSpeed: 25 - _viewerTeamOverride: -1 - _unknownPlayerColor: {r: 0.62, g: 0.62, b: 0.62, a: 1} - _damagedColor: {r: 0.42, g: 0.05, b: 0.05, a: 1} - _healthTintFloor: 0.25 ---- !u!114 &966459989 -MonoBehaviour: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MenuMusicPlayer + _source: {fileID: 1073198455} + _clip: {fileID: 8300000, guid: 85a05f77cb9f94a3684e85a7325e0184, type: 3} + _fadeOutSeconds: 1.25 +--- !u!82 &1073198455 +AudioSource: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 966459985} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: f4cba4e364582443899c3490f303f72f, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Gameplay::Nova.Gameplay.CombatFeedback.CombatEffectController ---- !u!4 &966459990 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 966459985} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1216497469 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1216497471} - - component: {fileID: 1216497470} - m_Layer: 0 - m_Name: Map - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1216497470 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1216497469} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 9b36a2ce3f98b46249dd0de03a577164, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation::Nova.Presentation.Maps.GlutrinneBlockoutView - _bootstrap: {fileID: 966459986} - _groundRenderer: {fileID: 620007469} - _sandColor: {r: 0.72, g: 0.6, b: 0.42, a: 1} - _crystalColor: {r: 0.2, g: 0.85, b: 0.9, a: 1} - _edgeColor: {r: 0.28, g: 0.24, b: 0.2, a: 1} - _rockColor: {r: 0.36, g: 0.31, b: 0.26, a: 1} - _groundTextureTiling: {x: 32, y: 32} ---- !u!4 &1216497471 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1216497469} - serializedVersion: 2 - m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} - m_LocalScale: {x: 1, y: 1, z: 1} - m_ConstrainProportionsScale: 0 - m_Children: [] - m_Father: {fileID: 0} - m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1530823774 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1530823779} - - component: {fileID: 1530823778} - - component: {fileID: 1530823777} - - component: {fileID: 1530823776} - - component: {fileID: 1530823775} - m_Layer: 0 - m_Name: MainMenu - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1530823775 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1530823774} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: e77b731ae61b8471192a9ee5a7727fdb, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MainMenuController - _document: {fileID: 1530823778} - _bootstrap: {fileID: 966459986} - _music: {fileID: 1530823776} - _cameraRig: {fileID: 536714282} - _gameplayHudRoot: {fileID: 250056992} - _keyArt: {fileID: 2800000, guid: 0f0988a5c327e451a990f97269d3ceee, type: 3} - _titleFont: {fileID: 12800000, guid: b95db3c2c8b9045b683866f321683fad, type: 3} - _bodyFont: {fileID: 12800000, guid: f028379ddea954641b86d6e9d5be7473, type: 3} - _title: HASHKRIEG - _titleFontSize: 92 - _titleLetterSpacing: 14 - _titleRuleWidth: 420 - _contentPadding: 120 - _buttonFontSize: 30 - _buttonWidth: 340 - _buttonHeight: 56 - _fieldFontSize: 17 - _fieldLabelWidth: 190 - _settingsWidth: 680 - _settingsScrollHeight: 480 - _titleColor: {r: 0.92, g: 0.96, b: 1, a: 1} - _accentColor: {r: 0.35, g: 0.78, b: 0.92, a: 1} - _bodyColor: {r: 0.88, g: 0.91, b: 0.95, a: 1} - _panelFill: {r: 0.05, g: 0.06, b: 0.08, a: 0.82} - _panelEdge: {r: 0.55, g: 0.62, b: 0.72, a: 0.5} - _buttonFill: {r: 0.08, g: 0.1, b: 0.13, a: 0.85} - _buttonHoverFill: {r: 0.16, g: 0.26, b: 0.32, a: 0.92} - _scrimColor: {r: 0.02, g: 0.03, b: 0.05, a: 0.42} - _disabledTextColor: {r: 0.52, g: 0.55, b: 0.6, a: 1} - _disabledEdgeColor: {r: 0.34, g: 0.38, b: 0.44, a: 0.45} - _saveIntervalSeconds: 0.35 ---- !u!114 &1530823776 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1530823774} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: bccc1231bdd82465fa987e6ad5bb61e2, type: 3} - m_Name: - m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MenuMusicPlayer - _source: {fileID: 1530823777} - _clip: {fileID: 8300000, guid: 85a05f77cb9f94a3684e85a7325e0184, type: 3} - _fadeOutSeconds: 1.25 ---- !u!82 &1530823777 -AudioSource: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1530823774} + m_GameObject: {fileID: 1073198452} m_Enabled: 1 serializedVersion: 4 OutputAudioMixerGroup: {fileID: -161172014317909320, guid: 7831b040d10e94eb9bddf12783bcee5e, type: 2} @@ -1255,13 +902,13 @@ AudioSource: m_PreInfinity: 2 m_PostInfinity: 2 m_RotationOrder: 4 ---- !u!114 &1530823778 +--- !u!114 &1073198456 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1530823774} + m_GameObject: {fileID: 1073198452} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0} @@ -1278,13 +925,64 @@ MonoBehaviour: m_PivotReferenceSize: 0 m_Pivot: 0 m_WorldSpaceCollider: {fileID: 0} ---- !u!4 &1530823779 +--- !u!4 &1073198457 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1073198452} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1166056781 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1166056783} + - component: {fileID: 1166056782} + m_Layer: 0 + m_Name: Map + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1166056782 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1166056781} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9b36a2ce3f98b46249dd0de03a577164, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation::Nova.Presentation.Maps.GlutrinneBlockoutView + _bootstrap: {fileID: 769591638} + _groundRenderer: {fileID: 587992136} + _sandColor: {r: 0.72, g: 0.6, b: 0.42, a: 1} + _crystalColor: {r: 0.2, g: 0.85, b: 0.9, a: 1} + _edgeColor: {r: 0.28, g: 0.24, b: 0.2, a: 1} + _rockColor: {r: 0.36, g: 0.31, b: 0.26, a: 1} + _groundTextureTiling: {x: 32, y: 32} +--- !u!4 &1166056783 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1530823774} + m_GameObject: {fileID: 1166056781} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -1293,7 +991,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1883692213 +--- !u!1 &1443730238 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -1301,9 +999,9 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 1883692216} - - component: {fileID: 1883692215} - - component: {fileID: 1883692214} + - component: {fileID: 1443730241} + - component: {fileID: 1443730240} + - component: {fileID: 1443730239} m_Layer: 0 m_Name: VersionBadge m_TagString: Untagged @@ -1311,32 +1009,32 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!114 &1883692214 +--- !u!114 &1443730239 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1883692213} + m_GameObject: {fileID: 1443730238} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: b5291f1c2dd449aea7a5874cd8d818bb, type: 3} m_Name: m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.VersionBadge - _document: {fileID: 1883692215} + _document: {fileID: 1443730240} _font: {fileID: 12800000, guid: f028379ddea954641b86d6e9d5be7473, type: 3} _fontSize: 12 _marginLeft: 10 _marginBottom: 8 _textColor: {r: 0.88, g: 0.91, b: 0.95, a: 1} _opacity: 0.55 ---- !u!114 &1883692215 +--- !u!114 &1443730240 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1883692213} + m_GameObject: {fileID: 1443730238} m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0} @@ -1353,13 +1051,13 @@ MonoBehaviour: m_PivotReferenceSize: 0 m_Pivot: 0 m_WorldSpaceCollider: {fileID: 0} ---- !u!4 &1883692216 +--- !u!4 &1443730241 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1883692213} + m_GameObject: {fileID: 1443730238} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} @@ -1368,7 +1066,7 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &2123726880 +--- !u!1 &1655146398 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -1376,8 +1074,8 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 2123726882} - - component: {fileID: 2123726881} + - component: {fileID: 1655146400} + - component: {fileID: 1655146399} m_Layer: 0 m_Name: Directional Light m_TagString: Untagged @@ -1385,13 +1083,13 @@ GameObject: m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!108 &2123726881 +--- !u!108 &1655146399 Light: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2123726880} + m_GameObject: {fileID: 1655146398} m_Enabled: 1 serializedVersion: 13 m_Type: 1 @@ -1450,15 +1148,364 @@ Light: m_LightUnit: 1 m_LuxAtDistance: 1 m_EnableSpotReflector: 1 ---- !u!4 &2123726882 +--- !u!4 &1655146400 Transform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2123726880} + m_GameObject: {fileID: 1655146398} serializedVersion: 2 - m_LocalRotation: {x: 0.12450414, y: -0.8617815, z: 0.25527132, w: 0.42031893} + m_LocalRotation: {x: 0.40305823, y: 0.27253202, z: -0.12708378, w: 0.8643611} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &2133751285 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2133751301} + - component: {fileID: 2133751300} + - component: {fileID: 2133751299} + - component: {fileID: 2133751298} + - component: {fileID: 2133751297} + - component: {fileID: 2133751296} + - component: {fileID: 2133751295} + - component: {fileID: 2133751294} + - component: {fileID: 2133751293} + - component: {fileID: 2133751292} + - component: {fileID: 2133751291} + - component: {fileID: 2133751290} + - component: {fileID: 2133751289} + - component: {fileID: 2133751288} + - component: {fileID: 2133751287} + - component: {fileID: 2133751286} + m_Layer: 0 + m_Name: UI + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &2133751286 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e920fc747d1c406fb1fa611b6934aa74, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.ResourceBarHud + _runner: {fileID: 769591639} + _uiScale: 1.5 + _fontSize: 13 + _margin: 8 + _topOffset: 31 + _barHeight: 22 + _warningColor: {r: 1, g: 0.72, b: 0.25, a: 1} + _criticalColor: {r: 1, g: 0.38, b: 0.32, a: 1} +--- !u!114 &2133751287 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 743caecd4c5e6831d67dc968613cbfc6, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.DebugHud + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _visible: 0 + _uiScale: 1.5 + _fontSize: 13 +--- !u!114 &2133751288 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4839e6caa28240839b51760036ea05f0, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.PauseMenuHud + _runner: {fileID: 769591639} + _menu: {fileID: 1073198453} + _matchFrame: {fileID: 2133751289} + _input: {fileID: 2133751300} + _uiScale: 1.5 + _panelWidth: 340 +--- !u!114 &2133751289 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9a8f5ae5c96d7491494f6d2974f32f3b, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MatchFrameHud + _runner: {fileID: 769591639} + _bootstrap: {fileID: 769591638} + _menu: {fileID: 1073198453} + _views: {fileID: 769591640} + _uiScale: 1.5 + _panelWidth: 340 +--- !u!114 &2133751290 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3fbdd2dfe068b4dc0a6f71442dc18c31, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.HealthBarHud + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _uiScale: 1.5 + _barWidth: 36 + _barHeight: 5 + _unitHeightOffset: 1.6 + _buildingHeightOffset: 3.4 +--- !u!114 &2133751291 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 885e2e9fac8724df08b326c82e983510, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.MinimapHud + _runner: {fileID: 769591639} + _buildMenu: {fileID: 2133751294} + _uiScale: 1.5 + _mapSize: 168 + _margin: 8 + _terrainColor: {r: 0.72, g: 0.6, b: 0.42, a: 1} + _exploredBrightness: 0.45 + _unexploredBrightness: 0.15 + _unitDotSize: 3 + _buildingDotSize: 4.5 + _radarPingColor: {r: 1, g: 0.55, b: 0.1, a: 1} +--- !u!114 &2133751292 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 956e9df8ef76b491abc04e94045ee0d7, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.FogOfWarOverlayView + _runner: {fileID: 769591639} + _unexploredAlpha: 0.85 + _exploredAlpha: 0.45 + _overlayHeight: 0.04 +--- !u!114 &2133751293 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 79cf751f533e746e993958254d4d427e, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.CommandCardHud + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _buildMenu: {fileID: 2133751294} + _bootstrap: {fileID: 769591638} + _uiScale: 1.5 + _panelWidth: 236 + _panelMargin: 8 +--- !u!114 &2133751294 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 22628296e159b4d5a8e9c701b125cee0, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.BuildMenuHud + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _uiScale: 1.5 + _buttonWidth: 108 + _buttonHeight: 62 + _barMargin: 8 + _hintMinSeconds: 6 + _hintMaxSeconds: 90 +--- !u!114 &2133751295 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a2b97178f25d64f78897be61bbbb70dd, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.ConstructionSiteMarkerView + _runner: {fileID: 769591639} + _markerHeight: 0.07 + _buildingColor: {r: 0.35, g: 0.9, b: 0.55, a: 0.55} + _pausedColor: {r: 1, g: 0.62, b: 0.15, a: 1} + _pulseSpeed: 4 +--- !u!114 &2133751296 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 60b14ad0059d4ecbb3bb0f16e8f28aaf, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.BuildZoneOverlayView + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _buildableColor: + serializedVersion: 2 + rgba: 442164806 + _spacingBlockedColor: + serializedVersion: 2 + rgba: 740857599 + _overlayHeight: 0.05 + _repaintIntervalSeconds: 0.25 +--- !u!114 &2133751297 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 2cef43f34213c4155a30156e42d58fa4, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.PlacementGhostView + _input: {fileID: 2133751300} + _ghostHeight: 0.09 + _validColor: {r: 0.3, g: 1, b: 0.4, a: 1} + _invalidColor: {r: 1, g: 0.3, b: 0.25, a: 1} +--- !u!114 &2133751298 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f602abebcfdbc4649af799cbeddc3fcc, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.RallyFlagView + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _markerHeight: 0.07 + _flagSize: 1 + _lineWidth: 0.18 + _rallyColor: {r: 1, g: 0.82, b: 0.25, a: 1} +--- !u!114 &2133751299 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bc78ab4760d9d417bb2865e8edf60d3d, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.SelectionMarkerView + _runner: {fileID: 769591639} + _input: {fileID: 2133751300} + _views: {fileID: 769591640} + _markerHeight: 0.06 + _unitMarkerSize: 2.4 + _buildingMarkerMargin: 0.4 + _selectionColor: {r: 0.35, g: 1, b: 0.4, a: 1} + _targetColor: {r: 1, g: 0.28, b: 0.22, a: 1} +--- !u!114 &2133751300 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 39a0624d641e60a06323d84ab3edc209, type: 3} + m_Name: + m_EditorClassIdentifier: Nova.Presentation.UI::Nova.Presentation.UI.RtsDeviceInput + _runner: {fileID: 769591639} + _camera: {fileID: 929546048} + _buildMenu: {fileID: 2133751294} + _commandCard: {fileID: 2133751293} + _minimap: {fileID: 0} + _menu: {fileID: 0} + _markerView: {fileID: 0} + _groundPlaneY: 0 + _dragThresholdPixels: 8 + _pickRadiusWorld: 1.5 + _fieldPickRadiusWorld: 2 + _doubleClickSeconds: 0.35 + _buildingDefId: 5 + _altBuildingDefId: 7 + _unitDefId: 2 + _altUnitDefId: 12 + _storageDefId: 6 + _vehicleFactoryDefId: 8 + _researchLabDefId: 9 + _radarDefId: 10 + _defensePlatformDefId: 11 + _refineryDefId: 4 + _builderDefId: 1 + _antiArmorDefId: 13 + _scoutDefId: 14 + _lightTankDefId: 15 + _battleTankDefId: 16 + _artilleryDefId: 17 +--- !u!4 &2133751301 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2133751285} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 @@ -1469,13 +1516,13 @@ Transform: SceneRoots: m_ObjectHideFlags: 0 m_Roots: - - {fileID: 536714285} - - {fileID: 2123726882} - - {fileID: 620007472} - - {fileID: 380136779} - - {fileID: 966459990} - - {fileID: 1216497471} - - {fileID: 250057008} - - {fileID: 1530823779} - - {fileID: 105837771} - - {fileID: 1883692216} + - {fileID: 929546051} + - {fileID: 1655146400} + - {fileID: 587992139} + - {fileID: 788616594} + - {fileID: 769591642} + - {fileID: 1166056783} + - {fileID: 2133751301} + - {fileID: 1073198457} + - {fileID: 288301846} + - {fileID: 1443730241} diff --git a/Assets/_Project/Settings/NovaPostProcessProfile.asset b/Assets/_Project/Settings/NovaPostProcessProfile.asset new file mode 100644 index 00000000..c99ffaac --- /dev/null +++ b/Assets/_Project/Settings/NovaPostProcessProfile.asset @@ -0,0 +1,66 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d7fd9488000d3734a9e00ee676215985, type: 3} + m_Name: NovaPostProcessProfile + m_EditorClassIdentifier: Unity.RenderPipelines.Core.Runtime::UnityEngine.Rendering.VolumeProfile + components: + - {fileID: 1017148902311703773} +--- !u!114 &1017148902311703773 +MonoBehaviour: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0b2db86121404754db890f4c8dfe81b2, type: 3} + m_Name: Bloom + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.Bloom + active: 1 + skipIterations: + m_OverrideState: 0 + m_Value: 1 + threshold: + m_OverrideState: 1 + m_Value: 1 + intensity: + m_OverrideState: 1 + m_Value: 1 + scatter: + m_OverrideState: 1 + m_Value: 0.65 + clamp: + m_OverrideState: 0 + m_Value: 65472 + tint: + m_OverrideState: 0 + m_Value: {r: 1, g: 1, b: 1, a: 1} + highQualityFiltering: + m_OverrideState: 1 + m_Value: 1 + filter: + m_OverrideState: 0 + m_Value: 0 + downscale: + m_OverrideState: 0 + m_Value: 0 + maxIterations: + m_OverrideState: 0 + m_Value: 6 + dirtTexture: + m_OverrideState: 0 + m_Value: {fileID: 0} + dimension: 1 + dirtIntensity: + m_OverrideState: 0 + m_Value: 0 diff --git a/Assets/_Project/Settings/NovaPostProcessProfile.asset.meta b/Assets/_Project/Settings/NovaPostProcessProfile.asset.meta new file mode 100644 index 00000000..5a639584 --- /dev/null +++ b/Assets/_Project/Settings/NovaPostProcessProfile.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d415dea3501744477bdf41c4de3085f0 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Settings/NovaUrp.asset b/Assets/_Project/Settings/NovaUrp.asset index c4adc971..56863f96 100644 --- a/Assets/_Project/Settings/NovaUrp.asset +++ b/Assets/_Project/Settings/NovaUrp.asset @@ -91,7 +91,7 @@ MonoBehaviour: m_MaxPixelLights: 0 m_ShadowAtlasResolution: 256 m_VolumeFrameworkUpdateMode: 0 - m_VolumeProfile: {fileID: 0} + m_VolumeProfile: {fileID: 11400000, guid: d415dea3501744477bdf41c4de3085f0, type: 2} apvScenesData: obsoleteSceneBounds: m_Keys: [] diff --git a/Assets/_Project/Settings/NovaUrpRenderer.asset b/Assets/_Project/Settings/NovaUrpRenderer.asset index 832bb9c2..5a4f36f0 100644 --- a/Assets/_Project/Settings/NovaUrpRenderer.asset +++ b/Assets/_Project/Settings/NovaUrpRenderer.asset @@ -27,7 +27,7 @@ MonoBehaviour: m_RendererFeatures: [] m_RendererFeatureMap: xrSystemData: {fileID: 0} - postProcessData: {fileID: 0} + postProcessData: {fileID: 11400000, guid: 41439944d30ece34e96484bdb6645b55, type: 2} m_AssetVersion: 3 m_PrepassLayerMask: serializedVersion: 2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4766a61e..677a42da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,16 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Glow und Licht von der Kameraseite (D-111).** Leuchtakzente der Art blühen + jetzt über URP-Bloom: Das Projekt renderte bisher ganz ohne Post-Processing, + weil dem Renderer die `postProcessData` fehlte; dazu kamen ein Bloom-Profil als + Grundstack des Pipeline-Assets, Post-Processing an der Hauptkamera und + Emission, die die URP-Validierung nicht mehr wegstreicht. Die Sonne steht + jetzt hoch und von der Kameraseite — vorher lag jede sichtbare Fassade im + Gegenlicht, was die Allianz-Basis düster machte. Das Allianz-Radar ist auf + Gebäudehöhe gestaucht. Neuer PlayMode-Showcase (`ArtShowcaseTests`) fotografiert + beide Basen vollständig in Spielgröße aus 18/30/42 m. Per Kamera geprüft, + **nicht gespielt** - **Asset-Pass über alle 34 Tripo-Assets: bewegliche Teile, Animation und Optik (D-110).** Ein rezeptgesteuerter Blender-Pass (`tools/art/asset_pass.py`, `asset_recipes.json`, headless) trennt die Modelle in benannte, gepivotete Teile, diff --git a/docs/README.md b/docs/README.md index 7c0216b7..e2b57226 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,7 +162,7 @@ gelisteten Kernverträge. Paket ausserhalb des Repos liegen, Inhalt und Installationsablauf - [AssetImport_Tripo_2026-08-06](assets/AssetImport_Tripo_2026-08-06.md) – Importprotokoll der 34 Tripo-Assets (GLB → FBX, LODs, Texturen) -- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.1.0, Entwurf) – +- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.2.0, Entwurf) – Blickrichtung, Teile-Animation und Optik der 34 Tripo-Assets, Werkzeuge und Grenzen - [VerticalSlice_MS1](assets/VerticalSlice_MS1.md) (0.2.0, Entwurf) – Vertical-Slice-Spezifikation der vier Erst-Assets diff --git a/docs/assets/AssetPass_2026-10-17.md b/docs/assets/AssetPass_2026-10-17.md index e4fef651..95166d60 100644 --- a/docs/assets/AssetPass_2026-10-17.md +++ b/docs/assets/AssetPass_2026-10-17.md @@ -1,6 +1,6 @@ # Asset-Pass: Blickrichtung, Teile-Animation, Optik -**Version:** 0.1.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 +**Version:** 0.2.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 ## Zweck @@ -123,7 +123,25 @@ Elternknotens umgerechnet. - Draufsicht und Fahrt-/Kampfpose aller 34 Prefabs mit `ArtPreviewCapture` gesichtet. -## 6. Was dieser Pass nicht löst +## 6. Glow und Licht (zweiter Schritt, D-111) + +- **Glow:** Leuchtakzente laufen jetzt durch Bloom. Zwei Masken trennen + „einfärben“ (Zierstreifen in Akzentfarbe) von „leuchten“ (strengere Schwelle, + 0,5–2 % der Textur); unter dem Leuchtakzent bleibt die Grundfarbe dunkel, + sonst brennt er weiß aus. URP behält `_EMISSION` nur mit GI-Flag + `RealtimeEmissive` — mit `None` strich die Material-Validierung das Keyword, + und die ersten Glow-Versuche zeigten schlicht nichts. +- **Licht:** Die Sonne kommt jetzt von der Kameraseite. Die dunklen + Allianz-Gebäude lagen am Gegenlicht, nicht an der Textur; trotzdem ziehen die + Rezepte der Allianz-Gebäude etwas heller (`target_mean` 0,42). +- **Allianz-Radar:** Die 20-m-Säule ist auf 45 % gestaucht (`squash`), sie maß + im Spiel mehrere Gebäudehöhen. +- **Nachweis in Spielgröße:** `ArtShowcaseTests` stellt alle Gebäude und + Einheiten beider Fraktionen in die laufende Partie und fotografiert sie durch + die Hauptkamera (mit Post-Processing) aus 18, 30 und 42 m Höhe nach + `output/demo/showcase_*.png`. + +## 7. Was dieser Pass nicht löst - **Die Geometrie bleibt Tripo-Qualität.** Flächen sind knubbelig; Glätten und doppelseitiges Rendern kaschieren, sie reparieren nicht. Eine @@ -135,15 +153,11 @@ Elternknotens umgerechnet. bewegen sich nicht. Ein Humanoid-Rig ist ein eigener Schritt. - **Ketten laufen nicht.** Die Kettentextur liegt verstreut im Tripo-Atlas, ein UV-Scrollen ist nicht möglich. -- **Kein Bloom.** Die Emission leuchtet, glüht aber nicht über; das bräuchte ein - Post-Processing-Volume in der Bootstrap-Szene. - **Leuchtpuls über `MaterialPropertyBlock`.** Wie schon die Fraktionstönung; ArtAssetStandard §5 untersagt das für die 500-Objekt-Last. Bis der `NovaUnit`-Shader mit Instanzdaten kommt, ist das bewusst so. - **TeamMask (B-Kanal) ist leer.** Spiegelpartien (Allianz gegen Allianz) sind farblich nicht unterscheidbar. -- Die Allianz-Gebäude wirken neben den Einheiten noch dunkel; die Farbregeln - sind fraktions-, nicht rollenweise eingestellt. ## Offene Punkte @@ -159,3 +173,4 @@ Elternknotens umgerechnet. | Version | Datum | Änderung | Autor | |---|---|---|---| | 0.1.0 | 2026-10-17 | Erstfassung: Blickrichtungsfehler, Asset-Pass mit Rezepten, Teile-Animation, Prüfwerkzeuge und Grenzen | Technical Art | +| 0.2.0 | 2026-10-17 | Glow über Bloom, zwei Akzentmasken, Sonne auf der Kameraseite, Radar gestaucht, Showcase in Spielgröße (D-111) | Technical Art | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 4c00971e..b46ec2c7 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.42.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 +**Version:** 1.43.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 ## Zweck @@ -3638,6 +3638,46 @@ dreht die älteren FBX um 180°). Das Asset-Paket muss neu gebaut werden, sonst sehen andere den alten Stand. Keine Änderung an der Simulation, keine Bewegung der Determinismus-Baselines. +### D-111 | verbindlich | Asset-Pass 2026-10-17 (Licht von der Kameraseite, Bloom für Leuchtakzente) + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („mach mit dem Glow weiter“), +Folgeschritt zu D-110. + +**Kontext:** Die Leuchtakzente aus D-110 glühten nicht. Am Projekt geprüft: Der +Renderer hatte keine `postProcessData` (URP rendert dann ohne jedes +Post-Processing), es gab kein Volume-Profil, die Kamera hatte Post-Processing +aus, und die URP-Validierung strich `_EMISSION`, weil die GI-Flags des +Materials auf `None` standen. Die Spielkamera-Aufnahmen zeigten außerdem, dass +die Allianz-Gebäude nicht wegen ihrer Textur dunkel waren, sondern weil die +Sonne (`Euler(33°, −128°)`) hinter ihnen stand: Jede Fassade, die die nach +Norden blickende RTS-Kamera zeigt, lag im Eigenschatten, und die Schatten +fielen zur Kamera hin. + +**Alternativen:** + +1. Bloom als Grundstack des Pipeline-Assets und Sonne auf die Kameraseite. +2. Ein Volume-Objekt in der Bootstrap-Szene und die Sonne unverändert lassen, + dafür die Allianz-Texturen weiter aufhellen. + +**Entscheidung:** Alternative 1. Bloom-Profil (Threshold 1,0) als +`volumeProfile` des Pipeline-Assets, `renderPostProcessing` an der Hauptkamera, +`postProcessData` am Renderer, Emission mit GI-Flag `RealtimeEmissive`. Die +Sonne steht auf `Euler(50°, 35°, 0)`: hoch, wie +[Lighting.md](../tech/Lighting.md) es für die Wüste vorsieht, und von links +unten aus Kamerasicht, sodass Schatten nach rechts oben wegfallen. + +**Begründung:** Das Pipeline-Profil wirkt in jeder Kamera — auch in +Vorschauen und Tests — und veraltet nicht mit der generierten Szene. +Texturen heller zu ziehen, um Gegenlicht auszugleichen, hätte jede Fläche +ausgewaschen, ohne die dunklen Fassaden zu beleuchten. Der Kommentar im +Generator führte die alte Sonne auf D-085 zurück; D-085 regelt das Baumodell, +die Sonnenrichtung war nie entschieden. + +**Konsequenzen:** Alle URP-Zugriffe laufen über Reflection und serialisierte +Felder, weil der Referenzvertrag aus D-061 keine URP-Assembly erlaubt +(`UrpPostProcessingSetup`). Die Bootstrap-Szene wurde neu erzeugt. Color Grading, +Vignette und Biom-Overrides bleiben offen. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3718,6 +3758,7 @@ der Determinismus-Baselines. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.43.0 | 2026-10-17 | D-111 aufgenommen: Bloom als Grundstack des Pipeline-Assets (fehlende `postProcessData` ergänzt, Kamera-Post-Processing an, Emission mit `RealtimeEmissive`), Sonne auf `Euler(50°, 35°)` von der Kameraseite statt Gegenlicht | Dennis Westermann / Orchestrator | | 1.42.0 | 2026-10-17 | D-110 aufgenommen: Blickrichtung über **eine** Abbildung Sim-Kurs → Unity-Gier (`ViewHeading`), Knoten-Vertrag `Body`/`Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*` zwischen Blender-Pass und `UnitRigAnimator`, doppelseitige Art-Materialien, neue Leuchttextur `T_*_EM`; Tripo-Modelle werden aufbereitet statt neu erzeugt | Dennis Westermann / Orchestrator | | 1.41.0 | 2026-08-18 | **D-108 in der Prämisse berichtigt und in der Sache neu gefasst.** Die Erstfassung behauptete, der Code lasse bereits jedes Gebäude als Bauanker gelten, und wollte das nur aussprechen — tatsächlich prüft `IsInsideBuildInfluence` seit D-104 auf HQ, Lager und Kraftwerk. Der ausführende Agent hat den Widerspruch beim Umsetzen von Paket 21.1 gefunden und angehalten. Der Inhaber hat daraufhin in Kenntnis der Lage neu entschieden: die Ankerliste wird bewusst geöffnet, **jedes eigene fertiggestellte Gebäude wird Anker**. Damit ist D-108 eine Verhaltensänderung (`RulesHash64`, Baselines, kanonischer KI-Pin), keine Festschreibung. `MinimumBuildingDistanceCells` bleibt bei 2 — die Messung aus 21.1 (15/23/23) weist die gemeldete Enge nicht als Abstandsproblem aus | Dennis Westermann / Orchestrator | | 1.40.0 | 2026-08-17 | D-108 und D-109 aufgenommen (Sprint 21, aus Testbericht T-01): Territorium wächst **kriechend an jedem eigenen Bauanker** — die bestehende Regel wird festgeschrieben statt geändert, und die gemeldete Enge wird an `MinimumBuildingDistanceCells` gemessen, bevor ein Wert fällt. Die Kartenmitte wird ein Gebiet mit schmalen Zufahrten, mit der verbindlichen Auflage, dass Begehbarkeit und Optik aus **einer** Struktur stammen — heute streut `GlutrinneBlockoutView` begehbare Deko-Felsen, während niemand ausser `ConstructionSystem` in das `CostField` schreibt | Dennis Westermann / Orchestrator | diff --git a/docs/tech/Lighting.md b/docs/tech/Lighting.md index 7e6a02ff..2dd7f982 100644 --- a/docs/tech/Lighting.md +++ b/docs/tech/Lighting.md @@ -1,6 +1,6 @@ # Lighting & Post-Processing -**Version:** 0.2.1 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 +**Version:** 0.3.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 ## Zweck @@ -131,6 +131,25 @@ Ein globaler `Volume` mit Basis-Profil; `LightingProfile.PostVolume` legt Biom-O **Reihenfolge:** FoW Full Screen Pass läuft **vor** Post-Processing (Injektor-Punkt in Rendering.md), damit FoW-Verdunkelung von Bloom/Grading konsistent mitbehandelt wird (Ghost-Gebäude blühen nicht). + +### Umsetzungsstand (2026-10-17, D-111) + +- **Sonne:** `BootstrapSceneGenerator` setzt das Directional Light auf + `Euler(50°, 35°, 0)` — hoch und von der Kameraseite, wie die Wüstenzeile + oben es verlangt. Die frühere `Euler(33°, −128°, 0)` stand hinter den + Gebäuden: Jede Fassade, die die RTS-Kamera (Blick nach Norden) zeigt, lag + im Eigenschatten. Farbe, Intensität und Schatten sind unverändert. +- **Bloom:** Ein Profil `Assets/_Project/Settings/NovaPostProcessProfile.asset` + mit Bloom (Threshold 1,0, Intensität 1,0, Scatter 0,65, High-Quality-Filter) + ist als `volumeProfile` des Pipeline-Assets eingetragen — es wirkt als + Grundstack jeder Kamera, ein Volume-Objekt in der Szene braucht es noch + nicht. Die Hauptkamera hat `renderPostProcessing` an. Voraussetzung war + `postProcessData` am Renderer, das seit dem URP-Setup fehlte; ohne es + rendert URP stillschweigend ganz ohne Post-Processing. +- **Emissive:** Leuchtakzente der Art (`T_*_EM`, HDR-Faktor 2,0) liegen über + dem Threshold, Terrain und unbeleuchtete Flächen darunter. Biom-Overrides, + Color Grading, Vignette und das `LightingProfile` sind noch nicht gebaut. + ## Offene Punkte - **Realtime-only-Verzicht auf jegliches Baking:** Empfehlung dieses Dokuments, aber formale DecisionLog-Entscheidung steht aus (Lighting war keiner der D-033–D-036-Blöcke). Risiko: Kampagne (Phase 3, D-020) mit Nah-Kamera-Momenten könnte APV/Baked-GI-Bedarf erzeugen – Status: offen, Entscheidungsvorlage für Sprint-3-Review. @@ -157,3 +176,4 @@ Ein globaler `Volume` mit Basis-Profil; `LightingProfile.PostVolume` legt Biom-O | 0.1.0 | 2026-07-21 | Erstfassung | Lead Graphics Engineer | | 0.2.0 | 2026-07-24 | Glutrinne als einzigen MS-1-Lichtumfang und exakten Unity-Pin gemäß D-056/D-060/D-061 festgelegt | Lead Graphics Engineer | | 0.2.1 | 2026-07-24 | Nächste Schritte auf klares Glutrinne/V2 begrenzt und Wetter-/Mond-Arbeit aus Sprint 7 entfernt | Lead Graphics Engineer | +| 0.3.0 | 2026-10-17 | Umsetzungsstand: Sonne auf die Kameraseite, Bloom über das Standard-Volume des Pipeline-Assets, fehlende `postProcessData` ergänzt (D-111) | Technical Art | diff --git a/tools/art/asset_pass.py b/tools/art/asset_pass.py index a5c875ee..26f2aa8c 100644 --- a/tools/art/asset_pass.py +++ b/tools/art/asset_pass.py @@ -414,11 +414,18 @@ def grade(bc, ao, edge, faction, t): in_band = ((h - lo) % 360) <= ((hi - lo) % 360) foreign = ((~in_band) & (s > 0.35) & (v > 0.15)).astype(np.float32)[..., None] + # Zwei Masken: "paint" faerbt Zierstreifen in der Akzentfarbe ein, "glow" + # (strenger) leuchtet zusaetzlich. Eine einzige weite Maske liess ganze + # hellblaue Paneele gluehen; eine einzige enge verlor die Zierstreifen. acc = t.get("accent") or t["accent_by_faction"][faction] hue_ok = ((h - acc["hue"][0]) % 360) <= ((acc["hue"][1] - acc["hue"][0]) % 360) - accent = (hue_ok & (s >= acc["sat"]) & (v >= acc["val"])).astype(np.float32) - # Akzente sauber aufweiten, damit die Emission an UV-Raendern nicht franst - accent = np.maximum(accent, np.roll(accent, 1, 0)); accent = np.maximum(accent, np.roll(accent, 1, 1)) + def mask(sat, val): + m = (hue_ok & (s >= sat) & (v >= val)).astype(np.float32) + # sauber aufweiten, damit die Emission an UV-Raendern nicht franst + m = np.maximum(m, np.roll(m, 1, 0)) + return np.maximum(m, np.roll(m, 1, 1)) + paint = mask(acc["sat"], acc["val"]) + accent = mask(acc.get("glow_sat", acc["sat"]), acc.get("glow_val", acc["val"])) painted = gradient_map(np.clip(Ls, 0, 1), PALETTES[faction]) mix = t["palette_mix_by_faction"][faction] @@ -430,7 +437,10 @@ def grade(bc, ao, edge, faction, t): base = base + edge[..., None] * t["edge_highlight"] * (1.0 - base) acc_col = hex_rgb(ACCENT[faction]) - base = base * (1 - accent[..., None]) + (acc_col * 0.85) * accent[..., None] + base = base * (1 - paint[..., None]) + (acc_col * 0.85) * paint[..., None] + # Unter dem Leuchtakzent bleibt die Grundfarbe dunkel: Die Emission traegt + # den Farbton. Hell darunter addiert sich mit der Emission zu Weiss. + base = base * (1 - accent[..., None]) + (acc_col * 0.35) * accent[..., None] base = np.clip(base, 0, 1) em = np.zeros_like(bc) @@ -448,7 +458,8 @@ def grade(bc, ao, edge, faction, t): out[..., 3] = 1 stats = {"lum_in_mean": round(mean, 4), "gain": round(gain, 3), "lum_out_mean": round(float((base @ W).mean()), 4), - "accent_share": round(float(accent.mean()), 4), "foreign_share": round(float(foreign.mean()), 4)} + "paint_share": round(float(paint.mean()), 4), "accent_share": round(float(accent.mean()), 4), + "foreign_share": round(float(foreign.mean()), 4)} return out, msk, em, stats @@ -570,6 +581,31 @@ def part_pivot(obj, cfg, bb): return (mn + mx) / 2 # wheel und Rest: Mittelpunkt +def squash(result, pivots, bb, cfg): + """Staucht alles unterhalb ``below`` (normiert) auf ``factor`` und rueckt + den Rest nach. Fuer Tuerme, die im Spiel mehrere Gebaeudehoehen messen + (Allianz-Radar: 20 m Saeule unter 6 m Schuessel). Laeuft nach dem Teilen, + damit die Rezept-Boxen weiter die Original-Bounding-Box meinen.""" + cut = bb[0].z + (bb[1].z - bb[0].z) * cfg["below"] + k = cfg["factor"] + + def f(z): + return bb[0].z + (z - bb[0].z) * k if z < cut else z - (cut - bb[0].z) * (1 - k) + + for d in result.values(): + for obj in d.values(): + mw = obj.matrix_world + inv = mw.inverted() + for v in obj.data.vertices: + w = mw @ v.co + w.z = f(w.z) + v.co = inv @ w + obj.data.update() + for name, p in pivots.items(): + p.z = f(p.z) + print(f" squash unter z={cut:.2f} auf {k:.0%}") + + # ---------------------------------------------------------------- Export def build_and_export(result, pivots, parents, kinds, stem, fbx_out): @@ -710,6 +746,9 @@ def main(): result, pivots, parents, kinds = split_parts(lods, r, bb) + if r.get("squash"): + squash(result, pivots, bb, r["squash"]) + if a.preview: os.makedirs(a.preview, exist_ok=True) render_preview(result, os.path.join(a.preview, f"{stem}_parts.png"), tex_paths) diff --git a/tools/art/asset_recipes.json b/tools/art/asset_recipes.json index 8c31a2f4..984728bb 100644 --- a/tools/art/asset_recipes.json +++ b/tools/art/asset_recipes.json @@ -18,7 +18,9 @@ 215 ], "sat": 0.45, - "val": 0.45 + "val": 0.45, + "glow_sat": 0.6, + "glow_val": 0.6 }, "Legion": { "hue": [ @@ -54,7 +56,14 @@ }, "BLDG_Alliance_Barracks": { "path": "Buildings/Alliance/Barracks", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_DefensePlatform": { "move_boxes": [ @@ -97,7 +106,14 @@ } ], "path": "Buildings/Alliance/DefensePlatform", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_HQ": { "path": "Buildings/Alliance/HQ", @@ -120,11 +136,25 @@ 0.82 ] } - ] + ], + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_Power": { "path": "Buildings/Alliance/Power", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_Radar": { "parts": [ @@ -147,7 +177,18 @@ } ], "path": "Buildings/Alliance/Radar", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + }, + "squash": { + "below": 0.84, + "factor": 0.45 + } }, "BLDG_Alliance_Refinery": { "path": "Buildings/Alliance/Refinery", @@ -171,19 +212,47 @@ ], "islands": 0.6 } - ] + ], + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_ResearchLab": { "path": "Buildings/Alliance/ResearchLab", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_Storage": { "path": "Buildings/Alliance/Storage", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Alliance_VehicleFactory": { "path": "Buildings/Alliance/VehicleFactory", - "faction": "Alliance" + "faction": "Alliance", + "texture": { + "target_mean": 0.42, + "palette_mix_by_faction": { + "Alliance": 0.65, + "Legion": 0.45 + } + } }, "BLDG_Legion_Barracks": { "path": "Buildings/Legion/Barracks", From 721c56e4ff9dffd19a926568b0e4ba964c10403e Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 13:17:48 +0200 Subject: [PATCH 05/13] feat(art): let infantry walk with rigid two-joint legs The four infantry models are single Tripo meshes without a skeleton, and automatic bone weighting does not survive their open, multi-shell geometry. The Blender pass now cuts thighs (pivot at the hip) and shins (pivot at the knee) into Leg_* / Shin_* nodes; recipe boxes keep hanging hands and side arms on the torso. UnitRigAnimator drives a two-joint walk cycle from the measured view speed: thighs swing +-25 deg in counter-phase, knees fold during the swing, the hips drop by exactly the shortening of the spread legs so the feet stay on the ground, and the torso kicks back on a visible shot. Cadence is capped at 3.2 steps/s: views are scaled to ~1 unit while the sim moves infantry 4 cells/s, and a slide-free cadence of ~9 steps/s reads as twitching. UnitMotionPresentationTests also records the walking soldier as a frame strip. Verified locally: headless 768/768, EditMode 676/676, PlayMode 17/17. Not played. Co-Authored-By: Claude Opus 5.5 --- .../PlayMode/UnitMotionPresentationTests.cs | 12 + .../Scripts/Gameplay/Match/UnitRigAnimator.cs | 93 ++++- CHANGELOG.md | 7 + docs/README.md | 2 +- docs/assets/ArtAssetStandard.md | 2 +- docs/assets/AssetPass_2026-10-17.md | 26 +- docs/tech/AnimationSystem.md | 7 +- tools/art/asset_recipes.json | 344 +++++++++++++++++- 8 files changed, 476 insertions(+), 17 deletions(-) diff --git a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs index b7351689..57bec8e7 100644 --- a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs +++ b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs @@ -105,6 +105,8 @@ public IEnumerator MovingViews_FaceTheirDirectionOfTravel() } bool shot = false; + int gaitShots = 0; + float start = Time.time; float until = Time.time + 1.6f; while (Time.time < until) { @@ -123,6 +125,16 @@ public IEnumerator MovingViews_FaceTheirDirectionOfTravel() worst[i] = Mathf.Max(worst[i], angle); } + // A close strip of the walking soldier (legs are rigged since the + // infantry pass): four frames 0.09 s apart. + int infantry = System.Array.FindIndex(Plan, p => p.Role == UnitRole.BasicInfantry); + if (gaitShots < 4 && Time.time > start + 0.2f + gaitShots * 0.09f + && views.TryGetView(ids[infantry], out GameObject soldier) && soldier != null) + { + CaptureRts($"{ShotDir}/motion_infantry_{gaitShots}.png", soldier.transform.position, 3.2f); + gaitShots++; + } + if (!shot && Time.time > until - 0.8f) { shot = true; diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs index bf9138cb..fefaedf0 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs @@ -44,6 +44,9 @@ public struct UnitRigInput /// Spin_* — turn continuously (radar dishes, rotors). /// Scan_* — sweep back and forth (dishes mounted off-axis). /// Arm_* — swing while the unit works. + /// Leg_* / Shin_* — rigid thighs and shins of infantry: + /// a two-joint walk cycle whose cadence follows the measured speed, so feet + /// do not slide; _R runs half a cycle behind _L. /// /// /// Axes are measured once in each node's parent space from the root's @@ -62,6 +65,22 @@ public sealed class UnitRigAnimator : MonoBehaviour public const string SpinPrefix = "Spin_"; public const string ArmPrefix = "Arm_"; public const string ScanPrefix = "Scan_"; + public const string LegPrefix = "Leg_"; + public const string ShinPrefix = "Shin_"; + + /// Thigh swing to either side of vertical, in degrees. + private const float ThighSwing = 25f; + + /// Knee bend at the height of the swing phase, in degrees. + private const float KneeBend = 35f; + + /// + /// Cadence ceiling. Views are scaled to their graybox size while the + /// sim moves infantry 4 cells/s — about 1 m of soldier sprinting + /// 4 m/s. A slide-free cadence would be ~9 steps/s and read as + /// twitching; above this rate the feet slide a little instead. + /// + private const float MaxStepsPerSecond = 3.2f; [SerializeField] private float _turretTurnRate = 140f; [SerializeField] private float _spinRate = 55f; @@ -98,6 +117,11 @@ private struct Node private readonly List _spins = new List(2); private readonly List _arms = new List(2); private readonly List _scans = new List(1); + private readonly List _legs = new List(2); + private readonly List _shins = new List(2); + private float _hipHeight; // root-local units, from the Leg_ pivots + private float _gaitPhase; + private float _fireKick; private Renderer[] _glowRenderers = System.Array.Empty(); private Color[] _glowBase = System.Array.Empty(); @@ -153,6 +177,10 @@ public void ResetPose() for (int i = 0; i < _spins.Count; i++) Restore(_spins[i], true); for (int i = 0; i < _arms.Count; i++) Restore(_arms[i], true); for (int i = 0; i < _scans.Count; i++) Restore(_scans[i], true); + for (int i = 0; i < _legs.Count; i++) Restore(_legs[i], true); + for (int i = 0; i < _shins.Count; i++) Restore(_shins[i], true); + _gaitPhase = 0f; + _fireKick = 0f; } /// Advances every animated node by one rendered frame. @@ -254,14 +282,27 @@ private void TickBody(in UnitRigInput input, float speed, float dt) Vector3 offset = Vector3.zero; float pitchTarget, rollTarget; - if (_infantryGait) + if (input.Fired) _fireKick = 1f; + _fireKick = Mathf.MoveTowards(_fireKick, 0f, dt * 5f); + + if (_legs.Count > 0) + { + TickLegs(speed, dt); + // Rigid legs get shorter as they spread (cos of the swing); the + // hips drop by exactly that, so the feet stay on the ground. + float spread = Mathf.Abs(Mathf.Sin(_gaitPhase)) * ThighSwing * _moveBlend; + offset = -_body.Up * (_hipHeight * (1f - Mathf.Cos(spread * Mathf.Deg2Rad))); + pitchTarget = 5f * _moveBlend - 6f * _fireKick; + rollTarget = Mathf.Sin(_gaitPhase) * 3f * _moveBlend; + } + else if (_infantryGait) { - // Two steps per ~1.4 m: a vertical bob at step rate, a sway at - // stride rate and a slight forward lean while moving. + // No leg nodes: a vertical bob at step rate, a sway at stride + // rate and a slight forward lean while moving. _bobPhase += speed * dt / 0.7f * Mathf.PI; float bob = Mathf.Abs(Mathf.Sin(_bobPhase)) * 0.06f * _size * _moveBlend; offset = _body.Up * bob; - pitchTarget = 6f * _moveBlend; + pitchTarget = 6f * _moveBlend - 6f * _fireKick; rollTarget = Mathf.Sin(_bobPhase) * 4f * _moveBlend; } else @@ -285,6 +326,34 @@ private void TickBody(in UnitRigInput input, float speed, float dt) * _body.RestRot; } + /// + /// Walk cycle: half a cycle per step, one step per ~0.85 hip heights + /// of ground covered (capped at ). Thighs swing about the hip (negative angle = + /// foot forward), the knee folds while the leg swings forward + /// (cos > 0) and straightens for the stance. + /// + private void TickLegs(float speed, float dt) + { + float rootScale = Mathf.Max(1e-4f, transform.lossyScale.y); + float stride = Mathf.Max(0.05f, _hipHeight * rootScale * 0.85f); + float stepsPerSecond = Mathf.Min(speed / stride, MaxStepsPerSecond); + _gaitPhase = Mathf.Repeat(_gaitPhase + stepsPerSecond * dt * Mathf.PI, Mathf.PI * 2f); + if (_moveBlend <= 0f) _gaitPhase = Mathf.MoveTowards(_gaitPhase, Mathf.Round(_gaitPhase / Mathf.PI) * Mathf.PI, dt * 4f); + + for (int i = 0; i < _legs.Count; i++) + { + Node leg = _legs[i]; + float thigh = -Mathf.Sin(_gaitPhase + leg.Phase) * ThighSwing * _moveBlend; + leg.T.localRotation = Quaternion.AngleAxis(thigh, leg.Right) * leg.RestRot; + } + for (int i = 0; i < _shins.Count; i++) + { + Node shin = _shins[i]; + float knee = Mathf.Max(0f, Mathf.Cos(_gaitPhase + shin.Phase)) * KneeBend * _moveBlend; + shin.T.localRotation = Quaternion.AngleAxis(knee, shin.Right) * shin.RestRot; + } + } + private void TickTurret(in UnitRigInput input, float dt) { if (!_hasTurret) return; @@ -372,6 +441,22 @@ private void EnsureBound() s.Phase = _spins.Count * 47f; _spins.Add(s); } + else if (n.StartsWith(LegPrefix, System.StringComparison.Ordinal) + || n.StartsWith(ShinPrefix, System.StringComparison.Ordinal)) + { + Node limb = Capture(t); + limb.Phase = n.EndsWith("_R", System.StringComparison.Ordinal) ? Mathf.PI : 0f; + if (n.StartsWith(LegPrefix, System.StringComparison.Ordinal)) + { + _legs.Add(limb); + float hip = transform.InverseTransformPoint(t.position).y; + _hipHeight = Mathf.Max(_hipHeight, hip); + } + else + { + _shins.Add(limb); + } + } else if (n.StartsWith(ScanPrefix, System.StringComparison.Ordinal)) { _scans.Add(Capture(t)); diff --git a/CHANGELOG.md b/CHANGELOG.md index 677a42da..20de6e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,13 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Infanterie geht.** Die vier Infanteristen haben in Blender abgetrennte Ober- + und Unterschenkel (`Leg_*`/`Shin_*`); `UnitRigAnimator` fährt daraus einen + Zwei-Gelenk-Gang mit Hüftsenkung, damit die Füße am Boden bleiben, und einen + Rückstoß des Oberkörpers beim Schuss. Die Kadenz folgt der Geschwindigkeit, + gedeckelt auf 3,2 Schritte/s, weil die verkleinerten Soldaten sonst zappeln. + Starre Glieder, kein Skelett; per Kamera im laufenden Spiel geprüft, **nicht + gespielt** - **Glow und Licht von der Kameraseite (D-111).** Leuchtakzente der Art blühen jetzt über URP-Bloom: Das Projekt renderte bisher ganz ohne Post-Processing, weil dem Renderer die `postProcessData` fehlte; dazu kamen ein Bloom-Profil als diff --git a/docs/README.md b/docs/README.md index e2b57226..72416b27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,7 +162,7 @@ gelisteten Kernverträge. Paket ausserhalb des Repos liegen, Inhalt und Installationsablauf - [AssetImport_Tripo_2026-08-06](assets/AssetImport_Tripo_2026-08-06.md) – Importprotokoll der 34 Tripo-Assets (GLB → FBX, LODs, Texturen) -- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.2.0, Entwurf) – +- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.3.0, Entwurf) – Blickrichtung, Teile-Animation und Optik der 34 Tripo-Assets, Werkzeuge und Grenzen - [VerticalSlice_MS1](assets/VerticalSlice_MS1.md) (0.2.0, Entwurf) – Vertical-Slice-Spezifikation der vier Erst-Assets diff --git a/docs/assets/ArtAssetStandard.md b/docs/assets/ArtAssetStandard.md index b87e46b3..491b69c8 100644 --- a/docs/assets/ArtAssetStandard.md +++ b/docs/assets/ArtAssetStandard.md @@ -121,7 +121,7 @@ Submesh-/Objektnamen mit Suffix `_LOD0` / `_LOD1` / `_LOD2`, z. B. im selben FBX-Export. Der Root-Dateiname trägt kein LOD-Suffix. **Animierbare Assets (D-110)** tragen statt dreier flacher Objekte eine -Knoten-Hierarchie (`Root` → `Body` → `Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*`); +Knoten-Hierarchie (`Root` → `Body` → `Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*`/`Leg_*` → `Shin_*`); jedes Teil liegt je LOD-Stufe als eigenes Objekt mit Suffix `_LOD0..2` darunter. Der Prefab-Bau sammelt alle Renderer einer Stufe in deren `LODGroup`-Eintrag. Vertrag und Werkzeuge: [AssetPass_2026-10-17.md](AssetPass_2026-10-17.md). diff --git a/docs/assets/AssetPass_2026-10-17.md b/docs/assets/AssetPass_2026-10-17.md index 95166d60..624af90e 100644 --- a/docs/assets/AssetPass_2026-10-17.md +++ b/docs/assets/AssetPass_2026-10-17.md @@ -1,6 +1,6 @@ # Asset-Pass: Blickrichtung, Teile-Animation, Optik -**Version:** 0.2.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 +**Version:** 0.3.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 ## Zweck @@ -87,6 +87,7 @@ Unity -batchmode -projectPath . -executeMethod Nova.Editor.ArtPreviewCapture.Cap | `Arm_*` | pendelt beim Arbeiten (Abbauen, Bauen) | Allianz-Sammler, beide Bauer | | `Spin_*` | dreht dauernd | Allianz-Radar, Allianz-Raffinerie (Kristall), Allianz-HQ (Antenne) | | `Scan_*` | schwenkt hin und her | Legion-Radar | + | `Leg_*` / `Shin_*` | Oberschenkel um die Hüfte, Unterschenkel ums Knie: Schrittzyklus | alle vier Infanteristen | Gebäude „atmen“ zusätzlich über ihre Leuchtakzente und pulsieren schneller, solange sie produzieren (nur eigene Gebäude, sonst verriete der Puls fremde @@ -141,7 +142,21 @@ Elternknotens umgerechnet. die Hauptkamera (mit Post-Processing) aus 18, 30 und 42 m Höhe nach `output/demo/showcase_*.png`. -## 7. Was dieser Pass nicht löst +## 7. Infanterie-Gang (dritter Schritt) + +Die Beine der vier Infanteristen sind in Blender als starre Glieder abgetrennt +(Oberschenkel mit Pivot an der Hüfte, Unterschenkel am Knie; die Boxen lassen +herabhängende Hände und Waffen am Körper). `UnitRigAnimator` fährt daraus einen +Zwei-Gelenk-Gang: Oberschenkel ±25°, Knie bis 35° in der Schwungphase, rechts +einen halben Zyklus hinter links; die Hüfte senkt sich genau um die Verkürzung +der gespreizten Beine, damit die Füße am Boden bleiben, und beim Schuss zuckt der +Oberkörper zurück. Die Kadenz folgt der gemessenen Geschwindigkeit, ist aber auf +3,2 Schritte/s gedeckelt: Im Spiel ist ein Soldat rund 1 Einheit groß und läuft +4 Einheiten/s, rutschfrei wären das etwa 9 Schritte/s. `UnitMotionPresentationTests` +nimmt den laufenden Soldaten zusätzlich als Bildfolge auf +(`output/demo/motion_infantry_*.png`). + +## 8. Was dieser Pass nicht löst - **Die Geometrie bleibt Tripo-Qualität.** Flächen sind knubbelig; Glätten und doppelseitiges Rendern kaschieren, sie reparieren nicht. Eine @@ -149,8 +164,10 @@ Elternknotens umgerechnet. schwarz. Sie ist deshalb im Rezept abgeschaltet. - **Legion-Kraftwerk und Legion-Forschungslabor sind als Panzer generiert** (Wanne, Kettenlaufwerk, Turm). Das ist nur durch Neugenerierung zu beheben. -- **Infanterie hat kein Skelett.** Sie wippt und neigt sich beim Gehen, die Beine - bewegen sich nicht. Ein Humanoid-Rig ist ein eigener Schritt. +- **Infanterie geht mit starren Gliedern, ohne Skelett.** Ober- und Unterschenkel + sind abgetrennte Teile; an Hüfte und Knie entstehen beim Schwingen kleine + Fugen, Arme und Waffe bleiben starr. Aus RTS-Distanz liest sich das als Gehen; + ein Humanoid-Rig mit Gewichtung bleibt ein eigener Schritt. - **Ketten laufen nicht.** Die Kettentextur liegt verstreut im Tripo-Atlas, ein UV-Scrollen ist nicht möglich. - **Leuchtpuls über `MaterialPropertyBlock`.** Wie schon die Fraktionstönung; @@ -174,3 +191,4 @@ Elternknotens umgerechnet. |---|---|---|---| | 0.1.0 | 2026-10-17 | Erstfassung: Blickrichtungsfehler, Asset-Pass mit Rezepten, Teile-Animation, Prüfwerkzeuge und Grenzen | Technical Art | | 0.2.0 | 2026-10-17 | Glow über Bloom, zwei Akzentmasken, Sonne auf der Kameraseite, Radar gestaucht, Showcase in Spielgröße (D-111) | Technical Art | +| 0.3.0 | 2026-10-17 | Infanterie-Gang mit starren Ober- und Unterschenkeln, Knoten `Leg_*`/`Shin_*` | Technical Art | diff --git a/docs/tech/AnimationSystem.md b/docs/tech/AnimationSystem.md index fd255278..45a21ada 100644 --- a/docs/tech/AnimationSystem.md +++ b/docs/tech/AnimationSystem.md @@ -63,7 +63,8 @@ Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne - **`UnitRigAnimator`** (`Nova.Gameplay.Match`) hängt an jeder Art-Ansicht und bewegt die vom Blender-Pass benannten Knoten: `Body` (Federung, bei Infanterie Schritt-Wippen), `Turret` (Gier zum sichtbaren Angriffsziel, gedeckelte Rate), - `Gun` (Rückstoß), `Wheel_*`, `Arm_*`, `Spin_*`, `Scan_*`; Gebäude pulsieren über + `Gun` (Rückstoß), `Wheel_*`, `Arm_*`, `Spin_*`, `Scan_*`, `Leg_*`/`Shin_*` + (Infanterie-Gang mit starren Gliedern); Gebäude pulsieren über ihre Emission, schneller beim Produzieren. Knoten-Vertrag und Werkzeuge: [../assets/AssetPass_2026-10-17.md](../assets/AssetPass_2026-10-17.md). - **Eingaben** kommen aus `UnitViewManager` und sind reine View-Daten: gemessene @@ -72,8 +73,8 @@ Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne `VisibleCombatFrameDiffer`, Arbeitszustand aus Ernte, Baustellen-Zuweisung und eigener Produktionsschlange. - **Abweichung von diesem Entwurf:** Die Turmrate ist ein View-Wert, nicht aus - der Sim gespiegelt — die Sim kennt keinen Turm. Infanterie hat noch kein - Mecanim-Rig. Der Emissionspuls schreibt wie die Fraktionstönung über + der Sim gespiegelt — die Sim kennt keinen Turm. Infanterie geht über starre + Bein-Knoten statt über ein Mecanim-Rig. Der Emissionspuls schreibt wie die Fraktionstönung über `MaterialPropertyBlock`. ## Animations-LOD (3 Stufen, Pflicht) diff --git a/tools/art/asset_recipes.json b/tools/art/asset_recipes.json index 984728bb..61c9901c 100644 --- a/tools/art/asset_recipes.json +++ b/tools/art/asset_recipes.json @@ -335,7 +335,101 @@ }, "UNIT_Alliance_AntiArmorInfantry": { "path": "Units/Alliance/AntiArmorInfantry", - "faction": "Alliance" + "faction": "Alliance", + "parts": [ + { + "name": "Leg_L", + "kind": "limb", + "box": [ + 0.2, + 0.5, + 0, + 1, + 0, + 0.47 + ], + "pivot": [ + 0.37, + 0.68, + 0.47 + ] + }, + { + "name": "Leg_R", + "kind": "limb", + "box": [ + [ + 0.5, + 0.86, + 0, + 1, + 0, + 0.47 + ], + [ + 0.5, + 1, + 0, + 1, + 0, + 0.2 + ] + ], + "pivot": [ + 0.68, + 0.68, + 0.47 + ] + }, + { + "name": "Shin_L", + "kind": "limb", + "from": "Leg_L", + "parent": "Leg_L", + "box": [ + 0.2, + 0.5, + 0, + 1, + 0, + 0.25 + ], + "pivot": [ + 0.37, + 0.68, + 0.25 + ] + }, + { + "name": "Shin_R", + "kind": "limb", + "from": "Leg_R", + "parent": "Leg_R", + "box": [ + [ + 0.5, + 0.86, + 0, + 1, + 0, + 0.25 + ], + [ + 0.5, + 1, + 0, + 1, + 0, + 0.2 + ] + ], + "pivot": [ + 0.68, + 0.68, + 0.25 + ] + } + ] }, "UNIT_Alliance_Artillery": { "parts": [ @@ -365,7 +459,81 @@ }, "UNIT_Alliance_BasicInfantry": { "path": "Units/Alliance/BasicInfantry", - "faction": "Alliance" + "faction": "Alliance", + "parts": [ + { + "name": "Leg_L", + "kind": "limb", + "box": [ + 0, + 0.5, + 0, + 1, + 0, + 0.47 + ], + "pivot": [ + 0.32, + 0.72, + 0.47 + ] + }, + { + "name": "Leg_R", + "kind": "limb", + "box": [ + 0.5, + 1, + 0, + 1, + 0, + 0.47 + ], + "pivot": [ + 0.71, + 0.72, + 0.47 + ] + }, + { + "name": "Shin_L", + "kind": "limb", + "from": "Leg_L", + "parent": "Leg_L", + "box": [ + 0, + 0.5, + 0, + 1, + 0, + 0.25 + ], + "pivot": [ + 0.32, + 0.72, + 0.25 + ] + }, + { + "name": "Shin_R", + "kind": "limb", + "from": "Leg_R", + "parent": "Leg_R", + "box": [ + 0.5, + 1, + 0, + 1, + 0, + 0.25 + ], + "pivot": [ + 0.71, + 0.72, + 0.25 + ] + } + ] }, "UNIT_Alliance_BattleTank": { "parts": [ @@ -549,7 +717,81 @@ }, "UNIT_Legion_AntiArmorInfantry": { "path": "Units/Legion/AntiArmorInfantry", - "faction": "Legion" + "faction": "Legion", + "parts": [ + { + "name": "Leg_L", + "kind": "limb", + "box": [ + 0.18, + 0.5, + 0, + 1, + 0, + 0.45 + ], + "pivot": [ + 0.33, + 0.55, + 0.45 + ] + }, + { + "name": "Leg_R", + "kind": "limb", + "box": [ + 0.5, + 0.8, + 0, + 1, + 0, + 0.45 + ], + "pivot": [ + 0.66, + 0.55, + 0.45 + ] + }, + { + "name": "Shin_L", + "kind": "limb", + "from": "Leg_L", + "parent": "Leg_L", + "box": [ + 0.18, + 0.5, + 0, + 1, + 0, + 0.25 + ], + "pivot": [ + 0.33, + 0.55, + 0.25 + ] + }, + { + "name": "Shin_R", + "kind": "limb", + "from": "Leg_R", + "parent": "Leg_R", + "box": [ + 0.5, + 0.8, + 0, + 1, + 0, + 0.25 + ], + "pivot": [ + 0.66, + 0.55, + 0.25 + ] + } + ] }, "UNIT_Legion_Artillery": { "parts": [ @@ -587,7 +829,101 @@ }, "UNIT_Legion_BasicInfantry": { "path": "Units/Legion/BasicInfantry", - "faction": "Legion" + "faction": "Legion", + "parts": [ + { + "name": "Leg_L", + "kind": "limb", + "box": [ + 0.18, + 0.5, + 0, + 1, + 0, + 0.47 + ], + "pivot": [ + 0.33, + 0.5, + 0.47 + ] + }, + { + "name": "Leg_R", + "kind": "limb", + "box": [ + [ + 0.5, + 0.83, + 0, + 1, + 0, + 0.47 + ], + [ + 0.5, + 1, + 0, + 1, + 0, + 0.2 + ] + ], + "pivot": [ + 0.67, + 0.5, + 0.47 + ] + }, + { + "name": "Shin_L", + "kind": "limb", + "from": "Leg_L", + "parent": "Leg_L", + "box": [ + 0.18, + 0.5, + 0, + 1, + 0, + 0.27 + ], + "pivot": [ + 0.33, + 0.5, + 0.27 + ] + }, + { + "name": "Shin_R", + "kind": "limb", + "from": "Leg_R", + "parent": "Leg_R", + "box": [ + [ + 0.5, + 0.83, + 0, + 1, + 0, + 0.27 + ], + [ + 0.5, + 1, + 0, + 1, + 0, + 0.2 + ] + ], + "pivot": [ + 0.67, + 0.5, + 0.27 + ] + } + ] }, "UNIT_Legion_BattleTank": { "parts": [ From f54e9bffab840c8df45d4d4fdb65826cd0731e42 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 17 Oct 2026 13:17:48 +0200 Subject: [PATCH 06/13] feat(art): scale defence towers to human size on a footprint pad Every building view is scaled to the width of its 3x3 footprint, which made the slender defence towers as wide as a factory and about four soldiers tall. The Blender pass now adds a flat concrete pad sized so that, after that scaling, a tower stands 0.7 footprints high - roughly twice a soldier, about a battle tank. The pad fills the blocked cells instead of leaving them an invisible wall; its UVs point at a calm, unsaturated texel, so it needs no extra material. The footprint itself stays 3x3: shrinking it for towers is a rules change (placement, pathfinding, AI, fingerprint and determinism baselines) and is left open for the owner. Decision: D-112. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ docs/README.md | 2 +- docs/assets/AssetPass_2026-10-17.md | 22 +++++++- docs/production/DecisionLog.md | 34 +++++++++++- tools/art/asset_pass.py | 83 +++++++++++++++++++++++++++-- tools/art/asset_recipes.json | 16 +++++- 6 files changed, 154 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20de6e9f..70c8d90e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -297,6 +297,11 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert +- **Abwehrtürme in menschlichem Maßstab (D-112).** Jedes Gebäude wird auf seinen + 3×3-Footprint skaliert, ein schlanker Turm maß dadurch vier Soldatenhöhen. Die + Türme stehen jetzt auf einem flachen Betonsockel, der den Footprint füllt, und + sind selbst rund doppelt so hoch wie ein Soldat. Der Footprint bleibt 3×3; ihn + zu verkleinern wäre eine Regeländerung und ist offen - **Die lebende Dokumentation heißt jetzt Hashkrieg (#14, Stufe 3).** 54 Dateien, 84 Stellen — die Marke in der Prosa unter `docs/**` und in den Wurzeldateien. Zwei Regeln haben den Umfang bestimmt. Erstens **E-3: nur die Marke.** Die diff --git a/docs/README.md b/docs/README.md index 72416b27..b28832bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,7 +162,7 @@ gelisteten Kernverträge. Paket ausserhalb des Repos liegen, Inhalt und Installationsablauf - [AssetImport_Tripo_2026-08-06](assets/AssetImport_Tripo_2026-08-06.md) – Importprotokoll der 34 Tripo-Assets (GLB → FBX, LODs, Texturen) -- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.3.0, Entwurf) – +- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.4.0, Entwurf) – Blickrichtung, Teile-Animation und Optik der 34 Tripo-Assets, Werkzeuge und Grenzen - [VerticalSlice_MS1](assets/VerticalSlice_MS1.md) (0.2.0, Entwurf) – Vertical-Slice-Spezifikation der vier Erst-Assets diff --git a/docs/assets/AssetPass_2026-10-17.md b/docs/assets/AssetPass_2026-10-17.md index 624af90e..4222ee83 100644 --- a/docs/assets/AssetPass_2026-10-17.md +++ b/docs/assets/AssetPass_2026-10-17.md @@ -1,6 +1,6 @@ # Asset-Pass: Blickrichtung, Teile-Animation, Optik -**Version:** 0.3.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 +**Version:** 0.4.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 ## Zweck @@ -156,7 +156,19 @@ Oberkörper zurück. Die Kadenz folgt der gemessenen Geschwindigkeit, ist aber a nimmt den laufenden Soldaten zusätzlich als Bildfolge auf (`output/demo/motion_infantry_*.png`). -## 8. Was dieser Pass nicht löst +## 8. Abwehrtürme: Maßstab (D-112) + +Jedes Gebäude wird im Spiel auf die Breite seines 3×3-Footprints skaliert. Ein +schlanker Turm maß dadurch rund vier Soldatenhöhen. Der Pass stellt die Türme +jetzt auf einen flachen Betonsockel (`pad` im Rezept), der so breit ist, dass der +Turm nach der Skalierung 0,7 × Footprint hoch ist — rund 2,1 Einheiten, etwa +doppelte Soldatenhöhe und so groß wie ein Kampfpanzer. Der Sockel füllt die +blockierte Fläche, statt sie als unsichtbare Wand stehen zu lassen; seine UVs +zeigen auf eine ruhige, ungesättigte Stelle der Textur, er braucht kein +eigenes Material. **Der Footprint selbst bleibt 3×3** — ihn zu verkleinern ist +eine Regeländerung (siehe Offene Punkte). + +## 9. Was dieser Pass nicht löst - **Die Geometrie bleibt Tripo-Qualität.** Flächen sind knubbelig; Glätten und doppelseitiges Rendern kaschieren, sie reparieren nicht. Eine @@ -178,6 +190,11 @@ nimmt den laufenden Soldaten zusätzlich als Bildfolge auf ## Offene Punkte +- **Footprint der Abwehrtürme** (Inhaberentscheidung): `BuildingFootprintCells = 3` + gilt global und ist im Code als vorläufig markiert. Ein 1×1- oder 2×2-Footprint + nur für Türme berührt Platzierung, Wegfindung, KI und den Match-Fingerprint und + verschiebt die Determinismus-Baselines; er bräuchte einen eigenen Regel-PR. + - Neugenerierung von Legion-Kraftwerk und Legion-Forschungslabor (Kosten pro Generierung, Inhaberentscheidung). - Spielerische Abnahme in einer gespielten Runde steht aus; dieser Pass ist @@ -192,3 +209,4 @@ nimmt den laufenden Soldaten zusätzlich als Bildfolge auf | 0.1.0 | 2026-10-17 | Erstfassung: Blickrichtungsfehler, Asset-Pass mit Rezepten, Teile-Animation, Prüfwerkzeuge und Grenzen | Technical Art | | 0.2.0 | 2026-10-17 | Glow über Bloom, zwei Akzentmasken, Sonne auf der Kameraseite, Radar gestaucht, Showcase in Spielgröße (D-111) | Technical Art | | 0.3.0 | 2026-10-17 | Infanterie-Gang mit starren Ober- und Unterschenkeln, Knoten `Leg_*`/`Shin_*` | Technical Art | +| 0.4.0 | 2026-10-17 | Abwehrtürme auf Sockel verkleinert, Footprint-Frage als offener Punkt (D-112) | Technical Art | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index b46ec2c7..67efb7c6 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.43.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 +**Version:** 1.44.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 ## Zweck @@ -3678,6 +3678,37 @@ Felder, weil der Referenzvertrag aus D-061 keine URP-Assembly erlaubt (`UrpPostProcessingSetup`). Die Bootstrap-Szene wurde neu erzeugt. Color Grading, Vignette und Biom-Overrides bleiben offen. +### D-112 | verbindlich | Asset-Pass 2026-10-17 (Abwehrtürme optisch verkleinert, Footprint bleibt) + +**Status:** Inhaberhinweis vom 2026-10-17: Die Abwehrtürme wirken mindestens +doppelt so groß wie sie sein sollten; gegen einen Menschen eher gleich hoch bis +doppelt so hoch. Neu erstellen oder skalieren ist freigestellt. + +**Kontext:** `UnitViewManager` skaliert jedes Gebäude auf die Breite seines +Footprints (`SimDefinitions.BuildingFootprintCells = 3`). Ein schlanker Turm wird +dadurch so breit wie eine Fabrik und rund vier Soldatenhöhen hoch. Der Footprint +ist ein globaler Wert, an über 15 Stellen in Simulation, KI, Platzierung, UI und +Match-Fingerprint verwendet und im Code als vorläufig markiert. + +**Alternativen:** + +1. Nur die Darstellung: Turm verkleinern, Footprint mit einem Sockel füllen. +2. Footprint für Türme verkleinern (1×1 oder 2×2) — Regeländerung. +3. Türme neu generieren. + +**Entscheidung:** Alternative 1. Der Blender-Pass stellt die Türme auf einen +flachen Sockel, sodass sie nach der Skalierung 0,7 × Footprint hoch sind (rund +doppelte Soldatenhöhe). Alternative 2 bleibt ausdrücklich offen. + +**Begründung:** Das Problem ist das Größenverhältnis, nicht das Modell; neu +generieren (3) würde bei unverändertem Footprint wieder auf Fabrikbreite +skaliert. Alternative 2 ist richtig, wenn Türme auch spielerisch dichter stehen +sollen, verschiebt aber Platzierung, Wegfindung, KI und die +Determinismus-Baselines und braucht einen eigenen Regel-PR mit Baseline-Fenster. + +**Konsequenzen:** Rezeptfeld `pad` in `tools/art/asset_recipes.json`. Die +blockierte Fläche ist jetzt sichtbar befestigt statt eine unsichtbare Wand. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3758,6 +3789,7 @@ Vignette und Biom-Overrides bleiben offen. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.44.0 | 2026-10-17 | D-112 aufgenommen: Abwehrtürme optisch auf rund doppelte Soldatenhöhe verkleinert (Sockel füllt den 3×3-Footprint), Footprint-Verkleinerung als Regeländerung offen gelassen | Dennis Westermann / Orchestrator | | 1.43.0 | 2026-10-17 | D-111 aufgenommen: Bloom als Grundstack des Pipeline-Assets (fehlende `postProcessData` ergänzt, Kamera-Post-Processing an, Emission mit `RealtimeEmissive`), Sonne auf `Euler(50°, 35°)` von der Kameraseite statt Gegenlicht | Dennis Westermann / Orchestrator | | 1.42.0 | 2026-10-17 | D-110 aufgenommen: Blickrichtung über **eine** Abbildung Sim-Kurs → Unity-Gier (`ViewHeading`), Knoten-Vertrag `Body`/`Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*` zwischen Blender-Pass und `UnitRigAnimator`, doppelseitige Art-Materialien, neue Leuchttextur `T_*_EM`; Tripo-Modelle werden aufbereitet statt neu erzeugt | Dennis Westermann / Orchestrator | | 1.41.0 | 2026-08-18 | **D-108 in der Prämisse berichtigt und in der Sache neu gefasst.** Die Erstfassung behauptete, der Code lasse bereits jedes Gebäude als Bauanker gelten, und wollte das nur aussprechen — tatsächlich prüft `IsInsideBuildInfluence` seit D-104 auf HQ, Lager und Kraftwerk. Der ausführende Agent hat den Widerspruch beim Umsetzen von Paket 21.1 gefunden und angehalten. Der Inhaber hat daraufhin in Kenntnis der Lage neu entschieden: die Ankerliste wird bewusst geöffnet, **jedes eigene fertiggestellte Gebäude wird Anker**. Damit ist D-108 eine Verhaltensänderung (`RulesHash64`, Baselines, kanonischer KI-Pin), keine Festschreibung. `MinimumBuildingDistanceCells` bleibt bei 2 — die Messung aus 21.1 (15/23/23) weist die gemeldete Enge nicht als Abstandsproblem aus | Dennis Westermann / Orchestrator | diff --git a/tools/art/asset_pass.py b/tools/art/asset_pass.py index 26f2aa8c..2c3dca74 100644 --- a/tools/art/asset_pass.py +++ b/tools/art/asset_pass.py @@ -489,7 +489,7 @@ def texture_pass(lod0, tex_path, faction, t, out_dir, stem): img.save() paths[suffix] = p lod0.data.materials.clear() - return paths, stats + return paths, stats, new_bc # ---------------------------------------------------------------- Teile @@ -606,6 +606,79 @@ def f(z): print(f" squash unter z={cut:.2f} auf {k:.0%}") +def pick_texel(bc, tone, radius=6): + """UV einer ruhigen, ungesaettigten Texturstelle im Helligkeitsband ``tone``. + + Der Sockel bekommt kein eigenes Material (ein Material je Asset); alle + seine UVs zeigen auf einen Texel, dessen Umgebung auch in den Mipmaps + einfarbig bleibt. + """ + rgb = bc[..., :3] + lum = rgb @ np.array([0.2126, 0.7152, 0.0722], dtype=np.float32) + sat = rgb.max(-1) - rgb.min(-1) + + def box(a): + c = np.cumsum(np.cumsum(np.pad(a, ((1, 0), (1, 0))), 0), 1) + k = 2 * radius + 1 + return (c[k:, k:] - c[:-k, k:] - c[k:, :-k] + c[:-k, :-k]) / (k * k) + + mean, mean2 = box(lum), box(lum * lum) + var = mean2 - mean * mean + sat_m = box(sat) + score = var * 50 + sat_m + np.where((mean >= tone[0]) & (mean <= tone[1]), 0, 10) + y, x = np.unravel_index(np.argmin(score), score.shape) + y, x = y + radius, x + radius + h, w = lum.shape + return (x + 0.5) / w, (y + 0.5) / h, float(mean[y - radius, x - radius]) + + +def add_pad(result, pivots, cfg, bc): + """Flacher Betonsockel unter einem Turm, der den Gebaeude-Footprint fuellt. + + UnitViewManager skaliert jedes Gebaeude auf die Breite seines 3x3-Footprints. + Ein schlanker Turm wurde dadurch vier Soldaten hoch. Der Sockel ist so breit, + dass der Turm nach der Skalierung ``height_ratio`` x Footprint misst; der Turm + wird um die Sockeldicke angehoben, Pivots wandern mit. + """ + objs0 = list(result["LOD0"].values()) + mn, mx = world_bbox(objs0) + height = mx.z - mn.z + side = max(height / cfg["height_ratio"], (mx.x - mn.x) * 1.05, (mx.y - mn.y) * 1.05) + thick = side * cfg.get("thickness", 0.035) + hull = result["LOD0"]["Hull"] + base = [hull.matrix_world @ v.co for v in hull.data.vertices] + base = [p for p in base if p.z < mn.z + height * 0.15] or base + cx = (min(p.x for p in base) + max(p.x for p in base)) / 2 + cy = (min(p.y for p in base) + max(p.y for p in base)) / 2 + u, v, tone = pick_texel(bc, cfg.get("tone", [0.18, 0.34])) + + lift = Vector((0, 0, thick)) + for d in result.values(): + for obj in d.values(): + obj.matrix_world = Matrix.Translation(lift) @ obj.matrix_world + for p in pivots.values(): + p.z += thick + + for lodname, d in result.items(): + me = bpy.data.meshes.new(f"pad_{lodname}") + bm = bmesh.new() + bmesh.ops.create_cube(bm, size=1.0) + bmesh.ops.scale(bm, vec=Vector((side, side, thick)), verts=bm.verts) + bmesh.ops.translate(bm, vec=Vector((cx, cy, mn.z + thick / 2)), verts=bm.verts) + top = [e for e in bm.edges if all(vv.co.z > mn.z + thick * 0.9 for vv in e.verts)] + bmesh.ops.bevel(bm, geom=top, offset=thick * 0.6, segments=1, affect="EDGES") + uv = bm.loops.layers.uv.new("UVMap") + for f in bm.faces: + for loop in f.loops: + loop[uv].uv = (u, v) + bm.to_mesh(me) + bm.free() + obj = bpy.data.objects.new(f"__Pad_{lodname}", me) + bpy.context.scene.collection.objects.link(obj) + d["Pad"] = obj + print(f" Sockel {side:.2f} m breit, {thick:.2f} m dick, Ton {tone:.2f} bei UV ({u:.3f}, {v:.3f})") + + # ---------------------------------------------------------------- Export def build_and_export(result, pivots, parents, kinds, stem, fbx_out): @@ -738,16 +811,20 @@ def main(): for o in lods: smooth_normals(o, r.get("smooth_angle", 0), merge) - tex_paths, stats = {}, {} + tex_paths, stats, graded = {}, {}, None if r["texture"].get("enabled", True) and not a.no_export: os.makedirs(out_dir, exist_ok=True) - tex_paths, stats = texture_pass(lods[0], src_tex, r["faction"], r["texture"], out_dir, stem) + tex_paths, stats, graded = texture_pass(lods[0], src_tex, r["faction"], r["texture"], out_dir, stem) print(f" Textur: {json.dumps(stats)}") result, pivots, parents, kinds = split_parts(lods, r, bb) if r.get("squash"): squash(result, pivots, bb, r["squash"]) + if r.get("pad"): + if graded is None: + graded = image_np(bpy.data.images.load(src_tex)).copy() + add_pad(result, pivots, r["pad"], graded) if a.preview: os.makedirs(a.preview, exist_ok=True) diff --git a/tools/art/asset_recipes.json b/tools/art/asset_recipes.json index 61c9901c..1600a13e 100644 --- a/tools/art/asset_recipes.json +++ b/tools/art/asset_recipes.json @@ -113,6 +113,13 @@ "Alliance": 0.65, "Legion": 0.45 } + }, + "pad": { + "height_ratio": 0.7, + "tone": [ + 0.34, + 0.5 + ] } }, "BLDG_Alliance_HQ": { @@ -284,7 +291,14 @@ } ], "path": "Buildings/Legion/DefensePlatform", - "faction": "Legion" + "faction": "Legion", + "pad": { + "height_ratio": 0.7, + "tone": [ + 0.34, + 0.5 + ] + } }, "BLDG_Legion_HQ": { "path": "Buildings/Legion/HQ", From 20bc14f5cb07ba3da375f4b5909793ba81b0ad0e Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 00:03:41 +0200 Subject: [PATCH 07/13] fix(movement): octile path costs, heading follows path, units make room The integration field priced diagonal steps through an integer division (14 / 10 = 1) as cheap as straight ones, so units drove staircases. Costs are now octile 10/14. Heading follows the path instead of the separation push (at most 30 degrees per tick), spacing is per unit class, large vehicles get group slots with a free rim, and idle own units step aside for an equal or larger moving unit. Rule revision V4; canonical AI pin 2726 -> 2721. D-116. Co-Authored-By: Claude Opus 5.5 --- .../Simulation/CostFieldEpochSnapshotTests.cs | 7 +- .../Simulation/KernelIntegrationTests.cs | 8 +- .../Simulation/MatchFingerprintV1Tests.cs | 12 +- .../EditMode/Simulation/TroopHandlingTests.cs | 166 ++++++++++++++++-- .../Simulation/Movement/MovementSpacing.cs | 57 ++++++ .../Movement/MovementSpacing.cs.meta | 2 + .../Simulation/Movement/MovementSystem.cs | 133 ++++++++++++-- .../Pathfinding/IntegrationField.cs | 15 +- .../Simulation/Replays/MatchFingerprint.cs | 23 ++- .../Simulation/State/UnitCommandStateView.cs | 94 +++++++--- docs/tech/Pathfinding.md | 16 +- .../CanonicalAiOutcomeTests.cs | 19 +- .../CostFieldEpochSnapshotTests.cs | 7 +- .../KernelIntegrationTests.cs | 9 +- .../MatchFingerprintTests.cs | 12 +- .../TroopHandlingTests.cs | 166 ++++++++++++++++-- 16 files changed, 655 insertions(+), 91 deletions(-) create mode 100644 Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs create mode 100644 Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs.meta diff --git a/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs b/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs index 7f60b391..bf8ebba0 100644 --- a/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs +++ b/Assets/Tests/EditMode/Simulation/CostFieldEpochSnapshotTests.cs @@ -107,10 +107,11 @@ public void CostFieldMutation_RegeneratesTheFlowFieldCacheInPlace() Assert.That(system.FlowFieldCacheCount, Is.EqualTo(1)); // (15,10) sits directly west of the would-be wall (x=16, - // y=10..13) on the destination row. Open terrain: the first - // strictly-lowest neighbour in iteration order is NorthEast. + // y=10..13) on the destination row. Open terrain with octile + // costs (D-116): straight east. (Before D-116 a diagonal cost the + // same as a straight step and this read NorthEast.) Assert.That(system.GetField(oldDestination).GetDirection(15, 10), - Is.EqualTo(Direction2D.NorthEast), "open-terrain baseline direction"); + Is.EqualTo(Direction2D.East), "open-terrain baseline direction"); TestHost.ApplyTerrain(system.CostField); diff --git a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs index f8c8005b..a481baeb 100644 --- a/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/KernelIntegrationTests.cs @@ -281,14 +281,18 @@ public void DiagonalMovement_IsNormalized_NoDiagonalSpeedup() [Test] public void Rotation_FollowsMovementDirection_ViaSimTrigAtan2() { - // The heading is SimTrig.Atan2 of the normalized steering vector: - // a pure diagonal move faces exactly 45 degrees (8192 angle units). + // The heading is SimTrig.Atan2 of the PATH direction, turned at + // most 30 degrees (5461 angle units) per tick (D-116): a pure + // diagonal move from heading 0 reaches exactly 45 degrees (8192) + // on the second tick. var host = TestHost.Create(Seed); EntityId unit = host.Entities.SpawnUnit(0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5)); host.Entities.GetUnitRef(unit).SetTarget(new GridPos2D(20, 20)); host.StepTick(); + Assert.AreEqual(5461, host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue, "one tick turns at most 30 degrees"); + host.StepTick(); Assert.AreEqual(8192, host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue); } diff --git a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs index 1c66f4c0..27e51640 100644 --- a/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs +++ b/Assets/Tests/EditMode/Simulation/MatchFingerprintV1Tests.cs @@ -100,11 +100,15 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); - Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); - Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); - Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); - Assert.That(current, Is.Not.EqualTo(revisionTwo), + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV4)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV4))); + ulong revisionThree = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3); + Assert.That(revisionThree, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); + Assert.That(revisionThree, Is.Not.EqualTo(revisionTwo), "D-104 placement and repair behavior must not share revision 2's rules identity"); + Assert.That(current, Is.Not.EqualTo(revisionThree), + "D-116 movement must not share revision 3's rules identity"); + Assert.That(current, Is.EqualTo(0x7E2E3E8C4CF1591EUL), "revision 4 canonical stream is frozen"); } [Test] diff --git a/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs b/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs index 4d319de5..aeedb5f8 100644 --- a/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs +++ b/Assets/Tests/EditMode/Simulation/TroopHandlingTests.cs @@ -224,18 +224,29 @@ public void ProducedUnits_OccupyDistinctCells() // 100 full-power build ticks per infantry, slack for the spawns. host.Step(6 * 100 + 50); - var cells = new HashSet(); - int count = 0; + // No stack: every pair of spawns stands at (near-)contact distance + // or further. Since D-116 infantry spaces at 0.3 cells each, so two + // soldiers may legitimately share one cell — distance, not cell + // identity, is the invariant. + var spawns = new List(); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) { - ref readonly UnitState u = ref units[i]; - if (!u.IsActive || u.Role != UnitRole.BasicInfantry) continue; - count++; - Assert.That(cells.Add(CellOfTransform(in u.Transform)), Is.True, - $"infantry {i} shares its cell with another spawn — the stack is back"); + if (units[i].IsActive && units[i].Role == UnitRole.BasicInfantry) spawns.Add(i); + } + Assert.That(spawns.Count, Is.EqualTo(5), "all five queued infantry spawned"); + for (int a = 0; a < spawns.Count; a++) + { + for (int b = a + 1; b < spawns.Count; b++) + { + ref readonly UnitState ua = ref units[spawns[a]]; + ref readonly UnitState ub = ref units[spawns[b]]; + SimFixed settled = (MovementSpacing.RadiusOf(in ua) + MovementSpacing.RadiusOf(in ub)) + * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); + Assert.That(ua.Transform.DistanceToSquared(in ub.Transform), Is.GreaterThanOrEqualTo(settled * settled), + $"infantry {spawns[a]} and {spawns[b]} stack — the spawn stack is back"); + } } - Assert.That(count, Is.EqualTo(5), "all five queued infantry spawned"); } // -------------------------------------------------------------- @@ -304,8 +315,9 @@ public void StandingSeparation_ExactOverlap_UsesTheIndexTieBreak() Assert.That(a.Transform.PositionX != b.Transform.PositionX || a.Transform.PositionY != b.Transform.PositionY, Is.True, "exactly stacked units must separate"); - // Same dead-zone residual as the offset case: 7/8 of contact. - SimFixed minDist = a.Radius + b.Radius; + // Same dead-zone residual as the offset case: 7/8 of contact, + // measured with the per-role spacing radius (D-116). + SimFixed minDist = MovementSpacing.RadiusOf(in a) + MovementSpacing.RadiusOf(in b); SimFixed settled = minDist * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); Assert.That(a.Transform.DistanceToSquared(in b.Transform), Is.GreaterThanOrEqualTo(settled * settled), @@ -406,6 +418,140 @@ public void Sell_FreesTheFootprintInTheCostField() "selling frees the footprint cells in the cost field"); } + // -------------------------------------------------------------- + // D-116: make way, heading, size-aware slots + // -------------------------------------------------------------- + + private static EntityId SpawnRole(Host host, UnitRole role, SimFixed x, SimFixed y, byte slot = 0) + { + SimDefinitions.TryGetUnit(slot == 0 ? FactionId.Alliance : FactionId.Legion, role, out SimUnitDefinition def); + return host.Entities.SpawnUnit(slot, new Transform2D(x, y), def.MoveSpeed, maxHealth: def.MaxHealth, role: def.Role); + } + + private static SimFixed Half(int cells) => SimFixed.FromInt(cells) + SimFixed.FromRaw(SimFixed.OneRaw / 2); + + [Test] + public void StandingInfantry_MakesWay_ForADrivingTank() + { + Host host = Host.Create(); + // Tank drives east along y = 30.5; a soldier stands right on its line. + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(26), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + + SimFixed maxTankDrift = SimFixed.Zero; + for (int t = 0; t < 60; t++) + { + host.StepTick(); + SimFixed drift = host.Entities.GetUnitRef(tank).Transform.PositionY - Half(30); + if (drift < SimFixed.Zero) drift = -drift; + if (drift > maxTankDrift) maxTankDrift = drift; + } + + ref readonly UnitState s = ref host.Entities.GetUnitRef(soldier); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + SimFixed sideStep = s.Transform.PositionY - Half(30); + if (sideStep < SimFixed.Zero) sideStep = -sideStep; + Assert.That(sideStep, Is.GreaterThan(MovementSpacing.RadiusOf(in k)), + "the soldier must have stepped off the tank's line"); + Assert.That(maxTankDrift, Is.LessThan(SimFixed.FromRaw(SimFixed.OneRaw / 4)), + "the tank keeps its line through the group instead of being pushed aside"); + Assert.That(SimFixed.WorldToGrid(k.Transform.PositionX), Is.EqualTo(34), "the tank arrived"); + } + + [Test] + public void FlowField_OpenGround_LeadsStraight_NotDiagonalFirst() + { + // Octile costs (D-116): east of the target on open ground the flow + // points straight east, not into a diagonal staircase. + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + host.Step(3); + FlowField f = host.Pathfinding.GetField(host.Entities.GetUnitRef(tank).TargetGridPos); + Assert.That(f, Is.Not.Null); + for (int x = 18; x < 34; x++) + { + Assert.That(f.GetDirection((ushort)x, 30), Is.EqualTo(Direction2D.East), $"cell ({x},30)"); + } + Assert.That(f.GetDirection(30, 33), Is.EqualTo(Direction2D.SouthEast), "off the row, diagonal first while it saves distance"); + } + + [Test] + public void StandingTank_DoesNotYield_ToInfantry() + { + Host host = Host.Create(); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(20), Half(30)); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(25), Half(30)); + host.SubmitMove(new[] { host.Raw(soldier) }, 32, 30); + host.Step(5); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + Assert.That(k.Transform.PositionY, Is.EqualTo(Half(30)), "a tank does not step aside for a soldier"); + Assert.That(k.Transform.PositionX, Is.EqualTo(Half(25))); + } + + [Test] + public void EnemyUnits_DoNotYield() + { + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId enemy = SpawnRole(host, UnitRole.BasicInfantry, Half(23), Half(31), slot: 1); + host.SubmitMove(new[] { host.Raw(tank) }, 30, 30); + host.Step(3); + ref readonly UnitState e = ref host.Entities.GetUnitRef(enemy); + Assert.That(e.Transform.PositionY, Is.EqualTo(Half(31)), "make-way is for friends only"); + } + + [Test] + public void Heading_IgnoresSeparation_InACrowd() + { + Host host = Host.Create(); + // A soldier walks east straight through a block of standing soldiers. + var crowd = new List(); + for (int y = 28; y <= 32; y++) + { + for (int x = 24; x <= 27; x++) crowd.Add(SpawnRole(host, UnitRole.BasicInfantry, Half(x), Half(y))); + } + EntityId walker = SpawnRole(host, UnitRole.BasicInfantry, Half(18), Half(30)); + host.SubmitMove(new[] { host.Raw(walker) }, 36, 30); + host.Step(3); + + for (int t = 0; t < 50; t++) + { + host.StepTick(); + ref readonly UnitState w = ref host.Entities.GetUnitRef(walker); + if (!w.IsMoving) break; + int raw = w.Transform.Rotation.RawValue; + int offEast = Math.Min(raw, 65536 - raw); + Assert.That(offEast, Is.LessThanOrEqualTo(8192 + 5461), + $"tick {t}: heading swung {offEast * 360.0 / 65536:F0} deg off the path — separation is steering the heading again"); + } + } + + [Test] + public void GroupMove_LargeUnits_ClaimSlotsWithAFreeRing() + { + Host host = Host.Create(); + var tanks = new List(); + for (int i = 0; i < 6; i++) tanks.Add(SpawnRole(host, UnitRole.BattleTank, Half(10 + i * 2), Half(10))); + var raws = new uint[tanks.Count]; + for (int i = 0; i < tanks.Count; i++) raws[i] = host.Raw(tanks[i]); + host.SubmitMove(raws, 40, 40); + host.StepTick(); + host.StepTick(); + + for (int a = 0; a < tanks.Count; a++) + { + GridPos2D ga = host.Entities.GetUnitRef(tanks[a]).GoalGridPos; + for (int b = a + 1; b < tanks.Count; b++) + { + GridPos2D gb = host.Entities.GetUnitRef(tanks[b]).GoalGridPos; + int cheb = Math.Max(Math.Abs(ga.X - gb.X), Math.Abs(ga.Y - gb.Y)); + Assert.That(cheb, Is.GreaterThanOrEqualTo(2), $"tank slots {ga} and {gb} are adjacent"); + } + } + } + private static GridPos2D CellOfTransform(in Transform2D transform) { return new GridPos2D( diff --git a/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs b/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs new file mode 100644 index 00000000..c29bd269 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs @@ -0,0 +1,57 @@ +using Nova.Core; +using Nova.Simulation.State; + +namespace Nova.Simulation.Movement +{ + /// + /// How much room a unit takes on the ground when units steer around each + /// other and claim group-move slots (D-116). Separate from + /// , which combat uses for range tests and + /// stays 0.5 for every unit so weapon reach does not shift. + /// + /// The values follow the art's view size (ArtViewScale, D-113): a soldier + /// is ~0.4 cells wide, a battle tank ~1.6. With one shared 0.5 radius, + /// tanks parked at contact distance overlapped visibly and infantry + /// stood a full cell apart. + /// + /// All values are exact Q16.16 integers derived from hundredths of a cell. + /// + public static class MovementSpacing + { + /// Largest spacing radius of any unit; bounds the neighbour query. + public static readonly SimFixed MaxRadius = Hundredths(78); + + /// Units at or above this radius claim a slot plus its 8 neighbours in group moves. + public static readonly SimFixed LargeRadius = Hundredths(60); + + private static readonly SimFixed Infantry = Hundredths(30); + private static readonly SimFixed AntiArmor = Hundredths(32); + private static readonly SimFixed Medium = Hundredths(50); + private static readonly SimFixed LightTank = Hundredths(62); + private static readonly SimFixed Harvester = Hundredths(70); + private static readonly SimFixed Heavy = Hundredths(78); + + /// Spacing radius of a mobile role; immobile entities keep their state radius. + public static SimFixed RadiusOf(in UnitState unit) + { + if (unit.MoveSpeed <= SimFixed.Zero) return unit.Radius; + switch (unit.Role) + { + case UnitRole.BasicInfantry: return Infantry; + case UnitRole.AntiArmorInfantry: return AntiArmor; + case UnitRole.Builder: return Medium; + case UnitRole.ScoutVehicle: return Medium; + case UnitRole.LightTank: return LightTank; + case UnitRole.Harvester: return Harvester; + case UnitRole.BattleTank: return Heavy; + case UnitRole.Artillery: return Heavy; + default: return unit.Radius; + } + } + + /// True when a unit needs a free ring of cells around its group-move slot. + public static bool IsLarge(in UnitState unit) => RadiusOf(in unit) >= LargeRadius; + + private static SimFixed Hundredths(int h) => SimFixed.FromRaw((SimFixed.OneRaw * h + 50) / 100); + } +} diff --git a/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs.meta b/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs.meta new file mode 100644 index 00000000..0f490011 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Movement/MovementSpacing.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 143b5bc4078ce4452b51497a0e5c9105 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Movement/MovementSystem.cs b/Assets/_Project/Scripts/Simulation/Movement/MovementSystem.cs index c071a2e6..3b328db6 100644 --- a/Assets/_Project/Scripts/Simulation/Movement/MovementSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Movement/MovementSystem.cs @@ -85,6 +85,42 @@ public sealed class MovementSystem : IStatefulSimSystem /// private static readonly SimFixed MinSteeringLengthSquared = SimFixed.FromRaw(7); + /// + /// Heading turn limit per tick (30 degrees, 5461 of 65536 angle units; + /// D-116): an 8-way flow snap turns over two ticks, a reversal over + /// six, instead of flipping in one. + /// + private const int MaxTurnUnitsPerTick = 5461; + + /// + /// Below this squared length (0.1 cells) the path vector near the goal + /// is too short to define a heading; the unit keeps the one it has. + /// + private static readonly SimFixed MinHeadingLengthSquared = SimFixed.FromRaw(SimFixed.OneRaw / 100); + + /// + /// Neighbour query half-width in cells: spacing radii reach 0.78 + /// (), so a pair can touch at + /// 1.56 cells — beyond the old 3x3 window. + /// + private const int QueryCells = 2; + + /// How far ahead of a mover a standing friend starts to make way (on top of both radii). + private static readonly SimFixed YieldLookAhead = SimFixed.FromRaw(SimFixed.OneRaw * 6 / 5); + + /// Lateral clearance a yielding unit opens beyond both radii (0.15 cells). + private static readonly SimFixed YieldMargin = SimFixed.FromRaw((SimFixed.OneRaw * 15 + 50) / 100); + + /// Share of the missing clearance a yielding unit steps per tick (0.6). + private static readonly SimFixed YieldGain = SimFixed.FromRaw((SimFixed.OneRaw * 3) / 5); + + /// + /// Weight of a yielding friend's separation on the mover (0.25): the + /// mover keeps its line and the group parts around it, instead of both + /// pushing each other off course. + /// + private static readonly SimFixed YielderSeparationWeight = SimFixed.FromRaw(SimFixed.OneRaw / 4); + private readonly EntityManager _entityManager; private readonly PathfindingSystem _pathfindingSystem; @@ -193,16 +229,20 @@ public void ExecuteTick(Tick tick) } } - // O(1) Local 3x3 Spatial Grid Neighborhood Separation — + // O(1) local 5x5 spatial-grid neighbourhood separation — // computed for moving and standing units alike, so an // arrived unit still makes room (and can be pushed). + // Contact distance uses the per-role spacing radius (D-116). SimFixed sepDx = SimFixed.Zero; SimFixed sepDy = SimFixed.Zero; + SimFixed yieldDx = SimFixed.Zero; + SimFixed yieldDy = SimFixed.Zero; + SimFixed myRadius = MovementSpacing.RadiusOf(in unit); - int minGx = Math.Max(0, gridX - 1); - int maxGx = Math.Min(_gridWidth - 1, gridX + 1); - int minGy = Math.Max(0, gridY - 1); - int maxGy = Math.Min(_gridHeight - 1, gridY + 1); + int minGx = Math.Max(0, gridX - QueryCells); + int maxGx = Math.Min(_gridWidth - 1, gridX + QueryCells); + int minGy = Math.Max(0, gridY - QueryCells); + int maxGy = Math.Min(_gridHeight - 1, gridY + QueryCells); for (int gy = minGy; gy <= maxGy; gy++) { @@ -215,7 +255,23 @@ public void ExecuteTick(Tick tick) { ref readonly UnitState other = ref units[otherIndex]; SimFixed distSq = unit.Transform.DistanceToSquared(in other.Transform); - SimFixed minDist = unit.Radius + other.Radius; + SimFixed otherRadius = MovementSpacing.RadiusOf(in other); + SimFixed minDist = myRadius + otherRadius; + + // A standing friend no bigger than me makes + // way (below); while I drive, its push on me + // is weak, so I keep my line through the group. + SimFixed weight = SimFixed.One; + if (moving && !other.IsMoving && other.MoveSpeed > SimFixed.Zero + && other.PlayerId == unit.PlayerId && otherRadius <= myRadius) + { + weight = YielderSeparationWeight; + } + if (!moving) + { + AccumulateYield(in unit, myRadius, in other, otherRadius, otherIndex > i, + ref yieldDx, ref yieldDy); + } if (distSq == SimFixed.Zero) { @@ -225,12 +281,12 @@ public void ExecuteTick(Tick tick) // index — the higher index yields in +x. // Deterministic across hosts (indices are // canonical state). - sepDx += otherIndex > i ? -minDist : minDist; + sepDx += (otherIndex > i ? -minDist : minDist) * weight; } else if (distSq < minDist * minDist) { SimFixed dist = SimTrig.Sqrt(distSq); - SimFixed pushFactor = (minDist - dist) / dist; + SimFixed pushFactor = (minDist - dist) / dist * weight; sepDx += (curX - other.Transform.PositionX) * pushFactor; sepDy += (curY - other.Transform.PositionY) * pushFactor; } @@ -260,7 +316,14 @@ public void ExecuteTick(Tick tick) SimFixed step = unit.MoveSpeed / TicksPerSecond; SimFixed nextX = curX + finalDx * step; SimFixed nextY = curY + finalDy * step; - SimAngle rotation = SimTrig.Atan2(finalDy, finalDx); + // Heading follows the PATH, not path plus separation: + // in a crowd the summed push could flip a unit by up + // to 180 degrees per tick. Turn-rate limited (D-116). + SimAngle rotation = unit.Transform.Rotation; + if (moveDx * moveDx + moveDy * moveDy > MinHeadingLengthSquared) + { + rotation = TurnToward(rotation, SimTrig.Atan2(moveDy, moveDx)); + } // Never step into an impassable cell (a building // footprint, once construction feeds the cost field): @@ -287,8 +350,8 @@ public void ExecuteTick(Tick tick) // raw overlap vector, damped, capped and dead-zoned. The // push approaches zero as the overlap closes, so arrived // units unstack without vibrating; rotation is untouched. - SimFixed pushDx = sepDx * StandingSeparationWeight; - SimFixed pushDy = sepDy * StandingSeparationWeight; + SimFixed pushDx = sepDx * StandingSeparationWeight + yieldDx; + SimFixed pushDy = sepDy * StandingSeparationWeight + yieldDy; SimFixed lenSq = pushDx * pushDx + pushDy * pushDy; if (lenSq > MinSteeringLengthSquared) @@ -311,6 +374,54 @@ public void ExecuteTick(Tick tick) } } + /// + /// Make way (D-116): a standing unit that sits in the corridor ahead of + /// a moving friend at least its own size steps sideways, away from the + /// mover's line, by a share of the clearance still missing. The mover's + /// heading is its path direction, so the corridor points where it is + /// going. A unit dead on the line steps to a side fixed by entity + /// order, so two hosts always part the same way. + /// + private static void AccumulateYield(in UnitState me, SimFixed myRadius, in UnitState mover, SimFixed moverRadius, + bool moverIsHigherIndex, ref SimFixed yieldDx, ref SimFixed yieldDy) + { + if (!mover.IsMoving || mover.MoveSpeed <= SimFixed.Zero) return; + if (mover.PlayerId != me.PlayerId) return; + if (moverRadius < myRadius) return; + + SimFixed dirX = SimTrig.Cos(mover.Transform.Rotation); + SimFixed dirY = SimTrig.Sin(mover.Transform.Rotation); + SimFixed relX = me.Transform.PositionX - mover.Transform.PositionX; + SimFixed relY = me.Transform.PositionY - mover.Transform.PositionY; + + SimFixed along = relX * dirX + relY * dirY; + SimFixed reach = moverRadius + myRadius + YieldLookAhead; + if (along <= SimFixed.Zero || along >= reach) return; + + SimFixed lateral = relX * -dirY + relY * dirX; // signed distance off the line + SimFixed clearance = moverRadius + myRadius + YieldMargin; + SimFixed absLateral = lateral < SimFixed.Zero ? -lateral : lateral; + if (absLateral >= clearance) return; + + SimFixed side = lateral > SimFixed.Zero ? SimFixed.One + : lateral < SimFixed.Zero ? -SimFixed.One + : (moverIsHigherIndex ? SimFixed.One : -SimFixed.One); + // Closer ahead of the mover means more urgent. + SimFixed urgency = SimFixed.One - along / reach; + SimFixed step = (clearance - absLateral) * YieldGain * urgency * side; + yieldDx += -dirY * step; + yieldDy += dirX * step; + } + + /// Turns toward by at most . + private static SimAngle TurnToward(SimAngle from, SimAngle to) + { + int delta = (short)unchecked((ushort)(to.RawValue - from.RawValue)); + if (delta > MaxTurnUnitsPerTick) delta = MaxTurnUnitsPerTick; + else if (delta < -MaxTurnUnitsPerTick) delta = -MaxTurnUnitsPerTick; + return SimAngle.FromRaw(unchecked((ushort)(from.RawValue + delta))); + } + /// True when the world position lies inside the map on a walkable cell. private bool IsWalkablePosition(SimFixed x, SimFixed y) { diff --git a/Assets/_Project/Scripts/Simulation/Pathfinding/IntegrationField.cs b/Assets/_Project/Scripts/Simulation/Pathfinding/IntegrationField.cs index 15d2ddca..1110d8aa 100644 --- a/Assets/_Project/Scripts/Simulation/Pathfinding/IntegrationField.cs +++ b/Assets/_Project/Scripts/Simulation/Pathfinding/IntegrationField.cs @@ -72,9 +72,18 @@ public void Generate(CostField costField, GridPos2D destination) if (cellCost >= CostField.ImpassableCost) continue; - // Diagonal movement cost weighting (14 vs 10 for cardinal) - ushort stepCost = (dx != 0 && dy != 0) ? (ushort)(cellCost * 14 / 10) : cellCost; - ushort newDist = (ushort)(currentDist + stepCost); + // Octile weighting: 10 per cardinal, 14 per diagonal step + // (~sqrt 2). The former `cellCost * 14 / 10` truncated to + // 1 on open ground (cost 1), so a diagonal cost exactly a + // straight step: every staircase was as short as the + // straight line, and the flow took the first strictly + // shorter neighbour in N, NE, E... order — units drove + // diagonal-first zigzags instead of straight lines (D-116). + // Saturates below Unreachable, so a pathological maze + // degrades to ties instead of wrapping around. + int stepCost = (dx != 0 && dy != 0) ? cellCost * 14 : cellCost * 10; + int summed = currentDist + stepCost; + ushort newDist = (ushort)(summed < Unreachable ? summed : Unreachable - 1); int neighborIndex = nY * Width + nX; if (newDist < _distances[neighborIndex]) diff --git a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs index bdc97a68..41a692e3 100644 --- a/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs +++ b/Assets/_Project/Scripts/Simulation/Replays/MatchFingerprint.cs @@ -123,8 +123,16 @@ public sealed class MatchFingerprint : IEquatable /// public const ushort RulesRevisionV3 = 3; + /// + /// D-116 movement feel layered onto revision 3: per-role spacing radii, + /// size-aware group-move slots, make-way steering and a path-following, + /// turn-limited heading. Snapshot bytes are unchanged, the trajectories + /// are not — old/new peers must not meet. + /// + public const ushort RulesRevisionV4 = 4; + /// The rules revision emitted by current hosts. - public const ushort CurrentRulesRevision = RulesRevisionV3; + public const ushort CurrentRulesRevision = RulesRevisionV4; /// Parser bound for one identifier string; checked before allocation. public const int MaxIdentifierBytes = 64; @@ -274,7 +282,8 @@ public static ulong ComputeRulesHash64(ushort rulesRevision) { if (rulesRevision != RulesRevisionV1 && rulesRevision != RulesRevisionV2 - && rulesRevision != RulesRevisionV3) + && rulesRevision != RulesRevisionV3 + && rulesRevision != RulesRevisionV4) { throw new ArgumentOutOfRangeException(nameof(rulesRevision), rulesRevision, "Unknown rules revision."); } @@ -283,7 +292,8 @@ public static ulong ComputeRulesHash64(ushort rulesRevision) hash.WriteFieldTag((uint)MatchContentStub.Rules); hash.WriteUInt32(rulesRevision == RulesRevisionV1 ? 5u - : rulesRevision == RulesRevisionV2 ? 7u : 14u); // ordered rule fields below + : rulesRevision == RulesRevisionV2 ? 7u + : rulesRevision == RulesRevisionV3 ? 14u : 16u); // ordered rule fields below hash.WriteFieldTag(1); hash.WriteUInt16(rulesRevision); hash.WriteFieldTag(2); @@ -318,6 +328,13 @@ public static ulong ComputeRulesHash64(ushort rulesRevision) hash.WriteFieldTag(14); hash.WriteInt32(ConstructionSystem.MinimumNonRefineryFieldDistanceCells); } + if (rulesRevision >= RulesRevisionV4) + { + hash.WriteFieldTag(15); + hash.WriteInt32(Movement.MovementSpacing.MaxRadius.RawValue); + hash.WriteFieldTag(16); + hash.WriteInt32(Movement.MovementSpacing.LargeRadius.RawValue); + } return hash.Digest(); } diff --git a/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs b/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs index b5cc998e..744b0245 100644 --- a/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs +++ b/Assets/_Project/Scripts/Simulation/State/UnitCommandStateView.cs @@ -434,6 +434,61 @@ private static uint ParseSingleEntityId(in CommandRecord record) /// Maximum Chebyshev ring of the formation slot search around a move target. private const int FormationMaxRing = 16; + /// + /// First cell, in ring order around (, ), + /// that a unit may claim; stamps it (and, for a large unit, its ring + /// of neighbours). False when the search radius is exhausted. + /// + private bool TryClaimFormationCell(int gx, int gy, bool large, int width, out int cx, out int cy) + { + CostField field = _pathfindingSystem.CostField; + for (int ring = 0; ring <= FormationMaxRing; ring++) + { + for (int dy = -ring; dy <= ring; dy++) + { + for (int dx = -ring; dx <= ring; dx++) + { + if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != ring) continue; + cx = gx + dx; + cy = gy + dy; + if (!field.IsInBounds(cx, cy) || !field.IsWalkable((ushort)cx, (ushort)cy)) continue; + if (_formationCellStamps[cy * width + cx] == _formationStamp) continue; + if (large && !NeighboursUnclaimed(cx, cy, width, field)) continue; + + _formationCellStamps[cy * width + cx] = _formationStamp; + if (large) StampNeighbours(cx, cy, width, field); + return true; + } + } + } + cx = cy = 0; + return false; + } + + private bool NeighboursUnclaimed(int cx, int cy, int width, CostField field) + { + for (int ny = cy - 1; ny <= cy + 1; ny++) + { + for (int nx = cx - 1; nx <= cx + 1; nx++) + { + if (!field.IsInBounds(nx, ny)) continue; + if (_formationCellStamps[ny * width + nx] == _formationStamp) return false; + } + } + return true; + } + + private void StampNeighbours(int cx, int cy, int width, CostField field) + { + for (int ny = cy - 1; ny <= cy + 1; ny++) + { + for (int nx = cx - 1; nx <= cx + 1; nx++) + { + if (field.IsInBounds(nx, ny)) _formationCellStamps[ny * width + nx] = _formationStamp; + } + } + } + private void ApplyMove(in MovePayload move) { // Canonical world-to-grid mapping: floor, also for negative values @@ -479,34 +534,23 @@ private void ApplyMove(in MovePayload move) // Formation distribution: ring 0 is the target cell itself, then // expanding Chebyshev rings in ascending (y, x) order — the - // convention of ProductionSystem's spawn search. A cell is - // claimable when it is walkable and not already claimed by this - // command; the stamp array tracks claims without allocation. + // convention of ProductionSystem's spawn search. Each unit, in + // ascending entity order, takes the first claimable cell: walkable + // and not yet claimed by this command. Large units (D-116, + // MovementSpacing.IsLarge) also need the 8 cells around their slot + // unclaimed and claim them too, so two tanks never park on + // adjacent cells and jostle at contact distance forever. For a + // group of small units this is exactly the former single sweep. + // The stamp array tracks claims without allocation. _formationStamp++; int next = 0; - for (int ring = 0; ring <= FormationMaxRing && next < count; ring++) + int width = _pathfindingSystem.CostField.Width; + for (; next < count; next++) { - for (int dy = -ring; dy <= ring && next < count; dy++) - { - for (int dx = -ring; dx <= ring && next < count; dx++) - { - if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != ring) continue; - int cx = gridX + dx; - int cy = gridY + dy; - if (!_pathfindingSystem.CostField.IsInBounds(cx, cy) - || !_pathfindingSystem.CostField.IsWalkable((ushort)cx, (ushort)cy)) - { - continue; - } - int cellIndex = cy * _pathfindingSystem.CostField.Width + cx; - if (_formationCellStamps[cellIndex] == _formationStamp) continue; - - _formationCellStamps[cellIndex] = _formationStamp; - _entityManager.GetUnitRef(ToEntityId(_formationIds[next])) - .SetTarget(target, new GridPos2D(cx, cy)); - next++; - } - } + ref UnitState member = ref _entityManager.GetUnitRef(ToEntityId(_formationIds[next])); + bool large = Movement.MovementSpacing.IsLarge(in member); + if (!TryClaimFormationCell(gridX, gridY, large, width, out int cx, out int cy)) break; + member.SetTarget(target, new GridPos2D(cx, cy)); } // More units than claimable cells (map edge, huge group): the diff --git a/docs/tech/Pathfinding.md b/docs/tech/Pathfinding.md index 0b061f39..63c84e78 100644 --- a/docs/tech/Pathfinding.md +++ b/docs/tech/Pathfinding.md @@ -1,6 +1,6 @@ # Pathfinding -**Version:** 1.0.0 | **Status:** verbindlich für MS-1 – G0 aktiv | **Verantwortungsbereich:** Lead AI Programmer | **Sprint:** 7 +**Version:** 1.1.0 | **Status:** verbindlich für MS-1 – G0 aktiv | **Verantwortungsbereich:** Lead AI Programmer | **Sprint:** 7 ## Zweck @@ -39,6 +39,13 @@ Physics und autoritative Unity-Mathematik sind verboten. Movement wird nach Production/Construction und vor FoW ausgeführt. Combat liest die danach committed Sicht. +**Integrationskosten (D-116):** oktil, 10 je gerader und 14 je diagonaler +Schritt, mal Zellkosten, gesättigt unter `Unreachable`. Bis D-116 stand dort +`cellCost * 14 / 10`, was auf offenem Gelände ganzzahlig 1 ergab: Diagonalen +kosteten so viel wie gerade Schritte, das Flussfeld wählte bei Gleichstand die +erste Richtung in N-, NO-, O-Reihenfolge, und Einheiten fuhren Treppen statt +Geraden. + ## 3. Flow-Field-Key Ein kanonischer Key enthält: @@ -69,7 +76,11 @@ liefern, bevor die Simulation fortgesetzt wird. ## 5. Spatial Hash Movement pflegt eine deterministische SpatialHash-Ansicht für Separation und -V5a. Combat darf später dieselbe committed räumliche Indizierung lesen, aber +V5a. Seit D-116 fragt die Separation ein 5×5-Fenster ab, weil der Abstand je +Einheitentyp (`MovementSpacing`, Infanterie 0,3 bis Kampfpanzer 0,78) eine +Berührdistanz bis 1,56 Zellen ergibt. Stehende eigene Einheiten im Fahrweg +einer gleich großen oder größeren fahrenden Einheit weichen seitlich aus, der +Kurs folgt dem Pfad (nicht der Abstoßung) mit höchstens 30° je Tick. Combat darf später dieselbe committed räumliche Indizierung lesen, aber nicht in den Movement-State zurückschreiben. Bucket- und Entityreihenfolge sind stabil; Full-Scan O(n²) ist im Scale-Fixture unzulässig. @@ -128,3 +139,4 @@ und exakte Feld-/Hash-/Byteparität. | 0.1.0 | 2026-07-21 | Erstfassung | Lead AI Programmer | | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 4 (D-043–D-052, Review-Findings) | Lead AI Programmer | | 1.0.0 | 2026-07-24 | Pathfinding auf 128²-MS-1, Q16.16, deterministischen 32-/8-MiB-Cache und getrennte 100-/500-Gates rebaselined | Lead AI Programmer | +| 1.1.0 | 2026-10-17 | D-116: oktile Integrationskosten (Ganzzahlfehler behoben), 5×5-Separation mit Abstand je Einheitentyp, Platzmachen, pfadgeführter Kurs | Technical Art / Orchestrator | diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 13c132a0..1897ea04 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -43,18 +43,19 @@ namespace Nova.SimRunner.Tests [TestFixture] public sealed class CanonicalAiOutcomeTests { - /// Decided tick of the canonical AI match, last moved by: Sprint 16.9 / D-104 and D-107 placement geometry. Previous value: 2705 (Sprint 16.8). - private const uint PinnedDecidedTick = 2726u; + /// Decided tick of the canonical AI match, last moved by: D-116 movement feel (octile flow costs, per-role spacing, make-way, turn-limited heading). Previous value: 2726 (Sprint 16.9 / D-104, D-107). + private const uint PinnedDecidedTick = 2721u; /// - /// End-state hash of the canonical AI match, last moved by: Sprint 16 - /// package 16.9 / D-104 and D-107. The footprint placement rules and - /// corrected mirrored HQ geometry move the simulated match without - /// changing the AI implementation. - /// AiBehaviorId is r7.E34435F9. - /// Previous value: 0x28F2CC571BCE6B76 (Sprint 16.8). + /// End-state hash of the canonical AI match, last moved by: D-116. The + /// flow field now prices a diagonal step at 14 against 10 (it was 1 + /// against 1 through integer truncation), units space by role, + /// friends make way and headings follow the path — the simulated match + /// moves without changing the AI implementation. + /// AiBehaviorId is r8.1E6E7AE3. + /// Previous value: 0x4A861D9FA2E57C6F (Sprint 16.9 / D-104, D-107). /// - private const string PinnedEndState = "0x4A861D9FA2E57C6F"; + private const string PinnedEndState = "0x6E15320BCC3F3EE4"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState() diff --git a/tools/Nova.SimRunner.Tests/CostFieldEpochSnapshotTests.cs b/tools/Nova.SimRunner.Tests/CostFieldEpochSnapshotTests.cs index 728b0f7c..c2d6f362 100644 --- a/tools/Nova.SimRunner.Tests/CostFieldEpochSnapshotTests.cs +++ b/tools/Nova.SimRunner.Tests/CostFieldEpochSnapshotTests.cs @@ -107,10 +107,11 @@ public void CostFieldMutation_RegeneratesTheFlowFieldCacheInPlace() Assert.That(system.FlowFieldCacheCount, Is.EqualTo(1)); // (15,10) sits directly west of the would-be wall (x=16, - // y=10..13) on the destination row. Open terrain: the first - // strictly-lowest neighbour in iteration order is NorthEast. + // y=10..13) on the destination row. Open terrain with octile + // costs (D-116): straight east. (Before D-116 a diagonal cost the + // same as a straight step and this read NorthEast.) Assert.That(system.GetField(oldDestination).GetDirection(15, 10), - Is.EqualTo(Direction2D.NorthEast), "open-terrain baseline direction"); + Is.EqualTo(Direction2D.East), "open-terrain baseline direction"); TestHost.ApplyTerrain(system.CostField); diff --git a/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs b/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs index 7fa23005..9b329b28 100644 --- a/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/KernelIntegrationTests.cs @@ -281,14 +281,19 @@ public void DiagonalMovement_IsNormalized_NoDiagonalSpeedup() [Test] public void Rotation_FollowsMovementDirection_ViaSimTrigAtan2() { - // The heading is SimTrig.Atan2 of the normalized steering vector: - // a pure diagonal move faces exactly 45 degrees (8192 angle units). + // The heading is SimTrig.Atan2 of the PATH direction, turned at + // most 30 degrees (5461 angle units) per tick (D-116): a pure + // diagonal move from heading 0 reaches exactly 45 degrees (8192) + // on the second tick. var host = TestHost.Create(Seed); EntityId unit = host.Entities.SpawnUnit(0, new Transform2D(SimFixed.FromFloat(10.5f), SimFixed.FromFloat(10.5f)), SimFixed.FromInt(5)); host.Entities.GetUnitRef(unit).SetTarget(new GridPos2D(20, 20)); host.StepTick(); + Assert.That(host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue, Is.EqualTo(5461), + "one tick turns at most 30 degrees"); + host.StepTick(); Assert.That(host.Entities.GetUnitRef(unit).Transform.Rotation.RawValue, Is.EqualTo(8192)); } diff --git a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs index 9a7c365d..0df61c08 100644 --- a/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs +++ b/tools/Nova.SimRunner.Tests/MatchFingerprintTests.cs @@ -101,11 +101,15 @@ public void CurrentRulesHash_MovesPastRevisionTwo_ForD104PlacementAndRepair() ulong revisionTwo = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV2); ulong current = MatchFingerprint.ComputeCurrentRulesHash64(); - Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV3)); - Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3))); - Assert.That(current, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); - Assert.That(current, Is.Not.EqualTo(revisionTwo), + Assert.That(MatchFingerprint.CurrentRulesRevision, Is.EqualTo(MatchFingerprint.RulesRevisionV4)); + Assert.That(current, Is.EqualTo(MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV4))); + ulong revisionThree = MatchFingerprint.ComputeRulesHash64(MatchFingerprint.RulesRevisionV3); + Assert.That(revisionThree, Is.EqualTo(0xD1B683837D12FCEFUL), "revision 3 canonical stream is frozen"); + Assert.That(revisionThree, Is.Not.EqualTo(revisionTwo), "D-104 placement and repair behavior must not share revision 2's rules identity"); + Assert.That(current, Is.Not.EqualTo(revisionThree), + "D-116 movement must not share revision 3's rules identity"); + Assert.That(current, Is.EqualTo(0x7E2E3E8C4CF1591EUL), "revision 4 canonical stream is frozen"); } [Test] diff --git a/tools/Nova.SimRunner.Tests/TroopHandlingTests.cs b/tools/Nova.SimRunner.Tests/TroopHandlingTests.cs index 8bb36af1..3326c2fc 100644 --- a/tools/Nova.SimRunner.Tests/TroopHandlingTests.cs +++ b/tools/Nova.SimRunner.Tests/TroopHandlingTests.cs @@ -224,18 +224,29 @@ public void ProducedUnits_OccupyDistinctCells() // 100 full-power build ticks per infantry, slack for the spawns. host.Step(6 * 100 + 50); - var cells = new HashSet(); - int count = 0; + // No stack: every pair of spawns stands at (near-)contact distance + // or further. Since D-116 infantry spaces at 0.3 cells each, so two + // soldiers may legitimately share one cell — distance, not cell + // identity, is the invariant. + var spawns = new List(); UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) { - ref readonly UnitState u = ref units[i]; - if (!u.IsActive || u.Role != UnitRole.BasicInfantry) continue; - count++; - Assert.That(cells.Add(CellOfTransform(in u.Transform)), Is.True, - $"infantry {i} shares its cell with another spawn — the stack is back"); + if (units[i].IsActive && units[i].Role == UnitRole.BasicInfantry) spawns.Add(i); + } + Assert.That(spawns.Count, Is.EqualTo(5), "all five queued infantry spawned"); + for (int a = 0; a < spawns.Count; a++) + { + for (int b = a + 1; b < spawns.Count; b++) + { + ref readonly UnitState ua = ref units[spawns[a]]; + ref readonly UnitState ub = ref units[spawns[b]]; + SimFixed settled = (MovementSpacing.RadiusOf(in ua) + MovementSpacing.RadiusOf(in ub)) + * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); + Assert.That(ua.Transform.DistanceToSquared(in ub.Transform), Is.GreaterThanOrEqualTo(settled * settled), + $"infantry {spawns[a]} and {spawns[b]} stack — the spawn stack is back"); + } } - Assert.That(count, Is.EqualTo(5), "all five queued infantry spawned"); } // -------------------------------------------------------------- @@ -304,8 +315,9 @@ public void StandingSeparation_ExactOverlap_UsesTheIndexTieBreak() Assert.That(a.Transform.PositionX != b.Transform.PositionX || a.Transform.PositionY != b.Transform.PositionY, Is.True, "exactly stacked units must separate"); - // Same dead-zone residual as the offset case: 7/8 of contact. - SimFixed minDist = a.Radius + b.Radius; + // Same dead-zone residual as the offset case: 7/8 of contact, + // measured with the per-role spacing radius (D-116). + SimFixed minDist = MovementSpacing.RadiusOf(in a) + MovementSpacing.RadiusOf(in b); SimFixed settled = minDist * SimFixed.FromRaw(SimFixed.OneRaw / 8 * 7); Assert.That(a.Transform.DistanceToSquared(in b.Transform), Is.GreaterThanOrEqualTo(settled * settled), @@ -406,6 +418,140 @@ public void Sell_FreesTheFootprintInTheCostField() "selling frees the footprint cells in the cost field"); } + // -------------------------------------------------------------- + // D-116: make way, heading, size-aware slots + // -------------------------------------------------------------- + + private static EntityId SpawnRole(Host host, UnitRole role, SimFixed x, SimFixed y, byte slot = 0) + { + SimDefinitions.TryGetUnit(slot == 0 ? FactionId.Alliance : FactionId.Legion, role, out SimUnitDefinition def); + return host.Entities.SpawnUnit(slot, new Transform2D(x, y), def.MoveSpeed, maxHealth: def.MaxHealth, role: def.Role); + } + + private static SimFixed Half(int cells) => SimFixed.FromInt(cells) + SimFixed.FromRaw(SimFixed.OneRaw / 2); + + [Test] + public void StandingInfantry_MakesWay_ForADrivingTank() + { + Host host = Host.Create(); + // Tank drives east along y = 30.5; a soldier stands right on its line. + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(26), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + + SimFixed maxTankDrift = SimFixed.Zero; + for (int t = 0; t < 60; t++) + { + host.StepTick(); + SimFixed drift = host.Entities.GetUnitRef(tank).Transform.PositionY - Half(30); + if (drift < SimFixed.Zero) drift = -drift; + if (drift > maxTankDrift) maxTankDrift = drift; + } + + ref readonly UnitState s = ref host.Entities.GetUnitRef(soldier); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + SimFixed sideStep = s.Transform.PositionY - Half(30); + if (sideStep < SimFixed.Zero) sideStep = -sideStep; + Assert.That(sideStep, Is.GreaterThan(MovementSpacing.RadiusOf(in k)), + "the soldier must have stepped off the tank's line"); + Assert.That(maxTankDrift, Is.LessThan(SimFixed.FromRaw(SimFixed.OneRaw / 4)), + "the tank keeps its line through the group instead of being pushed aside"); + Assert.That(SimFixed.WorldToGrid(k.Transform.PositionX), Is.EqualTo(34), "the tank arrived"); + } + + [Test] + public void FlowField_OpenGround_LeadsStraight_NotDiagonalFirst() + { + // Octile costs (D-116): east of the target on open ground the flow + // points straight east, not into a diagonal staircase. + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + host.SubmitMove(new[] { host.Raw(tank) }, 34, 30); + host.Step(3); + FlowField f = host.Pathfinding.GetField(host.Entities.GetUnitRef(tank).TargetGridPos); + Assert.That(f, Is.Not.Null); + for (int x = 18; x < 34; x++) + { + Assert.That(f.GetDirection((ushort)x, 30), Is.EqualTo(Direction2D.East), $"cell ({x},30)"); + } + Assert.That(f.GetDirection(30, 33), Is.EqualTo(Direction2D.SouthEast), "off the row, diagonal first while it saves distance"); + } + + [Test] + public void StandingTank_DoesNotYield_ToInfantry() + { + Host host = Host.Create(); + EntityId soldier = SpawnRole(host, UnitRole.BasicInfantry, Half(20), Half(30)); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(25), Half(30)); + host.SubmitMove(new[] { host.Raw(soldier) }, 32, 30); + host.Step(5); + ref readonly UnitState k = ref host.Entities.GetUnitRef(tank); + Assert.That(k.Transform.PositionY, Is.EqualTo(Half(30)), "a tank does not step aside for a soldier"); + Assert.That(k.Transform.PositionX, Is.EqualTo(Half(25))); + } + + [Test] + public void EnemyUnits_DoNotYield() + { + Host host = Host.Create(); + EntityId tank = SpawnRole(host, UnitRole.BattleTank, Half(20), Half(30)); + EntityId enemy = SpawnRole(host, UnitRole.BasicInfantry, Half(23), Half(31), slot: 1); + host.SubmitMove(new[] { host.Raw(tank) }, 30, 30); + host.Step(3); + ref readonly UnitState e = ref host.Entities.GetUnitRef(enemy); + Assert.That(e.Transform.PositionY, Is.EqualTo(Half(31)), "make-way is for friends only"); + } + + [Test] + public void Heading_IgnoresSeparation_InACrowd() + { + Host host = Host.Create(); + // A soldier walks east straight through a block of standing soldiers. + var crowd = new List(); + for (int y = 28; y <= 32; y++) + { + for (int x = 24; x <= 27; x++) crowd.Add(SpawnRole(host, UnitRole.BasicInfantry, Half(x), Half(y))); + } + EntityId walker = SpawnRole(host, UnitRole.BasicInfantry, Half(18), Half(30)); + host.SubmitMove(new[] { host.Raw(walker) }, 36, 30); + host.Step(3); + + for (int t = 0; t < 50; t++) + { + host.StepTick(); + ref readonly UnitState w = ref host.Entities.GetUnitRef(walker); + if (!w.IsMoving) break; + int raw = w.Transform.Rotation.RawValue; + int offEast = Math.Min(raw, 65536 - raw); + Assert.That(offEast, Is.LessThanOrEqualTo(8192 + 5461), + $"tick {t}: heading swung {offEast * 360.0 / 65536:F0} deg off the path — separation is steering the heading again"); + } + } + + [Test] + public void GroupMove_LargeUnits_ClaimSlotsWithAFreeRing() + { + Host host = Host.Create(); + var tanks = new List(); + for (int i = 0; i < 6; i++) tanks.Add(SpawnRole(host, UnitRole.BattleTank, Half(10 + i * 2), Half(10))); + var raws = new uint[tanks.Count]; + for (int i = 0; i < tanks.Count; i++) raws[i] = host.Raw(tanks[i]); + host.SubmitMove(raws, 40, 40); + host.StepTick(); + host.StepTick(); + + for (int a = 0; a < tanks.Count; a++) + { + GridPos2D ga = host.Entities.GetUnitRef(tanks[a]).GoalGridPos; + for (int b = a + 1; b < tanks.Count; b++) + { + GridPos2D gb = host.Entities.GetUnitRef(tanks[b]).GoalGridPos; + int cheb = Math.Max(Math.Abs(ga.X - gb.X), Math.Abs(ga.Y - gb.Y)); + Assert.That(cheb, Is.GreaterThanOrEqualTo(2), $"tank slots {ga} and {gb} are adjacent"); + } + } + } + private static GridPos2D CellOfTransform(in Transform2D transform) { return new GridPos2D( From d85afc7f9fbef28d944944d4b4f1324d7967989f Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 00:03:41 +0200 Subject: [PATCH 08/13] fix(construction): let any builder in reach progress a site Sites were bound to the player's lowest-index builder and only that builder's reach was checked, so a second builder could never build (#135 was misdiagnosed as the build-zone radius). When the bound builder is missing or out of reach, the site now passes to the lowest-index own builder that is in reach. Deterministic ascending scan; byte-identical with a single builder, so fingerprints and the AI pin do not move. Replaces the lowest-index-only part of D-085. D-118. Co-Authored-By: Claude Opus 5.5 --- .../Simulation/ConstructionSystemTests.cs | 20 ++++++++++++ .../Construction/ConstructionSystem.cs | 31 +++++++++++++++++-- .../ConstructionSystemTests.cs | 20 ++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 42abb371..022b57c7 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -625,6 +625,26 @@ public void SiteProgress_RequiresBuilderInReach_PausesWhenAway() "full power: exactly one Q16.16 tick of progress per tick"); } + [Test] + public void SiteProgress_SecondBuilderInReach_TakesOverFromFarLowIndexBuilder() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); + EntityId far = f.SpawnBuilder(0, 60, 60); // lowest index, far away + EntityId near = f.SpawnBuilder(0, 19, 20); // higher index, adjacent to the site + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); + + f.Step(10); + Assert.That(f.Construction.TryGetSite(UnitCommandStateView.ToRawEntityId(SiteEntity(f)), out _, out int progressRaw, out uint assigned), + Is.True); + Assert.That(progressRaw, Is.GreaterThan(0), "the adjacent second Builder builds the site"); + Assert.That(assigned, Is.EqualTo(UnitCommandStateView.ToRawEntityId(near)), + "the site is reassigned to the in-reach Builder"); + Assert.That(assigned, Is.Not.EqualTo(UnitCommandStateView.ToRawEntityId(far))); + } + [Test] public void SiteProgress_LowPower_ExactlyHalvesProgress() { diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 8b3612bd..abc5be58 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -915,7 +915,8 @@ private void ProgressSites() // still carry the Builder role and still belong to the site // owner (P2-2 defense-in-depth against tampered snapshots and // stale assignments); a dead, role-changed or foreign builder - // is replaced by the lowest-index own Builder. + // is replaced by the lowest-index own Builder; an out-of-reach + // bound builder yields to the lowest-index own Builder in reach. if (site.AssignedBuilderRaw != 0) { EntityId assignedId = UnitCommandStateView.ToEntityId(site.AssignedBuilderRaw); @@ -935,7 +936,16 @@ private void ProgressSites() EntityId builderId = UnitCommandStateView.ToEntityId(site.AssignedBuilderRaw); ref readonly UnitState builder = ref _entityManager.GetUnitRef(builderId); - if (!IsInReachOfFootprint(in builder, site.OriginX, site.OriginY)) continue; // held, not dropped + if (!IsInReachOfFootprint(in builder, site.OriginX, site.OriginY)) + { + // The bound builder is away: hand the site to the lowest- + // index own Builder that stands in reach (a second Builder + // next to the site must be able to build it). Nobody in + // reach: held, not dropped. + uint inReach = FindLowestIndexBuilderInReach(siteUnit.PlayerId, site.OriginX, site.OriginY); + if (inReach == 0) continue; + site.AssignedBuilderRaw = inReach; + } ref readonly PlayerEconomyState eco = ref _economy.GetPlayerEconomy(siteUnit.PlayerId); site.ProgressRaw += eco.ProductionSpeedMultiplierQ16.RawValue; @@ -1440,6 +1450,23 @@ private uint FindLowestIndexBuilder(byte playerSlot) return 0; } + /// Lowest-index living own Builder inside the reach of a footprint, or 0 (ascending-index scan, deterministic). + private uint FindLowestIndexBuilderInReach(byte playerSlot, int originX, int originY) + { + UnitState[] units = _entityManager.RawUnits; + int capacity = _entityManager.Capacity; + for (int i = 0; i < capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (unit.IsActive && unit.Role == UnitRole.Builder && unit.PlayerId == playerSlot + && IsInReachOfFootprint(in unit, originX, originY)) + { + return UnitCommandStateView.ToRawEntityId(unit.Id); + } + } + return 0; + } + /// Chebyshev reach rule against a 3x3 footprint rectangle: the unit's cell is the rectangle or adjacent (<= 1 per axis). private static bool IsInReachOfFootprint(in UnitState unit, int originX, int originY) { diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 886904f3..f24b00bb 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -625,6 +625,26 @@ public void SiteProgress_RequiresBuilderInReach_PausesWhenAway() "full power: exactly one Q16.16 tick of progress per tick"); } + [Test] + public void SiteProgress_SecondBuilderInReach_TakesOverFromFarLowIndexBuilder() + { + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 30, 20).IsValid, Is.True, "HQ prerequisite"); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 26, 20).IsValid, Is.True, "power provider"); + EntityId far = f.SpawnBuilder(0, 60, 60); // lowest index, far away + EntityId near = f.SpawnBuilder(0, 19, 20); // higher index, adjacent to the site + f.Step(1); + Assert.That(f.Construction.TryPlaceBuilding(0, 7, 20, 20), Is.True); + + f.Step(10); + Assert.That(f.Construction.TryGetSite(UnitCommandStateView.ToRawEntityId(SiteEntity(f)), out _, out int progressRaw, out uint assigned), + Is.True); + Assert.That(progressRaw, Is.GreaterThan(0), "the adjacent second Builder builds the site"); + Assert.That(assigned, Is.EqualTo(UnitCommandStateView.ToRawEntityId(near)), + "the site is reassigned to the in-reach Builder"); + Assert.That(assigned, Is.Not.EqualTo(UnitCommandStateView.ToRawEntityId(far))); + } + [Test] public void SiteProgress_LowPower_ExactlyHalvesProgress() { From c96db97579e864753bc364413b28f8ccdd567a93 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 00:04:29 +0200 Subject: [PATCH 09/13] feat(ui): Zero Hour command bar, tailored per unit type A UI Toolkit command bar for selected units (D-117): type chips, icon grid with hotkeys and tooltips, attack-move, patrol, guard, scatter and five formations as client orders over Move/Stop/AttackTarget, so the command register stays frozen. Buttons that do not apply are hidden rather than greyed: harvesters and builders get no combat orders, harvesters show a cargo bar and their state instead, and W/K/J/M act only on the armed part of a selection. Placing a building sends the selected builder, and right-clicking an own site sends selected builders to its approach cell (D-118). The escort holds an own harvester at the refinery while its unload animation plays. Co-Authored-By: Claude Opus 5.5 --- .../Gameplay/CommandCardPresenterTests.cs | 139 ++ .../Gameplay/FormationPlannerTests.cs | 127 ++ .../Gameplay/FormationPlannerTests.cs.meta | 11 + .../EditMode/Gameplay/StandingOrdersTests.cs | 165 ++ .../Gameplay/StandingOrdersTests.cs.meta | 11 + Assets/Tests/PlayMode/CommandBarTests.cs | 282 +++ Assets/Tests/PlayMode/CommandBarTests.cs.meta | 11 + .../Editor/BootstrapSceneGenerator.cs | 15 + Assets/_Project/Scenes/Bootstrap.unity | 1792 +++++++++-------- .../Gameplay/Input/FormationPlanner.cs | 222 ++ .../Gameplay/Input/FormationPlanner.cs.meta | 11 + .../Scripts/Gameplay/Input/StandingOrders.cs | 230 +++ .../Gameplay/Input/StandingOrders.cs.meta | 11 + .../Gameplay/UI/CommandCardPresenter.cs | 181 ++ .../Scripts/Presentation/UI/BuildMenuHud.cs | 3 + .../Scripts/Presentation/UI/CommandCardHud.cs | 6 + .../Presentation/UI/RtsDeviceInput.Orders.cs | 427 ++++ .../UI/RtsDeviceInput.Orders.cs.meta | 11 + .../Scripts/Presentation/UI/RtsDeviceInput.cs | 101 +- .../Scripts/Presentation/UI/UnitCommandBar.cs | 686 +++++++ .../Presentation/UI/UnitCommandBar.cs.meta | 11 + docs/tech/InputSystem.md | 14 +- docs/vision/CoreGameplay.md | 5 +- 23 files changed, 3604 insertions(+), 868 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta create mode 100644 Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta create mode 100644 Assets/Tests/PlayMode/CommandBarTests.cs create mode 100644 Assets/Tests/PlayMode/CommandBarTests.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Input/FormationPlanner.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Input/FormationPlanner.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Input/StandingOrders.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Input/StandingOrders.cs.meta create mode 100644 Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.Orders.cs create mode 100644 Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.Orders.cs.meta create mode 100644 Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs create mode 100644 Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs.meta diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs index e26567fd..cdb85b90 100644 --- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs @@ -358,6 +358,145 @@ public void TryFindRepairBuilder_NoOwnBuilder_ReturnsFalse() Assert.IsFalse(CommandCardPresenter.TryFindRepairBuilder(null, playerSlot: 0, out _)); } + // ---------------------------------------------------------------- + // Unit command bar: per-unit-type presence and harvester readout (D-117) + // ---------------------------------------------------------------- + + [Test] + public void GetBarCommands_Harvester_HidesCombatOrdersAndFormations() + { + var presenter = new CommandCardPresenter(); + + BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.Harvester }); + + Assert.IsTrue(bar.HasFlag(BarCommand.Move)); + Assert.IsTrue(bar.HasFlag(BarCommand.Stop)); + Assert.IsTrue(bar.HasFlag(BarCommand.Harvest)); + Assert.IsTrue(bar.HasFlag(BarCommand.ReturnCargo)); + foreach (BarCommand hidden in new[] + { + BarCommand.AttackMove, BarCommand.Guard, BarCommand.Patrol, + BarCommand.AttackTarget, BarCommand.Scatter, BarCommand.Formations, BarCommand.Repair, + }) + { + Assert.IsFalse(bar.HasFlag(hidden), $"{hidden} must be hidden for harvesters"); + } + } + + [Test] + public void GetBarCommands_Builder_OffersRepairButNoCombatOrders() + { + var presenter = new CommandCardPresenter(); + + BarCommand bar = presenter.GetBarCommands(FactionId.Legion, new[] { UnitRole.Builder }); + + Assert.IsTrue(bar.HasFlag(BarCommand.Repair)); + Assert.IsFalse(bar.HasFlag(BarCommand.Patrol)); + Assert.IsFalse(bar.HasFlag(BarCommand.Formations)); + Assert.IsFalse(bar.HasFlag(BarCommand.Harvest)); + } + + [Test] + public void GetBarCommands_ArmedUnits_OfferCombatOrdersAndFormationsButNoRoleActions() + { + var presenter = new CommandCardPresenter(); + + BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.LightTank, UnitRole.BasicInfantry }); + + foreach (BarCommand shown in new[] + { + BarCommand.Move, BarCommand.Stop, BarCommand.AttackMove, BarCommand.Guard, BarCommand.Patrol, + BarCommand.AttackTarget, BarCommand.Scatter, BarCommand.Formations, + }) + { + Assert.IsTrue(bar.HasFlag(shown), $"{shown} must be shown for armed units"); + } + Assert.IsFalse(bar.HasFlag(BarCommand.Harvest)); + Assert.IsFalse(bar.HasFlag(BarCommand.ReturnCargo)); + Assert.IsFalse(bar.HasFlag(BarCommand.Repair)); + } + + [Test] + public void GetBarCommands_MixedSelection_CombatGroupIsUnionRoleActionsAreIntersection() + { + var presenter = new CommandCardPresenter(); + + BarCommand bar = presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.Harvester, UnitRole.LightTank }); + + Assert.IsTrue(bar.HasFlag(BarCommand.Guard), "one armed unit is enough for the combat group"); + Assert.IsTrue(bar.HasFlag(BarCommand.Formations)); + Assert.IsFalse(bar.HasFlag(BarCommand.Harvest), "Harvest would be a lie over the tank"); + Assert.IsFalse(bar.HasFlag(BarCommand.ReturnCargo)); + } + + [Test] + public void GetBarCommands_NoMobileRole_ReturnsNone() + { + var presenter = new CommandCardPresenter(); + + Assert.AreEqual(BarCommand.None, presenter.GetBarCommands(FactionId.Alliance, new[] { UnitRole.HQ })); + Assert.AreEqual(BarCommand.None, presenter.GetBarCommands(FactionId.Alliance, new UnitRole[0])); + } + + [Test] + public void IsArmedRole_FollowsTheDefinitionRow() + { + Assert.IsTrue(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.LightTank)); + Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.Harvester)); + Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.Builder)); + Assert.IsFalse(CommandCardPresenter.IsArmedRole(FactionId.Alliance, UnitRole.HQ)); + } + + [Test] + public void GetHarvesterStatus_DerivesFromOrdersAndMovement() + { + var u = new UnitState(); + Assert.AreEqual(HarvesterStatus.Idle, CommandCardPresenter.GetHarvesterStatus(in u)); + + u.HarvestFieldId = 3; + u.IsMoving = true; + Assert.AreEqual(HarvesterStatus.DrivingToField, CommandCardPresenter.GetHarvesterStatus(in u)); + u.IsMoving = false; + Assert.AreEqual(HarvesterStatus.Harvesting, CommandCardPresenter.GetHarvesterStatus(in u)); + + u.IsReturningCargo = true; + u.IsMoving = true; + Assert.AreEqual(HarvesterStatus.DrivingToRefinery, CommandCardPresenter.GetHarvesterStatus(in u)); + u.IsMoving = false; + Assert.AreEqual(HarvesterStatus.WaitingAtRefinery, CommandCardPresenter.GetHarvesterStatus(in u)); + + Assert.AreEqual("Fährt zum Feld", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.DrivingToField)); + Assert.AreEqual("Sammelt", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.Harvesting)); + Assert.AreEqual("Fährt zur Raffinerie", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.DrivingToRefinery)); + Assert.AreEqual("Wartet an der Raffinerie", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.WaitingAtRefinery)); + Assert.AreEqual("Untätig", CommandCardPresenter.FormatHarvesterStatus(HarvesterStatus.Idle)); + } + + [Test] + public void SummarizeHarvesters_SumsCargoCapacityAndStatus_SkippingOtherRoles() + { + var entities = new EntityManager(8); + EntityId a = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Harvester); + EntityId b = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Harvester); + EntityId tank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), role: UnitRole.LightTank); + entities.GetUnitRef(a).CargoAE = 210; + entities.GetUnitRef(a).HarvestFieldId = 1; + entities.GetUnitRef(b).CargoAE = 100; + int cap = SimDefinitions.HarvesterCargoCapacityAE(FactionId.Legion); + var presenter = new CommandCardPresenter(); + + HarvesterSummary sum = presenter.SummarizeHarvesters(FactionId.Legion, new[] { a, b, tank }, entities); + + Assert.AreEqual(2, sum.Count); + Assert.AreEqual(310, sum.CargoAE); + Assert.AreEqual(2 * cap, sum.CapacityAE); + Assert.AreEqual(1, sum.Harvesting); + Assert.AreEqual(1, sum.Idle); + Assert.AreEqual(310f / (2 * cap), sum.Fill, 1e-5f); + Assert.AreEqual($"Ladung 310 / {2 * cap}", CommandCardPresenter.FormatCargo(sum.CargoAE, sum.CapacityAE)); + Assert.AreEqual("1× Sammelt · 1× Untätig", CommandCardPresenter.FormatHarvesterStatusLine(in sum)); + } + // ---------------------------------------------------------------- // Display names (canonical GDD name tables) // ---------------------------------------------------------------- diff --git a/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs new file mode 100644 index 00000000..3bb4111e --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using Nova.Gameplay; +using EntityId = Nova.Core.EntityId; + +namespace Nova.Gameplay.Tests +{ + /// Client-side formation slots (D-117): shape, facing, spacing and crossing-free assignment. + [TestFixture] + public class FormationPlannerTests + { + private static readonly Vector2 MapMin = Vector2.zero; + private static readonly Vector2 MapMax = new Vector2(127f, 127f); + + private static List Group(int n, Vector2 at, float radius = 0.3f) + { + var list = new List(); + for (int i = 0; i < n; i++) + { + list.Add(new FormationMember(new EntityId(i, 1), at + new Vector2((i % 4) * 1.2f, (i / 4) * 1.2f), radius)); + } + return list; + } + + [Test] + public void Loose_AndSingleUnits_LeaveTheSpreadToTheSimulation() + { + var slots = new List(); + Assert.That(FormationPlanner.Plan(FormationKind.Loose, Group(6, new Vector2(10, 10)), new Vector2(40, 40), MapMin, MapMax, slots), Is.False); + Assert.That(FormationPlanner.Plan(FormationKind.Line, Group(1, new Vector2(10, 10)), new Vector2(40, 40), MapMin, MapMax, slots), Is.False); + } + + [TestCase(FormationKind.Line)] + [TestCase(FormationKind.Column)] + [TestCase(FormationKind.Wedge)] + [TestCase(FormationKind.Block)] + public void EverySlot_KeepsAtLeastTheContactSpacing(FormationKind kind) + { + var members = Group(9, new Vector2(10, 10), 0.6f); + var slots = new List(); + Assert.That(FormationPlanner.Plan(kind, members, new Vector2(40, 30), MapMin, MapMax, slots), Is.True); + Assert.That(slots.Count, Is.EqualTo(members.Count)); + for (int a = 0; a < slots.Count; a++) + { + for (int b = a + 1; b < slots.Count; b++) + { + Assert.That((slots[a] - slots[b]).magnitude, Is.GreaterThanOrEqualTo(2f * 0.6f * 0.84f), + $"{kind}: slots {a} and {b} overlap"); + } + } + } + + [Test] + public void Line_StandsAcrossTheDirectionOfTravel() + { + var members = Group(5, new Vector2(10, 10)); + var slots = new List(); + Vector2 centroid = Vector2.zero; + foreach (FormationMember m in members) centroid += m.Position; + centroid /= members.Count; + Vector2 target = centroid + new Vector2(0f, 30f); // travel due north + FormationPlanner.Plan(FormationKind.Line, members, target, MapMin, MapMax, slots); + foreach (Vector2 s in slots) + { + Assert.That(s.y, Is.EqualTo(target.y).Within(0.01f), "a line facing north lies east-west"); + } + } + + [Test] + public void Column_RunsAlongTheDirectionOfTravel() + { + var members = Group(6, new Vector2(10, 10)); + var slots = new List(); + FormationPlanner.Plan(FormationKind.Column, members, new Vector2(40f, 11f), MapMin, MapMax, slots); + float minX = float.MaxValue, maxX = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; + foreach (Vector2 s in slots) + { + minX = Mathf.Min(minX, s.x); maxX = Mathf.Max(maxX, s.x); + minY = Mathf.Min(minY, s.y); maxY = Mathf.Max(maxY, s.y); + } + Assert.That(maxX - minX, Is.GreaterThan(maxY - minY), "a column heading east is longer east-west"); + } + + [Test] + public void Wedge_PutsOneUnitAtTheTip() + { + var members = Group(5, new Vector2(10, 10)); + var slots = new List(); + FormationPlanner.Plan(FormationKind.Wedge, members, new Vector2(10.6f, 40f), MapMin, MapMax, slots); + float best = float.MinValue; + int tips = 0; + foreach (Vector2 s in slots) best = Mathf.Max(best, s.y); + foreach (Vector2 s in slots) if (Mathf.Abs(s.y - best) < 0.01f) tips++; + Assert.That(tips, Is.EqualTo(1)); + } + + [Test] + public void Assignment_DoesNotCrossLeftAndRight() + { + // Three units side by side moving north: the westmost keeps the west slot. + var members = new List + { + new FormationMember(new EntityId(0, 1), new Vector2(20, 10), 0.3f), + new FormationMember(new EntityId(1, 1), new Vector2(10, 10), 0.3f), + new FormationMember(new EntityId(2, 1), new Vector2(15, 10), 0.3f), + }; + var slots = new List(); + FormationPlanner.Plan(FormationKind.Line, members, new Vector2(15, 40), MapMin, MapMax, slots); + Assert.That(slots[1].x, Is.LessThan(slots[2].x)); + Assert.That(slots[2].x, Is.LessThan(slots[0].x)); + } + + [Test] + public void Slots_StayInsideTheMap() + { + var members = Group(12, new Vector2(3, 3)); + var slots = new List(); + FormationPlanner.Plan(FormationKind.Line, members, new Vector2(0.5f, 0.5f), MapMin, MapMax, slots); + foreach (Vector2 s in slots) + { + Assert.That(s.x, Is.InRange(0f, 127f)); + Assert.That(s.y, Is.InRange(0f, 127f)); + } + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta new file mode 100644 index 00000000..bef3afc4 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/FormationPlannerTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ff244b9fb9af4fdfbd230bfc800e4def +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs new file mode 100644 index 00000000..c48d7fd4 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using Nova.Core; +using Nova.Gameplay; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.Gameplay.Tests +{ + /// Attack-move, patrol and guard as client-side orders over plain commands (D-117). + [TestFixture] + public class StandingOrdersTests + { + private sealed class FakeWorld : IStandingOrderWorld + { + public readonly Dictionary Own = new Dictionary(); + public readonly Dictionary Enemies = new Dictionary(); + public float Range = 5f; + + public bool TryGetOwnUnit(EntityId id, out UnitState unit) => Own.TryGetValue(id, out unit); + public float WeaponRange(in UnitState unit) => Range; + public bool IsAliveAndVisible(EntityId enemy) => Enemies.ContainsKey(enemy); + + public bool TryFindVisibleEnemy(Vector2 centre, float radius, out EntityId enemy) + { + enemy = EntityId.Invalid; + float best = radius; + foreach (KeyValuePair kv in Enemies) + { + float d = (kv.Value - centre).magnitude; + if (d <= best) { best = d; enemy = kv.Key; } + } + return enemy.IsValid; + } + + public EntityId Add(int index, float x, float y, bool moving = false) + { + var id = new EntityId(index, 1); + var u = new UnitState { Id = id, IsActive = true, IsMoving = moving, AttackTarget = EntityId.Invalid, + Transform = new Transform2D(SimFixed.FromFloat(x), SimFixed.FromFloat(y)) }; + Own[id] = u; + return id; + } + + public void Place(EntityId id, float x, float y, bool moving) + { + UnitState u = Own[id]; + u.Transform = new Transform2D(SimFixed.FromFloat(x), SimFixed.FromFloat(y)); + u.IsMoving = moving; + Own[id] = u; + } + } + + [Test] + public void AttackMove_MovesOn_AttacksWhatItMeets_AndEndsOnArrival() + { + var world = new FakeWorld(); + EntityId tank = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetAttackMove(tank, new Vector2(40, 10)); + var actions = new List(); + + orders.Tick(world, actions); + Assert.That(actions, Has.Count.EqualTo(1)); + Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Move)); + + world.Place(tank, 20, 10, moving: true); + world.Enemies[new EntityId(9, 1)] = new Vector2(26, 11); + actions.Clear(); + orders.Tick(world, actions); + Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Attack), "an enemy in reach is engaged"); + + world.Enemies.Clear(); + world.Place(tank, 22, 10, moving: false); + actions.Clear(); + orders.Tick(world, actions); + Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Move), "after the fight the drive resumes"); + Assert.That(actions[0].Point, Is.EqualTo(new Vector2(40, 10))); + + world.Place(tank, 39.5f, 10, moving: false); + actions.Clear(); + orders.Tick(world, actions); + Assert.That(orders.KindOf(tank), Is.EqualTo(StandingOrderKind.None), "arrived: the order ends"); + } + + [Test] + public void Patrol_TurnsAroundAtEachEnd() + { + var world = new FakeWorld(); + EntityId scout = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetPatrol(scout, new Vector2(10, 10), new Vector2(30, 10)); + var actions = new List(); + orders.Tick(world, actions); + Assert.That(actions[0].Point, Is.EqualTo(new Vector2(30, 10))); + + world.Place(scout, 29.5f, 10, moving: false); + actions.Clear(); + orders.Tick(world, actions); + Assert.That(actions[0].Point, Is.EqualTo(new Vector2(10, 10)), "back to the start"); + Assert.That(orders.KindOf(scout), Is.EqualTo(StandingOrderKind.Patrol), "a patrol never ends by itself"); + } + + [Test] + public void Move_IsNotResent_WhileTheUnitDrivesThere() + { + var world = new FakeWorld(); + EntityId scout = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetPatrol(scout, new Vector2(10, 10), new Vector2(30, 10)); + var actions = new List(); + orders.Tick(world, actions); + world.Place(scout, 15, 10, moving: true); + actions.Clear(); + orders.Tick(world, actions); + Assert.That(actions, Is.Empty, "no command spam while the order is underway"); + } + + [Test] + public void Guard_Engages_ThenReturnsWhenDrawnOff() + { + var world = new FakeWorld(); + EntityId inf = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetGuard(inf, new Vector2(10, 10)); + var enemy = new EntityId(9, 1); + world.Enemies[enemy] = new Vector2(14, 10); + var actions = new List(); + orders.Tick(world, actions); + Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Attack)); + + world.Place(inf, 20, 10, moving: true); // chased too far + actions.Clear(); + orders.Tick(world, actions); + Assert.That(actions[0].Kind, Is.EqualTo(StandingActionKind.Return)); + Assert.That(actions[0].Point, Is.EqualTo(new Vector2(10, 10))); + } + + [Test] + public void Guard_IgnoresEnemiesFarFromItsPost() + { + var world = new FakeWorld(); + EntityId inf = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetGuard(inf, new Vector2(10, 10)); + world.Enemies[new EntityId(9, 1)] = new Vector2(30, 10); + var actions = new List(); + orders.Tick(world, actions); + Assert.That(actions, Is.Empty); + } + + [Test] + public void DeadUnits_DropTheirOrders() + { + var world = new FakeWorld(); + EntityId inf = world.Add(1, 10, 10); + var orders = new StandingOrders(); + orders.SetGuard(inf, new Vector2(10, 10)); + world.Own.Clear(); + orders.Tick(world, new List()); + Assert.That(orders.Count, Is.Zero); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta new file mode 100644 index 00000000..58d0f9b8 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/StandingOrdersTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dcfe8497732943a5aa54a1c402ea177c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/PlayMode/CommandBarTests.cs b/Assets/Tests/PlayMode/CommandBarTests.cs new file mode 100644 index 00000000..6aa42b37 --- /dev/null +++ b/Assets/Tests/PlayMode/CommandBarTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using UnityEngine.UIElements; +using Nova.Core; +using Nova.Gameplay; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; +using Object = UnityEngine.Object; + +namespace Nova.PlayMode.Tests +{ + /// + /// The unit command bar and its group orders in the running match (D-117). + /// Presentation types are reached by reflection — the test assemblies may + /// not reference the presentation layer (quality/scripts/run_gate_check.py), + /// the same arrangement MainMenuTests use. The match is started through the + /// menu's own StartMatch, so the cockpit is exactly what a player sees. + /// Evidence: output/demo/commandbar_*.png (world plus UI composited). + /// + public sealed class CommandBarTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + + [UnityTest] + public IEnumerator SelectedTanks_GetTheBar_AndPatrolInLine() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + yield return null; + + Component menu = FindByTypeName("MainMenuController"); + Assert.NotNull(menu, "no MainMenuController in the Bootstrap scene"); + Invoke(menu, "StartMatch"); + yield return null; + + var bootstrap = Object.FindAnyObjectByType(); + Assert.IsTrue(bootstrap.IsMatchReady, "the menu's StartMatch did not build a match"); + MatchRunner runner = bootstrap.Runner; + Component input = FindByTypeName("RtsDeviceInput"); + Assert.NotNull(input); + FactionId faction = runner.Economy.GetSlotFaction(0); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.LightTank, out SimUnitDefinition tank)); + + var ids = new List(); + for (int i = 0; i < 6; i++) + { + ids.Add(runner.Entities.SpawnUnit(0, + new Transform2D(SimFixed.FromInt(24 + (i % 3) * 2), SimFixed.FromInt(20 + (i / 3) * 2)), + tank.MoveSpeed, role: tank.Role)); + } + yield return new WaitForSeconds(0.5f); + + var selection = (SelectionManager)Get(input, "Selection"); + selection.ReplaceSelection(ids.ToArray()); + yield return null; + yield return null; + + Type barType = FindType("Nova.Presentation.UI.UnitCommandBar"); + Assert.NotNull(barType, "UnitCommandBar type missing"); + Assert.IsTrue((bool)barType.GetProperty("ShowsUnits").GetValue(null), "the command bar must show for selected units"); + + Invoke(input, "SetFormation", FormationKind.Line); + Vector3 patrolEnd = new Vector3(44f, 0f, 22f); + Invoke(input, "OrderPatrolAt", patrolEnd); + + yield return new WaitForSeconds(1.2f); + yield return CaptureWithUi($"{ShotDir}/commandbar_patrol.png", new Vector3(32f, 0f, 21f), 16f); + + // Out to the far end, then back: the patrol turns around on its own. + float maxX = float.MinValue; + bool turned = false; + float deadline = Time.time + 25f; + while (Time.time < deadline && !turned) + { + yield return new WaitForSeconds(0.25f); + float x = MeanX(runner, ids); + if (x > maxX) maxX = x; + if (maxX > 41f && x < maxX - 2f) turned = true; + } + Assert.IsTrue(turned, $"the patrol never turned around (max mean x {maxX:F1})"); + + // A line facing east stands north-south at the far end. + float minY = float.MaxValue, maxY = float.MinValue; + foreach (EntityId id in ids) + { + runner.Entities.TryGetUnit(id, out UnitState u); + minY = Mathf.Min(minY, u.Transform.PositionY.ToFloat()); + maxY = Mathf.Max(maxY, u.Transform.PositionY.ToFloat()); + } + Assert.That(maxY - minY, Is.GreaterThan(3f), "the line spreads across the direction of travel"); + Debug.Log($"[CommandBar] patrol turned at mean x {maxX:F1}, line spread {maxY - minY:F1}"); + } + + [UnityTest] + public IEnumerator SelectedHarvesters_ShowCargoReadout_AndNoCombatOrders() + { + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + yield return null; + + Component menu = FindByTypeName("MainMenuController"); + Assert.NotNull(menu, "no MainMenuController in the Bootstrap scene"); + Invoke(menu, "StartMatch"); + yield return null; + + var bootstrap = Object.FindAnyObjectByType(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + Component input = FindByTypeName("RtsDeviceInput"); + Assert.NotNull(input); + FactionId faction = runner.Economy.GetSlotFaction(0); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.Harvester, out SimUnitDefinition harvester)); + + var ids = new List(); + for (int i = 0; i < 2; i++) + { + EntityId id = runner.Entities.SpawnUnit(0, + new Transform2D(SimFixed.FromInt(24 + i * 2), SimFixed.FromInt(20)), + harvester.MoveSpeed, role: harvester.Role); + runner.Entities.GetUnitRef(id).CargoAE = 100; + ids.Add(id); + } + yield return new WaitForSeconds(0.3f); + + var selection = (SelectionManager)Get(input, "Selection"); + selection.ReplaceSelection(ids.ToArray()); + yield return null; + yield return null; + + Component bar = FindByTypeName("UnitCommandBar"); + Assert.NotNull(bar, "UnitCommandBar missing"); + var document = (UIDocument)bar.GetType().GetField("_document", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(bar); + VisualElement root = document.rootVisualElement; + + foreach (string hidden in new[] { "Patrol", "Guard", "AttackMove", "AttackTarget", "Scatter", "Loose", "Line", "Column", "Wedge", "Block", "Repair" }) + { + VisualElement button = root.Q($"cmd-{hidden}"); + Assert.NotNull(button, $"cmd-{hidden} missing"); + Assert.AreEqual(DisplayStyle.None, button.style.display.value, $"{hidden} must be hidden for harvesters"); + } + foreach (string shown in new[] { "Move", "Stop", "Harvest", "Deliver" }) + { + VisualElement button = root.Q($"cmd-{shown}"); + Assert.NotNull(button, $"cmd-{shown} missing"); + Assert.AreNotEqual(DisplayStyle.None, button.style.display.value, $"{shown} must stay visible for harvesters"); + } + + VisualElement cargo = root.Q("cargo-panel"); + Assert.NotNull(cargo, "cargo panel missing"); + Assert.AreEqual(DisplayStyle.Flex, cargo.style.display.value, "harvester selection must show the cargo readout"); + int capacity = SimDefinitions.HarvesterCargoCapacityAE(faction); + Assert.AreEqual($"Ladung 200 / {2 * capacity}", root.Q private void DispatchBuilderToConstructionSite(int originX, int originY) { - if (!CommandCardPresenter.TryFindRepairBuilder(_runner.Entities, _dispatcher.LocalSlot, out EntityId builder)) + EntityId builder = EntityId.Invalid; + ReadOnlySpan selectedBuilders = BuilderSelection(out int selectedBuilderCount); + if (selectedBuilderCount > 0) builder = selectedBuilders[0]; + if (!builder.IsValid + && !CommandCardPresenter.TryFindRepairBuilder(_runner.Entities, _dispatcher.LocalSlot, out builder)) { _lastCommandStatus += $" — {ConstructionSiteStatus.NoBuilderWarning}"; Debug.LogWarning("[RtsDeviceInput] Placed without a living own Builder — the site will pause until one is built (U at the HQ)."); @@ -975,6 +989,12 @@ private void UpdateHarvesterEscort() if (!unit.IsActive || unit.PlayerId != slot || unit.Role != UnitRole.Harvester) continue; uint rawId = UnitCommandStateView.ToRawEntityId(unit.Id); + if (_harvesterLastCargo.TryGetValue(rawId, out int lastCargo) && lastCargo > 0 && unit.CargoAE == 0 + && unit.HarvestFieldId != 0) + { + _harvesterHoldUntil[rawId] = Time.time + HarvesterUnloadHoldSeconds; + } + _harvesterLastCargo[rawId] = unit.CargoAE; if (unit.HarvestFieldId != 0 || unit.IsReturningCargo || unit.CargoAE > 0) { _autoAssignedHarvesters.Add(rawId); @@ -1011,6 +1031,7 @@ private void UpdateHarvesterEscort() { ushort fieldId = unit.HarvestFieldId; if (fieldId == 0) continue; + if (_harvesterHoldUntil.TryGetValue(rawId, out float holdUntil) && Time.time < holdUntil) continue; if (!economy.TryGetField(fieldId, out AetheriumField field)) continue; if (field.IsExhausted) continue; if (InFieldReach(cellX, cellY, field.GridPos.X, field.GridPos.Y)) continue; @@ -1330,6 +1351,12 @@ private void HandleOrders(Vector2 mouse) } if (Input.GetKeyDown(KeyCode.S)) OrderStop(); + // D-117 group orders (free letters: W Wache, K Patrouille, + // J Verteilen, M Marschordnung). + if (Input.GetKeyDown(KeyCode.W)) OrderGuard(); + if (Input.GetKeyDown(KeyCode.K)) RequestPatrolOrder(); + if (Input.GetKeyDown(KeyCode.J)) OrderScatter(); + if (Input.GetKeyDown(KeyCode.M)) CycleFormation(); if (Input.GetKeyDown(KeyCode.A) && TryScreenPointToGround(mouse, out Vector3 attackAt)) { @@ -1532,6 +1559,7 @@ private bool IsPointerOverHud(Vector2 mouse) { return IsPointerOverBuildMenu(mouse) || (_commandCard != null && _commandCard.IsPointerOverPanel(mouse)) + || UnitCommandBar.IsPointerOverAny(mouse) || (_minimap != null && _minimap.IsPointerOverMinimap(mouse)); } @@ -1581,6 +1609,14 @@ private void HandleOrderPickInput(Vector2 mouse) case PendingOrder.Repair: if (TryResolveRepairAt(at)) _pendingOrder = PendingOrder.None; break; + case PendingOrder.AttackMove: + ResolveAttackMoveAt(at); + _pendingOrder = PendingOrder.None; + break; + case PendingOrder.Patrol: + ResolvePatrolAt(at); + _pendingOrder = PendingOrder.None; + break; } } @@ -1614,6 +1650,14 @@ private void DispatchContextRightClick(Vector3 moveTo) return; } + // 3a. Own active construction site with Builders selected -> walk + // them to the site's approach cell (the sim hands the site to + // whichever own Builder stands in reach) + if (HasBuilderInSelection(mobile) && TryResolveBuildSiteAt(moveTo)) + { + return; + } + // 3. Repair for builders if (HasBuilderInSelection(mobile) && TryResolveRepairAt(moveTo)) { @@ -1624,6 +1668,49 @@ private void DispatchContextRightClick(Vector3 moveTo) ResolveMoveAt(moveTo); } + /// + /// Right-click on an OWN active construction site with Builders in the + /// selection: sends the selected Builders to the site's deterministic + /// approach cell instead of treating the click as repair/move. The + /// site unit sits on the centre cell of its 3x3 footprint (origin + 1). + /// + private bool TryResolveBuildSiteAt(Vector3 at) + { + EntityManager entities = _runner.Entities; + ConstructionSystem construction = _runner.Construction; + if (entities == null || construction == null) return false; + + int cx = Nova.Core.SimFixed.WorldToGrid(Nova.Core.SimFixed.FromFloat(at.x)); + int cy = Nova.Core.SimFixed.WorldToGrid(Nova.Core.SimFixed.FromFloat(at.z)); + int f = SimDefinitions.BuildingFootprintCells; + byte slot = _dispatcher.LocalSlot; + UnitState[] units = entities.RawUnits; + int capacity = entities.Capacity; + for (int i = 0; i < capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != slot) continue; + if (!construction.IsActiveSite(unit.Id)) continue; + + int originX = Nova.Core.SimFixed.WorldToGrid(unit.Transform.PositionX) - 1; + int originY = Nova.Core.SimFixed.WorldToGrid(unit.Transform.PositionY) - 1; + if (cx < originX || cx >= originX + f || cy < originY || cy >= originY + f) continue; + + ReadOnlySpan builders = BuilderSelection(out int builderCount); + if (builderCount == 0) return false; + + ConstructionSiteStatus.ApproachCell(originX, originY, f, ConstructionSystem.GridSize, + out int cellX, out int cellY); + ClearPursuitAndMarkHarvesters(builders); + _focusedAttackTarget = EntityId.Invalid; + Report("Move (build site)", _dispatcher.MoveTo(builders, + Nova.Core.SimFixed.FromInt(cellX), Nova.Core.SimFixed.FromInt(cellY))); + _markerView?.SpawnClickFeedback(at, ClickFeedbackKind.Move); + return true; + } + return false; + } + /// The plain move order; shared by RMB and the card's Move pick. private void ResolveMoveAt(Vector3 moveTo) { @@ -1636,7 +1723,7 @@ private void ResolveMoveAt(Vector3 moveTo) { ClearPursuitAndMarkHarvesters(mobile); _focusedAttackTarget = EntityId.Invalid; - Report("Move", _dispatcher.MoveTo(mobile, moveTo.x, moveTo.z)); + IssueGroupMove(mobile, moveTo); _markerView?.SpawnClickFeedback(moveTo, ClickFeedbackKind.Move); } } @@ -1662,10 +1749,9 @@ private void ResolveAttackAt(Vector3 attackAt) } else { - ClearPursuitAndMarkHarvesters(mobile); - _focusedAttackTarget = EntityId.Invalid; - Report("Move (no target)", _dispatcher.MoveTo(mobile, attackAt.x, attackAt.z)); - _markerView?.SpawnClickFeedback(attackAt, ClickFeedbackKind.Move); + // No enemy under the cursor: C&C attack-move (D-117) — drive + // there and fight whatever shows up on the way. + ResolveAttackMoveAt(attackAt); } } @@ -1715,6 +1801,7 @@ private void ClearPursuitAndMarkHarvesters(ReadOnlySpan entities) for (int i = 0; i < entities.Length; i++) { _pursuitTargets.Remove(entities[i]); + _standing.Clear(entities[i]); if (mgr != null && mgr.TryGetUnit(entities[i], out UnitState unit) && unit.Role == UnitRole.Harvester) { _autoAssignedHarvesters.Add(UnitCommandStateView.ToRawEntityId(unit.Id)); diff --git a/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs b/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs new file mode 100644 index 00000000..533dd4d7 --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs @@ -0,0 +1,686 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UIElements; +using Nova.Gameplay; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.Presentation.UI +{ + /// + /// The unit command bar (D-117): a Command & Conquer: Generals – + /// Zero Hour style control panel in the bottom-right corner, built in UI + /// Toolkit (D-083). Shown whenever the local player has mobile units + /// selected; the IMGUI command card keeps buildings and sites. + /// + /// Header: lead type and count, current formation. Tailored per + /// unit type: buttons that make no sense for the selection (patrol for a + /// harvester, formations without armed units) are HIDDEN, not greyed — + /// see . + /// Harvester selections show a cargo readout (fill bar, "Ladung x / y", + /// status line) instead. + /// Selection chips per unit type — click to keep only that type. + /// A 5×3 grid of square buttons with drawn icons and hotkey badges: + /// orders (move, attack-move, stop, guard, patrol), role actions (scatter, + /// harvest, deliver, repair, attack target) and five formations. + /// A tooltip line that names the hovered button and its hotkey, or + /// the armed pick. + /// + /// Icons are painted with — no texture assets. + /// + public sealed class UnitCommandBar : MonoBehaviour + { + private static readonly List Instances = new List(1); + + /// True while a bar shows a unit selection (the IMGUI card then stays out of the way). + public static bool ShowsUnits + { + get + { + for (int i = 0; i < Instances.Count; i++) if (Instances[i]._visible) return true; + return false; + } + } + + /// Pointer test in raw screen coordinates (Input.mousePosition), for the world-click lock. + public static bool IsPointerOverAny(Vector2 rawMouse) + { + for (int i = 0; i < Instances.Count; i++) if (Instances[i].IsPointerOver(rawMouse)) return true; + return false; + } + + [SerializeField] private UIDocument _document; + [SerializeField] private RtsDeviceInput _input; + [SerializeField] private BuildMenuHud _buildMenu; + [SerializeField] private Font _titleFont; + [SerializeField] private Font _bodyFont; + [SerializeField] private float _buttonSize = 58f; + [SerializeField] private float _margin = 12f; + + private const int Columns = 5; + + private enum Cmd + { + Move, AttackMove, Stop, Guard, Patrol, + Scatter, Harvest, Deliver, Repair, AttackTarget, + Loose, Line, Column, Wedge, Block, + } + + private sealed class Slot + { + public Cmd Cmd; + public VisualElement Root; + public VisualElement Icon; + public Label Hotkey; + public string Name; + public string Key; + public string Help; + public bool Enabled; + public bool Active; + public bool Visible = true; + } + + private readonly CommandCardPresenter _presenter = new CommandCardPresenter(); + private readonly SelectionGroup[] _groups = new SelectionGroup[16]; + private readonly UnitRole[] _roles = new UnitRole[16]; + private readonly List _slots = new List(15); + private VisualElement _bar; + private Label _title; + private Label _formationLabel; + private VisualElement _chips; + private Label _hint; + private VisualElement _cargoPanel; + private VisualElement _cargoFill; + private Label _cargoText; + private Label _cargoStatus; + private bool _visible; + private bool _built; + private string _hoverText; + private int _chipSignature = -1; + private Color _accent = new Color(0.28f, 0.78f, 0.92f); + + private void OnEnable() => Instances.Add(this); + + private void OnDisable() + { + Instances.Remove(this); + _visible = false; + } + + private void Update() + { + if (!_built) Build(); + if (_bar == null) return; + + bool show = ComputeVisible(out FactionId faction, out ReadOnlySpan selection); + if (show != _visible) + { + _visible = show; + _bar.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; + } + if (!show) return; + + _accent = faction == FactionId.Legion ? new Color(0.95f, 0.52f, 0.22f) : new Color(0.30f, 0.80f, 0.95f); + Refresh(faction, selection); + PlaceBar(); + } + + private bool ComputeVisible(out FactionId faction, out ReadOnlySpan selection) + { + faction = FactionId.Alliance; + selection = default; + if (_input == null || !_input.IsWired || ModalSurfaceLink.Open) return false; + MatchRunner runner = FindRunner(); + if (runner == null || !runner.IsRunning || runner.Entities == null) return false; + + selection = _input.Selection.SelectedEntities; + if (selection.Length == 0) return false; + byte slot = runner.Session != null ? runner.Session.LocalSlot : (byte)0; + faction = runner.Economy.GetSlotFaction(slot); + for (int i = 0; i < selection.Length; i++) + { + if (runner.Entities.TryGetUnit(selection[i], out UnitState u) + && u.PlayerId == slot && !SimDefinitions.IsBuildingRole(u.Role) && u.Role != UnitRole.Unit) + { + return true; + } + } + return false; + } + + private MatchRunner _runner; + + private MatchRunner FindRunner() + { + if (_runner == null) _runner = FindAnyObjectByType(); + return _runner; + } + + // ---------------------------------------------------------------- + // Build + // ---------------------------------------------------------------- + + private void Build() + { + if (_document == null || _document.rootVisualElement == null) return; + _built = true; + VisualElement root = _document.rootVisualElement; + + _bar = new VisualElement { name = "unit-command-bar", pickingMode = PickingMode.Position }; + _bar.style.position = Position.Absolute; + _bar.style.flexDirection = FlexDirection.Column; + _bar.style.paddingLeft = _bar.style.paddingRight = 10; + _bar.style.paddingTop = 8; + _bar.style.paddingBottom = 8; + _bar.style.backgroundColor = new Color(0.05f, 0.07f, 0.10f, 0.93f); + SetBorder(_bar, 1f, new Color(0.02f, 0.03f, 0.05f, 1f)); + _bar.style.borderTopWidth = 2f; + _bar.style.borderTopLeftRadius = _bar.style.borderTopRightRadius = 4f; + _bar.style.display = DisplayStyle.None; + root.Add(_bar); + + var header = new VisualElement(); + header.style.flexDirection = FlexDirection.Row; + header.style.justifyContent = Justify.SpaceBetween; + header.style.marginBottom = 4; + _title = MakeLabel(15, _titleFont, Color.white); + _formationLabel = MakeLabel(12, _bodyFont, new Color(0.75f, 0.8f, 0.85f)); + header.Add(_title); + header.Add(_formationLabel); + _bar.Add(header); + + _chips = new VisualElement(); + _chips.style.flexDirection = FlexDirection.Row; + _chips.style.flexWrap = Wrap.Wrap; + _chips.style.marginBottom = 6; + _bar.Add(_chips); + + BuildCargoPanel(); + + var grid = new VisualElement(); + grid.style.flexDirection = FlexDirection.Row; + grid.style.flexWrap = Wrap.Wrap; + grid.style.width = Columns * (_buttonSize + 4f); + _bar.Add(grid); + + AddSlot(grid, Cmd.Move, "Bewegen", "RMB", "Einheiten zum Zielpunkt schicken; die gewählte Formation gilt."); + AddSlot(grid, Cmd.AttackMove, "Angriffsbewegung", "A", "Zum Ziel fahren und unterwegs jeden sichtbaren Feind angreifen."); + AddSlot(grid, Cmd.Stop, "Stopp", "S", "Alle Befehle abbrechen und stehen bleiben."); + AddSlot(grid, Cmd.Guard, "Bewachen", "W", "Die Stellung halten: Feinde in der Nähe angreifen, danach zurückkehren."); + AddSlot(grid, Cmd.Patrol, "Patrouille", "K", "Zwischen jetziger Position und Ziel pendeln und unterwegs kämpfen."); + AddSlot(grid, Cmd.Scatter, "Verteilen", "J", "Die Gruppe auseinanderziehen — gegen Flächenschaden."); + AddSlot(grid, Cmd.Harvest, "Ernten", "H", "Sammler zu einem Aetherium-Feld schicken."); + AddSlot(grid, Cmd.Deliver, "Fracht abliefern", "R", "Sammler bringen ihre Ladung zur Raffinerie."); + AddSlot(grid, Cmd.Repair, "Reparieren", "", "Bauer reparieren ein beschädigtes eigenes Gebäude."); + AddSlot(grid, Cmd.AttackTarget, "Ziel angreifen", "", "Einen bestimmten Feind auswählen und angreifen."); + AddSlot(grid, Cmd.Loose, "Formation: Locker", "M", "Keine feste Aufstellung — die Gruppe verteilt sich ums Ziel."); + AddSlot(grid, Cmd.Line, "Formation: Linie", "M", "Breite Front quer zur Marschrichtung."); + AddSlot(grid, Cmd.Column, "Formation: Kolonne", "M", "Schmale Marschkolonne, zwei Einheiten breit."); + AddSlot(grid, Cmd.Wedge, "Formation: Keil", "M", "Speerspitze: eine Einheit vorn, die anderen gestaffelt dahinter."); + AddSlot(grid, Cmd.Block, "Formation: Block", "M", "Kompaktes Viereck."); + + _hint = MakeLabel(12, _bodyFont, new Color(0.82f, 0.86f, 0.9f)); + _hint.style.marginTop = 6; + _hint.style.whiteSpace = WhiteSpace.Normal; + _hint.style.width = Columns * (_buttonSize + 4f); + _hint.style.minHeight = 30; + _bar.Add(_hint); + } + + private static readonly Color AetheriumColor = new Color(0.35f, 0.9f, 1f); + + /// Harvester readout: a fill bar in Aetherium colour with the cargo text on it, status line below. + private void BuildCargoPanel() + { + _cargoPanel = new VisualElement { name = "cargo-panel", pickingMode = PickingMode.Ignore }; + _cargoPanel.style.flexDirection = FlexDirection.Column; + _cargoPanel.style.marginBottom = 8; + _cargoPanel.style.width = Columns * (_buttonSize + 4f); + _cargoPanel.style.display = DisplayStyle.None; + + var track = new VisualElement { pickingMode = PickingMode.Ignore }; + track.style.height = 26; + track.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 1f); + SetBorder(track, 1f, new Color(0.22f, 0.29f, 0.36f)); + track.style.borderTopLeftRadius = track.style.borderTopRightRadius = 3f; + track.style.borderBottomLeftRadius = track.style.borderBottomRightRadius = 3f; + + _cargoFill = new VisualElement { pickingMode = PickingMode.Ignore }; + _cargoFill.style.position = Position.Absolute; + _cargoFill.style.left = 0; + _cargoFill.style.top = 0; + _cargoFill.style.bottom = 0; + _cargoFill.style.width = new Length(0f, LengthUnit.Percent); + _cargoFill.style.backgroundColor = AetheriumColor; + track.Add(_cargoFill); + + _cargoText = MakeLabel(14, _titleFont, Color.white); + _cargoText.name = "cargo-text"; + _cargoText.style.position = Position.Absolute; + _cargoText.style.left = 0; + _cargoText.style.right = 0; + _cargoText.style.top = 0; + _cargoText.style.bottom = 0; + _cargoText.style.unityTextAlign = TextAnchor.MiddleCenter; + _cargoText.style.textShadow = new TextShadow { offset = new Vector2(1f, 1f), blurRadius = 2f, color = new Color(0f, 0f, 0f, 0.95f) }; + track.Add(_cargoText); + _cargoPanel.Add(track); + + _cargoStatus = MakeLabel(12, _bodyFont, new Color(0.82f, 0.9f, 0.95f)); + _cargoStatus.style.marginTop = 3; + _cargoPanel.Add(_cargoStatus); + _bar.Add(_cargoPanel); + } + + private void AddSlot(VisualElement grid, Cmd cmd, string name, string key, string help) + { + var slot = new Slot { Cmd = cmd, Name = name, Key = key, Help = help }; + var button = new VisualElement { name = $"cmd-{cmd}", pickingMode = PickingMode.Position }; + button.style.width = _buttonSize; + button.style.height = _buttonSize; + button.style.marginRight = 4; + button.style.marginBottom = 4; + button.style.borderTopLeftRadius = button.style.borderTopRightRadius = 3f; + button.style.borderBottomLeftRadius = button.style.borderBottomRightRadius = 3f; + SetBorder(button, 1f, new Color(0.2f, 0.27f, 0.34f)); + + var icon = new VisualElement { pickingMode = PickingMode.Ignore }; + icon.style.position = Position.Absolute; + icon.style.left = icon.style.top = icon.style.right = icon.style.bottom = 0; + icon.generateVisualContent += ctx => DrawIcon(ctx, slot); + button.Add(icon); + + var hotkey = MakeLabel(10, _bodyFont, new Color(1f, 1f, 1f, 0.85f)); + hotkey.text = key; + hotkey.pickingMode = PickingMode.Ignore; + hotkey.style.position = Position.Absolute; + hotkey.style.left = 3; + hotkey.style.top = 1; + button.Add(hotkey); + + button.RegisterCallback(_ => _hoverText = Describe(slot)); + button.RegisterCallback(_ => _hoverText = null); + button.RegisterCallback(e => + { + if (e.button != 0 || !slot.Enabled) return; + Execute(slot.Cmd); + e.StopPropagation(); + }); + + slot.Root = button; + slot.Icon = icon; + slot.Hotkey = hotkey; + _slots.Add(slot); + grid.Add(button); + } + + private static string Describe(Slot s) + { + string key = string.IsNullOrEmpty(s.Key) ? string.Empty : $" ({s.Key})"; + string state = s.Enabled ? string.Empty : " — für diese Auswahl nicht verfügbar"; + return $"{s.Name}{key}: {s.Help}{state}"; + } + + // ---------------------------------------------------------------- + // Refresh + // ---------------------------------------------------------------- + + private void Refresh(FactionId faction, ReadOnlySpan selection) + { + MatchRunner runner = FindRunner(); + int groupCount = _presenter.SummarizeSelection(selection, runner.Entities, _groups); + int roleCount = 0; + int mobile = 0; + int harvesters = 0; + UnitRole leadRole = UnitRole.Unit; + int signature = 17; + for (int i = 0; i < groupCount; i++) + { + if (SimDefinitions.IsBuildingRole(_groups[i].Role)) continue; + if (mobile == 0) leadRole = _groups[i].Role; + mobile += _groups[i].Count; + if (_groups[i].Role == UnitRole.Harvester) harvesters += _groups[i].Count; + _roles[roleCount++] = _groups[i].Role; + signature = signature * 31 + (int)_groups[i].Role * 101 + _groups[i].Count; + } + + BarCommand shown = _presenter.GetBarCommands(faction, _roles.AsSpan(0, roleCount)); + bool harvesterOnly = mobile > 0 && harvesters == mobile; + + if (harvesterOnly && mobile > 1) _title.text = $"{mobile} Sammler"; + else if (mobile <= 1) _title.text = CommandCardPresenter.UnitDisplayName(faction, leadRole); + else _title.text = $"{CommandCardPresenter.UnitDisplayName(faction, leadRole)} — {mobile} Einheiten"; + + bool formations = shown.HasFlag(BarCommand.Formations); + _formationLabel.style.display = formations ? DisplayStyle.Flex : DisplayStyle.None; + _formationLabel.text = $"Formation: {FormationPlanner.DisplayName(_input.Formation)}"; + + _cargoPanel.style.display = harvesterOnly ? DisplayStyle.Flex : DisplayStyle.None; + if (harvesterOnly) RefreshCargo(faction, selection, runner); + _bar.style.borderTopColor = _accent; + + if (signature != _chipSignature) + { + _chipSignature = signature; + RebuildChips(faction, groupCount); + } + + StandingOrderKind standing = _input.SelectionStandingOrder; + string armedPick = _input.ArmedOrderName; + + foreach (Slot s in _slots) + { + switch (s.Cmd) + { + case Cmd.Move: Set(s, shown.HasFlag(BarCommand.Move), armedPick == "Bewegen"); break; + case Cmd.AttackMove: Set(s, shown.HasFlag(BarCommand.AttackMove), armedPick == "Angriffsbewegung" || standing == StandingOrderKind.AttackMove); break; + case Cmd.Stop: Set(s, shown.HasFlag(BarCommand.Stop), false); break; + case Cmd.Guard: Set(s, shown.HasFlag(BarCommand.Guard), standing == StandingOrderKind.Guard); break; + case Cmd.Patrol: Set(s, shown.HasFlag(BarCommand.Patrol), armedPick == "Patrouille" || standing == StandingOrderKind.Patrol); break; + case Cmd.Scatter: Set(s, shown.HasFlag(BarCommand.Scatter), false, mobile > 1); break; + case Cmd.Harvest: Set(s, shown.HasFlag(BarCommand.Harvest), armedPick == "Ernten"); break; + case Cmd.Deliver: Set(s, shown.HasFlag(BarCommand.ReturnCargo), false); break; + case Cmd.Repair: Set(s, shown.HasFlag(BarCommand.Repair), armedPick == "Reparieren"); break; + case Cmd.AttackTarget: Set(s, shown.HasFlag(BarCommand.AttackTarget), armedPick == "Angriff"); break; + case Cmd.Loose: Set(s, formations, _input.Formation == FormationKind.Loose); break; + case Cmd.Line: Set(s, formations, _input.Formation == FormationKind.Line); break; + case Cmd.Column: Set(s, formations, _input.Formation == FormationKind.Column); break; + case Cmd.Wedge: Set(s, formations, _input.Formation == FormationKind.Wedge); break; + case Cmd.Block: Set(s, formations, _input.Formation == FormationKind.Block); break; + } + } + + if (_hoverText != null) _hint.text = _hoverText; + else if (!string.IsNullOrEmpty(armedPick)) _hint.text = $"{armedPick}: Ziel wählen — Linksklick bestätigt, Rechtsklick bricht ab"; + else _hint.text = harvesterOnly ? "Rechtsklick: bewegen · H: ernten · R: Fracht abliefern" : StandingText(standing); + } + + private void RefreshCargo(FactionId faction, ReadOnlySpan selection, MatchRunner runner) + { + HarvesterSummary sum = _presenter.SummarizeHarvesters(faction, selection, runner.Entities); + _cargoFill.style.width = new Length(sum.Fill * 100f, LengthUnit.Percent); + _cargoText.text = CommandCardPresenter.FormatCargo(sum.CargoAE, sum.CapacityAE); + _cargoStatus.text = CommandCardPresenter.FormatHarvesterStatusLine(in sum); + } + + private static string StandingText(StandingOrderKind standing) + { + switch (standing) + { + case StandingOrderKind.AttackMove: return "Auftrag: Angriffsbewegung läuft"; + case StandingOrderKind.Patrol: return "Auftrag: Patrouille läuft"; + case StandingOrderKind.Guard: return "Auftrag: Stellung bewachen"; + default: return "Rechtsklick: bewegen · A: Angriffsbewegung · Formation mit M wechseln"; + } + } + + /// Visible buttons are clickable (hidden ones take no grid space); can still grey a shown one (scatter of a single unit). + private void Set(Slot s, bool visible, bool active, bool usable = true) + { + bool enabled = visible && usable; + if (s.Visible == visible && s.Enabled == enabled && s.Active == active && s.Root.userData != null) return; + s.Root.userData = this; // first refresh always paints + s.Visible = visible; + s.Root.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; + s.Enabled = enabled; + s.Active = active; + Color fill = !enabled ? new Color(0.07f, 0.08f, 0.10f, 0.9f) + : active ? Color.Lerp(new Color(0.10f, 0.14f, 0.19f), _accent, 0.35f) + : new Color(0.10f, 0.14f, 0.19f, 0.97f); + s.Root.style.backgroundColor = fill; + SetBorder(s.Root, active ? 2f : 1f, active ? _accent : new Color(0.22f, 0.29f, 0.36f)); + s.Hotkey.style.color = enabled ? new Color(1f, 1f, 1f, 0.85f) : new Color(1f, 1f, 1f, 0.25f); + s.Icon.MarkDirtyRepaint(); + } + + private void RebuildChips(FactionId faction, int groupCount) + { + _chips.Clear(); + for (int i = 0; i < groupCount; i++) + { + SelectionGroup g = _groups[i]; + if (SimDefinitions.IsBuildingRole(g.Role)) continue; + UnitRole role = g.Role; + var chip = new Label($"{CommandCardPresenter.UnitDisplayName(faction, role)} ×{g.Count}"); + if (_bodyFont != null) chip.style.unityFontDefinition = FontDefinition.FromFont(_bodyFont); + chip.style.fontSize = 12; + chip.style.color = Color.white; + chip.style.backgroundColor = new Color(0.12f, 0.17f, 0.23f); + SetBorder(chip, 1f, HudChrome.RoleAccentColor(role)); + chip.style.paddingLeft = chip.style.paddingRight = 6; + chip.style.paddingTop = chip.style.paddingBottom = 1; + chip.style.marginRight = 4; + chip.style.marginBottom = 3; + chip.style.borderTopLeftRadius = chip.style.borderTopRightRadius = 3f; + chip.style.borderBottomLeftRadius = chip.style.borderBottomRightRadius = 3f; + chip.RegisterCallback(_ => _hoverText = "Klick: nur diesen Typ auswählen"); + chip.RegisterCallback(_ => _hoverText = null); + chip.RegisterCallback(e => + { + if (e.button != 0) return; + _input.FilterSelectionToRole(role); + e.StopPropagation(); + }); + _chips.Add(chip); + } + } + + private void Execute(Cmd cmd) + { + switch (cmd) + { + case Cmd.Move: _input.RequestMoveOrder(); break; + case Cmd.AttackMove: _input.RequestAttackMoveOrder(); break; + case Cmd.Stop: _input.OrderStop(); break; + case Cmd.Guard: _input.OrderGuard(); break; + case Cmd.Patrol: _input.RequestPatrolOrder(); break; + case Cmd.Scatter: _input.OrderScatter(); break; + case Cmd.Harvest: _input.RequestHarvestOrder(); break; + case Cmd.Deliver: _input.OrderReturnCargo(); break; + case Cmd.Repair: _input.RequestRepairOrder(); break; + case Cmd.AttackTarget: _input.RequestAttackOrder(); break; + case Cmd.Loose: _input.SetFormation(FormationKind.Loose); break; + case Cmd.Line: _input.SetFormation(FormationKind.Line); break; + case Cmd.Column: _input.SetFormation(FormationKind.Column); break; + case Cmd.Wedge: _input.SetFormation(FormationKind.Wedge); break; + case Cmd.Block: _input.SetFormation(FormationKind.Block); break; + } + } + + /// Bottom-right, directly above the build bar's reserve (screen px converted to panel px). + private void PlaceBar() + { + IPanel panel = _bar.panel; + if (panel == null) return; + float reserveScreen = _buildMenu != null ? _buildMenu.OccupiedHeight * Mathf.Max(1f, _buildMenu.UiScale) : 0f; + Vector2 origin = RuntimePanelUtils.ScreenToPanel(panel, new Vector2(0f, 0f)); + Vector2 reserve = RuntimePanelUtils.ScreenToPanel(panel, new Vector2(0f, reserveScreen)); + _bar.style.right = _margin; + _bar.style.bottom = (reserve.y - origin.y) + _margin; + } + + private bool IsPointerOver(Vector2 rawMouse) + { + if (!_visible || _bar == null || _bar.panel == null) return false; + Vector2 p = RuntimePanelUtils.ScreenToPanel(_bar.panel, new Vector2(rawMouse.x, Screen.height - rawMouse.y)); + return _bar.worldBound.Contains(p); + } + + // ---------------------------------------------------------------- + // Icons + // ---------------------------------------------------------------- + + private void DrawIcon(MeshGenerationContext ctx, Slot slot) + { + Rect r = slot.Icon.contentRect; + if (r.width < 4f) return; + Painter2D p = ctx.painter2D; + Color ink = slot.Enabled ? (slot.Active ? Color.white : new Color(0.85f, 0.92f, 0.97f)) : new Color(0.5f, 0.55f, 0.6f, 0.4f); + p.strokeColor = ink; + p.fillColor = ink; + p.lineWidth = Mathf.Max(2f, r.width * 0.055f); + p.lineCap = LineCap.Round; + p.lineJoin = LineJoin.Round; + var c = new Vector2(r.width * 0.5f, r.height * 0.56f); + float u = r.width * 0.30f; + + switch (slot.Cmd) + { + case Cmd.Move: Arrow(p, c + new Vector2(-u, u), c + new Vector2(u, -u), u * 0.45f); break; + case Cmd.AttackMove: + Arrow(p, c + new Vector2(-u, u), c + new Vector2(u * 0.4f, -u * 0.4f), u * 0.4f); + Crosshair(p, c + new Vector2(u * 0.55f, -u * 0.55f), u * 0.42f, _accent); + p.strokeColor = ink; + break; + case Cmd.Stop: Octagon(p, c, u * 0.95f, ink); break; + case Cmd.Guard: Shield(p, c, u); break; + case Cmd.Patrol: Loop(p, c, u * 0.8f); break; + case Cmd.Scatter: + for (int i = 0; i < 4; i++) + { + float a = Mathf.PI * 0.25f + i * Mathf.PI * 0.5f; + var d = new Vector2(Mathf.Cos(a), Mathf.Sin(a)); + Arrow(p, c + d * u * 0.25f, c + d * u * 1.05f, u * 0.3f); + } + break; + case Cmd.Harvest: Crystal(p, c, u, new Color(0.35f, 0.9f, 1f, slot.Enabled ? 1f : 0.35f)); break; + case Cmd.Deliver: + Box(p, c + new Vector2(0, u * 0.35f), u * 0.75f); + Arrow(p, c + new Vector2(0, -u * 1.05f), c + new Vector2(0, -u * 0.05f), u * 0.35f); + break; + case Cmd.Repair: Wrench(p, c, u); break; + case Cmd.AttackTarget: Crosshair(p, c, u * 0.9f, ink); break; + case Cmd.Loose: Dots(p, c, u, new[] { new Vector2(-0.8f, -0.5f), new Vector2(0.3f, -0.9f), new Vector2(0.9f, 0.1f), new Vector2(-0.2f, 0.4f), new Vector2(-0.9f, 0.8f), new Vector2(0.6f, 0.9f) }); break; + case Cmd.Line: Dots(p, c, u, new[] { new Vector2(-1f, 0f), new Vector2(-0.5f, 0f), new Vector2(0f, 0f), new Vector2(0.5f, 0f), new Vector2(1f, 0f) }); break; + case Cmd.Column: Dots(p, c, u, new[] { new Vector2(-0.3f, -1f), new Vector2(0.3f, -1f), new Vector2(-0.3f, -0.33f), new Vector2(0.3f, -0.33f), new Vector2(-0.3f, 0.33f), new Vector2(0.3f, 0.33f), new Vector2(-0.3f, 1f), new Vector2(0.3f, 1f) }); break; + case Cmd.Wedge: Dots(p, c, u, new[] { new Vector2(0f, -0.9f), new Vector2(-0.45f, -0.3f), new Vector2(0.45f, -0.3f), new Vector2(-0.9f, 0.3f), new Vector2(0.9f, 0.3f) }); break; + case Cmd.Block: Dots(p, c, u, new[] { new Vector2(-0.7f, -0.7f), new Vector2(0f, -0.7f), new Vector2(0.7f, -0.7f), new Vector2(-0.7f, 0f), new Vector2(0f, 0f), new Vector2(0.7f, 0f), new Vector2(-0.7f, 0.7f), new Vector2(0f, 0.7f), new Vector2(0.7f, 0.7f) }); break; + } + } + + private static void Arrow(Painter2D p, Vector2 from, Vector2 to, float head) + { + p.BeginPath(); p.MoveTo(from); p.LineTo(to); p.Stroke(); + Vector2 d = (to - from).normalized; + var n = new Vector2(-d.y, d.x); + p.BeginPath(); + p.MoveTo(to - d * head + n * head * 0.6f); + p.LineTo(to); + p.LineTo(to - d * head - n * head * 0.6f); + p.Stroke(); + } + + private static void Crosshair(Painter2D p, Vector2 c, float r, Color color) + { + p.strokeColor = color; + p.BeginPath(); p.Arc(c, r * 0.7f, 0f, 360f); p.Stroke(); + p.BeginPath(); p.MoveTo(c + new Vector2(-r, 0)); p.LineTo(c + new Vector2(-r * 0.35f, 0)); p.Stroke(); + p.BeginPath(); p.MoveTo(c + new Vector2(r, 0)); p.LineTo(c + new Vector2(r * 0.35f, 0)); p.Stroke(); + p.BeginPath(); p.MoveTo(c + new Vector2(0, -r)); p.LineTo(c + new Vector2(0, -r * 0.35f)); p.Stroke(); + p.BeginPath(); p.MoveTo(c + new Vector2(0, r)); p.LineTo(c + new Vector2(0, r * 0.35f)); p.Stroke(); + } + + private static void Octagon(Painter2D p, Vector2 c, float r, Color ink) + { + p.fillColor = new Color(0.85f, 0.2f, 0.18f, ink.a); + p.BeginPath(); + for (int i = 0; i < 8; i++) + { + float a = Mathf.PI / 8f + i * Mathf.PI / 4f; + var v = c + new Vector2(Mathf.Cos(a), Mathf.Sin(a)) * r; + if (i == 0) p.MoveTo(v); else p.LineTo(v); + } + p.ClosePath(); + p.Fill(); + p.Stroke(); + } + + private static void Shield(Painter2D p, Vector2 c, float u) + { + p.BeginPath(); + p.MoveTo(c + new Vector2(0, -u)); + p.LineTo(c + new Vector2(u * 0.85f, -u * 0.6f)); + p.LineTo(c + new Vector2(u * 0.7f, u * 0.35f)); + p.LineTo(c + new Vector2(0, u)); + p.LineTo(c + new Vector2(-u * 0.7f, u * 0.35f)); + p.LineTo(c + new Vector2(-u * 0.85f, -u * 0.6f)); + p.ClosePath(); + p.Stroke(); + p.BeginPath(); p.MoveTo(c + new Vector2(0, -u * 0.55f)); p.LineTo(c + new Vector2(0, u * 0.55f)); p.Stroke(); + } + + private static void Loop(Painter2D p, Vector2 c, float r) + { + p.BeginPath(); p.Arc(c, r, 200f, 340f); p.Stroke(); + p.BeginPath(); p.Arc(c, r, 20f, 160f); p.Stroke(); + Vector2 e1 = c + new Vector2(Mathf.Cos(340f * Mathf.Deg2Rad), Mathf.Sin(340f * Mathf.Deg2Rad)) * r; + Vector2 e2 = c + new Vector2(Mathf.Cos(160f * Mathf.Deg2Rad), Mathf.Sin(160f * Mathf.Deg2Rad)) * r; + Arrow(p, e1 + new Vector2(-r * 0.3f, -r * 0.25f), e1, r * 0.4f); + Arrow(p, e2 + new Vector2(r * 0.3f, r * 0.25f), e2, r * 0.4f); + } + + private static void Crystal(Painter2D p, Vector2 c, float u, Color color) + { + p.fillColor = color; + p.BeginPath(); + p.MoveTo(c + new Vector2(0, -u)); + p.LineTo(c + new Vector2(u * 0.55f, -u * 0.2f)); + p.LineTo(c + new Vector2(0, u)); + p.LineTo(c + new Vector2(-u * 0.55f, -u * 0.2f)); + p.ClosePath(); + p.Fill(); + } + + private static void Box(Painter2D p, Vector2 c, float h) + { + p.BeginPath(); + p.MoveTo(c + new Vector2(-h, -h * 0.6f)); + p.LineTo(c + new Vector2(h, -h * 0.6f)); + p.LineTo(c + new Vector2(h, h * 0.6f)); + p.LineTo(c + new Vector2(-h, h * 0.6f)); + p.ClosePath(); + p.Stroke(); + } + + private static void Wrench(Painter2D p, Vector2 c, float u) + { + Vector2 a = c + new Vector2(-u * 0.75f, u * 0.75f); + Vector2 b = c + new Vector2(u * 0.35f, -u * 0.35f); + p.BeginPath(); p.MoveTo(a); p.LineTo(b); p.Stroke(); + p.BeginPath(); p.Arc(c + new Vector2(u * 0.55f, -u * 0.55f), u * 0.38f, 160f, 470f); p.Stroke(); + } + + private static void Dots(Painter2D p, Vector2 c, float u, Vector2[] at) + { + float r = u * 0.14f; + for (int i = 0; i < at.Length; i++) + { + p.BeginPath(); + p.Arc(c + at[i] * u * 0.9f, r, 0f, 360f); + p.Fill(); + } + } + + // ---------------------------------------------------------------- + + private static Label MakeLabel(int size, Font font, Color color) + { + var l = new Label { pickingMode = PickingMode.Ignore }; + l.style.fontSize = size; + l.style.color = color; + if (font != null) l.style.unityFontDefinition = FontDefinition.FromFont(font); + return l; + } + + private static void SetBorder(VisualElement e, float width, Color color) + { + e.style.borderLeftWidth = e.style.borderRightWidth = width; + e.style.borderTopWidth = e.style.borderBottomWidth = width; + e.style.borderLeftColor = e.style.borderRightColor = color; + e.style.borderTopColor = e.style.borderBottomColor = color; + } + } +} diff --git a/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs.meta b/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs.meta new file mode 100644 index 00000000..28c10990 --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/UnitCommandBar.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0de155872e7140d4805cfa8284133c55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/tech/InputSystem.md b/docs/tech/InputSystem.md index 7aea21e7..3d191349 100644 --- a/docs/tech/InputSystem.md +++ b/docs/tech/InputSystem.md @@ -1,6 +1,6 @@ # Eingabesystem -**Version:** 1.0.0 | **Status:** verbindlich für MS-1 – G0 aktiv | **Verantwortungsbereich:** Lead UI/UX Designer | **Sprint:** 7 +**Version:** 1.1.0 | **Status:** verbindlich für MS-1 – G0 aktiv | **Verantwortungsbereich:** Lead UI/UX Designer | **Sprint:** 7 ## Zweck @@ -49,6 +49,17 @@ MS-1 benötigt: - SetRallyPoint; - InstallDefenseModule. +**Gruppenbefehle ohne neue CommandKinds (D-117):** Angriffsbewegung, +Patrouille, Bewachen, Verteilen und Formationen sind lokale Aufträge, die der +Client in die bestehenden Kinds Move, Stop und AttackTarget übersetzt — wie die +Angriffsverfolgung. Formationen schicken einen Move je Einheit auf ihren Platz; +Daueraufträge (`StandingOrders`) prüfen alle 0,5 s, ob ein sichtbarer Feind +bekämpft oder das nächste Ziel angefahren werden muss. Der Feindblick ist +nebelgebunden. Tasten: **A** auf freien Boden = Angriffsbewegung, **W** +Bewachen, **K** Patrouille, **J** Verteilen, **M** Formation wechseln, **S** +Stopp. Die Befehlsleiste (`UnitCommandBar`, UI Toolkit nach D-083) zeigt alle +Befehle mit Kürzel und Tooltip. + Pause, Save und Load sind versionierte Session-Aktionen an einer Tickgrenze, keine direkte State-Mutation. Zurückgestellte Ability-, Research-, Capture-, Luft-, Mauer- und Superweapon-Aktionen sind nicht aktiv. @@ -109,3 +120,4 @@ Rohes Binding-JSON ist kein Replay-/Save-Payload. |---|---|---|---| | 0.1.0 | 2026-07-21 | Erstfassung | Lead UI/UX Designer | | 1.0.0 | 2026-07-24 | Input auf CommandIntent-Vertrauensgrenze, ≤100-ms-Feedback, Rebinding und MS-1-Accessibility rebaselined | Lead UI/UX Designer | +| 1.1.0 | 2026-10-17 | Gruppenbefehle (Angriffsbewegung, Patrouille, Bewachen, Verteilen, Formationen) als Client-Aufträge über Move/Stop/AttackTarget, Tastenbelegung, Befehlsleiste (D-117) | Orchestrator | diff --git a/docs/vision/CoreGameplay.md b/docs/vision/CoreGameplay.md index 5773b857..4cd72523 100644 --- a/docs/vision/CoreGameplay.md +++ b/docs/vision/CoreGameplay.md @@ -1,6 +1,6 @@ # Core Gameplay – Spielgefühl, Kamera, Steuerung, UI -**Version:** 0.2.0 | **Status:** Entwurf (Korrekturlauf Sprint 2) | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 2 +**Version:** 0.2.1 | **Status:** Entwurf (Korrekturlauf Sprint 2) | **Verantwortungsbereich:** Lead Gameplay Designer | **Sprint:** 2 ## Zweck @@ -34,7 +34,7 @@ Feel-Bausteine (abstimmungsrelevant für Art/Audio): |---|---|---| | Befehlsmarker | 0,5 s Einblendung, fraktionsneutrale Form, Teamfarbe | Sofort-Feedback ohne Visuellen Lärm | | Auswahl-Voice | 1 kurze Zeile pro Einheitentyp, Cooldown 3 s | C&C-Nostalgie (H1), kein Voice-Spam | -| Zerstörungs-Feedback | Wrack bleibt 20–30 s liegen (D-012), danach Fade | Schlachtfeld liest sich als "Geschichte" des Gefechts | +| Zerstörungs-Feedback | Wrack bleibt liegen (D-115; ältere versinken erst ab 60 Einheiten- bzw. 20 Gebäudewracks, Neubau räumt die Fläche) | Schlachtfeld liest sich als "Geschichte" des Gefechts | | Treffer-Feedback | Zahleneinblendungen **aus**; Gesundheitsbalken + Trefferblitz | Zahlenflut widerspricht Lesbarkeitsprinzip | | Spielgeschwindigkeit | 1,0 fest im Skirmish; Pause nur im Solo | SP-first (D-007), keine Speed-Debatten | @@ -162,3 +162,4 @@ Zwei Profile: **Standard** (alle Elemente) und **Kompakt** (Minimap und Befehlsl |---|---|---|---| | 0.1.0 | 2026-07-21 | Erstfassung | Lead Gameplay Designer | | 0.2.0 | 2026-07-21 | Korrekturlauf Sprint 2 (D-020–D-030): Kamera-Rotation erst ab Beta (D-029), kein Ingame-Voice-Chat (D-029), D-025-Referenz (Alpha-FFA lokal/Online ab Beta), Aggressions-Modi für Verteidigungsplattformen festgelegt, Streuen mit Playtest-Vorbehalt bestätigt, Links repariert (Units.md → Infantry/Vehicles/Aircraft) | Lead Gameplay Designer | +| 0.2.1 | 2026-10-17 | Zerstörungs-Feedback: Wracks bleiben liegen statt 20–30 s (D-115) | Technical Art | From a9e2602055cafa9ce9a77b9bc5c10769f5319240 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 00:04:29 +0200 Subject: [PATCH 10/13] feat(art): wrecks, class scale, harvester cargo, rebuilt Legion buildings - Every asset gets a wreck variant that the view switches to on death, plus ambient motion: dust trails, chimney smoke, idle turret sweeps (D-115). - One scale rule per class keeps vehicle proportions; the selection ring follows the model size (D-113). - Harvesters carry a visible ore load with Aetherium crystals that grows with CargoAE; on unload the Legion bed (new Bed part) tips back and the Alliance robot leans back while the load slides out. - Legion power plant, vehicle factory, lab and barracks were tanks or broken Tripo meshes; they are now built procedurally from new concept art (tools/art/procedural). Hunyuan3D was tried and rejected because its licence excludes the EU (D-119). glb_to_unity_fbx gains --keep-slivers. The art binaries stay local (gitignored); the asset package must be rebuilt to share them. Co-Authored-By: Claude Opus 5.5 --- .../EditMode/Gameplay/ArtViewScaleTests.cs | 65 +++ .../Gameplay/ArtViewScaleTests.cs.meta | 11 + .../Gameplay/HarvesterDumpCycleTests.cs | 74 ++++ .../Gameplay/HarvesterDumpCycleTests.cs.meta | 2 + .../HarvesterCargoPresentationTests.cs | 115 +++++ .../HarvesterCargoPresentationTests.cs.meta | 2 + .../PlayMode/UnitMotionPresentationTests.cs | 60 +++ .../Tests/PlayMode/WreckPresentationTests.cs | 148 +++++++ .../PlayMode/WreckPresentationTests.cs.meta | 11 + .../Buildings/Legion/Barracks/PROVENANCE.json | 39 +- .../Buildings/Legion/Power/PROVENANCE.json | 40 +- .../Legion/ResearchLab/PROVENANCE.json | 39 +- .../Legion/VehicleFactory/PROVENANCE.json | 40 +- .../_Project/Editor/ArtAssetPrefabBuilder.cs | 53 +++ Assets/_Project/Editor/ArtPreviewCapture.cs | 133 +++++- .../Scripts/Gameplay/Match/AmbientFx.cs | 163 +++++++ .../Scripts/Gameplay/Match/AmbientFx.cs.meta | 2 + .../Scripts/Gameplay/Match/ArtViewScale.cs | 82 ++++ .../Gameplay/Match/ArtViewScale.cs.meta | 11 + .../Scripts/Gameplay/Match/HarvesterCargo.cs | 389 +++++++++++++++++ .../Gameplay/Match/HarvesterCargo.cs.meta | 2 + .../Scripts/Gameplay/Match/UnitRigAnimator.cs | 397 ++++++++++++++++- .../Scripts/Gameplay/Match/UnitViewManager.cs | 170 +++++--- .../Scripts/Gameplay/Match/ViewWrecks.cs | 277 ++++++++++++ .../Scripts/Gameplay/Match/ViewWrecks.cs.meta | 11 + .../Presentation/UI/SelectionMarkerView.cs | 8 +- tools/art/asset_pass.py | 16 + tools/art/asset_recipes.json | 75 +++- tools/art/asset_wreck.py | 405 ++++++++++++++++++ tools/art/glb_to_unity_fbx.py | 5 +- tools/art/procedural/README.md | 28 ++ tools/art/procedural/bake_projected.py | 74 ++++ tools/art/procedural/build_all.sh | 30 ++ tools/art/procedural/build_barracks.py | 82 ++++ tools/art/procedural/build_factory.py | 34 ++ tools/art/procedural/build_lab.py | 87 ++++ tools/art/procedural/build_power.py | 36 ++ tools/art/procedural/kit.py | 52 +++ tools/art/procedural/prep_textures.py | 39 ++ 39 files changed, 3153 insertions(+), 154 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta create mode 100644 Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta create mode 100644 Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs create mode 100644 Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs.meta create mode 100644 Assets/Tests/PlayMode/WreckPresentationTests.cs create mode 100644 Assets/Tests/PlayMode/WreckPresentationTests.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs.meta create mode 100644 tools/art/asset_wreck.py create mode 100644 tools/art/procedural/README.md create mode 100644 tools/art/procedural/bake_projected.py create mode 100755 tools/art/procedural/build_all.sh create mode 100644 tools/art/procedural/build_barracks.py create mode 100644 tools/art/procedural/build_factory.py create mode 100644 tools/art/procedural/build_lab.py create mode 100644 tools/art/procedural/build_power.py create mode 100644 tools/art/procedural/kit.py create mode 100644 tools/art/procedural/prep_textures.py diff --git a/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs new file mode 100644 index 00000000..30af64fd --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs @@ -0,0 +1,65 @@ +using NUnit.Framework; +using UnityEngine; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; + +namespace Nova.Gameplay.Tests +{ + /// + /// Map size of art models (EditMode lane, D-113): buildings fill their + /// footprint, units keep their authored proportions under one metric + /// factor, small units get a readable minimum height. + /// + [TestFixture] + public class ArtViewScaleTests + { + [Test] + public void Building_ShrinksToFootprintWidth() + { + float f = ArtViewScale.Factor(UnitRole.Barracks, new Vector3(9f, 8f, 6f)); + Assert.That(9f * f, Is.EqualTo(SimDefinitions.BuildingFootprintCells).Within(1e-4f)); + } + + [Test] + public void Building_SmallerThanFootprint_KeepsAuthoredSize() + { + Assert.That(ArtViewScale.Factor(UnitRole.Storage, new Vector3(2f, 2f, 2f)), Is.EqualTo(1f)); + } + + [Test] + public void Vehicles_ShareOneFactor_SoTheirRatiosSurvive() + { + float tank = ArtViewScale.Factor(UnitRole.BattleTank, new Vector3(7.5f, 6.5f, 7.5f)); + float scout = ArtViewScale.Factor(UnitRole.ScoutVehicle, new Vector3(2.8f, 4f, 4.5f)); + Assert.That(tank, Is.EqualTo(scout)); + Assert.That(7.5f * tank, Is.LessThan(SimDefinitions.BuildingFootprintCells * 0.65f), + "a battle tank must read clearly smaller than a building"); + } + + [Test] + public void Infantry_GetsTheMinimumHeight_IndependentOfWeaponWidth() + { + float rifle = ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(0.8f, 1.8f, 0.9f)); + float wideLauncher = ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(1.5f, 1.8f, 0.9f)); + Assert.That(1.8f * rifle, Is.EqualTo(ArtViewScale.MinViewHeight(UnitRole.BasicInfantry)).Within(1e-4f)); + Assert.That(wideLauncher, Is.EqualTo(rifle), "width must not shrink a soldier"); + } + + [Test] + public void Infantry_StaysShorterThanABattleTank() + { + float tankHeight = 6.5f * ArtViewScale.Factor(UnitRole.BattleTank, new Vector3(7.5f, 6.5f, 7.5f)); + float soldier = 1.8f * ArtViewScale.Factor(UnitRole.BasicInfantry, new Vector3(0.8f, 1.8f, 0.9f)); + Assert.That(soldier, Is.LessThan(tankHeight * 0.7f)); + } + + [Test] + public void TryFactor_ResolvesRoleNames_AndRejectsUnknownOnes() + { + Assert.That(ArtViewScale.TryFactor("LightTank", Vector3.one, out float f), Is.True); + Assert.That(f, Is.EqualTo(ArtViewScale.UnitCellsPerMetre)); + Assert.That(ArtViewScale.TryFactor("Zeppelin", Vector3.one, out _), Is.False); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta new file mode 100644 index 00000000..3bae6b56 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/ArtViewScaleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 475b6bd96c894dcebfd61f9e3c9a7b4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs new file mode 100644 index 00000000..1d799b21 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs @@ -0,0 +1,74 @@ +using NUnit.Framework; +using Nova.Gameplay.Match; + +namespace Nova.Gameplay.Tests +{ + /// + /// The unload timeline of a harvester view (pure logic): ease in/out of + /// the bed, the pile letting go while it is tipped, a total of about two seconds. + /// + [TestFixture] + public class HarvesterDumpCycleTests + { + private static HarvesterDumpCycle Run(float seconds) + { + var c = new HarvesterDumpCycle(); + c.Start(); + const float step = 1f / 60f; + // Whole frames plus the remainder: a float accumulator stops up to a frame short. + int frames = (int)(seconds / step); + for (int i = 0; i < frames; i++) c.Advance(step); + c.Advance(seconds - frames * step); + return c; + } + + [Test] + public void Idle_IsFlatAndInactive() + { + var c = new HarvesterDumpCycle(); + Assert.IsFalse(c.Active); + Assert.AreEqual(0f, c.Tilt01); + Assert.AreEqual(0f, c.Release01); + } + + [Test] + public void Tilt_RisesWithEaseInOut_HoldsAndReturns() + { + Assert.That(Run(0.05f).Tilt01, Is.LessThan(0.02f), "ease-in: slow start"); + Assert.That(Run(HarvesterDumpCycle.TiltUpSeconds * 0.5f).Tilt01, Is.EqualTo(0.5f).Within(0.03f)); + Assert.That(Run(HarvesterDumpCycle.TiltUpSeconds + 0.2f).Tilt01, Is.EqualTo(1f).Within(1e-3f), "hold"); + float back = Run(HarvesterDumpCycle.TiltUpSeconds + HarvesterDumpCycle.HoldSeconds + HarvesterDumpCycle.ReturnSeconds * 0.5f).Tilt01; + Assert.That(back, Is.EqualTo(0.5f).Within(0.03f), "halfway back at half the return time"); + } + + [Test] + public void Release_StartsWhenTipped_AndFinishes() + { + Assert.AreEqual(0f, Run(HarvesterDumpCycle.ReleaseStartSeconds - 0.02f).Release01, 1e-4f); + float mid = Run(HarvesterDumpCycle.ReleaseStartSeconds + HarvesterDumpCycle.ReleaseSeconds * 0.5f).Release01; + Assert.That(mid, Is.EqualTo(0.25f).Within(0.03f), "accelerating slide"); + Assert.That(Run(HarvesterDumpCycle.ReleaseStartSeconds + HarvesterDumpCycle.ReleaseSeconds + 0.02f).Release01, Is.EqualTo(1f).Within(1e-3f)); + } + + [Test] + public void Cycle_EndsAfterAboutTwoSeconds() + { + Assert.That(HarvesterDumpCycle.TotalSeconds, Is.InRange(1.7f, 2.1f)); + Assert.IsTrue(Run(HarvesterDumpCycle.TotalSeconds - 0.05f).Active); + var done = Run(HarvesterDumpCycle.TotalSeconds + 0.1f); + Assert.IsFalse(done.Active); + Assert.AreEqual(0f, done.Tilt01); + } + + [Test] + public void Restart_BeginsAgain_AndResetStops() + { + var c = Run(1.0f); + c.Start(); + Assert.That(c.Tilt01, Is.EqualTo(0f).Within(1e-4f)); + c.Advance(0.1f); + c.Reset(); + Assert.IsFalse(c.Active); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta new file mode 100644 index 00000000..321ef42b --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/HarvesterDumpCycleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d75d408a2f02a4885b4bab2708207f64 \ No newline at end of file diff --git a/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs b/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs new file mode 100644 index 00000000..19b81f73 --- /dev/null +++ b/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs @@ -0,0 +1,115 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + /// + /// Proof in the running match that a harvester view shows its load: the + /// procedural cargo pile grows with UnitState.CargoAE and the dump + /// bed tips back when the cargo drops to zero (the sim empties a + /// harvester in one tick, so the unload animation is presentation only and + /// keyed on the cargo > 0 to 0 edge). Needs the art prefabs built with + /// the Bed / Cargo_Anchor nodes (ArtAssetPrefabBuilder). + /// Frames are awaited with yield return null only; never + /// WaitForEndOfFrame under batchmode. + /// + public sealed class HarvesterCargoPresentationTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const byte Slot = 0; + + private static IEnumerator Settle(float seconds) + { + float until = Time.time + seconds; + while (Time.time < until) yield return null; + } + + [UnityTest] + public IEnumerator HarvesterView_PileGrowsWithCargo_AndBedTipsAfterUnload() + { + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady, "the canonical opening position was not built"); + + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views, "Bootstrap scene contains no UnitViewManager"); + + FactionId faction = runner.Economy.GetSlotFaction(Slot); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.Harvester, out SimUnitDefinition def)); + int capacity = SimDefinitions.HarvesterCargoCapacityAE(faction); + + EntityId id = runner.Entities.SpawnUnit(Slot, + new Transform2D(SimFixed.FromInt(26), SimFixed.FromInt(22)), def.MoveSpeed, role: def.Role); + Assert.IsTrue(id.IsValid, "setup: harvester spawn failed"); + + yield return Settle(0.6f); + Assert.IsTrue(views.TryGetView(id, out GameObject view) && view != null, "the harvester owns no view"); + var rig = view.GetComponent(); + Assert.NotNull(rig, "the art harvester view carries no rig animator (graybox fallback?)"); + Assert.IsTrue(rig.CarriesCargo, + "the harvester prefab has neither a Bed nor a Cargo_Anchor node: rebuild it with the art pass"); + CargoPile pile = rig.Pile; + Assert.IsFalse(pile.Visible, "an empty harvester must not show a pile"); + + // --- the pile grows with the load, smoothly -------------------------------- + float lastY = 0f; + int[] steps = { capacity / 4, capacity / 2, capacity }; + foreach (int cargo in steps) + { + runner.Entities.GetUnitRef(id).CargoAE = cargo; + yield return Settle(1.2f); + float y = pile.MoundScale.y; + Assert.IsTrue(pile.Visible, $"a harvester carrying {cargo} AE shows no pile"); + Assert.That(pile.Fill, Is.EqualTo(cargo / (float)capacity).Within(0.03f)); + Assert.Greater(y, lastY + 1e-4f, $"the pile did not grow when the load rose to {cargo} AE"); + lastY = y; + } + + // --- unload: the bed tips back, then returns ------------------------------ + Transform bed = rig.BedTransform; + Quaternion rest = bed != null ? bed.localRotation : Quaternion.identity; + runner.Entities.GetUnitRef(id).CargoAE = 0; + float maxAngle = 0f, maxTilt = 0f; + bool sawDump = false; + float until = Time.time + 1.2f; + while (Time.time < until) + { + yield return null; + sawDump |= rig.Dumping; + maxTilt = Mathf.Max(maxTilt, rig.BedTilt01); + if (bed != null) maxAngle = Mathf.Max(maxAngle, Quaternion.Angle(rest, bed.localRotation)); + } + Assert.IsTrue(sawDump, "cargo > 0 to 0 did not start the dump animation"); + Assert.Greater(maxTilt, 0.9f, "the dump never reached its tipped pose"); + if (bed != null) + { + Assert.Greater(maxAngle, 40f, $"the bed rotated only {maxAngle:F1} degrees"); + } + + yield return Settle(HarvesterDumpCycle.TotalSeconds); + Assert.IsFalse(rig.Dumping, "the dump animation never ended"); + Assert.IsFalse(pile.Visible, "the pile stayed behind after the dump"); + if (bed != null) + { + Assert.Less(Quaternion.Angle(rest, bed.localRotation), 1f, "the bed did not return"); + } + + // A load that arrives later starts the pile again from empty. + runner.Entities.GetUnitRef(id).CargoAE = capacity / 2; + yield return Settle(1.2f); + Assert.IsTrue(pile.Visible, "the pile did not come back with the next load"); + } + } +} diff --git a/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs.meta b/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs.meta new file mode 100644 index 00000000..3e79556c --- /dev/null +++ b/Assets/Tests/PlayMode/HarvesterCargoPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 635671894488d4dd1b93f8e83514addd \ No newline at end of file diff --git a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs index 57bec8e7..c478578a 100644 --- a/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs +++ b/Assets/Tests/PlayMode/UnitMotionPresentationTests.cs @@ -154,6 +154,66 @@ public IEnumerator MovingViews_FaceTheirDirectionOfTravel() } } + /// + /// D-116 in the running match: a battle tank drives east straight + /// through a block of standing infantry. The soldiers must open a lane + /// and the tank must keep its line. Evidence strip: + /// output/demo/makeway_*.png. + /// + [UnityTest] + public IEnumerator Tank_DrivesThroughInfantry_ThatMakesWay() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + var bootstrap = Object.FindAnyObjectByType(); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + FactionId faction = runner.Economy.GetSlotFaction(Slot); + CostField cost = runner.Pathfinding.CostField; + + Assert.IsTrue(FindOpenLane(cost, 30, 26, 1, 0, 22, out int sx, out int sy), "setup: no open lane"); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.BattleTank, out SimUnitDefinition tankDef)); + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, UnitRole.BasicInfantry, out SimUnitDefinition infDef)); + EntityId tank = runner.Entities.SpawnUnit(Slot, new Transform2D(SimFixed.FromInt(sx), SimFixed.FromInt(sy)), tankDef.MoveSpeed, role: tankDef.Role); + var soldiers = new List(); + for (int x = 0; x < 3; x++) + { + for (int y = -1; y <= 1; y++) + { + soldiers.Add(runner.Entities.SpawnUnit(Slot, + new Transform2D(SimFixed.FromInt(sx + 9 + x), SimFixed.FromInt(sy + y)), + infDef.MoveSpeed, role: infDef.Role)); + } + } + yield return new WaitForSeconds(0.8f); + + var focus = new Vector3(sx + 10f, 0f, sy); + CaptureRts($"{ShotDir}/makeway_0.png", focus, 9f); + Assert.AreEqual(CommandIngressResult.Accepted, runner.Ingress.TrySubmitIntent(CommandIntent.Create( + new MovePayload(new[] { UnitCommandStateView.ToRawEntityId(tank) }, SimFixed.FromInt(sx + 20), SimFixed.FromInt(sy))), out _)); + + float maxDrift = 0f; + int shot = 1; + float start = Time.time; + while (Time.time < start + 7f) + { + yield return null; + if (!runner.Entities.TryGetUnit(tank, out UnitState t)) break; + maxDrift = Mathf.Max(maxDrift, Mathf.Abs(t.Transform.PositionY.ToFloat() - sy)); + if (shot < 6 && Time.time > start + 0.8f + shot * 0.55f) + { + CaptureRts($"{ShotDir}/makeway_{shot}.png", focus, 9f); + shot++; + } + if (!t.IsMoving && Time.time > start + 1f) break; + } + Assert.IsTrue(runner.Entities.TryGetUnit(tank, out UnitState done)); + Assert.That(SimFixed.WorldToGrid(done.Transform.PositionX), Is.EqualTo(sx + 20), "the tank arrived"); + Assert.That(maxDrift, Is.LessThan(0.6f), "the tank kept its line through the group"); + Debug.Log($"[UnitMotionPresentation] make-way: tank drift max {maxDrift:F2} cells"); + } + private static Vector3 Flat(Vector3 v) { v.y = 0f; diff --git a/Assets/Tests/PlayMode/WreckPresentationTests.cs b/Assets/Tests/PlayMode/WreckPresentationTests.cs new file mode 100644 index 00000000..928571db --- /dev/null +++ b/Assets/Tests/PlayMode/WreckPresentationTests.cs @@ -0,0 +1,148 @@ +using System.Collections; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + /// + /// Destroyed units stay on the field (D-115): in the running match, every + /// own mobile role is spawned and then removed from the simulation; each + /// art view must turn into a wreck that is still there seconds later, + /// with its live model off and its wreck mesh on. Without art (fresh + /// clone, graybox primitives) the views keep the short fade-out, and the + /// test only proves that no wreck appears out of nowhere. + /// Evidence shots go to output/demo/wrecks_*.png. + /// + public sealed class WreckPresentationTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + private const string ShotDir = "output/demo"; + + private static readonly UnitRole[] Roles = + { + UnitRole.Builder, UnitRole.Harvester, UnitRole.BasicInfantry, UnitRole.AntiArmorInfantry, + UnitRole.ScoutVehicle, UnitRole.LightTank, UnitRole.BattleTank, UnitRole.Artillery, + }; + + [UnityTest] + public IEnumerator DestroyedUnits_LeaveWrecksThatStay() + { + Directory.CreateDirectory(ShotDir); + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views); + FactionId faction = runner.Economy.GetSlotFaction(0); + + var ids = new List(); + var wreckViews = new List(); + for (int i = 0; i < Roles.Length; i++) + { + Assert.IsTrue(SimDefinitions.TryGetUnit(faction, Roles[i], out SimUnitDefinition def)); + EntityId id = runner.Entities.SpawnUnit(0, + new Transform2D(SimFixed.FromInt(24 + (i % 4) * 3), SimFixed.FromInt(20 + (i / 4) * 3)), + def.MoveSpeed, role: def.Role); + Assert.IsTrue(id.IsValid); + ids.Add(id); + } + yield return new WaitForSeconds(0.6f); + + int withWreck = 0; + foreach (EntityId id in ids) + { + Assert.IsTrue(views.TryGetView(id, out GameObject view) && view != null, $"{id} owns no view"); + if (ViewWrecks.FindWreck(view) != null) + { + withWreck++; + wreckViews.Add(view); + } + } + + // Kill them the way the game does: inside a simulation tick, by + // combat. (Removing them between ticks would let the views vanish + // before the frame differ sees a death.) One hit point each, and + // enemy battle tanks right next to them; auto-acquire does the rest. + foreach (EntityId id in ids) SetHealth(runner, id, 1); + FactionId enemy = runner.Economy.GetSlotFaction(1); + Assert.IsTrue(SimDefinitions.TryGetUnit(enemy, UnitRole.BattleTank, out SimUnitDefinition tank)); + for (int k = 0; k < 4; k++) + { + runner.Entities.SpawnUnit(1, new Transform2D(SimFixed.FromInt(23 + k * 4), SimFixed.FromInt(26)), + tank.MoveSpeed, maxHealth: tank.MaxHealth, role: tank.Role); + } + + float deadline = Time.time + 12f; + while (Time.time < deadline && ids.Exists(id => runner.Entities.TryGetUnit(id, out _))) + { + yield return null; + } + Assert.IsFalse(ids.Exists(id => runner.Entities.TryGetUnit(id, out _)), + "setup: the enemy tanks did not kill the one-hit-point units in time"); + yield return new WaitForSeconds(2.5f); + + Assert.That(views.WreckCount, Is.EqualTo(withWreck), + "every art view with a wreck child must stay as a wreck after its death"); + foreach (GameObject wreckView in wreckViews) + { + Assert.IsTrue(wreckView.activeInHierarchy, $"{wreckView.name}: wreck vanished"); + Assert.IsTrue(ViewWrecks.FindWreck(wreckView).gameObject.activeSelf, $"{wreckView.name}: wreck mesh not shown"); + } + + if (withWreck > 0) + { + CaptureRts($"{ShotDir}/wrecks_10m.png", new Vector3(28.5f, 0f, 22f), 10f); + } + Debug.Log($"[WreckPresentation] {withWreck}/{ids.Count} views left a wreck"); + } + + /// Test-setup write (iterators cannot hold ref locals). + private static void SetHealth(MatchRunner runner, EntityId id, int health) + { + ref UnitState u = ref runner.Entities.GetUnitRef(id); + u.CurrentHealth = health; + } + + private static void CaptureRts(string path, Vector3 focus, float height) + { + Camera camera = Camera.main; + Transform t = camera.transform; + Vector3 oldPos = t.position; + Quaternion oldRot = t.rotation; + var rt = new RenderTexture(1600, 900, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; + try + { + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + t.SetPositionAndRotation(focus - rot * Vector3.forward * (height / Mathf.Sin(55f * Mathf.Deg2Rad)), rot); + camera.targetTexture = rt; + camera.Render(); + RenderTexture.active = rt; + var tex = new Texture2D(1600, 900, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0f, 0f, 1600f, 900f), 0, 0); + tex.Apply(); + File.WriteAllBytes(path, tex.EncodeToPNG()); + RenderTexture.active = null; + Object.DestroyImmediate(tex); + } + finally + { + camera.targetTexture = null; + t.SetPositionAndRotation(oldPos, oldRot); + rt.Release(); + Object.DestroyImmediate(rt); + } + } + } +} diff --git a/Assets/Tests/PlayMode/WreckPresentationTests.cs.meta b/Assets/Tests/PlayMode/WreckPresentationTests.cs.meta new file mode 100644 index 00000000..74c40b01 --- /dev/null +++ b/Assets/Tests/PlayMode/WreckPresentationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4e99c3b5f78c4da2b9d7ccaea8af1acd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Art/Buildings/Legion/Barracks/PROVENANCE.json b/Assets/_Project/Art/Buildings/Legion/Barracks/PROVENANCE.json index cc381be2..d793e133 100644 --- a/Assets/_Project/Art/Buildings/Legion/Barracks/PROVENANCE.json +++ b/Assets/_Project/Art/Buildings/Legion/Barracks/PROVENANCE.json @@ -1,37 +1,40 @@ { "assetId": "legion.building.Barracks", - "originType": "ai-generated", - "sourceName": "Tripo AI (Tripo3D)", + "originType": "procedural", + "sourceName": "Blender 5.2 prozedural (bl/build_barracks.py, Sitzung 2026-10-17)", "sourceUrl": "", "licenseId": "", "licenseUrl": "", - "retrievedAt": "2026-08-05", - "sourceFileHash": "458a12622a4e3f348a6236f1d514d6e64098aeaf6b3701d9c49c60010c5cf651", + "retrievedAt": "2026-10-17", + "sourceFileHash": "d8a926745ed03e2602440c721befddf262fa5e0a6fa1de234a7613c3aaea8633", "attributionRequired": null, "attributionText": "", "redistributionAllowed": null, - "modifications": "GLB -> FBX ueber Blender 5.2.0 LTS headless: auf Zielmass skaliert (9.0 m, fit=footprint), Origin auf Bodenmittelpunkt, Yaw-Korrektur -90 Grad auf Forward +Z, LOD0/LOD1/LOD2 per Decimate auf die Budgets aus docs/tech/AssetBudget.md §1, BaseColor-Textur entpackt und auf Standard-Groesse skaliert (ArtAssetStandard.md §4.2). Keine geometrische Neuerstellung.", + "modifications": "Textur: Konzeptbild (img/legion_building_Barracks.png, gpt-image-1 ueber generate_v2.py mit Stilplatte) per Box-Projektion auf die Form gelegt und in Cycles auf Smart-UV gebacken, Basisfarbe per Gamma auf mittlere Luminanz 0,12 gehoben; danach glb_to_unity_fbx.py --keep-slivers und asset_pass.py. Ersetzt das als Panzer generierte bzw. zerfallene Tripo-Modell (Altstand unter unity_ready/_replaced_2026-10-17).", "derivedFrom": "", "verifiedBy": "", "verifiedAt": "", - "aiProvider": "Tripo AI", - "aiModel": "Tripo3D", + "aiProvider": "", + "aiModel": "", "aiModelVersion": "", - "promptText": "steampunk bunker 3d model", - "inputImages": [], - "generatedAt": "2026-08-05", + "promptText": "", + "inputImages": [ + "img/legion_building_Barracks.png" + ], + "generatedAt": "2026-10-17", "providerTermsUrl": "", "providerTermsRetrievedAt": "", "commercialUseGranted": null, "outputOwnership": "", - "_sourceFileName": "steampunk+bunker+3d+model.glb", + "_sourceFileName": "legion_Barracks.glb", "_providerEvidence": "Provider aus dem GLB selbst belegt: die eingebettete Textur traegt den Namen 'tripo_image__0'. promptText ist aus dem Download-Dateinamen rekonstruiert, nicht vom Anbieter bestaetigt.", "_TODO": [ - "sourceUrl: Generierungs-URL/Job-ID bei Tripo nachtragen", - "providerTermsUrl + providerTermsRetrievedAt: Tripo-Nutzungsbedingungen abrufen und datieren", - "commercialUseGranted: aus den Tripo-Bedingungen des genutzten Tarifs belegen", - "outputOwnership: woertliches Zitat der Rechteklausel eintragen", - "aiModelVersion: konkrete Tripo-Modellversion eintragen", - "verifiedBy + verifiedAt: Vier-Augen-Verifikation durchfuehren und eintragen" - ] + "verifiedBy + verifiedAt eintragen" + ], + "_vorgaenger": { + "sourceName": "Tripo AI (Tripo3D)", + "aiProvider": "Tripo AI", + "_sourceFileName": "steampunk+bunker+3d+model.glb", + "sourceFileHash": "458a12622a4e3f348a6236f1d514d6e64098aeaf6b3701d9c49c60010c5cf651" + } } \ No newline at end of file diff --git a/Assets/_Project/Art/Buildings/Legion/Power/PROVENANCE.json b/Assets/_Project/Art/Buildings/Legion/Power/PROVENANCE.json index f603003b..d436bc41 100644 --- a/Assets/_Project/Art/Buildings/Legion/Power/PROVENANCE.json +++ b/Assets/_Project/Art/Buildings/Legion/Power/PROVENANCE.json @@ -1,37 +1,41 @@ { "assetId": "legion.building.Power", - "originType": "ai-generated", - "sourceName": "Tripo AI (Tripo3D)", + "originType": "procedural", + "sourceName": "Blender 5.2 prozedural (build_power.py, Sitzung 2026-10-17)", "sourceUrl": "", "licenseId": "", "licenseUrl": "", - "retrievedAt": "2026-08-05", - "sourceFileHash": "fc3496e5636676d39294c1bae226206455ec9be8fc892669f83d343090a5a463", + "retrievedAt": "2026-10-17", + "sourceFileHash": "3e433da67de06aa1a439c9a07522581c4543fe814e6a542bd828016eff652418", "attributionRequired": null, "attributionText": "", "redistributionAllowed": null, - "modifications": "GLB -> FBX ueber Blender 5.2.0 LTS headless: auf Zielmass skaliert (9.0 m, fit=footprint), Origin auf Bodenmittelpunkt, Yaw-Korrektur -90 Grad auf Forward +Z, LOD0/LOD1/LOD2 per Decimate auf die Budgets aus docs/tech/AssetBudget.md §1, BaseColor-Textur entpackt und auf Standard-Groesse skaliert (ArtAssetStandard.md §4.2). Keine geometrische Neuerstellung.", + "modifications": "Textur: Konzeptbild (img/v2test/legion_building_Power.png, gpt-image-1 ueber generate_v2.py mit Stilplatte) per Box-Projektion auf die Form gelegt und in Cycles auf Smart-UV gebacken, Basisfarbe per Gamma auf mittlere Luminanz 0,12 gehoben; danach glb_to_unity_fbx.py --keep-slivers und asset_pass.py. Ersetzt das als Panzer generierte bzw. zerfallene Tripo-Modell (Altstand unter unity_ready/_replaced_2026-10-17).", "derivedFrom": "", "verifiedBy": "", "verifiedAt": "", - "aiProvider": "Tripo AI", - "aiModel": "Tripo3D", + "aiProvider": "", + "aiModel": "", "aiModelVersion": "", - "promptText": "steampunk tank 3d model", - "inputImages": [], - "generatedAt": "2026-08-05", + "promptText": "", + "inputImages": [ + "img/v2test/legion_building_Power.png" + ], + "generatedAt": "2026-10-17", "providerTermsUrl": "", "providerTermsRetrievedAt": "", "commercialUseGranted": null, "outputOwnership": "", - "_sourceFileName": "steampunk+tank+3d+model.glb", + "_sourceFileName": "proc_power.glb", "_providerEvidence": "Provider aus dem GLB selbst belegt: die eingebettete Textur traegt den Namen 'tripo_image__0'. promptText ist aus dem Download-Dateinamen rekonstruiert, nicht vom Anbieter bestaetigt.", "_TODO": [ - "sourceUrl: Generierungs-URL/Job-ID bei Tripo nachtragen", - "providerTermsUrl + providerTermsRetrievedAt: Tripo-Nutzungsbedingungen abrufen und datieren", - "commercialUseGranted: aus den Tripo-Bedingungen des genutzten Tarifs belegen", - "outputOwnership: woertliches Zitat der Rechteklausel eintragen", - "aiModelVersion: konkrete Tripo-Modellversion eintragen", - "verifiedBy + verifiedAt: Vier-Augen-Verifikation durchfuehren und eintragen" - ] + "verifiedBy + verifiedAt eintragen" + ], + "_vorgaenger": { + "sourceName": "Tripo AI (Tripo3D)", + "aiProvider": "Tripo AI", + "_sourceFileName": "steampunk+tank+3d+model.glb", + "sourceFileHash": "fc3496e5636676d39294c1bae226206455ec9be8fc892669f83d343090a5a463" + }, + "_hinweis": "Eine Hunyuan3D-2.1-Form wurde verworfen: die Tencent-Hunyuan-Community-Lizenz gilt nicht in der EU." } \ No newline at end of file diff --git a/Assets/_Project/Art/Buildings/Legion/ResearchLab/PROVENANCE.json b/Assets/_Project/Art/Buildings/Legion/ResearchLab/PROVENANCE.json index 5f94e4fc..44b68cfe 100644 --- a/Assets/_Project/Art/Buildings/Legion/ResearchLab/PROVENANCE.json +++ b/Assets/_Project/Art/Buildings/Legion/ResearchLab/PROVENANCE.json @@ -1,37 +1,40 @@ { "assetId": "legion.building.ResearchLab", - "originType": "ai-generated", - "sourceName": "Tripo AI (Tripo3D)", + "originType": "procedural", + "sourceName": "Blender 5.2 prozedural (bl/build_lab.py, Sitzung 2026-10-17)", "sourceUrl": "", "licenseId": "", "licenseUrl": "", - "retrievedAt": "2026-08-05", - "sourceFileHash": "f84bf6b21c056a26b531f21c2aa25f6081084c426f4558d9eaf501f593d80637", + "retrievedAt": "2026-10-17", + "sourceFileHash": "06b3eccd13415afdcaa0797f17561d1b5884ac98684a3092eb3b8445bff850f3", "attributionRequired": null, "attributionText": "", "redistributionAllowed": null, - "modifications": "GLB -> FBX ueber Blender 5.2.0 LTS headless: auf Zielmass skaliert (9.0 m, fit=footprint), Origin auf Bodenmittelpunkt, Yaw-Korrektur -90 Grad auf Forward +Z, LOD0/LOD1/LOD2 per Decimate auf die Budgets aus docs/tech/AssetBudget.md §1, BaseColor-Textur entpackt und auf Standard-Groesse skaliert (ArtAssetStandard.md §4.2). Keine geometrische Neuerstellung.", + "modifications": "Textur: Konzeptbild (img/v2test/legion_building_ResearchLab_b.png, gpt-image-1 ueber generate_v2.py mit Stilplatte) per Box-Projektion auf die Form gelegt und in Cycles auf Smart-UV gebacken, Basisfarbe per Gamma auf mittlere Luminanz 0,12 gehoben; danach glb_to_unity_fbx.py --keep-slivers und asset_pass.py. Ersetzt das als Panzer generierte bzw. zerfallene Tripo-Modell (Altstand unter unity_ready/_replaced_2026-10-17).", "derivedFrom": "", "verifiedBy": "", "verifiedAt": "", - "aiProvider": "Tripo AI", - "aiModel": "Tripo3D", + "aiProvider": "", + "aiModel": "", "aiModelVersion": "", - "promptText": "rusted tank 3d model", - "inputImages": [], - "generatedAt": "2026-08-05", + "promptText": "", + "inputImages": [ + "img/v2test/legion_building_ResearchLab_b.png" + ], + "generatedAt": "2026-10-17", "providerTermsUrl": "", "providerTermsRetrievedAt": "", "commercialUseGranted": null, "outputOwnership": "", - "_sourceFileName": "rusted+tank+3d+model.glb", + "_sourceFileName": "legion_ResearchLab.glb", "_providerEvidence": "Provider aus dem GLB selbst belegt: die eingebettete Textur traegt den Namen 'tripo_image__0'. promptText ist aus dem Download-Dateinamen rekonstruiert, nicht vom Anbieter bestaetigt.", "_TODO": [ - "sourceUrl: Generierungs-URL/Job-ID bei Tripo nachtragen", - "providerTermsUrl + providerTermsRetrievedAt: Tripo-Nutzungsbedingungen abrufen und datieren", - "commercialUseGranted: aus den Tripo-Bedingungen des genutzten Tarifs belegen", - "outputOwnership: woertliches Zitat der Rechteklausel eintragen", - "aiModelVersion: konkrete Tripo-Modellversion eintragen", - "verifiedBy + verifiedAt: Vier-Augen-Verifikation durchfuehren und eintragen" - ] + "verifiedBy + verifiedAt eintragen" + ], + "_vorgaenger": { + "sourceName": "Tripo AI (Tripo3D)", + "aiProvider": "Tripo AI", + "_sourceFileName": "rusted+tank+3d+model.glb", + "sourceFileHash": "f84bf6b21c056a26b531f21c2aa25f6081084c426f4558d9eaf501f593d80637" + } } \ No newline at end of file diff --git a/Assets/_Project/Art/Buildings/Legion/VehicleFactory/PROVENANCE.json b/Assets/_Project/Art/Buildings/Legion/VehicleFactory/PROVENANCE.json index daed365d..a07d4d0d 100644 --- a/Assets/_Project/Art/Buildings/Legion/VehicleFactory/PROVENANCE.json +++ b/Assets/_Project/Art/Buildings/Legion/VehicleFactory/PROVENANCE.json @@ -1,37 +1,41 @@ { "assetId": "legion.building.VehicleFactory", - "originType": "ai-generated", - "sourceName": "Tripo AI (Tripo3D)", + "originType": "procedural", + "sourceName": "Blender 5.2 prozedural (build_factory.py, Sitzung 2026-10-17)", "sourceUrl": "", "licenseId": "", "licenseUrl": "", - "retrievedAt": "2026-08-05", - "sourceFileHash": "6591b8306822b1d2e0ce24621a96cc3f7044b9907bb2c7b8ce072c432a98b599", + "retrievedAt": "2026-10-17", + "sourceFileHash": "fa9ab51eee0a4d0da4cb1d4b20468c8286483e877859878a743bcc9f80809a2d", "attributionRequired": null, "attributionText": "", "redistributionAllowed": null, - "modifications": "GLB -> FBX ueber Blender 5.2.0 LTS headless: auf Zielmass skaliert (12.0 m, fit=footprint), Origin auf Bodenmittelpunkt, Yaw-Korrektur -90 Grad auf Forward +Z, LOD0/LOD1/LOD2 per Decimate auf die Budgets aus docs/tech/AssetBudget.md §1, BaseColor-Textur entpackt und auf Standard-Groesse skaliert (ArtAssetStandard.md §4.2). Keine geometrische Neuerstellung.", + "modifications": "Textur: Konzeptbild (img/v2test/legion_building_VehicleFactory_b.png, gpt-image-1 ueber generate_v2.py mit Stilplatte) per Box-Projektion auf die Form gelegt und in Cycles auf Smart-UV gebacken, Basisfarbe per Gamma auf mittlere Luminanz 0,12 gehoben; danach glb_to_unity_fbx.py --keep-slivers und asset_pass.py. Ersetzt das als Panzer generierte bzw. zerfallene Tripo-Modell (Altstand unter unity_ready/_replaced_2026-10-17).", "derivedFrom": "", "verifiedBy": "", "verifiedAt": "", - "aiProvider": "Tripo AI", - "aiModel": "Tripo3D", + "aiProvider": "", + "aiModel": "", "aiModelVersion": "", - "promptText": "rusty tank robot 3d model", - "inputImages": [], - "generatedAt": "2026-08-05", + "promptText": "", + "inputImages": [ + "img/v2test/legion_building_VehicleFactory_b.png" + ], + "generatedAt": "2026-10-17", "providerTermsUrl": "", "providerTermsRetrievedAt": "", "commercialUseGranted": null, "outputOwnership": "", - "_sourceFileName": "rusty+tank+robot+3d+model.glb", + "_sourceFileName": "proc_factory.glb", "_providerEvidence": "Provider aus dem GLB selbst belegt: die eingebettete Textur traegt den Namen 'tripo_image__0'. promptText ist aus dem Download-Dateinamen rekonstruiert, nicht vom Anbieter bestaetigt.", "_TODO": [ - "sourceUrl: Generierungs-URL/Job-ID bei Tripo nachtragen", - "providerTermsUrl + providerTermsRetrievedAt: Tripo-Nutzungsbedingungen abrufen und datieren", - "commercialUseGranted: aus den Tripo-Bedingungen des genutzten Tarifs belegen", - "outputOwnership: woertliches Zitat der Rechteklausel eintragen", - "aiModelVersion: konkrete Tripo-Modellversion eintragen", - "verifiedBy + verifiedAt: Vier-Augen-Verifikation durchfuehren und eintragen" - ] + "verifiedBy + verifiedAt eintragen" + ], + "_vorgaenger": { + "sourceName": "Tripo AI (Tripo3D)", + "aiProvider": "Tripo AI", + "_sourceFileName": "rusty+tank+robot+3d+model.glb", + "sourceFileHash": "6591b8306822b1d2e0ce24621a96cc3f7044b9907bb2c7b8ce072c432a98b599" + }, + "_hinweis": "Eine Hunyuan3D-2.1-Form wurde verworfen: die Tencent-Hunyuan-Community-Lizenz gilt nicht in der EU." } \ No newline at end of file diff --git a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs index a04ce124..335d8a71 100644 --- a/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs +++ b/Assets/_Project/Editor/ArtAssetPrefabBuilder.cs @@ -35,6 +35,14 @@ public static class ArtAssetPrefabBuilder private const string UrpLitShader = "Universal Render Pipeline/Lit"; + /// + /// Name of the inactive child that carries the destroyed state + /// (SM_*_Wreck.fbx, D-115); ViewWrecks swaps to it on death. + /// + public const string WreckNode = Nova.Gameplay.Match.ViewWrecks.WreckNode; + + private const string WreckSuffix = "_Wreck"; + /// HDR gain on the _EM map: accents must read under the full desert sun. private const float EmissionIntensity = 2.0f; @@ -66,6 +74,8 @@ public static int Build() string fbxPath = AssetDatabase.GUIDToAssetPath(guid); string stem = Path.GetFileNameWithoutExtension(fbxPath); if (!stem.StartsWith("SM_", System.StringComparison.Ordinal)) continue; + // Wreck meshes ride inside their parent's prefab, never alone. + if (stem.EndsWith(WreckSuffix, System.StringComparison.Ordinal)) continue; // SM_UNIT_Alliance_LightTank -> PF_UNIT_Alliance_LightTank string suffix = stem.Substring("SM_".Length); @@ -241,6 +251,8 @@ private static bool BuildPrefab(string fbxPath, string dir, string prefabName, M group.SetLODs(levels.ToArray()); group.RecalculateBounds(); + AttachWreck(instance, fbxPath, dir, prefabName); + PrefabUtility.SaveAsPrefabAsset(instance, $"{dir}/{prefabName}.prefab"); return true; } @@ -249,5 +261,46 @@ private static bool BuildPrefab(string fbxPath, string dir, string prefabName, M Object.DestroyImmediate(instance); } } + /// + /// Adds the sibling SM_*_Wreck.fbx as an inactive child named + /// with its own charred material + /// (M_*_Wreck on T_*_Wreck_BC). The wreck is not part of + /// the LODGroup: it is a single mesh, shown alone once the live model + /// is switched off. No wreck file — no child, the view keeps the + /// fade-out death. + /// + private static void AttachWreck(GameObject instance, string fbxPath, string dir, string prefabName) + { + string wreckPath = fbxPath.Substring(0, fbxPath.Length - ".fbx".Length) + WreckSuffix + ".fbx"; + var wreckModel = AssetDatabase.LoadAssetAtPath(wreckPath); + if (wreckModel == null) return; + + string suffix = prefabName.Substring("PF_".Length) + WreckSuffix; + string matPath = $"{dir}/M_{suffix}.mat"; + var material = AssetDatabase.LoadAssetAtPath(matPath); + if (material == null) + { + material = new Material(Shader.Find(UrpLitShader)); + AssetDatabase.CreateAsset(material, matPath); + } + var baseMap = AssetDatabase.LoadAssetAtPath($"{dir}/T_{suffix}_BC.png"); + material.SetTexture("_BaseMap", baseMap); + material.SetFloat("_Metallic", 0.1f); + material.SetFloat("_Smoothness", 0.12f); + material.SetFloat("_Cull", (float)CullMode.Off); + material.doubleSidedGI = true; + EditorUtility.SetDirty(material); + + var wreck = (GameObject)PrefabUtility.InstantiatePrefab(wreckModel); + PrefabUtility.UnpackPrefabInstance(wreck, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction); + wreck.name = WreckNode; + wreck.transform.SetParent(instance.transform, false); + foreach (Renderer r in wreck.GetComponentsInChildren(true)) + { + r.sharedMaterial = material; + r.shadowCastingMode = ShadowCastingMode.On; + } + wreck.SetActive(false); + } } } diff --git a/Assets/_Project/Editor/ArtPreviewCapture.cs b/Assets/_Project/Editor/ArtPreviewCapture.cs index d66d5d7f..80ccd4fa 100644 --- a/Assets/_Project/Editor/ArtPreviewCapture.cs +++ b/Assets/_Project/Editor/ArtPreviewCapture.cs @@ -18,8 +18,8 @@ namespace Nova.Editor /// draws it — without anybody opening the editor. /// /// Unity -batchmode -projectPath . -executeMethod Nova.Editor.ArtPreviewCapture.CaptureFromCommandLine \ - /// -captureOut /abs/dir [-captureFilter BattleTank] [-captureHeading 0] [-captureTime 0.0] - /// [-captureBuildPrefabs] -logFile log -quit + /// -captureOut /abs/dir [-captureFilter BattleTank] [-captureHeading 0] [-captureTime 0.0] [-captureDump 0.7] + /// [-captureBuildPrefabs] [-captureWreck] [-captureLineup] -logFile log -quit /// /// Per prefab it writes <name>_top.png (orthographic, from /// above, with a red bar pointing along the simulated move direction) and @@ -39,6 +39,7 @@ public static void CaptureFromCommandLine() string filter = Arg("-captureFilter", ""); float heading = float.Parse(Arg("-captureHeading", "0"), System.Globalization.CultureInfo.InvariantCulture); float time = float.Parse(Arg("-captureTime", "0"), System.Globalization.CultureInfo.InvariantCulture); + PreviewDumpSeconds = float.Parse(Arg("-captureDump", "-1"), System.Globalization.CultureInfo.InvariantCulture); int written = 0; try { @@ -49,7 +50,9 @@ public static void CaptureFromCommandLine() ArtAssetPrefabBuilder.Build(); ArtAssetAutoSync.SyncRegistry(); } - written = Capture(outDir, filter, heading, time); + written = HasFlag("-captureLineup") + ? CaptureLineup(outDir) + : Capture(outDir, filter, heading, time); } catch (Exception e) { @@ -92,6 +95,7 @@ public static int Capture(string outDir, string filter, float simHeadingDegrees, if (HasFlag("-captureDump")) Dump(instance.transform, ""); ApplyFactionTint(instance, path); PoseAnimation(instance, path, animationTime); + if (HasFlag("-captureWreck")) ShowWreck(instance); Bounds b = WorldBounds(instance); float extent = Mathf.Max(b.extents.x, b.extents.z, b.extents.y * 0.7f, 0.5f); @@ -110,6 +114,104 @@ public static int Capture(string outDir, string filter, float simHeadingDegrees, return paths.Count; } + /// + /// Size line-up per faction: every prefab at exactly the scale the + /// game gives it (), buildings in a back + /// row, units in front, a 1-cell grid on the ground. Writes + /// lineup_<Faction>_rts.png (RTS pitch) and + /// _side.png (eye level), and logs each model's view size in + /// cells — the table proportion decisions are made from. + /// + public static int CaptureLineup(string outDir) + { + Directory.CreateDirectory(outDir); + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + SetUpStage(); + GridLines(40); + + int count = 0; + foreach (string faction in new[] { "Alliance", "Legion" }) + { + var placed = new List(); + float bx = -13.5f, ux = -12f; + foreach (string path in PrefabPaths($"_{faction}_")) + { + string stem = Path.GetFileNameWithoutExtension(path); + string roleName = stem.Substring(stem.LastIndexOf('_') + 1); + bool building = stem.StartsWith("PF_BLDG_", StringComparison.Ordinal); + + GameObject go = UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath(path)); + Bounds raw = WorldBounds(go); + if (!ArtViewScale.TryFactor(roleName, raw.size, out float f)) + { + UnityEngine.Object.DestroyImmediate(go); + continue; + } + go.transform.localScale = Vector3.one * f; + ApplyFactionTint(go, path); + Bounds b = WorldBounds(go); + + float width = building ? 3.4f : Mathf.Max(b.size.x, 0.6f) + 0.5f; + float x = building ? bx + width * 0.5f : ux + width * 0.5f; + go.transform.position += new Vector3(x - b.center.x, -b.min.y, (building ? 3f : -1.2f) - b.center.z); + if (building) bx += width; else ux += width; + placed.Add(go); + count++; + Debug.Log($"[ArtPreviewCapture:lineup] {faction,-8} {roleName,-18} factor={f:F3} width={b.size.x:F2} depth={b.size.z:F2} height={b.size.y:F2} cells"); + } + + Bounds all = new Bounds(Vector3.zero, Vector3.one); + foreach (GameObject go in placed) all.Encapsulate(WorldBounds(go)); + const int W = 1600, H = 560; + Camera rts = NewCamera(); + rts.fieldOfView = 24f; + rts.aspect = (float)W / H; + Quaternion rot = Quaternion.Euler(55f, 0f, 0f); + rts.transform.SetPositionAndRotation(all.center - rot * Vector3.forward * (all.extents.x * 2.0f), rot); + Shoot(rts, Path.Combine(outDir, $"lineup_{faction}_rts.png"), W, H); + + Camera side = NewCamera(); + side.orthographic = true; + side.aspect = (float)W / H; + side.orthographicSize = all.extents.x * 1.04f * H / W; + side.transform.SetPositionAndRotation(new Vector3(all.center.x, side.orthographicSize * 0.92f, -30f), Quaternion.identity); + Shoot(side, Path.Combine(outDir, $"lineup_{faction}_side.png"), W, H); + + foreach (GameObject go in placed) UnityEngine.Object.DestroyImmediate(go); + } + return count; + } + + private static List PrefabPaths(string contains) + { + var paths = new List(); + foreach (string guid in AssetDatabase.FindAssets("t:Prefab PF_", new[] { "Assets/_Project/Art" })) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (Path.GetFileName(path).StartsWith("PF_", StringComparison.Ordinal) && path.Contains(contains)) paths.Add(path); + } + paths.Sort(StringComparer.Ordinal); + return paths; + } + + /// Thin dark lines every cell, so sizes read off the picture. + private static void GridLines(int half) + { + var dark = new Material(Shader.Find("Universal Render Pipeline/Unlit")); + dark.SetColor("_BaseColor", new Color(0.45f, 0.37f, 0.27f)); + for (int i = -half; i <= half; i++) + { + foreach (bool alongX in new[] { true, false }) + { + GameObject line = GameObject.CreatePrimitive(PrimitiveType.Cube); + UnityEngine.Object.DestroyImmediate(line.GetComponent()); + line.GetComponent().sharedMaterial = dark; + line.transform.localScale = alongX ? new Vector3(half * 2f, 0.01f, 0.03f) : new Vector3(0.03f, 0.01f, half * 2f); + line.transform.position = alongX ? new Vector3(0f, 0.005f, i) : new Vector3(i, 0.005f, 0f); + } + } + } + /// Same sun, ambient and sand ground as the generated Bootstrap scene. private static void SetUpStage() { @@ -152,13 +254,28 @@ private static void ApplyFactionTint(GameObject instance, string path) /// (turret swung toward a target front-right, gun recoiling), exactly /// as UnitViewManager would attach it at runtime. /// + /// -captureDump: seconds into a harvester unload to show; negative = no unload. + private static float PreviewDumpSeconds = -1f; + private static void PoseAnimation(GameObject instance, string path, float seconds) { if (seconds <= 0f) return; UnitRigAnimator rig = instance.GetComponent(); if (rig == null) rig = instance.AddComponent(); rig.InfantryGait = path.Contains("Infantry"); - rig.PreviewPose(seconds); + rig.PreviewPose(seconds, PreviewDumpSeconds); + } + + /// Switches a view to its destroyed state, exactly as ViewWrecks does on death. + private static void ShowWreck(GameObject instance) + { + Transform wreck = ViewWrecks.FindWreck(instance); + if (wreck == null) return; + foreach (Renderer r in instance.GetComponentsInChildren(true)) + { + if (!r.transform.IsChildOf(wreck)) r.enabled = false; + } + wreck.gameObject.SetActive(true); } private static GameObject MoveMarker(Bounds b, Vector3 dir, float extent) @@ -207,9 +324,9 @@ private static Camera NewCamera() return cam; } - private static void Shoot(Camera cam, string file) + private static void Shoot(Camera cam, string file, int width = Size, int height = Size) { - var rt = new RenderTexture(Size, Size, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; + var rt = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { antiAliasing = 4 }; try { var request = new RenderPipeline.StandardRequest { destination = rt }; @@ -226,8 +343,8 @@ private static void Shoot(Camera cam, string file) RenderTexture previous = RenderTexture.active; RenderTexture.active = rt; - var tex = new Texture2D(Size, Size, TextureFormat.RGB24, false); - tex.ReadPixels(new Rect(0, 0, Size, Size), 0, 0); + var tex = new Texture2D(width, height, TextureFormat.RGB24, false); + tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); tex.Apply(); RenderTexture.active = previous; File.WriteAllBytes(file, tex.EncodeToPNG()); diff --git a/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs b/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs new file mode 100644 index 00000000..2e0b37e5 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs @@ -0,0 +1,163 @@ +using UnityEngine; + +namespace Nova.Gameplay.Match +{ + /// + /// Shared, emit-only particle systems for ambient motion: sand kicked up + /// behind driving vehicles and smoke rising from chimneys and wrecks. + /// One world-space system per kind serves every emitter in the match — + /// a hundred tanks cost one renderer, not a hundred (AnimationSystem.md + /// performance budget). Pure view; nothing here is read back. + /// + public static class AmbientFx + { + private static GameObject _root; + private static ParticleSystem _dust; + private static ParticleSystem _smoke; + private static Material _material; + private static Texture2D _puff; + + /// Sand cloud at ground level; in world units. + public static void Dust(Vector3 position, Vector3 drift, float size) + { + if (!Ensure()) return; + var p = new ParticleSystem.EmitParams + { + position = position + Random.insideUnitSphere * size * 0.25f, + velocity = drift + new Vector3(Random.Range(-0.15f, 0.15f), Random.Range(0.25f, 0.5f), Random.Range(-0.15f, 0.15f)), + startSize = size * Random.Range(0.8f, 1.3f), + startLifetime = Random.Range(0.9f, 1.4f), + startColor = new Color(0.78f, 0.66f, 0.48f, 0.55f), + rotation = Random.Range(0f, 360f), + }; + _dust.Emit(p, 1); + } + + /// Small glowing Aetherium-cyan puff (unloading harvesters); in world units. + public static void GlowDust(Vector3 position, Vector3 drift, float size) + { + if (!Ensure()) return; + var p = new ParticleSystem.EmitParams + { + position = position, + velocity = drift, + startSize = size, + startLifetime = Random.Range(0.55f, 0.9f), + startColor = new Color(0.45f, 0.95f, 1f, 0.8f), + rotation = Random.Range(0f, 360f), + }; + _dust.Emit(p, 1); + } + + /// Dark rising smoke puff; in world units. + public static void Smoke(Vector3 position, float size, float darkness = 0.25f) + { + if (!Ensure()) return; + float g = Mathf.Lerp(0.6f, 0.12f, darkness); + var p = new ParticleSystem.EmitParams + { + position = position + Random.insideUnitSphere * size * 0.15f, + velocity = new Vector3(Random.Range(-0.12f, 0.12f) + 0.15f, Random.Range(0.7f, 1.1f), Random.Range(-0.12f, 0.12f) + 0.1f), + startSize = size * Random.Range(0.7f, 1.1f), + startLifetime = Random.Range(2.2f, 3.2f), + startColor = new Color(g, g * 0.97f, g * 0.94f, 0.6f), + rotation = Random.Range(0f, 360f), + }; + _smoke.Emit(p, 1); + } + + private static bool Ensure() + { + if (_root != null) return _material != null; + _root = new GameObject("AmbientFx") { hideFlags = HideFlags.DontSave }; + _material = CreateMaterial(); + _dust = Create("Dust", 900, 2.2f); + _smoke = Create("Smoke", 600, 3.0f); + return _material != null; + } + + private static ParticleSystem Create(string name, int max, float growth) + { + var go = new GameObject(name); + go.transform.SetParent(_root.transform, false); + var ps = go.AddComponent(); + ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); + ParticleSystem.MainModule main = ps.main; + main.loop = false; + main.playOnAwake = false; + main.simulationSpace = ParticleSystemSimulationSpace.World; + main.maxParticles = max; + main.gravityModifier = -0.02f; + ParticleSystem.EmissionModule emission = ps.emission; + emission.enabled = false; + ParticleSystem.ShapeModule shape = ps.shape; + shape.enabled = false; + + // grow and fade out over the lifetime + ParticleSystem.SizeOverLifetimeModule size = ps.sizeOverLifetime; + size.enabled = true; + size.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, growth)); + ParticleSystem.ColorOverLifetimeModule color = ps.colorOverLifetime; + color.enabled = true; + var gradient = new Gradient(); + gradient.SetKeys( + new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) }, + new[] { new GradientAlphaKey(0f, 0f), new GradientAlphaKey(1f, 0.12f), new GradientAlphaKey(0f, 1f) }); + color.color = gradient; + ParticleSystem.VelocityOverLifetimeModule vel = ps.velocityOverLifetime; + vel.enabled = false; + + var r = go.GetComponent(); + r.sharedMaterial = _material; + r.renderMode = ParticleSystemRenderMode.Billboard; + r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + r.receiveShadows = false; + r.sortMode = ParticleSystemSortMode.Distance; + ps.Play(); + return ps; + } + + private static Material CreateMaterial() + { + Shader shader = Shader.Find("Universal Render Pipeline/Particles/Unlit"); + if (shader == null) shader = Shader.Find("Particles/Standard Unlit"); + if (shader == null) return null; + var m = new Material(shader) { name = "Nova_AmbientFx_Runtime", hideFlags = HideFlags.DontSave }; + m.SetTexture("_BaseMap", PuffTexture()); + m.SetTexture("_MainTex", PuffTexture()); + // URP particle unlit: transparent, alpha-blended, no depth write + m.SetFloat("_Surface", 1f); + m.SetFloat("_Blend", 0f); + m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); + m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); + m.SetFloat("_ZWrite", 0f); + m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); + m.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent; + return m; + } + + /// Soft round puff, generated once (no texture asset). + private static Texture2D PuffTexture() + { + if (_puff != null) return _puff; + const int n = 64; + _puff = new Texture2D(n, n, TextureFormat.RGBA32, false) { hideFlags = HideFlags.DontSave, wrapMode = TextureWrapMode.Clamp }; + var px = new Color32[n * n]; + for (int y = 0; y < n; y++) + { + for (int x = 0; x < n; x++) + { + float dx = (x + 0.5f) / n * 2f - 1f, dy = (y + 0.5f) / n * 2f - 1f; + float d = Mathf.Sqrt(dx * dx + dy * dy); + float noise = Mathf.PerlinNoise(x * 0.18f, y * 0.18f) * 0.35f + 0.65f; + float a = Mathf.Clamp01(1f - d) * noise; + a = a * a * (3f - 2f * a); + px[y * n + x] = new Color32(255, 255, 255, (byte)(a * 255f)); + } + } + _puff.SetPixels32(px); + _puff.Apply(); + return _puff; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs.meta new file mode 100644 index 00000000..667ad506 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/AmbientFx.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 39eb7bdc90ec048259d23626af188ac5 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs b/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs new file mode 100644 index 00000000..7569465d --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs @@ -0,0 +1,82 @@ +using UnityEngine; +using Nova.Simulation.Definitions; +using Nova.Simulation.State; + +namespace Nova.Gameplay.Match +{ + /// + /// The one rule that turns an art model's authored size into its size on + /// the map (D-113). Art is authored in metres with one grid cell = 3 m + /// (ArtAssetStandard.md §1), so the ratios BETWEEN models are already + /// right; the view only has to pick one factor per class: + /// + /// Buildings shrink to their footprint width + /// (); smaller art keeps + /// its authored size. + /// Units share one metric factor, + /// : a 7.5 m battle tank becomes 1.65 + /// cells, a 4.5 m scout 1.0. The former rule squeezed every unit to its + /// graybox primitive, which made a harvester shorter than a light tank, a + /// builder smaller than a soldier, and a battle tank twice a soldier's + /// height and three quarters of a barracks wide. + /// Small units get a minimum view HEIGHT + /// (): at true scale a soldier would be half a + /// cell tall and unreadable from the RTS camera. Height, not width — a + /// launcher held sideways must not shrink its soldier. + /// + /// + public static class ArtViewScale + { + /// + /// Cells per metre for unit art. Buildings sit at 1/3 (3 m per cell); + /// vehicles run a third smaller so a tank reads at about a soldier and + /// a half (real: 1.3x) and half a building's width, and neighbours at + /// a collision radius of half a cell overlap less. + /// + public const float UnitCellsPerMetre = 0.22f; + + /// Uniform scale for a model of (world bounds at scale 1). + public static float Factor(UnitRole role, Vector3 authoredSize) + { + if (SimDefinitions.IsBuildingRole(role)) + { + float target = SimDefinitions.BuildingFootprintCells; + float current = Mathf.Max(authoredSize.x, authoredSize.z); + return current > target && current > 1e-4f ? target / current : 1f; + } + + float factor = UnitCellsPerMetre; + float minHeight = MinViewHeight(role); + if (authoredSize.y > 1e-4f && authoredSize.y * factor < minHeight) + { + factor = minHeight / authoredSize.y; + } + return factor; + } + + /// + /// by role NAME (the suffix of a PF_ prefab), for + /// editor tooling that does not reference the simulation assembly. + /// False for a name that is no . + /// + public static bool TryFactor(string roleName, Vector3 authoredSize, out float factor) + { + factor = 1f; + if (!System.Enum.TryParse(roleName, out UnitRole role)) return false; + factor = Factor(role, authoredSize); + return true; + } + + /// Readability floor for small units, in cells of view height (0 = none). + public static float MinViewHeight(UnitRole role) + { + switch (role) + { + case UnitRole.BasicInfantry: return 0.85f; + case UnitRole.AntiArmorInfantry: return 0.9f; + case UnitRole.Builder: return 1.1f; + default: return 0f; + } + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs.meta new file mode 100644 index 00000000..b0e510b3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ArtViewScale.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fd9349a2e2e4bae8a05e52638287742 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs b/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs new file mode 100644 index 00000000..52716bdb --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs @@ -0,0 +1,389 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Nova.Gameplay.Match +{ + /// + /// Timeline of one unloading, presentation only. The simulation empties a + /// harvester in a single tick; the view plays this ~1.9 s sequence on that + /// edge: the bed (or the body of a bed-less robot) tips back with an ease + /// in/out, the pile slides out while it is tipped, and the bed returns. + /// Pure value logic, no UnityEngine objects, so EditMode tests pin it. + /// + public struct HarvesterDumpCycle + { + public const float TiltUpSeconds = 0.6f; + public const float HoldSeconds = 0.45f; + public const float ReturnSeconds = 0.8f; + public const float TotalSeconds = TiltUpSeconds + HoldSeconds + ReturnSeconds; + + /// The pile lets go once the bed is about three quarters up. + public const float ReleaseStartSeconds = 0.45f; + public const float ReleaseSeconds = 0.9f; + + private float _t; + private bool _active; + + public bool Active => _active; + public float Elapsed => _t; + + /// (Re)starts the sequence from the beginning. + public void Start() + { + _t = 0f; + _active = true; + } + + public void Reset() + { + _t = 0f; + _active = false; + } + + public void Advance(float dt) + { + if (!_active) return; + _t += Mathf.Max(0f, dt); + if (_t >= TotalSeconds) + { + _t = 0f; + _active = false; + } + } + + /// 0 = bed down, 1 = fully tipped. Smootherstep up, hold, smoothstep back. + public float Tilt01 + { + get + { + if (!_active) return 0f; + if (_t < TiltUpSeconds) return SmootherStep(_t / TiltUpSeconds); + if (_t < TiltUpSeconds + HoldSeconds) return 1f; + return 1f - SmootherStep((_t - TiltUpSeconds - HoldSeconds) / ReturnSeconds); + } + } + + /// + /// 0 = the load still sits in the bed, 1 = it is gone. Accelerates, like + /// ore sliding down an incline. + /// + public float Release01 + { + get + { + if (!_active) return 0f; + float x = Mathf.Clamp01((_t - ReleaseStartSeconds) / ReleaseSeconds); + return x * x; + } + } + + private static float SmootherStep(float x) + { + x = Mathf.Clamp01(x); + return x * x * x * (x * (x * 6f - 15f) + 10f); + } + } + + /// + /// The Aetherium load of a harvester, generated at runtime (no asset): a + /// low faceted mound with a few crystal shards, glowing in Aetherium cyan + /// through the same URP bloom the art pass uses. It hangs on the bed node + /// (or on the Cargo_Anchor of a bed-less model) so it tips with it, + /// and its size follows the fill level smoothly; empty = not rendered. + /// + /// The faction/health tint of UnitViewManager is written into every + /// renderer of the view as a property block of _BaseColor, which + /// would override a material colour; the pile therefore carries its colour + /// in a texture and only the emission is a material colour. + /// + /// + public sealed class CargoPile + { + private const int ShardCount = 16; + + private static Mesh _moundMesh; + private static Mesh _shardMesh; + private static Material _moundMaterial; + private static Material _shardMaterial; + private static bool _materialsFailed; + + private readonly Transform _wrapper; + private readonly Transform _mound; + private readonly Transform _shards; + private readonly Renderer _moundRenderer; + private readonly Renderer _shardRenderer; + private readonly Vector3 _size; // root-local: width, mound height, depth + private readonly Vector3 _basePosition; // anchor-local + private readonly Vector3 _slideAxis; // anchor-local, toward the unit's rear, per root-local unit + private readonly Vector3 _downAxis; // anchor-local, per root-local unit + + private float _fill; + private float _release; + + /// Current display fill, 0..1 (after ). + public float Fill => _fill; + + /// Current slide-out progress, 0..1. + public float Release => _release; + + /// World-space centre of the pile (for the dust burst). + public Vector3 WorldCenter => _wrapper != null ? _wrapper.position : Vector3.zero; + + /// Mound scale after the last , in root-local units. + public Vector3 MoundScale => _mound != null ? _mound.localScale : Vector3.zero; + + public bool Visible => _moundRenderer != null && _moundRenderer.enabled; + + private CargoPile(Transform root, Transform anchor, Vector3 baseRootLocal, Vector3 sizeRootLocal) + { + _size = sizeRootLocal; + + var wrapperGo = new GameObject("Cargo"); + _wrapper = wrapperGo.transform; + _wrapper.SetParent(anchor, false); + // The wrapper's axes follow the unit root, and one of its local + // units is one ROOT-local unit: everything below is then written + // in root-local metres whatever the FBX axis baking did to the anchor. + float anchorUnit = Mathf.Max(1e-5f, anchor.TransformVector(Vector3.up).magnitude); + float rootUnit = Mathf.Max(1e-5f, root.TransformVector(Vector3.up).magnitude); + _wrapper.localRotation = Quaternion.Inverse(anchor.rotation) * root.rotation; + _wrapper.localScale = Vector3.one * (rootUnit / anchorUnit); + _basePosition = anchor.InverseTransformPoint(root.TransformPoint(baseRootLocal)); + _wrapper.localPosition = _basePosition; + _slideAxis = anchor.InverseTransformVector(root.TransformVector(Vector3.back)); + _downAxis = anchor.InverseTransformVector(root.TransformVector(Vector3.down)); + + _mound = CreatePart("Mound", GetMoundMesh(), GetMoundMaterial(), out _moundRenderer); + _shards = CreatePart("Shards", GetShardMesh(), GetShardMaterial(), out _shardRenderer); + Apply(0f, 0f); + } + + /// + /// Builds a pile under . + /// is the middle of the pile's footprint at the bed floor and + /// its (width, mound height, depth), both + /// in the unit root's local space. Null when no shader is available. + /// + public static CargoPile Create(Transform root, Transform anchor, Vector3 baseRootLocal, Vector3 sizeRootLocal) + { + if (root == null || anchor == null) return null; + if (GetMoundMaterial() == null || GetShardMaterial() == null) return null; + return new CargoPile(root, anchor, baseRootLocal, sizeRootLocal); + } + + /// + /// Sets the displayed fill (0 = empty and invisible, 1 = heaped) and how + /// far the load has slid out of the bed (0 = in, 1 = gone). + /// + public void Apply(float fill, float release) + { + _fill = Mathf.Clamp01(fill); + _release = Mathf.Clamp01(release); + bool visible = _fill > 0.004f && _release < 0.995f; + if (_moundRenderer != null) _moundRenderer.enabled = visible; + if (_shardRenderer != null) _shardRenderer.enabled = visible; + if (!visible) return; + + float grow = Mathf.Sqrt(_fill); + float shrink = 1f - 0.65f * _release; + float widthK = Mathf.Lerp(0.82f, 1f, grow) * shrink; + float heightK = (0.08f + 0.92f * _fill) * (1f - 0.8f * _release); + _mound.localScale = new Vector3(_size.x * widthK, _size.y * heightK, _size.z * widthK); + + // The crystals break through the ore only once there is a heap. + float shardK = Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(0.08f, 0.7f, _fill)); + // Crystals keep a minimum height so they break through even a shallow load. + _shards.localScale = new Vector3(_size.x * widthK, _size.y * Mathf.Max(heightK, 0.45f) * shardK, _size.z * widthK); + _shards.gameObject.SetActive(shardK > 0.01f); + + float r = _release; + _wrapper.localPosition = _basePosition + + _slideAxis * (_size.z * 1.1f * r) + + _downAxis * (_size.y * 0.35f * r); + } + + // ------------------------------------------------------------ build + + private Transform CreatePart(string name, Mesh mesh, Material material, out Renderer renderer) + { + var go = new GameObject(name); + go.transform.SetParent(_wrapper, false); + go.AddComponent().sharedMesh = mesh; + var mr = go.AddComponent(); + mr.sharedMaterial = material; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + renderer = mr; + return go.transform; + } + + private static Material GetMoundMaterial() + { + EnsureMaterials(); + return _moundMaterial; + } + + private static Material GetShardMaterial() + { + EnsureMaterials(); + return _shardMaterial; + } + + private static void EnsureMaterials() + { + if (_moundMaterial != null && _shardMaterial != null) return; + if (_materialsFailed) return; + Shader shader = Shader.Find("Universal Render Pipeline/Lit"); + if (shader == null) shader = Shader.Find("Standard"); + if (shader == null) { _materialsFailed = true; return; } + + // Aetherium Cyan of the HUD (0.20, 0.88, 0.92). + _moundMaterial = MakeMaterial(shader, "Nova_Cargo_Ore", + new Color(0.20f, 0.17f, 0.15f), new Color(0.02f, 0.10f, 0.12f) * 0.5f, 0.15f); + _shardMaterial = MakeMaterial(shader, "Nova_Cargo_Crystal", + new Color(0.10f, 0.62f, 0.72f), new Color(0.20f, 0.88f, 0.92f) * 0.9f, 0.75f); + } + + private static Material MakeMaterial(Shader shader, string name, Color albedo, Color emission, float smoothness) + { + var m = new Material(shader) { name = name, hideFlags = HideFlags.DontSave }; + var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false) { hideFlags = HideFlags.DontSave, wrapMode = TextureWrapMode.Clamp }; + tex.SetPixels(new[] { albedo, albedo, albedo, albedo }); + tex.Apply(); + if (m.HasProperty("_BaseMap")) m.SetTexture("_BaseMap", tex); + if (m.HasProperty("_MainTex")) m.SetTexture("_MainTex", tex); + if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", Color.white); + if (m.HasProperty("_Color")) m.SetColor("_Color", Color.white); + if (m.HasProperty("_Smoothness")) m.SetFloat("_Smoothness", smoothness); + if (m.HasProperty("_Glossiness")) m.SetFloat("_Glossiness", smoothness); + if (m.HasProperty("_EmissionColor")) m.SetColor("_EmissionColor", emission); + m.EnableKeyword("_EMISSION"); + // URP drops _EMISSION during material validation unless the GI flag + // allows realtime emission (see AssetPass_2026-10-17 section 6). + m.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive; + return m; + } + + // ------------------------------------------------------------- mesh + + /// + /// Unit mound: footprint radius 0.5 around the origin, height 1, flat + /// shaded and slightly lumpy. Deterministic. + /// + internal static Mesh GetMoundMesh() + { + if (_moundMesh != null) return _moundMesh; + const int Segments = 14; + const int Rings = 4; + var rng = new System.Random(7731); + // Grid of vertices on a dome, perturbed; triangles then get their own vertices (flat look). + var grid = new Vector3[(Rings + 1) * Segments]; + for (int ring = 0; ring <= Rings; ring++) + { + float rho = 1f - ring / (float)Rings; // 1 at the rim, 0 at the top + for (int s = 0; s < Segments; s++) + { + float a = s / (float)Segments * Mathf.PI * 2f; + float lump = 0.88f + 0.24f * (float)rng.NextDouble(); + // Superellipse (n = 4): a rounded rectangle that fills a dump bed. + float c4 = Mathf.Pow(Mathf.Abs(Mathf.Cos(a)), 4f), s4 = Mathf.Pow(Mathf.Abs(Mathf.Sin(a)), 4f); + float square = Mathf.Pow(c4 + s4, -0.25f); + float rad = 0.5f * square * Mathf.Sqrt(rho) * (ring == 0 ? 1f : lump); + float h = Mathf.Pow(Mathf.Max(0f, 1f - rho * rho), 0.45f) * (0.85f + 0.3f * (float)rng.NextDouble()); + if (ring == Rings) h = 1f; + grid[ring * Segments + s] = new Vector3(Mathf.Cos(a) * rad, ring == 0 ? 0f : h, Mathf.Sin(a) * rad); + } + } + var verts = new List(); + var tris = new List(); + void Tri(Vector3 a, Vector3 b, Vector3 c) + { + int i = verts.Count; + verts.Add(a); verts.Add(b); verts.Add(c); + tris.Add(i); tris.Add(i + 1); tris.Add(i + 2); + } + for (int ring = 0; ring < Rings; ring++) + { + for (int s = 0; s < Segments; s++) + { + int s1 = (s + 1) % Segments; + Vector3 a = grid[ring * Segments + s], b = grid[ring * Segments + s1]; + Vector3 c = grid[(ring + 1) * Segments + s], d = grid[(ring + 1) * Segments + s1]; + // Outward-facing (viewed from outside, clockwise in Unity): a -> c -> b etc. + Tri(a, c, b); + if (ring + 1 < Rings) Tri(b, c, d); + } + } + _moundMesh = FinishMesh("Nova_CargoMound", verts, tris); + return _moundMesh; + } + + /// + /// Unit shard cluster: hexagonal crystals with + /// pointed tips, rooted inside the mound (y from 0.3) and leaning outward. + /// + internal static Mesh GetShardMesh() + { + if (_shardMesh != null) return _shardMesh; + var rng = new System.Random(4096); + var verts = new List(); + var tris = new List(); + for (int k = 0; k < ShardCount; k++) + { + float a = (k / (float)ShardCount) * Mathf.PI * 2f + (float)rng.NextDouble() * 0.6f; + float dist = k == 0 ? 0.04f : 0.10f + 0.30f * (float)rng.NextDouble(); + var root = new Vector3(Mathf.Cos(a) * dist, 0.25f - 0.3f * dist, Mathf.Sin(a) * dist); + float height = k == 0 ? 1.6f : 0.95f + 0.6f * (float)rng.NextDouble(); + float radius = 0.06f + 0.045f * (float)rng.NextDouble(); + float lean = k == 0 ? 6f : 12f + 18f * (float)rng.NextDouble(); + Quaternion tilt = Quaternion.AngleAxis(lean, new Vector3(-Mathf.Sin(a), 0f, Mathf.Cos(a))) + * Quaternion.AngleAxis((float)rng.NextDouble() * 60f, Vector3.up); + AddCrystal(verts, tris, root, tilt, radius, height); + } + _shardMesh = FinishMesh("Nova_CargoShards", verts, tris); + return _shardMesh; + } + + private static void AddCrystal(List verts, List tris, Vector3 root, Quaternion rot, float radius, float height) + { + const int Sides = 6; + var ring0 = new Vector3[Sides]; + var ring1 = new Vector3[Sides]; + float shoulder = height * 0.68f; + for (int i = 0; i < Sides; i++) + { + float ang = i / (float)Sides * Mathf.PI * 2f; + var p = new Vector3(Mathf.Cos(ang) * radius, 0f, Mathf.Sin(ang) * radius); + ring0[i] = root + rot * p; + ring1[i] = root + rot * (p + Vector3.up * shoulder); + } + Vector3 tip = root + rot * (Vector3.up * height); + void Tri(Vector3 a, Vector3 b, Vector3 c) + { + int n = verts.Count; + verts.Add(a); verts.Add(b); verts.Add(c); + tris.Add(n); tris.Add(n + 1); tris.Add(n + 2); + } + for (int i = 0; i < Sides; i++) + { + int j = (i + 1) % Sides; + Tri(ring0[i], ring1[i], ring0[j]); + Tri(ring0[j], ring1[i], ring1[j]); + Tri(ring1[i], tip, ring1[j]); + } + } + + private static Mesh FinishMesh(string name, List verts, List tris) + { + var mesh = new Mesh { name = name, hideFlags = HideFlags.DontSave }; + mesh.SetVertices(verts); + mesh.SetTriangles(tris, 0); + var uvs = new Vector2[verts.Count]; + for (int i = 0; i < uvs.Length; i++) uvs[i] = new Vector2(0.5f, 0.5f); + mesh.uv = uvs; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs.meta new file mode 100644 index 00000000..3f4785b3 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/HarvesterCargo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8512bfbd169c459db92dd01aa1fa4be \ No newline at end of file diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs index fefaedf0..3eec8b91 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs @@ -24,6 +24,15 @@ public struct UnitRigInput /// Harvesting, building, producing: the role's "busy" motion runs. public bool Working; + + /// Harvester load, 0 (empty) .. 1 (full). Drives the cargo pile. + public float CargoFill; + + /// True on the frame the harvester's load was unloaded (cargo > 0 to 0). + public bool Dump; + + /// Fill the harvester carried right before the unload; read when is set. + public float DumpFromFill; } /// @@ -44,6 +53,14 @@ public struct UnitRigInput /// Spin_* — turn continuously (radar dishes, rotors). /// Scan_* — sweep back and forth (dishes mounted off-axis). /// Arm_* — swing while the unit works. + /// Smoke_* — chimneys: puff smoke, faster while the building works. + /// Ambient: vehicles kick up sand while driving and idle-rumble when + /// standing, turrets scan the ground when they have no target, infantry + /// breathe and look around (D-115). + /// Bed — dump body of a harvester, pivoted at its rear hinge: tips + /// back when the load is unloaded. A procedural Aetherium pile rides on it and + /// grows with the fill; models without a bed (Cargo_Anchor only) carry + /// the pile on their back and tip the whole body instead. /// Leg_* / Shin_* — rigid thighs and shins of infantry: /// a two-joint walk cycle whose cadence follows the measured speed, so feet /// do not slide; _R runs half a cycle behind _L. @@ -67,6 +84,15 @@ public sealed class UnitRigAnimator : MonoBehaviour public const string ScanPrefix = "Scan_"; public const string LegPrefix = "Leg_"; public const string ShinPrefix = "Shin_"; + public const string SmokePrefix = "Smoke_"; + public const string BedNode = "Bed"; + public const string CargoAnchorNode = "Cargo_Anchor"; + + /// How far the dump bed tips back, in degrees. + public const float BedTiltDegrees = 50f; + + /// How far a bed-less harvester leans back to shed its load, in degrees. + public const float BodyTiltDegrees = 11f; /// Thigh swing to either side of vertical, in degrees. private const float ThighSwing = 25f; @@ -119,10 +145,37 @@ private struct Node private readonly List _scans = new List(1); private readonly List _legs = new List(2); private readonly List _shins = new List(2); + private Node _bed; + private bool _hasBed; + private Transform _cargoAnchor; + private CargoPile _pile; + private HarvesterDumpCycle _dump; + private float _bedSign = -1f; // measured at bind: which way lifts the front + private float _bodyTiltSign = -1f; + private float _cargoShown; + private bool _cargoInit; + private float _frozenFill; + private float _workBlend; + private float _workDustTimer; + private float _burstAccum; + private float _bodyTiltDeg; + private float _armShake; + private Vector3 _tiltPivotBody; // body-local point that stays put while the body leans back + private Vector3 _tiltPivotRoot; // the same point in root-local space at rest private float _hipHeight; // root-local units, from the Leg_ pivots private float _gaitPhase; private float _fireKick; + private readonly List _smokes = new List(2); + private float _time; + private float _idleTime; + private float _dustAccum; + private float _smokeTimer; + private float _lookYaw; + private float _lookTarget; + private float _lookTimer; + private float _footprintWorld = 1f; + private Renderer[] _glowRenderers = System.Array.Empty(); private Color[] _glowBase = System.Array.Empty(); private float _glowPhase; @@ -169,6 +222,7 @@ public void ResetPose() { EnsureBound(); _turretYaw = 0f; _recoil = 0f; _bobPhase = 0f; _wheelAngle = 0f; + _armShake = 0f; _armPhase = 0f; _armBlend = 0f; _lastSpeed = 0f; _pitch = 0f; _roll = 0f; _moveBlend = 0f; Restore(_body, _hasBody); Restore(_turret, _hasTurret); @@ -181,6 +235,48 @@ public void ResetPose() for (int i = 0; i < _shins.Count; i++) Restore(_shins[i], true); _gaitPhase = 0f; _fireKick = 0f; + _idleTime = 0f; + _lookYaw = 0f; + _dustAccum = 0f; + ResetCargo(); + } + + /// True when the model can carry a load (a dump bed or a back anchor). + public bool CarriesCargo + { + get { EnsureBound(); return _pile != null; } + } + + /// True while the unload sequence plays. + public bool Dumping => _dump.Active; + + /// Bed tilt, 0 (down) .. 1 (fully tipped); 0 for models without a bed. + public float BedTilt01 => _dump.Tilt01; + + /// The procedural cargo pile, or null when the model carries none. + public CargoPile Pile + { + get { EnsureBound(); return _pile; } + } + + /// The dump bed node, or null. + public Transform BedTransform + { + get { EnsureBound(); return _hasBed ? _bed.T : null; } + } + + private void ResetCargo() + { + _dump.Reset(); + _cargoShown = 0f; + _cargoInit = false; + _frozenFill = 0f; + _workBlend = 0f; + _workDustTimer = 0f; + _burstAccum = 0f; + _bodyTiltDeg = 0f; + Restore(_bed, _hasBed); + if (_pile != null) _pile.Apply(0f, 0f); } /// Advances every animated node by one rendered frame. @@ -192,6 +288,12 @@ public void Tick(in UnitRigInput input, float dt) float speed = input.Speed; float moving = speed > 0.15f ? 1f : 0f; _moveBlend = Mathf.MoveTowards(_moveBlend, moving, dt * 4f); + _time += dt; + _idleTime = moving > 0f || input.HasTarget ? 0f : _idleTime + dt; + + TickDust(speed, dt); + TickSmoke(input.Working, dt); + TickCargo(in input, dt); TickBody(input, speed, dt); TickTurret(input, dt); @@ -224,7 +326,7 @@ public void Tick(in UnitRigInput input, float dt) for (int i = 0; i < _arms.Count; i++) { Node a = _arms[i]; - float swing = Mathf.Sin(_armPhase + a.Phase) * _armSwingDegrees * _armBlend; + float swing = Mathf.Sin(_armPhase + a.Phase) * _armSwingDegrees * _armBlend + _armShake; a.T.localRotation = Quaternion.AngleAxis(swing, a.Right) * a.RestRot; } @@ -232,6 +334,94 @@ public void Tick(in UnitRigInput input, float dt) _lastSpeed = speed; } + /// + /// Harvester load: the pile follows the fill, an unload plays the dump + /// sequence (bed or body tips, pile slides out in a burst of glowing + /// dust, everything returns) and a working harvester labours. + /// + private void TickCargo(in UnitRigInput input, float dt) + { + if (_pile == null) return; + + _workBlend = Mathf.MoveTowards(_workBlend, input.Working && !_dump.Active ? 1f : 0f, dt * 2.5f); + TickWorkDust(dt); + + if (input.Dump) + { + _dump.Start(); + _frozenFill = Mathf.Clamp01(input.DumpFromFill > 0f ? input.DumpFromFill : Mathf.Max(_cargoShown, 0.5f)); + _cargoShown = 0f; + _burstAccum = 0f; + } + _dump.Advance(dt); + + float fill, release = 0f; + if (_dump.Active) + { + fill = _frozenFill; + release = _dump.Release01; + } + else + { + float target = Mathf.Clamp01(input.CargoFill); + if (!_cargoInit) + { + _cargoShown = target; + _cargoInit = true; + } + else + { + // Eases toward the sim value: the sim fills in steps, the view should not. + _cargoShown = Mathf.Lerp(_cargoShown, target, 1f - Mathf.Exp(-dt * 3f)); + if (Mathf.Abs(_cargoShown - target) < 0.001f) _cargoShown = target; + } + fill = _cargoShown; + } + + float tilt = _dump.Tilt01; + bool releasing = _dump.Active && release > 0f && release < 1f; + // The bed shudders while the load lets go. + float shake = releasing ? Mathf.Sin(_time * 46f) * 1.4f * Mathf.Sin(release * Mathf.PI) : 0f; + if (_hasBed) + { + _bed.T.localRotation = Quaternion.AngleAxis(_bedSign * (BedTiltDegrees * tilt + shake), _bed.Right) * _bed.RestRot; + } + else + { + _bodyTiltDeg = BodyTiltDegrees * tilt; + } + _armShake = releasing ? Mathf.Sin(_time * 17f) * 16f * Mathf.Sin(release * Mathf.PI) : 0f; + + _pile.Apply(fill, release); + + if (releasing) + { + _burstAccum += dt * 55f; + Vector3 back = -transform.forward; + while (_burstAccum >= 1f) + { + _burstAccum -= 1f; + Vector3 pos = _pile.WorldCenter + Random.insideUnitSphere * (_footprintWorld * 0.12f); + Vector3 drift = back * Random.Range(0.4f, 1.1f) + Vector3.up * Random.Range(0.2f, 0.6f); + float size = _footprintWorld * Random.Range(0.10f, 0.20f); + AmbientFx.GlowDust(pos, drift, size); + if (Random.value < 0.5f) AmbientFx.Dust(pos - transform.up * (_footprintWorld * 0.08f), drift * 0.5f, size * 1.4f); + } + } + } + + /// Digging while harvesting: a little sand now and then in front of the vehicle. + private void TickWorkDust(float dt) + { + if (_workBlend < 0.8f) return; + _workDustTimer -= dt; + if (_workDustTimer > 0f) return; + _workDustTimer = Random.Range(0.35f, 0.6f); + Vector3 pos = transform.position + transform.forward * (_footprintWorld * 0.5f) + + transform.right * Random.Range(-0.25f, 0.25f) * _footprintWorld; + AmbientFx.Dust(pos, transform.forward * 0.3f, _footprintWorld * 0.22f); + } + private void TickGlow(bool working, float dt) { if (!_emissivePulse || _glowRenderers.Length == 0) return; @@ -255,7 +445,13 @@ private void TickGlow(bool working, float dt) /// Editor/preview hook (ArtPreviewCapture): a representative mid-drive, /// mid-fight pose after of simulated time. /// - public void PreviewPose(float seconds) + public void PreviewPose(float seconds) => PreviewPose(seconds, -1f); + + /// + /// As ; with >= 0 a harvester + /// then unloads and is shown that far into the dump (ArtPreviewCapture -captureDump). + /// + public void PreviewPose(float seconds, float dumpSeconds) { ResetPose(); var input = new UnitRigInput @@ -265,6 +461,7 @@ public void PreviewPose(float seconds) HasTarget = true, TargetPosition = transform.position + transform.rotation * new Vector3(20f, 0f, 12f), Working = true, + CargoFill = 0.5f, }; const float step = 1f / 30f; for (float t = 0f; t < seconds; t += step) @@ -272,6 +469,11 @@ public void PreviewPose(float seconds) input.Fired = t + step >= seconds - 0.05f && t < seconds - 0.05f; Tick(input, step); } + if (dumpSeconds < 0f) return; + var unload = new UnitRigInput { CargoFill = 0f, Dump = true, DumpFromFill = 0.9f }; + Tick(unload, step); + unload.Dump = false; + for (float t = step; t < dumpSeconds; t += step) Tick(unload, step); } private void TickBody(in UnitRigInput input, float speed, float dt) @@ -294,6 +496,10 @@ private void TickBody(in UnitRigInput input, float speed, float dt) offset = -_body.Up * (_hipHeight * (1f - Mathf.Cos(spread * Mathf.Deg2Rad))); pitchTarget = 5f * _moveBlend - 6f * _fireKick; rollTarget = Mathf.Sin(_gaitPhase) * 3f * _moveBlend; + // Standing: breathe, and now and then look around. + float rest = 1f - _moveBlend; + offset += _body.Up * (Mathf.Sin(_time * 1.6f) * 0.006f * _size * rest); + TickLook(dt, rest); } else if (_infantryGait) { @@ -313,7 +519,20 @@ private void TickBody(in UnitRigInput input, float speed, float dt) float bob = (Mathf.Sin(_bobPhase) * 0.6f + Mathf.Sin(_bobPhase * 2.3f) * 0.4f) * 0.012f * _size * _moveBlend; offset = _body.Up * bob; pitchTarget = Mathf.Clamp(-accel * 0.9f, -3.5f, 3.5f); + if (_workBlend > 0f) + { + // Harvesting: the engine labours (fast small shake) and the + // vehicle nods slowly with the pull of the load. + offset += _body.Up * ((Mathf.Sin(_time * 33f) * 0.0045f + Mathf.Sin(_time * 21f) * 0.003f) * _size * _workBlend); + pitchTarget += Mathf.Sin(_time * 2.1f) * 1.1f * _workBlend; + } rollTarget = Mathf.Clamp(-input.TurnRate * 0.05f, -3f, 3f) * _moveBlend; + if (!_emissivePulse) + { + // Engine idle: a faint high-frequency rumble while standing. + float idle = (1f - _moveBlend) * 0.0025f * _size; + offset += _body.Up * (Mathf.Sin(_time * 47f) * idle + Mathf.Sin(_time * 31f) * idle * 0.6f); + } } float k = 1f - Mathf.Exp(-dt * 8f); @@ -321,9 +540,65 @@ private void TickBody(in UnitRigInput input, float speed, float dt) _roll = Mathf.Lerp(_roll, rollTarget, k); _body.T.localPosition = _body.RestPos + offset * _body.Scale; - _body.T.localRotation = Quaternion.AngleAxis(_pitch, _body.Right) + _body.T.localRotation = Quaternion.AngleAxis(_lookYaw, _body.Up) + * Quaternion.AngleAxis(_pitch + _bodyTiltSign * _bodyTiltDeg, _body.Right) * Quaternion.AngleAxis(_roll, _body.Forward) * _body.RestRot; + if (_bodyTiltDeg > 0.01f) KeepTiltPivotInPlace(); + } + + /// + /// Leaning back about the body origin would sink the rear into the ground; + /// the body is shifted so the rear-bottom point stays where it was. + /// + private void KeepTiltPivotInPlace() + { + Vector3 now = transform.InverseTransformPoint(_body.T.TransformPoint(_tiltPivotBody)); + Vector3 worldDelta = transform.TransformVector(_tiltPivotRoot - now); + Transform parent = _body.T.parent != null ? _body.T.parent : transform; + _body.T.localPosition += parent.InverseTransformVector(worldDelta); + } + + /// Infantry glance: a new look direction every few seconds, eased. + private void TickLook(float dt, float rest) + { + _lookTimer -= dt; + if (_lookTimer <= 0f) + { + _lookTimer = Random.Range(2.5f, 5.5f); + _lookTarget = Random.value < 0.4f ? 0f : Random.Range(-22f, 22f); + } + _lookYaw = Mathf.MoveTowards(_lookYaw, _lookTarget * rest, dt * 40f); + } + + /// Sand kicked up behind a driving vehicle, one puff per stretch of ground. + private void TickDust(float speed, float dt) + { + if (_infantryGait || _legs.Count > 0 || _emissivePulse || speed < 0.3f) return; + _dustAccum += speed * dt; + float every = Mathf.Max(0.18f, _footprintWorld * 0.35f); + if (_dustAccum < every) return; + _dustAccum = 0f; + Vector3 back = -transform.forward * (_footprintWorld * 0.45f); + Vector3 side = transform.right * (_footprintWorld * 0.3f); + Vector3 drift = -transform.forward * speed * 0.15f; + float size = _footprintWorld * 0.45f; + AmbientFx.Dust(transform.position + back + side, drift, size); + AmbientFx.Dust(transform.position + back - side, drift, size); + } + + /// Chimney smoke: a steady puff, twice as often while working. + private void TickSmoke(bool working, float dt) + { + if (_smokes.Count == 0) return; + _smokeTimer -= dt; + if (_smokeTimer > 0f) return; + _smokeTimer = working ? 0.22f : 0.5f; + float size = _footprintWorld * 0.16f; + for (int i = 0; i < _smokes.Count; i++) + { + if (_smokes[i] != null) AmbientFx.Smoke(_smokes[i].position, size, working ? 0.55f : 0.35f); + } } /// @@ -359,6 +634,7 @@ private void TickTurret(in UnitRigInput input, float dt) if (!_hasTurret) return; float desired = 0f; + float rate = _turretTurnRate; if (input.HasTarget) { Vector3 local = transform.InverseTransformPoint(input.TargetPosition); @@ -367,8 +643,15 @@ private void TickTurret(in UnitRigInput input, float dt) desired = Mathf.Atan2(local.x, local.z) * Mathf.Rad2Deg; } } + else if (_idleTime > 2.5f) + { + // Idle scan: the turret sweeps the ground slowly, with pauses + // at the ends of the arc (the sine flattens there). + desired = Mathf.Sin((_idleTime - 2.5f) * 0.32f + _gaitPhase) * 38f; + rate = 25f; + } float delta = Mathf.DeltaAngle(_turretYaw, desired); - _turretYaw += Mathf.Clamp(delta, -_turretTurnRate * dt, _turretTurnRate * dt); + _turretYaw += Mathf.Clamp(delta, -rate * dt, rate * dt); _turret.T.localRotation = Quaternion.AngleAxis(_turretYaw, _turret.Up) * _turret.RestRot; } @@ -394,6 +677,7 @@ private void EnsureBound() Bounds b = RendererBounds(transform); float rootScale = Mathf.Max(1e-4f, transform.lossyScale.x); _size = Mathf.Max(0.5f, Mathf.Max(b.size.x, b.size.z) / rootScale); + _footprintWorld = Mathf.Max(0.3f, Mathf.Max(b.size.x, b.size.z)); Transform body = FindDeep(transform, BodyNode); if (body == null) @@ -424,6 +708,11 @@ private void EnsureBound() _glowBase = glowBase.ToArray(); _glowPhase = Random.value * 6.28f; + Transform bed = FindLive(transform, BedNode); + _hasBed = bed != null; + if (_hasBed) _bed = Capture(bed); + BuildCargoPile(bed, FindLive(transform, CargoAnchorNode)); + var all = GetComponentsInChildren(true); for (int i = 0; i < all.Length; i++) { @@ -457,6 +746,10 @@ private void EnsureBound() _shins.Add(limb); } } + else if (n.StartsWith(SmokePrefix, System.StringComparison.Ordinal)) + { + _smokes.Add(t); + } else if (n.StartsWith(ScanPrefix, System.StringComparison.Ordinal)) { _scans.Add(Capture(t)); @@ -470,6 +763,75 @@ private void EnsureBound() } } + /// + /// Hangs the procedural cargo pile on the bed (tips with it) or, for a + /// model without one, on its Cargo_Anchor (the body leans back instead). + /// Everything is measured in the unit root's local space. + /// + private void BuildCargoPile(Transform bed, Transform anchor) + { + if (bed != null && TryLocalBounds(bed, out Bounds lb)) + { + var size = new Vector3(lb.size.x * 0.86f, lb.size.y * 0.5f, lb.size.z * 0.84f); + var floor = new Vector3(lb.center.x, lb.min.y + lb.size.y * 0.10f, lb.center.z); + _pile = CargoPile.Create(transform, bed, floor, size); + _bedSign = LiftSign(_bed, new Vector3(lb.center.x, lb.center.y, lb.max.z)); + } + else if (anchor != null && _hasBody) + { + float f = _size; + // Slightly forward and up onto the rear deck: at the anchor itself half the load hung + // over the tail, further forward the hull hides it from the RTS camera. + Vector3 p = transform.InverseTransformPoint(anchor.position) + new Vector3(0f, f * 0.07f, f * 0.04f); + _pile = CargoPile.Create(transform, anchor, p, new Vector3(f * 0.30f, f * 0.09f, f * 0.22f)); + if (_pile != null && TryLocalBounds(transform, out Bounds all)) + { + _tiltPivotRoot = new Vector3(all.center.x, all.min.y, all.min.z); + _tiltPivotBody = _body.T.InverseTransformPoint(transform.TransformPoint(_tiltPivotRoot)); + _bodyTiltSign = LiftSign(_body, new Vector3(all.center.x, all.center.y, all.max.z)); + } + } + } + + /// + /// Which sign of a rotation about the node's right axis lifts a point at the + /// front of the model. Measured instead of assumed, because the FBX axis + /// conversion may mirror the node's coordinate system. + /// + private float LiftSign(Node n, Vector3 frontRootLocal) + { + Vector3 inNode = n.T.InverseTransformPoint(transform.TransformPoint(frontRootLocal)); + Quaternion rot = n.T.localRotation; + Vector3 pos = n.T.localPosition; + n.T.localRotation = Quaternion.AngleAxis(-10f, n.Right) * n.RestRot; + float y = transform.InverseTransformPoint(n.T.TransformPoint(inNode)).y; + n.T.localRotation = rot; + n.T.localPosition = pos; + return y > frontRootLocal.y ? -1f : 1f; + } + + /// Mesh bounds of in the unit root's local space (wreck excluded). + private bool TryLocalBounds(Transform sub, out Bounds bounds) + { + bounds = default; + bool any = false; + Transform wreck = transform.Find(ViewWrecks.WreckNode); + foreach (MeshFilter mf in sub.GetComponentsInChildren(true)) + { + if (mf.sharedMesh == null) continue; + if (wreck != null && mf.transform.IsChildOf(wreck)) continue; + Bounds mb = mf.sharedMesh.bounds; + for (int i = 0; i < 8; i++) + { + Vector3 c = mb.center + Vector3.Scale(mb.extents, new Vector3((i & 1) == 0 ? -1f : 1f, (i & 2) == 0 ? -1f : 1f, (i & 4) == 0 ? -1f : 1f)); + Vector3 p = transform.InverseTransformPoint(mf.transform.TransformPoint(c)); + if (!any) { bounds = new Bounds(p, Vector3.zero); any = true; } + else bounds.Encapsulate(p); + } + } + return any; + } + /// /// Single-mesh prefabs have no suspension node: wrap every direct child /// in a fresh Body so the bob/lean has something to move without @@ -518,6 +880,19 @@ private static void Restore(Node n, bool has) n.T.localRotation = n.RestRot; } + /// Like , but never looks into the wreck child. + private static Transform FindLive(Transform root, string name) + { + if (root.name == name) return root; + foreach (Transform child in root) + { + if (child.name == ViewWrecks.WreckNode) continue; + Transform hit = FindLive(child, name); + if (hit != null) return hit; + } + return null; + } + private static Transform FindDeep(Transform root, string name) { if (root.name == name) return root; @@ -529,12 +904,18 @@ private static Transform FindDeep(Transform root, string name) return null; } + /// World bounds of the live model (the wreck child, D-115, is not part of it). private static Bounds RendererBounds(Transform t) { - Renderer[] rs = t.GetComponentsInChildren(true); - if (rs.Length == 0) return new Bounds(t.position, Vector3.one); - Bounds b = rs[0].bounds; - for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds); + Transform wreck = t.Find(ViewWrecks.WreckNode); + bool any = false; + Bounds b = new Bounds(t.position, Vector3.one); + foreach (Renderer r in t.GetComponentsInChildren(true)) + { + if (wreck != null && r.transform.IsChildOf(wreck)) continue; + if (!any) { b = r.bounds; any = true; } + else b.Encapsulate(r.bounds); + } return b; } } diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index 98a57d8b..fb06e0d9 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -78,20 +78,20 @@ public class UnitViewManager : MonoBehaviour private const float PrefabGroundOffset = 0f; /// - /// Uniform scale factor that shrinks a prefab's horizontal extent to - /// its simulation footprint when the measured bounds come out larger - /// than the footprint. Art is authored at the ArtAssetStandard.md - /// convention (1 grid cell = 3 m) while the simulation world runs 1 - /// cell = 1 world unit, so the raw PF_* meshes are ~3x too large and - /// would overlap (GB-005 finding). Normalizing here — at the view - /// boundary, from the mesh's own bounds — keeps every future drop-in - /// model swappable without touching prefabs or game logic. Art that - /// arrives SMALLER than its footprint keeps its authored scale - /// (factor 1): upscaling a detailed miniature reads worse than a - /// slightly small model, and the authored look is preserved. + /// Uniform scale factor per source prefab, measured once in + /// . Art is authored at the + /// ArtAssetStandard.md convention (1 grid cell = 3 m) while the + /// simulation world runs 1 cell = 1 world unit, so the raw PF_* meshes + /// are ~3x too large and would overlap (GB-005 finding). Normalizing + /// here — at the view boundary, from the mesh's own bounds — keeps + /// every future drop-in model swappable without touching prefabs or + /// game logic. /// private readonly Dictionary _prefabScaleFactors = new Dictionary(); + /// Scaled horizontal extent (max of width, depth) per source prefab, measured with the factor. + private readonly Dictionary _prefabFootprints = new Dictionary(); + [Header("References")] [SerializeField] private MatchRunner _matchRunner; [SerializeField] private GameObject _unitPrefab; @@ -136,7 +136,10 @@ public class UnitViewManager : MonoBehaviour // rendered position/yaw so speed and turn rate are measured on the // VIEW — the smoothed motion the player sees, not the 10-Hz sim steps. private UnitRigAnimator[] _viewRigs; + private float[] _viewFootprints; private Vector3[] _lastViewPositions; + /// Cargo of each bound harvester view on its previous frame: the unload edge (>0 to 0) starts the dump animation. + private int[] _lastCargoAE; private float[] _lastViewYaws; private bool[] _firedThisFrame; private bool[] _buildingThisFrame; @@ -193,6 +196,11 @@ private static Material PrimitiveMaterial /// Views currently held outside the slot table for the 0.8-second death presentation. public int ActiveDeathHoldCount => _deathHolds.Count; + /// Destroyed art views currently lying on the field (D-115). + public int WreckCount => _wrecks != null ? _wrecks.Count : 0; + + private ViewWrecks _wrecks; + /// /// Team slot whose committed Fog-of-War view drives the rendered set. /// Negative means "follow ". @@ -243,6 +251,22 @@ public bool TryGetView(EntityId id, out GameObject view) /// is what keeps world picking Fog-of-War legal /// (docs/tech/CameraSystem.md section 1). /// + /// + /// Horizontal size of an entity's view in world units (the larger of + /// width and depth at its authored orientation), for markers that + /// should hug the model rather than a fixed graybox size. + /// + public bool TryGetViewFootprint(EntityId id, out float size) + { + size = 0f; + if (_viewInstances == null || !id.IsValid) return false; + int slot = id.Index; + if (slot < 0 || slot >= _viewInstances.Length) return false; + if (_boundIds[slot] != id || _viewInstances[slot] == null) return false; + size = _viewFootprints[slot]; + return size > 0f; + } + public bool TryGetEntityId(GameObject viewObject, out EntityId id) { id = EntityId.Invalid; @@ -265,6 +289,7 @@ public bool TryGetEntityId(GameObject viewObject, out EntityId id) private void LateUpdate() { AdvanceDeathHolds(Time.deltaTime); + _wrecks?.Advance(Time.deltaTime); if (_matchRunner == null || !_matchRunner.IsRunning) return; EntityManager entities = _matchRunner.Entities; @@ -488,7 +513,9 @@ private void EnsureBuffers() _lastSeenFrame = new int[capacity]; _tracked = new bool[capacity]; _viewRigs = new UnitRigAnimator[capacity]; + _viewFootprints = new float[capacity]; _lastViewPositions = new Vector3[capacity]; + _lastCargoAE = new int[capacity]; _lastViewYaws = new float[capacity]; _firedThisFrame = new bool[capacity]; _buildingThisFrame = new bool[capacity]; @@ -539,6 +566,14 @@ private void AcquireView(int slot, in UnitState unit) float groundOffset; GameObject sourcePrefab = ResolveViewPrefab(in unit); + // A building (or its site) going up clears the wrecks on its + // footprint, so a ruin never pokes through a fresh wall. + if (_wrecks != null && SimDefinitions.IsBuildingRole(unit.Role)) + { + _wrecks.ClearArea(new Vector3(unit.Transform.PositionX.ToFloat(), 0f, unit.Transform.PositionY.ToFloat()), + SimDefinitions.BuildingFootprintCells * 0.85f); + } + if (sourcePrefab != null) { shapeKey = PrefabShapeKey; @@ -619,6 +654,9 @@ private void AcquireView(int slot, in UnitState unit) rig.ResetPose(); } _viewRigs[slot] = rig; + _viewFootprints[slot] = sourcePrefab != null && _prefabFootprints.TryGetValue(sourcePrefab, out float footprint) + ? footprint + : Mathf.Max(instance.transform.localScale.x, instance.transform.localScale.z); if (!_tracked[slot]) { @@ -671,56 +709,32 @@ private GameObject ResolveViewPrefab(in UnitState unit) } /// - /// Measures the freshly instantiated prefab view and caches the - /// uniform scale factor that brings its horizontal extent down to the - /// simulation footprint of its role (see the field remarks on - /// ). The instance must be unscaled - /// (fresh Instantiate) and parented under this transform — - /// world bounds and the local scale applied afterwards share the same - /// parent, so the ratio stays exact. Meshes without renderers, or art - /// already smaller than its footprint, keep factor 1. + /// Measures the freshly instantiated prefab view once per source + /// prefab and caches the uniform scale + /// prescribes for its role. /// private float NormalizePrefabScale(GameObject sourcePrefab, GameObject instance, in UnitState unit) { float factor = 1f; - - Renderer[] renderers = instance.GetComponentsInChildren(true); - if (renderers.Length > 0) + Transform wreck = ViewWrecks.FindWreck(instance); + bool any = false; + Bounds bounds = default; + foreach (Renderer r in instance.GetComponentsInChildren(true)) { - Bounds bounds = renderers[0].bounds; - for (int i = 1; i < renderers.Length; i++) - { - bounds.Encapsulate(renderers[i].bounds); - } - - float target = TargetViewSize(EffectiveViewRole(in unit)); - float current = Mathf.Max(bounds.size.x, bounds.size.z); - if (current > target && current > 1e-4f) - { - factor = target / current; - } + // The inactive wreck child may sprawl wider (rubble); the live + // model alone decides the scale. + if (wreck != null && r.transform.IsChildOf(wreck)) continue; + if (!any) { bounds = r.bounds; any = true; } + else bounds.Encapsulate(r.bounds); } - - _prefabScaleFactors[sourcePrefab] = factor; - return factor; - } - - /// - /// Horizontal world size a view of this role should occupy. Buildings - /// fill their 3x3-cell footprint (, - /// 1 cell = 1 world unit); units reuse the graybox shape table's - /// larger horizontal extent so art and primitive fallback read at the - /// same size for the same role. - /// - private static float TargetViewSize(UnitRole role) - { - if (SimDefinitions.IsBuildingRole(role)) + if (any) { - return SimDefinitions.BuildingFootprintCells; + factor = ArtViewScale.Factor(EffectiveViewRole(in unit), bounds.size); + _prefabFootprints[sourcePrefab] = Mathf.Max(bounds.size.x, bounds.size.z) * factor; } - GetRoleShape(role, out _, out Vector3 primitiveScale); - return Mathf.Max(primitiveScale.x, primitiveScale.z); + _prefabScaleFactors[sourcePrefab] = factor; + return factor; } /// @@ -775,6 +789,31 @@ private bool BeginDeathHold(EntityId id) GameObject instance = _viewInstances[slot]; _viewObjectToSlot.Remove(instance); + // Art with a wreck child stays on the field as a wreck (D-115); + // graybox primitives and art without one keep the fade-out. + Transform wreck = _viewSourcePrefabs[slot] != null ? ViewWrecks.FindWreck(instance) : null; + if (wreck != null) + { + Collider[] wreckColliders = instance.GetComponentsInChildren(includeInactive: true); + var wreckColliderStates = new bool[wreckColliders.Length]; + for (int i = 0; i < wreckColliders.Length; i++) + { + wreckColliderStates[i] = wreckColliders[i] != null && wreckColliders[i].enabled; + if (wreckColliders[i] != null) wreckColliders[i].enabled = false; + } + if (_wrecks == null) _wrecks = new ViewWrecks(ReturnInstanceToPool); + _wrecks.Begin(instance, wreck, _viewShapeKeys[slot], _viewSourcePrefabs[slot], + SimDefinitions.IsBuildingRole(_viewRoles[slot]), wreckColliders, wreckColliderStates); + _viewRigs[slot] = null; + _viewInstances[slot] = null; + _viewRenderers[slot] = null; + _boundIds[slot] = EntityId.Invalid; + _viewSourcePrefabs[slot] = null; + _viewOwners[slot] = -1; + _viewHealthSteps[slot] = -1; + return true; + } + Renderer[] renderers = instance.GetComponentsInChildren(includeInactive: true); Collider[] colliders = instance.GetComponentsInChildren(includeInactive: true); var originalMaterials = new Material[renderers.Length][]; @@ -883,6 +922,7 @@ private void CompleteDeathHoldAt(int index) private void CompleteAllDeathHolds() { + _wrecks?.CompleteAll(); for (int i = _deathHolds.Count - 1; i >= 0; i--) { CompleteDeathHoldAt(i); @@ -1041,10 +1081,13 @@ private void AnimateRig(int slot, in UnitState unit, bool spawned) Transform t = _viewInstances[slot].transform; Vector3 pos = t.position; float yaw = t.eulerAngles.y; + // A (re)bound view starts from the unit's current load, so a harvester + // entering view empty or full never counts as having just unloaded. if (rig == null || spawned) { _lastViewPositions[slot] = pos; _lastViewYaws[slot] = yaw; + _lastCargoAE[slot] = unit.CargoAE; return; } @@ -1071,9 +1114,32 @@ private void AnimateRig(int slot, in UnitState unit, bool spawned) || IsProducingForViewer(in unit)), }; input.HasTarget = TryGetTargetViewPosition(in unit, out input.TargetPosition); + if (unit.Role == UnitRole.Harvester) + { + int capacity = HarvesterCapacityAE(in unit); + int previous = _lastCargoAE[slot]; + input.CargoFill = Mathf.Clamp01(unit.CargoAE / (float)capacity); + if (previous > 0 && unit.CargoAE <= 0) + { + input.Dump = true; + input.DumpFromFill = Mathf.Clamp01(previous / (float)capacity); + } + _lastCargoAE[slot] = unit.CargoAE; + } rig.Tick(in input, dt); } + /// Cargo capacity of this harvester's faction; the global ceiling when the faction cannot be resolved. + private int HarvesterCapacityAE(in UnitState unit) + { + EconomySystem economy = _matchRunner != null ? _matchRunner.Economy : null; + if (economy != null && unit.PlayerId < EconomySystem.MaxPlayers) + { + return Mathf.Max(1, SimDefinitions.HarvesterCargoCapacityAE(economy.GetSlotFaction(unit.PlayerId))); + } + return SimDefinitions.MaxHarvesterCargoCapacityAE; + } + /// /// World position of the unit's attack target, read from the target's /// VIEW — a target without a bound view is not visible to the viewer, diff --git a/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs b/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs new file mode 100644 index 00000000..aff09d50 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Nova.Gameplay.Match +{ + /// + /// Destroyed units and buildings that stay on the field (D-115). A dead + /// art view whose prefab carries an inactive + /// child is not faded out and pooled: it goes dark for a moment, swaps + /// its live model for the wreck mesh (charred, broken — built by + /// tools/art/asset_wreck.py) and then lies there. + /// + /// Wrecks are pure view: they block nothing, are not pickable and ignore + /// the fog (a wreck is what the player saw die). Two caps keep a long + /// match bounded — beyond unit wrecks or + /// ruins the oldest sinks into the sand. + /// A new building clears every wreck inside its footprint + /// (), so ruins never poke through fresh walls. + /// + /// + public sealed class ViewWrecks + { + /// Name of the inactive child that carries the destroyed state. + public const string WreckNode = "Wreck"; + + public const int MaxUnitWrecks = 60; + public const int MaxBuildingWrecks = 20; + + /// Live model goes dark and jolts before the swap. + public const float BlastSeconds = 0.25f; + + /// The wreck drops onto the ground after the swap. + public const float SettleSeconds = 0.35f; + + /// A fresh wreck smoulders this long, thinning out. + public const float SmokeSeconds = 9f; + + /// A capped or cleared wreck sinks this long before it is pooled. + public const float SinkSeconds = 2.5f; + + private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); + private static readonly int ColorId = Shader.PropertyToID("_Color"); + + private readonly List _entries = new List(64); + private readonly Action _returnToPool; + private readonly MaterialPropertyBlock _block = new MaterialPropertyBlock(); + + /// Called with (instance, shapeKey, sourcePrefab) once a wreck is gone and restored. + public ViewWrecks(Action returnToPool) + { + _returnToPool = returnToPool; + } + + public int Count => _entries.Count; + + /// The wreck child of a view, or null when its prefab has none. + public static Transform FindWreck(GameObject instance) + { + return instance != null ? instance.transform.Find(WreckNode) : null; + } + + /// + /// Turns a detached, dead view into a wreck. The caller has already + /// removed it from every slot table and disabled its colliders. + /// + public void Begin(GameObject instance, Transform wreck, int shapeKey, GameObject sourcePrefab, + bool isBuilding, Collider[] colliders, bool[] colliderStates) + { + var live = new List(); + foreach (Renderer r in instance.GetComponentsInChildren(true)) + { + if (!r.transform.IsChildOf(wreck)) live.Add(r); + } + + var entry = new Entry + { + Instance = instance, + Wreck = wreck, + ShapeKey = shapeKey, + SourcePrefab = sourcePrefab, + IsBuilding = isBuilding, + Live = live.ToArray(), + Colliders = colliders, + ColliderStates = colliderStates, + StartPosition = instance.transform.position, + Size = isBuilding ? 1.5f : 0.6f, + }; + _entries.Add(entry); + EnforceCap(isBuilding); + } + + /// Sinks every wreck whose position lies within (ground plane). + public void ClearArea(Vector3 centre, float radius) + { + float r2 = radius * radius; + for (int i = 0; i < _entries.Count; i++) + { + Entry e = _entries[i]; + Vector3 d = e.StartPosition - centre; + if (d.x * d.x + d.z * d.z <= r2) StartSink(e, 0.5f); + } + } + + public void Advance(float deltaTime) + { + float dt = Mathf.Max(0f, deltaTime); + for (int i = _entries.Count - 1; i >= 0; i--) + { + Entry e = _entries[i]; + if (e.Instance == null) + { + _entries.RemoveAt(i); + continue; + } + e.Elapsed += dt; + + if (e.SinkDuration > 0f) + { + e.SinkElapsed += dt; + float s = Mathf.Clamp01(e.SinkElapsed / e.SinkDuration); + float depth = (e.IsBuilding ? 1.6f : 0.7f) * s * s; + e.Instance.transform.position = e.RestPosition + Vector3.down * depth; + if (s >= 1f) + { + Complete(i); + } + continue; + } + + if (!e.Swapped) + { + // Blast: live model blackens and jolts up a little. + float t = Mathf.Clamp01(e.Elapsed / BlastSeconds); + Tint(e.Live, Color.Lerp(Color.white, new Color(0.08f, 0.07f, 0.06f, 1f), t)); + float jolt = Mathf.Sin(t * Mathf.PI) * 0.12f * e.Size; + e.Instance.transform.position = e.StartPosition + Vector3.up * jolt; + if (t >= 1f) Swap(e); + continue; + } + + if (e.Elapsed < SmokeSeconds) + { + // Smoulder: dense at first, thinning toward the end. + e.SmokeTimer -= dt; + if (e.SmokeTimer <= 0f) + { + float k = e.Elapsed / SmokeSeconds; + e.SmokeTimer = Mathf.Lerp(0.18f, 0.9f, k); + Vector3 top = e.StartPosition + Vector3.up * (e.IsBuilding ? 1.2f : 0.45f) * e.Size; + AmbientFx.Smoke(top, (e.IsBuilding ? 1.1f : 0.55f) * e.Size, 0.85f); + } + } + + if (!e.Settled) + { + // Settle: the wreck drops the last bit and bounces once. + float t = Mathf.Clamp01((e.Elapsed - BlastSeconds) / SettleSeconds); + float bounce = (1f - t) * Mathf.Abs(Mathf.Cos(t * Mathf.PI * 1.5f)) * 0.18f * e.Size; + e.Instance.transform.position = e.StartPosition + Vector3.up * bounce; + if (t >= 1f) + { + e.Settled = true; + e.Instance.transform.position = e.StartPosition; + e.RestPosition = e.StartPosition; + } + } + } + } + + /// Restores and pools every wreck at once (match reset). + public void CompleteAll() + { + for (int i = _entries.Count - 1; i >= 0; i--) Complete(i); + } + + private void Swap(Entry e) + { + e.Swapped = true; + for (int i = 0; i < e.Live.Length; i++) + { + if (e.Live[i] != null) e.Live[i].enabled = false; + } + e.Wreck.gameObject.SetActive(true); + e.RestPosition = e.StartPosition; + } + + private void EnforceCap(bool isBuilding) + { + int cap = isBuilding ? MaxBuildingWrecks : MaxUnitWrecks; + int alive = 0; + for (int i = _entries.Count - 1; i >= 0; i--) + { + Entry e = _entries[i]; + if (e.IsBuilding != isBuilding || e.SinkDuration > 0f) continue; + alive++; + if (alive > cap) StartSink(e, SinkSeconds); + } + } + + private static void StartSink(Entry e, float seconds) + { + if (e.SinkDuration > 0f) return; + if (!e.Swapped) + { + // Not even swapped yet: skip straight to a short sink of the wreck. + e.Swapped = true; + for (int i = 0; i < e.Live.Length; i++) + { + if (e.Live[i] != null) e.Live[i].enabled = false; + } + e.Wreck.gameObject.SetActive(true); + } + e.Settled = true; + e.RestPosition = e.Instance.transform.position; + e.SinkDuration = Mathf.Max(0.05f, seconds); + e.SinkElapsed = 0f; + } + + private void Complete(int index) + { + Entry e = _entries[index]; + _entries.RemoveAt(index); + if (e.Instance == null) return; + + for (int i = 0; i < e.Live.Length; i++) + { + if (e.Live[i] == null) continue; + e.Live[i].enabled = true; + e.Live[i].SetPropertyBlock(null); + } + if (e.Wreck != null) e.Wreck.gameObject.SetActive(false); + for (int c = 0; c < e.Colliders.Length; c++) + { + if (e.Colliders[c] != null) e.Colliders[c].enabled = e.ColliderStates[c]; + } + e.Instance.transform.position = e.StartPosition; + UnitRigAnimator rig = e.Instance.GetComponent(); + if (rig != null) rig.ResetPose(); + _returnToPool?.Invoke(e.Instance, e.ShapeKey, e.SourcePrefab); + } + + private void Tint(Renderer[] renderers, Color color) + { + for (int i = 0; i < renderers.Length; i++) + { + Renderer r = renderers[i]; + if (r == null) continue; + r.GetPropertyBlock(_block); + _block.SetColor(BaseColorId, color); + _block.SetColor(ColorId, color); + r.SetPropertyBlock(_block); + } + } + + private sealed class Entry + { + public GameObject Instance; + public Transform Wreck; + public int ShapeKey; + public GameObject SourcePrefab; + public bool IsBuilding; + public Renderer[] Live; + public Collider[] Colliders; + public bool[] ColliderStates; + public Vector3 StartPosition; + public Vector3 RestPosition; + public float Size; + public float Elapsed; + public bool Swapped; + public bool Settled; + public float SinkDuration; + public float SinkElapsed; + public float SmokeTimer; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs.meta new file mode 100644 index 00000000..d42d048e --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/ViewWrecks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c3b7c9693bfe4e308b57802a13329066 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs b/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs index f1b0b79f..b62ff80f 100644 --- a/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/SelectionMarkerView.cs @@ -53,7 +53,7 @@ public sealed class SelectionMarkerView : MonoBehaviour [Header("Marker look")] [Tooltip("Height above the ground plane; avoids z-fighting with the ground quad and the map blockout markers.")] [SerializeField] private float _markerHeight = 0.06f; - [Tooltip("Edge length of the unit marker in world units — slightly larger than the biggest graybox vehicle body.")] + [Tooltip("Upper bound of the unit marker edge in world units; below it the marker follows the view's footprint.")] [SerializeField] private float _unitMarkerSize = 2.4f; [Tooltip("Extra edge the building marker gains over the 3x3 footprint, so the outline is not fully hidden under the body.")] [SerializeField] private float _buildingMarkerMargin = 0.4f; @@ -223,6 +223,12 @@ private void PlaceMarker(GameObject marker, in UnitState unit, EntityId id, floa float size = building ? SimDefinitions.BuildingFootprintCells + _buildingMarkerMargin : _unitMarkerSize; + // Units: hug the model (ArtViewScale sizes vary from a 0.4-cell + // soldier to a 1.8-cell artillery piece), capped at the old fixed size. + if (!building && _views != null && _views.TryGetViewFootprint(id, out float footprint)) + { + size = Mathf.Clamp(footprint * 1.2f + 0.25f, 0.8f, _unitMarkerSize); + } float shift = building ? 0.5f : 0f; // footprint centre, see class remarks Transform markerTransform = marker.transform; diff --git a/tools/art/asset_pass.py b/tools/art/asset_pass.py index 2c3dca74..ed6e7d4e 100644 --- a/tools/art/asset_pass.py +++ b/tools/art/asset_pass.py @@ -818,6 +818,11 @@ def main(): print(f" Textur: {json.dumps(stats)}") result, pivots, parents, kinds = split_parts(lods, r, bb) + # Emitter: leere Knoten (Smoke_* ...), an denen Unity Partikel ausstoesst + for em in r.get("emitters", []): + pivots[em["name"]] = denorm(bb, em["pos"]) + parents[em["name"]] = em.get("parent", "Body") + kinds[em["name"]] = "emitter" if r.get("squash"): squash(result, pivots, bb, r["squash"]) @@ -826,6 +831,17 @@ def main(): graded = image_np(bpy.data.images.load(src_tex)).copy() add_pad(result, pivots, r["pad"], graded) + if r.get("wreck", {}).get("enabled", True) and (not a.no_export or a.preview): + import asset_wreck # noqa: E402 (Blender-Python, gleiches Verzeichnis) + if graded is None: + graded = image_np(bpy.data.images.load(src_tex)).copy() + category = "unit" if stem.startswith("UNIT_") else "building" + wreck_dir = out_dir if not a.no_export else (a.preview or out_dir) + os.makedirs(wreck_dir, exist_ok=True) + asset_wreck.build_wreck(result, pivots, kinds, r, stem, category, graded, wreck_dir, + lod=r.get("wreck", {}).get("lod", "LOD0"), + preview_png=os.path.join(a.preview, f"{stem}_wreck.png") if a.preview else "") + if a.preview: os.makedirs(a.preview, exist_ok=True) render_preview(result, os.path.join(a.preview, f"{stem}_parts.png"), tex_paths) diff --git a/tools/art/asset_recipes.json b/tools/art/asset_recipes.json index 1600a13e..9dc65c72 100644 --- a/tools/art/asset_recipes.json +++ b/tools/art/asset_recipes.json @@ -115,7 +115,7 @@ } }, "pad": { - "height_ratio": 0.7, + "height_ratio": 0.55, "tone": [ 0.34, 0.5 @@ -263,7 +263,9 @@ }, "BLDG_Legion_Barracks": { "path": "Buildings/Legion/Barracks", - "faction": "Legion" + "faction": "Legion", + "drop_needles": false, + "smooth_angle": 0 }, "BLDG_Legion_DefensePlatform": { "drop_boxes": [ @@ -293,7 +295,7 @@ "path": "Buildings/Legion/DefensePlatform", "faction": "Legion", "pad": { - "height_ratio": 0.7, + "height_ratio": 0.55, "tone": [ 0.34, 0.5 @@ -302,11 +304,27 @@ }, "BLDG_Legion_HQ": { "path": "Buildings/Legion/HQ", - "faction": "Legion" + "faction": "Legion", + "emitters": [ + { + "name": "Smoke_Chimney", + "pos": [ + 0.25, + 0.62, + 0.86 + ] + } + ] }, "BLDG_Legion_Power": { "path": "Buildings/Legion/Power", - "faction": "Legion" + "faction": "Legion", + "drop_needles": false, + "smooth_angle": 0, + "emitters": [ + {"name": "Smoke_ChimneyL", "pos": [0.27, 0.59, 1.0]}, + {"name": "Smoke_ChimneyR", "pos": [0.47, 0.59, 1.0]} + ] }, "BLDG_Legion_Radar": { "parts": [ @@ -333,11 +351,23 @@ }, "BLDG_Legion_Refinery": { "path": "Buildings/Legion/Refinery", - "faction": "Legion" + "faction": "Legion", + "emitters": [ + { + "name": "Smoke_Chimney", + "pos": [ + 0.62, + 0.35, + 1.0 + ] + } + ] }, "BLDG_Legion_ResearchLab": { "path": "Buildings/Legion/ResearchLab", - "faction": "Legion" + "faction": "Legion", + "drop_needles": false, + "smooth_angle": 0 }, "BLDG_Legion_Storage": { "path": "Buildings/Legion/Storage", @@ -345,7 +375,9 @@ }, "BLDG_Legion_VehicleFactory": { "path": "Buildings/Legion/VehicleFactory", - "faction": "Legion" + "faction": "Legion", + "drop_needles": false, + "smooth_angle": 0 }, "UNIT_Alliance_AntiArmorInfantry": { "path": "Units/Alliance/AntiArmorInfantry", @@ -646,6 +678,12 @@ ] } ], + "emitters": [ + { + "name": "Cargo_Anchor", + "pos": [0.4, 0.83, 0.69] + } + ], "path": "Units/Alliance/Harvester", "faction": "Alliance" }, @@ -1006,6 +1044,10 @@ "faction": "Legion" }, "UNIT_Legion_Harvester": { + "drop_boxes": [ + [0.07, 0.93, 0.62, 0.96, 0.46, 1], + [0, 1, 0.62, 1, 0.74, 1] + ], "parts": [ { "name": "Wheel_FL", @@ -1019,7 +1061,7 @@ 0, 0.14 ], - "islands": 0.7 + "islands": 0.55 }, { "name": "Wheel_FR", @@ -1033,7 +1075,7 @@ 0.86, 1 ], - "islands": 0.7 + "islands": 0.55 }, { "name": "Wheel_RL", @@ -1047,7 +1089,7 @@ 0, 0.14 ], - "islands": 0.7 + "islands": 0.4 }, { "name": "Wheel_RR", @@ -1061,7 +1103,16 @@ 0.86, 1 ], - "islands": 0.7 + "islands": 0.4 + }, + { + "name": "Bed", + "kind": "bed", + "box": [ + [0, 1, 0.62, 1, 0.43, 1], + [0.1, 0.9, 0.62, 1, 0.37, 0.43] + ], + "pivot": [0.5, 0.99, 0.45] } ], "path": "Units/Legion/Harvester", diff --git a/tools/art/asset_wreck.py b/tools/art/asset_wreck.py new file mode 100644 index 00000000..4f46a6e1 --- /dev/null +++ b/tools/art/asset_wreck.py @@ -0,0 +1,405 @@ +"""Wrack-Variante eines Assets fuer tools/art/asset_pass.py (D-115). + +Aus den bereits getrennten Teilen (Hull, Turret, Gun, Wheel_*, Arm_*, Spin_*, +Leg_*, Pad ...) einer LOD-Stufe entsteht ein einzelnes, statisches Mesh: + +* Fahrzeuge: Turm aus dem Ring gesprengt und verdreht, Rohr haengt, ein Rad + fehlt, Wanne sackt schief ab, Dellen. +* Infanterie: liegt auf dem Ruecken. +* Gebaeude: oberhalb einer gezackten Bruchkante weggebrochen, bewegliche Teile + (Schuessel, Turm) liegen daneben am Boden, Truemmer im Footprint. + +Die Textur ist eine verkohlte Fassung der gegradeten Basisfarbe, ohne Leuchten. +Geschrieben werden ``SM__Wreck.fbx`` (ein Objekt ``SM__Wreck`` unter +``Root``, gleiche Achsen wie das Haupt-FBX) und ``T__Wreck_BC.png``. +Alle Zufallswerte haengen am Asset-Namen: zwei Laeufe ergeben dasselbe Wrack. +""" + +import math +import os +import random +import zlib + +import bmesh +import bpy +import numpy as np +from mathutils import Matrix, Vector, noise + + +def _rng(stem): + return random.Random(zlib.crc32(stem.encode())) + + +def _dup(obj, name): + o = obj.copy() + o.data = obj.data.copy() + o.name = name + bpy.context.scene.collection.objects.link(o) + o.parent = None + o.matrix_world = obj.matrix_world.copy() + return o + + +def _bake_world(o): + """Weltmatrix ins Mesh backen, damit spaetere Drehungen um Weltpunkte gehen.""" + o.data.transform(o.matrix_world) + o.matrix_world = Matrix.Identity(4) + + +def _rotate_about(o, pivot, axis, deg): + m = Matrix.Translation(pivot) @ Matrix.Rotation(math.radians(deg), 4, axis) @ Matrix.Translation(-pivot) + o.data.transform(m) + + +def _translate(o, v): + o.data.transform(Matrix.Translation(v)) + + +def _bbox(objs): + pts = [v.co for o in objs for v in o.data.vertices] + mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) + mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) + return mn, mx + + +def _dent(o, amp, scale, seed): + """Weiche Verbiegung durch ein raeumlich glattes Vektorfeld. + + Nicht entlang der Normalen: Tripo-Meshes bestehen aus hunderten loser + Schalen, deren Normalen in alle Richtungen zeigen - dort reisst eine + Normalen-Verschiebung jede Naht auf. Ein glattes Feld bewegt benachbarte + Schalen gemeinsam und verbiegt das Ganze wie Blech. + """ + off = Vector((seed * 7.1, seed * 3.3, seed * 1.7)) + for v in o.data.vertices: + v.co += noise.noise_vector(v.co * scale + off) * amp + + +def _break_top(o, cut_z, jag, seed): + """Alle Faces oberhalb einer gezackten Bruchkante entfernen.""" + bm = bmesh.new() + bm.from_mesh(o.data) + off = Vector((seed * 2.3, seed * 5.9, 0)) + doomed = [] + for f in bm.faces: + c = f.calc_center_median() + h = cut_z + noise.noise(Vector((c.x * 0.9, c.y * 0.9, 0)) + off) * jag + # nach der hoechsten Ecke, nicht nach dem Mittelpunkt: grosse + # Dreiecke ragen sonst als lange Spiesse ueber die Bruchkante + if max(v.co.z for v in f.verts) > h: + doomed.append(f) + removed = len(doomed) + bmesh.ops.delete(bm, geom=doomed, context="FACES") + bm.to_mesh(o.data) + bm.free() + return removed + + +def _islands(o): + bm = bmesh.new() + bm.from_mesh(o.data) + bm.faces.ensure_lookup_table() + seen, out = set(), [] + for f in bm.faces: + if f.index in seen: + continue + isl, stack = [], [f] + while stack: + x = stack.pop() + if x.index in seen: + continue + seen.add(x.index) + isl.append(x.index) + for e in x.edges: + stack.extend(l for l in e.link_faces if l.index not in seen) + out.append(isl) + bm.free() + return out + + +def _rubble(src, count, mn, mx, ground, rng, name): + """Kleine Inseln des abgebrochenen Oberteils als Truemmer am Boden verteilen.""" + if src is None or not src.data.polygons: + return None + bmx = bmesh.new() + bmx.from_mesh(src.data) + bmx.faces.ensure_lookup_table() + limit = (mx - mn).length * 0.14 + + def small(i): + pts = [v.co for fi in i for v in bmx.faces[fi].verts] + d = max(max(p[k] for p in pts) - min(p[k] for p in pts) for k in range(3)) + return d <= limit + + isl = [i for i in _islands(src) if 4 <= len(i) <= 120 and small(i)] + bmx.free() + rng.shuffle(isl) + pick = set(f for i in isl[:count] for f in i) + if not pick: + return None + bm = bmesh.new() + bm.from_mesh(src.data) + bm.faces.ensure_lookup_table() + keep = [f for f in bm.faces if f.index not in pick] + bmesh.ops.delete(bm, geom=keep, context="FACES") + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + bm.free() + deb = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(deb) + # jede Insel einzeln umwerfen und auf den Boden legen + for isl_faces in _islands(deb): + bmv = bmesh.new() + bmv.from_mesh(deb.data) + bmv.faces.ensure_lookup_table() + verts = {v for fi in isl_faces for v in bmv.faces[fi].verts} + c = sum((v.co for v in verts), Vector()) / len(verts) + target = Vector((rng.uniform(mn.x, mx.x), rng.uniform(mn.y, mx.y), ground)) + # flach liegend: freie Drehung um die Hochachse, wenig Kippen + rot = (Matrix.Rotation(rng.uniform(0, 2 * math.pi), 3, "Z") + @ Matrix.Rotation(rng.uniform(-0.35, 0.35), 3, "X")) + for v in verts: + v.co = rot @ (v.co - c) + target + low = min(v.co.z for v in verts) + for v in verts: + v.co.z += ground - low + bmv.to_mesh(deb.data) + bmv.free() + return deb + + +def _is_needle(o, diag): + mn, mx = _bbox([o]) + d = sorted(mx - mn) + return d[2] > 0.08 * diag and d[1] < 0.25 * d[2] + + +def _drop_needles(o, diag, max_faces=80, min_len=0.1, max_width=0.1): + """Lange, duenne Inseln entfernen: Reste angeschnittener Flaechen.""" + bm = bmesh.new() + bm.from_mesh(o.data) + bm.faces.ensure_lookup_table() + doomed = [] + for isl in _islands(o): + if len(isl) > max_faces: + continue + pts = [v.co for fi in isl for v in bm.faces[fi].verts] + d = sorted(max(p[k] for p in pts) - min(p[k] for p in pts) for k in range(3)) + if d[2] > min_len * diag and d[1] < max_width * d[2]: + doomed.extend(bm.faces[fi] for fi in isl) + if doomed: + bmesh.ops.delete(bm, geom=doomed, context="FACES") + bm.to_mesh(o.data) + bm.free() + return len(doomed) + + +def char_texture(graded, stem): + """Verkohlte Basisfarbe: entsaettigt, stark abgedunkelt, fleckiger Russ.""" + rgb = graded[..., :3] + h, w = rgb.shape[:2] + lum = rgb @ np.array([0.2126, 0.7152, 0.0722], dtype=np.float32) + gray = np.repeat(lum[..., None], 3, -1) + desat = rgb * 0.3 + gray * 0.7 + rs = np.random.RandomState(zlib.crc32(stem.encode()) & 0x7FFFFFFF) + # Russflecken: grobes Rauschen, hochskaliert und weichgezeichnet + coarse = rs.rand(h // 32 + 2, w // 32 + 2).astype(np.float32) + yy = np.linspace(0, coarse.shape[0] - 1.001, h) + xx = np.linspace(0, coarse.shape[1] - 1.001, w) + y0, x0 = yy.astype(int), xx.astype(int) + fy, fx = (yy - y0)[:, None], (xx - x0)[None, :] + c00 = coarse[y0][:, x0]; c01 = coarse[y0][:, x0 + 1] + c10 = coarse[y0 + 1][:, x0]; c11 = coarse[y0 + 1][:, x0 + 1] + blot = (c00 * (1 - fx) + c01 * fx) * (1 - fy) + (c10 * (1 - fx) + c11 * fx) * fy + fine = rs.rand(h, w).astype(np.float32) + soot = np.clip(blot * 0.8 + fine * 0.2, 0, 1) + dark = 0.24 + 0.32 * soot + out = np.empty_like(graded) + burnt = desat * dark[..., None] + # ein Hauch Glutrost in hellen, russarmen Partien + rust = np.array([0.32, 0.14, 0.06], dtype=np.float32) + burnt = burnt + rust * (np.clip(lum - 0.35, 0, 1) * (1 - soot))[..., None] * 0.6 + out[..., :3] = np.clip(burnt, 0, 1) + out[..., 3] = 1 + return out + + +def build_wreck(result, pivots, kinds, recipe, stem, category, graded, out_dir, lod="LOD1", preview_png=""): + """Erzeugt Wrack-Mesh und -Textur und exportiert sie. Liefert den FBX-Pfad.""" + rng = _rng(stem) + seed = (zlib.crc32(stem.encode()) % 997) / 31.0 + parts = result.get(lod) or result["LOD0"] + objs = {} + for name, obj in parts.items(): + d = _dup(obj, f"__wreck_{name}") + _bake_world(d) + objs[name] = d + + mn, mx = _bbox(list(objs.values())) + ext = mx - mn + ground = mn.z + up, right, fwd = Vector((0, 0, 1)), Vector((1, 0, 0)), Vector((0, -1, 0)) + cfg = recipe.get("wreck", {}) + infantry = category == "unit" and any(n.startswith("Leg_") for n in objs) + extra = [] + + if infantry: + # nach hinten umfallen (Vorwaerts = -Y), leicht zur Seite gedreht + pivot = Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, ground)) + side = rng.choice((-1, 1)) + for o in objs.values(): + _rotate_about(o, pivot, right, -86.0) + _rotate_about(o, pivot, up, side * rng.uniform(15, 50)) + elif category == "unit": + for name, o in objs.items(): + kind = kinds.get(name, "body") + p = pivots.get(name, Vector()) + if kind == "turret": + yaw = rng.uniform(25, 70) * rng.choice((-1, 1)) + _rotate_about(o, p, up, yaw) + _rotate_about(o, p, right, rng.uniform(-9, 9)) + _rotate_about(o, p, fwd, rng.uniform(-8, 8)) + _translate(o, Vector((rng.uniform(-0.06, 0.06) * ext.x, rng.uniform(-0.05, 0.05) * ext.y, -0.03 * ext.z))) + # das Rohr haengt am Turm: dieselbe Drehung, dann Senkung + for gname, go in objs.items(): + if kinds.get(gname) == "gun": + _rotate_about(go, p, up, yaw) + _rotate_about(go, pivots[gname], right, 14.0) + elif kind == "arm": + _rotate_about(o, p, right, rng.uniform(18, 35)) + elif kind == "wheel" and rng.random() < 0.35: + _rotate_about(o, p, fwd, rng.uniform(25, 60)) + _translate(o, Vector((0, 0, -0.04 * ext.z))) + # Wanne sackt schief ab (alles ausser abgefallenen Teilen) + tilt_axis = Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), 0)).normalized() + base_pivot = Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, ground)) + for o in objs.values(): + _rotate_about(o, base_pivot, tilt_axis, rng.uniform(3, 6)) + _translate(o, Vector((0, 0, -0.05 * ext.z))) + else: # Gebaeude + cut = ground + ext.z * cfg.get("cut", rng.uniform(0.42, 0.58)) + jag = ext.z * 0.12 + top_parts = [] + for name, o in list(objs.items()): + kind = kinds.get(name, "body") + if kind in ("spin", "scan") and _is_needle(o, ext.length): + # duenne Antennen nicht umlegen: sie laegen als Spiess quer im Bild + bpy.data.objects.remove(o, do_unlink=True) + del objs[name] + elif kind in ("spin", "scan", "turret", "gun"): + # abgesprengt: neben dem Gebaeude auf den Boden legen + p = pivots.get(name, Vector()) + lo = min(v.co.z for v in o.data.vertices) + _rotate_about(o, p, Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), 0)).normalized(), rng.uniform(55, 100)) + lo2 = min(v.co.z for v in o.data.vertices) + c = sum((v.co for v in o.data.vertices), Vector()) / len(o.data.vertices) + ang = rng.uniform(0, 2 * math.pi) + dest = Vector(((mn.x + mx.x) / 2 + math.cos(ang) * ext.x * 0.32, + (mn.y + mx.y) / 2 + math.sin(ang) * ext.y * 0.32, 0)) + _translate(o, Vector((dest.x - c.x, dest.y - c.y, ground - lo2))) + elif kind != "pad": + top = _dup(o, f"__top_{name}") + n = _break_top(o, cut, jag, seed) + # Gegenstueck behalten: nur das Oberteil als Truemmerquelle + bmt = bmesh.new() + bmt.from_mesh(top.data) + keep = [f for f in bmt.faces if f.calc_center_median().z <= cut + jag] + bmesh.ops.delete(bmt, geom=keep, context="FACES") + bmt.to_mesh(top.data) + bmt.free() + top_parts.append(top) + for i, t in enumerate(top_parts): + deb = _rubble(t, cfg.get("rubble", 14) // max(1, len(top_parts)), mn, mx, ground, rng, f"__rubble_{i}") + bpy.data.objects.remove(t, do_unlink=True) + if deb is not None: + extra.append(deb) + + for o in list(objs.values()) + extra: + _dent(o, ext.length * 0.012, 1.2 / max(ext.length, 1e-3), seed) + + # zusammenfuegen + all_objs = [o for o in list(objs.values()) + extra if o.data.polygons] + bpy.ops.object.select_all(action="DESELECT") + for o in all_objs: + o.select_set(True) + bpy.context.view_layer.objects.active = all_objs[0] + bpy.ops.object.join() + wreck = bpy.context.view_layer.objects.active + removed = _drop_needles(wreck, ext.length) + if removed: + print(f" Wrack-Splitter: {removed} Faces entfernt") + wreck.name = f"SM_{stem}_Wreck" + wreck.data.name = wreck.name + # Bodenkontakt: tiefster Punkt auf den urspruenglichen Boden + low = min(v.co.z for v in wreck.data.vertices) + wreck.data.transform(Matrix.Translation(Vector((0, 0, ground - low)))) + wreck.data.materials.clear() + + # Textur + tex = char_texture(graded, stem) + img = bpy.data.images.new(f"{stem}_Wreck_BC", tex.shape[1], tex.shape[0], alpha=True) + img.colorspace_settings.name = "sRGB" + img.pixels.foreach_set(np.ascontiguousarray(tex, dtype=np.float32).ravel()) + tex_path = os.path.join(out_dir, f"T_{stem}_Wreck_BC.png") + img.filepath_raw = tex_path + img.file_format = "PNG" + img.save() + + # Export: gleiche Achsen und Root-Knoten wie das Haupt-FBX + sc = bpy.context.scene + root = bpy.data.objects.new("Root", None) + sc.collection.objects.link(root) + wreck.parent = root + fbx_path = os.path.join(out_dir, f"SM_{stem}_Wreck.fbx") + bpy.ops.object.select_all(action="DESELECT") + root.select_set(True) + wreck.select_set(True) + bpy.ops.export_scene.fbx( + filepath=fbx_path, use_selection=True, object_types={"EMPTY", "MESH"}, + apply_unit_scale=True, global_scale=1.0, apply_scale_options="FBX_SCALE_NONE", + axis_forward="-Z", axis_up="Y", bake_space_transform=False, + use_mesh_modifiers=True, mesh_smooth_type="OFF", path_mode="STRIP", + add_leaf_bones=False) + tris = sum(len(p.vertices) - 2 for p in wreck.data.polygons) + if preview_png: + _preview(wreck, img, preview_png) + bpy.data.objects.remove(wreck, do_unlink=True) + bpy.data.objects.remove(root, do_unlink=True) + print(f" Wrack: {tris} Dreiecke -> {os.path.basename(fbx_path)}") + return fbx_path, wreck_preview_bounds(mn, mx) + + +def _preview(obj, img, out_png): + """Kontrollbild: Wrack mit verkohlter Textur, schraeg von vorne rechts.""" + sc = bpy.context.scene + hidden = [o for o in sc.objects if o is not obj and o.type == "MESH" and not o.hide_render] + for o in hidden: + o.hide_render = True + mat = bpy.data.materials.new("wreck_prev") + tree = mat.node_tree + bsdf = next(n for n in tree.nodes if n.type == "BSDF_PRINCIPLED") + tn = tree.nodes.new("ShaderNodeTexImage") + tn.image = img + tree.links.new(tn.outputs[0], bsdf.inputs[0]) + obj.data.materials.append(mat) + sc.render.engine = "BLENDER_WORKBENCH" + sc.display.shading.light = "STUDIO" + sc.display.shading.color_type = "TEXTURE" + sc.render.resolution_x, sc.render.resolution_y = 600, 600 + mn, mx = _bbox([obj]) + ctr, size = (mn + mx) / 2, max(mx - mn) + cd = bpy.data.cameras.new("wc"); cd.type = "ORTHO"; cd.ortho_scale = size * 1.6 + cam = bpy.data.objects.new("wc", cd); sc.collection.objects.link(cam); sc.camera = cam + loc = ctr + Vector((size * 2.2, -size * 2.2, size * 1.8)) + cam.location = loc + cam.rotation_euler = (ctr - loc).to_track_quat("-Z", "Y").to_euler() + sc.render.filepath = out_png + bpy.ops.render.render(write_still=True) + obj.data.materials.clear() + for o in hidden: + o.hide_render = False + bpy.data.objects.remove(cam, do_unlink=True) + + +def wreck_preview_bounds(mn, mx): + return [list(mn), list(mx)] diff --git a/tools/art/glb_to_unity_fbx.py b/tools/art/glb_to_unity_fbx.py index 44942e60..f2d91fdf 100644 --- a/tools/art/glb_to_unity_fbx.py +++ b/tools/art/glb_to_unity_fbx.py @@ -59,6 +59,9 @@ def parse_args(argv): p.add_argument("--budgets", type=int, nargs=3, required=True, help="Dreiecksbudget LOD0 LOD1 LOD2 aus docs/tech/AssetBudget.md Abschnitt 1") p.add_argument("--report", default="") + p.add_argument("--keep-slivers", action="store_true", + help="Splitterfilter aus: fuer modellierte (nicht generierte) Quellen, deren " + "duenne Teile (Leitern, Antennen) an UV-Naehten wie Splitter aussehen") return p.parse_args(argv) @@ -318,7 +321,7 @@ def main(): obj = join_meshes(meshes) source_tris = triangle_count(obj) - obj, slivers_removed = remove_slivers(obj) + obj, slivers_removed = (obj, 0) if args.keep_slivers else remove_slivers(obj) factor = scale_to_target(obj, args.fit, args.size) apply_yaw(obj, args.yaw) origin_to_ground_center(obj) diff --git a/tools/art/procedural/README.md b/tools/art/procedural/README.md new file mode 100644 index 00000000..e533f414 --- /dev/null +++ b/tools/art/procedural/README.md @@ -0,0 +1,28 @@ +# Prozedurale Gebäude (Legion) + +Kraftwerk, Fahrzeugfabrik, Forschungslabor und Kaserne der Legion waren als +Tripo-Modelle unbrauchbar (drei davon als Panzer generiert, die Kaserne +zerfallen). Diese Skripte bauen sie in Blender 5.2 headless nach den +Konzeptbildern neu; die Textur wird aus dem Konzeptbild projiziert und gebacken. + +Ablauf je Gebäude (Beispiel Kraftwerk, `` = Arbeitsordner): + +```bash +B=/Applications/Blender.app/Contents/MacOS/Blender +$B -b --python tools/art/procedural/build_power.py -- /proc_power.glb +$B -b --python tools/art/procedural/bake_projected.py -- /proc_power.glb front.png side.png /tex_Power.glb 60000 +$B -b --python tools/art/glb_to_unity_fbx.py -- --input /tex_Power.glb \ + --out-fbx ../Hashkrieg_Assets/3d/unity_ready/Buildings/Legion/Power/SM_BLDG_Legion_Power.fbx \ + --out-texture ../Hashkrieg_Assets/3d/unity_ready/Buildings/Legion/Power/T_BLDG_Legion_Power_BC.png \ + --fit footprint --size 9.0 --texture-size 2048 --budgets 20000 8000 2000 --keep-slivers +# Basisfarbe auf mittlere Luminanz 0,12 heben (Konzeptbilder sind sehr dunkel), dann: +$B -b --python tools/art/asset_pass.py -- --asset BLDG_Legion_Power --src ../Hashkrieg_Assets/3d/unity_ready --dst Assets/_Project/Art +``` + +`front.png`/`side.png` sind das auf das Objekt beschnittene Konzeptbild bzw. ein +Ausschnitt seiner Wandfläche. Zielmaße: Fabrik 12 m, alle anderen 9 m +(Footprint). Vorderseite der Bauskripte ist Blender −Y; `bake_projected.py` +dreht sie auf die Tripo-Konvention +X, die `glb_to_unity_fbx.py` erwartet. + +Eine KI-Form (Hunyuan3D 2.1) wurde verworfen: deren Community-Lizenz gilt nicht +in der EU. diff --git a/tools/art/procedural/bake_projected.py b/tools/art/procedural/bake_projected.py new file mode 100644 index 00000000..89de7475 --- /dev/null +++ b/tools/art/procedural/bake_projected.py @@ -0,0 +1,74 @@ +"""Hunyuan white mesh + concept image -> textured GLB (Tripo-like: front on +X).""" +import bpy, sys, math, bmesh +a = sys.argv[sys.argv.index('--')+1:] +src, front, side, out, tris = a[0], a[1], a[2], a[3], int(a[4]) +top = a[5] if len(a) > 5 else side # roof: a calm plate texture reads better from the RTS camera than projected wall detail +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=src) +ms = [o for o in bpy.context.scene.objects if o.type == 'MESH'] +bpy.ops.object.select_all(action='DESELECT') +for o in ms: o.select_set(True) +bpy.context.view_layer.objects.active = ms[0] +if len(ms) > 1: bpy.ops.object.join() +ob = bpy.context.view_layer.objects.active +bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) +n0 = sum(len(p.vertices) - 2 for p in ob.data.polygons) +if n0 > tris: + m = ob.modifiers.new('dec', 'DECIMATE'); m.ratio = tris / n0 + bpy.ops.object.modifier_apply(modifier=m.name) +print('tris', n0, '->', sum(len(p.vertices) - 2 for p in ob.data.polygons)) +for p in ob.data.polygons: p.use_smooth = True +ob.data.materials.clear() +# UVs +bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT') +bpy.ops.uv.smart_project(angle_limit=math.radians(60), island_margin=0.004) +bpy.ops.object.mode_set(mode='OBJECT') +# projection material +mat = bpy.data.materials.new('proj'); mat.use_nodes = True; nt = mat.node_tree; nt.nodes.clear() +N = nt.nodes.new; L = nt.links.new +tc = N('ShaderNodeTexCoord'); sep = N('ShaderNodeSeparateXYZ'); L(tc.outputs['Generated'], sep.inputs[0]) +nrm = N('ShaderNodeSeparateXYZ'); L(tc.outputs['Normal'], nrm.inputs[0]) +def img(path): + t = N('ShaderNodeTexImage'); t.image = bpy.data.images.load(path); t.extension = 'EXTEND'; return t +def uv(u, v, flip=False): + c = N('ShaderNodeCombineXYZ') + if flip: + m = N('ShaderNodeMath'); m.operation = 'SUBTRACT'; m.inputs[0].default_value = 1.0; L(u, m.inputs[1]); L(m.outputs[0], c.inputs[0]) + else: L(u, c.inputs[0]) + L(v, c.inputs[1]); return c +X, Y, Z = sep.outputs[0], sep.outputs[1], sep.outputs[2] +tf = img(front); L(uv(X, Z).outputs[0], tf.inputs[0]) # front/back (Y faces) +ts = img(side); L(uv(Y, Z).outputs[0], ts.inputs[0]) # sides (X faces) +tt = img(top); L(uv(X, Y).outputs[0], tt.inputs[0]) # top/bottom (Z faces) +def w(comp): + ab = N('ShaderNodeMath'); ab.operation = 'ABSOLUTE'; L(comp, ab.inputs[0]) + pw = N('ShaderNodeMath'); pw.operation = 'POWER'; L(ab.outputs[0], pw.inputs[0]); pw.inputs[1].default_value = 4.0 + return pw.outputs[0] +wx, wy, wz = w(nrm.outputs[0]), w(nrm.outputs[1]), w(nrm.outputs[2]) +s1 = N('ShaderNodeMath'); s1.operation = 'ADD'; L(wx, s1.inputs[0]); L(wy, s1.inputs[1]) +s2 = N('ShaderNodeMath'); s2.operation = 'ADD'; L(s1.outputs[0], s2.inputs[0]); L(wz, s2.inputs[1]) +def scaled(col, wt): + d = N('ShaderNodeMath'); d.operation = 'DIVIDE'; L(wt, d.inputs[0]); L(s2.outputs[0], d.inputs[1]) + mm = N('ShaderNodeMix'); mm.data_type = 'RGBA'; mm.blend_type = 'MULTIPLY'; mm.inputs['Factor'].default_value = 1.0 + L(col, mm.inputs['A']); L(d.outputs[0], mm.inputs['B']); return mm.outputs['Result'] +def add(a_, b_): + mm = N('ShaderNodeMix'); mm.data_type = 'RGBA'; mm.blend_type = 'ADD'; mm.inputs['Factor'].default_value = 1.0 + L(a_, mm.inputs['A']); L(b_, mm.inputs['B']); return mm.outputs['Result'] +col = add(add(scaled(tf.outputs['Color'], wy), scaled(ts.outputs['Color'], wx)), scaled(tt.outputs['Color'], wz)) +em = N('ShaderNodeEmission'); L(col, em.inputs['Color']); outn = N('ShaderNodeOutputMaterial'); L(em.outputs[0], outn.inputs['Surface']) +bake = bpy.data.images.new('baked', 2048, 2048); bt = N('ShaderNodeTexImage'); bt.image = bake; nt.nodes.active = bt +ob.data.materials.append(mat) +sc = bpy.context.scene; sc.render.engine = 'CYCLES'; sc.cycles.samples = 1; sc.cycles.device = 'CPU' +sc.render.bake.margin = 8 +bpy.ops.object.bake(type='EMIT') +bake.filepath_raw = out.replace('.glb', '_BC.png'); bake.file_format = 'PNG'; bake.save() +# final material +m2 = bpy.data.materials.new('M_baked'); m2.use_nodes = True +bsdf = next(n for n in m2.node_tree.nodes if n.type == 'BSDF_PRINCIPLED') +t2 = m2.node_tree.nodes.new('ShaderNodeTexImage'); t2.image = bake +m2.node_tree.links.new(t2.outputs['Color'], bsdf.inputs['Base Color']) +ob.data.materials.clear(); ob.data.materials.append(m2) +# front is -Y in Hunyuan output; Tripo convention (what glb_to_unity_fbx expects) is +X +ob.rotation_euler = (0, 0, math.radians(90)); bpy.ops.object.transform_apply(rotation=True) +bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=False) +print('WROTE', out) diff --git a/tools/art/procedural/build_all.sh b/tools/art/procedural/build_all.sh new file mode 100755 index 00000000..f0f30682 --- /dev/null +++ b/tools/art/procedural/build_all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Baut die vier prozeduralen Legion-Gebaeude neu: Geometrie -> Projektion/Bake -> Konvertat -> Asset-Pass. +# Aufruf aus dem Repo-Root: tools/art/procedural/build_all.sh [Gebaeude ...] +set -euo pipefail +W="$1"; shift || true +B=/Applications/Blender.app/Contents/MacOS/Blender +P=tools/art/procedural +ASSETS=../Hashkrieg_Assets +U=$ASSETS/3d/unity_ready/Buildings/Legion +I=$ASSETS/img +PY=${PYTHON:-python3} +mkdir -p "$W" +build() { # name script concept size [crop] + local n=$1 script=$2 concept=$3 size=$4; shift 4 + $PY $P/prep_textures.py "$concept" "$W/$n" "$@" + $B -b --python $P/$script -- "$W/proc_$n.glb" | grep -E "^tris" + $B -b --python $P/bake_projected.py -- "$W/proc_$n.glb" "$W/${n}_front.png" "$W/${n}_side.png" "$W/tex_$n.glb" 60000 "$W/${n}_top.png" | grep WROTE + $B -b --python tools/art/glb_to_unity_fbx.py -- --input "$W/tex_$n.glb" \ + --out-fbx "$U/$n/SM_BLDG_Legion_$n.fbx" --out-texture "$U/$n/T_BLDG_Legion_${n}_BC.png" \ + --fit footprint --size "$size" --texture-size 2048 --budgets 20000 8000 2000 --keep-slivers \ + --report "$W/report_$n.json" | grep -o '"dims_unity_xyz_m": \[[^]]*\]' + $B -b --python tools/art/asset_pass.py -- --asset "BLDG_Legion_$n" --src $ASSETS/3d/unity_ready \ + --dst Assets/_Project/Art --preview "$W/prev" | grep -E "Textur" +} +ALL=("$@") +sel() { [ ${#ALL[@]} -eq 0 ] && return 0; for x in "${ALL[@]}"; do [ "$x" = "$1" ] && return 0; done; return 1; } +sel Power && build Power build_power.py $I/v2test/legion_building_Power.png 9.0 +sel VehicleFactory && build VehicleFactory build_factory.py $I/v2test/legion_building_VehicleFactory_b.png 12.0 +sel ResearchLab && build ResearchLab build_lab.py $I/v2test/legion_building_ResearchLab_b.png 9.0 160 20 935 980 +sel Barracks && build Barracks build_barracks.py $I/legion_building_Barracks.png 9.0 diff --git a/tools/art/procedural/build_barracks.py b/tools/art/procedural/build_barracks.py new file mode 100644 index 00000000..56c075e0 --- /dev/null +++ b/tools/art/procedural/build_barracks.py @@ -0,0 +1,82 @@ +"""Procedural Legion recruit camp: quonset huts, canvas awning, watch platform. Front = -Y.""" +import bpy, bmesh, math, sys +out = sys.argv[sys.argv.index('--')+1] +bpy.ops.wm.read_factory_settings(use_empty=True) +parts = [] +def box(c, s, rot=(0, 0, 0)): + bpy.ops.mesh.primitive_cube_add(size=1, location=c, rotation=rot); o = bpy.context.object; o.scale = s; parts.append(o); return o +def cyl(c, r, d, rot=(0, 0, 0), v=12): + bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=d, location=c, rotation=rot, vertices=v); o = bpy.context.object; parts.append(o); return o +def quonset(cx, cy, r, L, ribs=10): + """Half-cylinder hut along Y, centred at (cx, cy), with end walls and corrugation ribs.""" + bm = bmesh.new(); seg = 20 + ring = lambda y: [bm.verts.new((cx + r*math.cos(math.pi*i/seg), y, r*math.sin(math.pi*i/seg))) for i in range(seg+1)] + a, b = ring(cy - L/2), ring(cy + L/2) + for i in range(seg): + bm.faces.new((a[i], a[i+1], b[i+1], b[i])) + bm.faces.new(list(reversed(a))); bm.faces.new(b) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + me = bpy.data.meshes.new('q'); bm.to_mesh(me); o = bpy.data.objects.new('q', me); bpy.context.scene.collection.objects.link(o) + sol = o.modifiers.new('s', 'SOLIDIFY'); sol.thickness = 0.06 + bpy.context.view_layer.objects.active = o; bpy.ops.object.modifier_apply(modifier='s'); parts.append(o) + for k in range(ribs + 1): + y = cy - L/2 + k * L / ribs + bpy.ops.mesh.primitive_torus_add(location=(cx, y, 0), rotation=(math.pi/2, 0, 0), major_radius=r + 0.03, + minor_radius=0.035, major_segments=24, minor_segments=6) + t = bpy.context.object + bpy.ops.object.mode_set(mode='EDIT'); tb = bmesh.from_edit_mesh(t.data) + bmesh.ops.delete(tb, geom=[v for v in tb.verts if v.co.y < -0.02], context='VERTS') # keep upper half (local y = world z) + bmesh.update_edit_mesh(t.data); bpy.ops.object.mode_set(mode='OBJECT'); parts.append(t) +# concrete-ish base slab +box((0, 0.1, 0.05), (3.0, 2.9, 0.1)) +quonset(-0.35, 0.25, 1.05, 2.6) # main hut (front facing -Y) +quonset(1.05, 0.55, 0.62, 2.0, 8) # low side hut +# front wall of main hut: door opening frame + canvas awning +box((-0.35, -1.06, 0.5), (0.7, 0.05, 1.0)) # dark door panel +box((-0.35, -1.12, 1.04), (0.9, 0.08, 0.08)) # lintel +aw = box((-0.35, -1.45, 1.02), (1.25, 0.8, 0.03), rot=(math.radians(-18), 0, 0)) # sloped canvas +for x in (-0.95, 0.25): + box((x, -1.8, 0.5), (0.05, 0.05, 1.0)) # awning poles +# window/vent boxes on the main front +for x in (-1.05, 0.35): + box((x, -1.06, 0.62), (0.3, 0.06, 0.2)) +# watch platform on the right front: 4 legs, deck, rail, roof +px, py, h = 1.15, -0.75, 1.75 +for dx in (-0.35, 0.35): + for dy in (-0.35, 0.35): + box((px + dx, py + dy, (h + 0.66)/2), (0.07, 0.07, h + 0.66)) +box((px, py, h), (0.85, 0.85, 0.07)) +for dy in (-0.42, 0.42): + box((px, py + dy, h + 0.28), (0.85, 0.04, 0.04)) +for dx in (-0.42, 0.42): + box((px + dx, py, h + 0.28), (0.04, 0.85, 0.04)) +box((px, py, h + 0.68), (1.05, 1.05, 0.05), rot=(0, math.radians(6), 0)) # tin roof +# cross braces +for s in (-1, 1): + box((px, py - 0.35, h*0.45), (0.04, 0.04, 1.2), rot=(0, s*math.radians(28), 0)) +# ladder to the platform +for dx in (-0.14, 0.14): + box((px + dx, py - 0.55, h/2), (0.04, 0.04, h), rot=(math.radians(-10), 0, 0)) +for k in range(8): + z = 0.15 + k * (h - 0.25) / 7 + box((px, py - 0.55 + (z - h/2) * math.tan(math.radians(10)), z), (0.3, 0.035, 0.035)) +# cable loops between huts and a generator box +box((1.15, 1.25, 0.3), (0.5, 0.4, 0.5)); cyl((1.15, 1.25, 0.7), 0.06, 0.4, v=10) +for i in range(3): + bpy.ops.curve.primitive_bezier_curve_add(location=(0, 0, 0)); c = bpy.context.object + sp = c.data.splines[0]; p0, p1 = sp.bezier_points + p0.co = (-0.6 + i*0.3, 0.9, 1.0); p1.co = (1.1, 1.2, 0.55) + p0.handle_right = (0.0 + i*0.2, 1.3, 0.4); p1.handle_left = (0.8, 1.2, 0.2) + p0.handle_left = p0.co; p1.handle_right = p1.co + c.data.bevel_depth = 0.025; c.data.bevel_resolution = 1 + bpy.ops.object.convert(target='MESH'); parts.append(bpy.context.object) +# antenna on the main hut +cyl((-0.9, 0.9, 1.4), 0.02, 1.2, v=6) +bpy.ops.object.select_all(action='DESELECT') +for o in parts: o.select_set(True) +bpy.context.view_layer.objects.active = parts[0] +bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) +bpy.ops.object.join(); ob = bpy.context.object +for p in ob.data.polygons: p.use_smooth = False +bpy.ops.export_scene.gltf(filepath=out, export_format='GLB') +print('tris', sum(len(p.vertices)-2 for p in ob.data.polygons)) diff --git a/tools/art/procedural/build_factory.py b/tools/art/procedural/build_factory.py new file mode 100644 index 00000000..ba8471d5 --- /dev/null +++ b/tools/art/procedural/build_factory.py @@ -0,0 +1,34 @@ +"""Legion vehicle assembly hangar: wide quonset hall, big roll-up bay door, roof gantry crane, side stair, fan banks. Front = -Y.""" +import bpy, sys, math, os +sys.path.insert(0, os.path.dirname(__file__)); from kit import * +out = sys.argv[sys.argv.index('--')+1] +bpy.ops.wm.read_factory_settings(use_empty=True) +box((0, 0.1, 0.06), (3.2, 3.6, 0.12)) # apron +box((-0.1, 0.25, 0.55), (2.5, 3.0, 1.0)) # straight walls +quonset(-0.1, 0.25, 1.25, 3.0, 12, z0=1.0, ends=True) # arched roof +box((-0.1, -1.27, 0.5), (2.5, 0.08, 1.0)) # front wall (arch end cap above) +box((-0.1, -1.33, 0.75), (1.4, 0.06, 1.4)) # roll-up door +for k in range(9): box((-0.1, -1.37, 0.12 + k*0.155), (1.38, 0.025, 0.04)) +box((-0.1, -1.36, 1.5), (1.6, 0.08, 0.1)) # door header (glow strip) +for x in (-0.86, 0.66): box((x, -1.34, 0.78), (0.1, 0.1, 1.55)) +fan(-1.0, -1.33, 1.65, 0.36); fan(-0.55, -1.33, 1.65, 0.36) # fan bank above-left +box((0.75, -1.33, 1.7), (0.5, 0.08, 0.35)) # panel +# gantry crane on the roof ridge +for y in (-0.9, 0.0): + box((-0.1, y, 2.45), (0.1, 0.1, 0.5)) +box((-0.1, -0.45, 2.72), (0.12, 1.3, 0.1)); box((0.45, -0.9, 2.72), (1.1, 0.1, 0.1)) +cyl((0.9, -0.9, 2.45), 0.012, 0.5, v=6); ring((0.9, -0.9, 2.18), 0.06, 0.018, rot=(math.pi/2, 0, 0)) +# external stair to a foreman platform on the right +box((1.45, -0.85, 1.25), (0.5, 0.6, 0.05)) +for (x, y) in [(1.25, -1.1), (1.65, -1.1), (1.65, -0.6)]: box((x, y, 0.65), (0.05, 0.05, 1.3)) +for k in range(9): + box((1.45, 0.75 - k*0.17, 0.15 + k*0.125), (0.42, 0.16, 0.03)) +box((1.68, 0.05, 0.75), (0.03, 1.5, 0.03), rot=(math.radians(36), 0, 0)) +box((1.68, -0.85, 1.55), (0.03, 0.6, 0.03)) +# hanging inspection lamps +for x in (-0.6, 0.4): cyl((x, -1.45, 1.85), 0.07, 0.08, v=10) +# side wall vents and cables +for y in (-0.6, 0.3, 1.2): box((-1.37, y, 0.6), (0.06, 0.4, 0.25)) +pipe([(-1.38, -1.0, 0.2), (-1.42, -0.6, 1.2), (-1.0, -0.3, 2.1)], 0.04) +pipe([(1.2, 1.4, 0.2), (1.25, 1.2, 0.9), (1.0, 0.8, 1.9)], 0.05) +finish(out) diff --git a/tools/art/procedural/build_lab.py b/tools/art/procedural/build_lab.py new file mode 100644 index 00000000..694ec802 --- /dev/null +++ b/tools/art/procedural/build_lab.py @@ -0,0 +1,87 @@ +"""Procedural Legion war lab: welded shipping-container stack. Front = -Y (Hunyuan convention used by bake_projected).""" +import bpy, bmesh, math, sys +from mathutils import Vector, Matrix +out = sys.argv[sys.argv.index('--')+1] +bpy.ops.wm.read_factory_settings(use_empty=True) +parts = [] +def box(c, s, rot=0.0): + bpy.ops.mesh.primitive_cube_add(size=1, location=c, rotation=(0, 0, rot)); o = bpy.context.object + o.scale = s; parts.append(o); return o +def cyl(c, r, d, rot=(0, 0, 0), v=16): + bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=d, location=c, rotation=rot, vertices=v); o = bpy.context.object; parts.append(o); return o +def container(cx, cy, cz, L, W, H, axis='x'): + # body + corrugation ribs + corner posts + if axis == 'x': box((cx, cy, cz + H/2), (L, W, H)) + else: box((cx, cy, cz + H/2), (W, L, H)) + n = int(L / 0.28) + for i in range(n): + t = -L/2 + (i + 0.5) * L / n + if axis == 'x': + box((cx + t, cy - W/2 - 0.02, cz + H/2), (0.09, 0.05, H*0.86)) + box((cx + t, cy + W/2 + 0.02, cz + H/2), (0.09, 0.05, H*0.86)) + else: + box((cx - W/2 - 0.02, cy + t, cz + H/2), (0.05, 0.09, H*0.86)) + box((cx + W/2 + 0.02, cy + t, cz + H/2), (0.05, 0.09, H*0.86)) + hx, hy = (L/2, W/2) if axis == 'x' else (W/2, L/2) + for sx in (-1, 1): + for sy in (-1, 1): + box((cx + sx*hx, cy + sy*hy, cz + H/2), (0.16, 0.16, H + 0.04)) + # top/bottom rails + for zz in (cz + 0.04, cz + H - 0.04): + box((cx, cy - hy, zz), (2*hx + 0.1, 0.14, 0.12)); box((cx, cy + hy, zz), (2*hx + 0.1, 0.14, 0.12)) + box((cx - hx, cy, zz), (0.14, 2*hy + 0.1, 0.12)); box((cx + hx, cy, zz), (0.14, 2*hy + 0.1, 0.12)) +# footprint ~ 3 x 3 m normalised; scaled later by the converter +H = 1.15 +container(0.0, 0.0, 0.0, 3.0, 2.4, H, 'x') # ground container, long side facing front +container(-0.15, 0.1, H, 2.6, 2.2, H, 'y') # second, rotated 90 deg +container(0.55, -0.05, 2*H, 1.6, 1.4, H*0.8, 'x') # small top box (server room) +# front door + frame +box((0.75, -1.24, 0.55), (0.6, 0.06, 1.0)); box((0.75, -1.27, 1.08), (0.72, 0.06, 0.08)) +# fan banks on the front +for (x, z) in [(-0.85, 0.42), (-0.85, 0.9), (-0.25, 0.42)]: + box((x, -1.24, z), (0.5, 0.06, 0.42)) + cyl((x, -1.29, z), 0.17, 0.04, rot=(math.pi/2, 0, 0), v=20) + for k in range(4): + b = box((x, -1.31, z), (0.3, 0.012, 0.05)); b.rotation_euler = (0, k*math.pi/4, 0) +# vents on upper container front +for x in (-0.9, -0.2, 0.5): + box((x, -1.02, H + 0.75), (0.5, 0.06, 0.22)) +# monitors cluster (upper right front) +for (x, z) in [(0.6, H + 0.55), (0.95, H + 0.5), (0.78, H + 0.25)]: + box((x, -1.0, z), (0.26, 0.12, 0.2)) +# scaffold walkway along the right side at mid height +box((1.62, -0.1, H + 0.02), (0.5, 2.3, 0.06)) +for y in (-1.15, -0.4, 0.35, 1.0): + box((1.85, y, H/2), (0.06, 0.06, H)); box((1.85, y, H + 0.35), (0.04, 0.04, 0.7)) +box((1.85, -0.1, H + 0.7), (0.04, 2.3, 0.04)); box((1.85, -0.1, H + 0.4), (0.03, 2.3, 0.03)) +# exterior ladder on the left side to the roof +for sx in (-0.18, 0.18): + box((-1.56, -0.6 + sx, H*1.4), (0.04, 0.04, H*2.8)) +for k in range(14): + box((-1.56, -0.6, 0.2 + k*0.22), (0.04, 0.36, 0.03)) +# roof: antenna cluster + dish +top = 2*H + H*0.8 +for (x, y, h) in [(0.1, 0.2, 1.2), (0.35, -0.3, 0.9), (0.9, 0.3, 1.5), (-0.6, 0.6, 0.8)]: + cyl((x, y, top + h/2), 0.025, h, v=8); cyl((x, y, top + h*0.75), 0.008, 0.35, rot=(0, math.pi/2, 0), v=6) +cyl((0.9, 0.3, top + 0.06), 0.08, 0.12, v=10) +bpy.ops.mesh.primitive_uv_sphere_add(radius=0.42, location=(-0.6, -0.1, top + 0.55), segments=24, ring_count=12) +d = bpy.context.object; parts.append(d) +bpy.ops.object.mode_set(mode='EDIT'); bm = bmesh.from_edit_mesh(d.data) +bmesh.ops.delete(bm, geom=[v for v in bm.verts if v.co.z > -0.24], context='VERTS'); bmesh.update_edit_mesh(d.data) +bpy.ops.object.mode_set(mode='OBJECT') +d.rotation_euler = (math.radians(-60), 0, math.radians(20)) +cyl((-0.6, -0.1, top + 0.25), 0.05, 0.5, v=8) +# cable looms down the front left +for i, x in enumerate((-1.3, -1.2, 0.3)): + c = cyl((x, -1.27, H), 0.03, 2*H, v=8) +# join, bevel-ish smoothing via weighted normals +bpy.ops.object.select_all(action='DESELECT') +for o in parts: o.select_set(True) +bpy.context.view_layer.objects.active = parts[0] +bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) +bpy.ops.object.join(); ob = bpy.context.object +bev = ob.modifiers.new('bev', 'BEVEL'); bev.width = 0.015; bev.segments = 1; bev.limit_method = 'ANGLE' +bpy.ops.object.modifier_apply(modifier=bev.name) +ob.name = 'lab' +bpy.ops.export_scene.gltf(filepath=out, export_format='GLB') +print('tris', sum(len(p.vertices)-2 for p in ob.data.polygons)) diff --git a/tools/art/procedural/build_power.py b/tools/art/procedural/build_power.py new file mode 100644 index 00000000..469b0076 --- /dev/null +++ b/tools/art/procedural/build_power.py @@ -0,0 +1,36 @@ +"""Legion heavy generator: stacked turbine blocks, twin corrugated stacks, fan racks, ladder, catwalk. Front = -Y.""" +import bpy, sys, math, os +sys.path.insert(0, os.path.dirname(__file__)); from kit import * +out = sys.argv[sys.argv.index('--')+1] +bpy.ops.wm.read_factory_settings(use_empty=True) +box((0, 0.1, 0.06), (3.0, 2.8, 0.12)) # plinth +box((-0.35, 0.1, 0.85), (2.1, 2.4, 1.5)) # main generator hall +box((-0.35, 0.1, 1.72), (1.7, 2.1, 0.3)) # roof step +box((-0.35, -1.12, 0.62), (0.75, 0.08, 1.0)); box((-0.35, -1.17, 1.18), (0.95, 0.06, 0.12)) # door + lintel +box((-0.35, -1.13, 1.42), (1.1, 0.08, 0.3)) # glowing grille panel +for x in (-1.1, 0.4): # plate seams + box((x, -1.12, 0.85), (0.06, 0.06, 1.5)) +# server/fan rack tower on the right +box((1.15, -0.1, 0.75), (0.75, 1.9, 1.3)); box((1.15, 0.0, 1.55), (0.6, 1.4, 0.35)) +for z in (0.45, 0.95): + fan(1.0, -1.08, z, 0.36); fan(1.32, -1.08, z, 0.36) +box((1.15, -1.1, 1.55), (0.5, 0.08, 0.28)) # control panel +# twin chimneys with rings +for x in (-0.85, -0.25): + cyl((x, 0.35, 2.55), 0.26, 1.6, v=20) + for z in (1.95, 2.35, 2.8, 3.25): ring((x, 0.35, z), 0.27, 0.035) + cyl((x, 0.35, 3.4), 0.3, 0.14, v=20) +# catwalk + railing on the roof edge +box((-0.35, -0.95, 1.9), (2.1, 0.45, 0.05)) +for x in (-1.35, -0.6, 0.15, 0.65): box((x, -1.15, 2.15), (0.035, 0.035, 0.5)) +box((-0.35, -1.15, 2.38), (2.1, 0.035, 0.035)) +ladder(0.78, -1.05, 0.12, 2.2, axis='x') +# cable looms and pipes +pipe([(-1.4, -1.15, 0.2), (-1.42, -1.2, 0.9), (-1.2, -1.2, 1.5), (-0.9, -0.6, 1.95)], 0.04) +pipe([(0.75, -0.7, 1.0), (0.6, -0.9, 1.4), (0.2, -0.6, 1.88)], 0.05) +pipe([(-1.45, 0.6, 0.3), (-1.6, 0.5, 0.8), (-1.45, 0.4, 1.3)], 0.06) +# fuel drums at the side +for (x, y) in [(-1.6, -0.6), (-1.6, -0.15)]: + cyl((x, y, 0.4), 0.2, 0.56, v=14); ring((x, y, 0.3), 0.205, 0.02); ring((x, y, 0.55), 0.205, 0.02) +cyl((0.9, 1.0, 2.0), 0.02, 1.0, v=6) # antenna +finish(out) diff --git a/tools/art/procedural/kit.py b/tools/art/procedural/kit.py new file mode 100644 index 00000000..4c77e4a5 --- /dev/null +++ b/tools/art/procedural/kit.py @@ -0,0 +1,52 @@ +import bpy, bmesh, math +parts = [] +def box(c, s, rot=(0, 0, 0)): + bpy.ops.mesh.primitive_cube_add(size=1, location=c, rotation=rot); o = bpy.context.object; o.scale = s; parts.append(o); return o +def cyl(c, r, d, rot=(0, 0, 0), v=16): + bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=d, location=c, rotation=rot, vertices=v); o = bpy.context.object; parts.append(o); return o +def ring(c, r, minor, rot=(0, 0, 0)): + bpy.ops.mesh.primitive_torus_add(location=c, rotation=rot, major_radius=r, minor_radius=minor, major_segments=20, minor_segments=6) + parts.append(bpy.context.object); return bpy.context.object +def quonset(cx, cy, r, L, ribs=10, z0=0.0, ends=True): + bm = bmesh.new(); seg = 20 + rg = lambda y: [bm.verts.new((cx + r*math.cos(math.pi*i/seg), y, z0 + r*math.sin(math.pi*i/seg))) for i in range(seg+1)] + a, b = rg(cy - L/2), rg(cy + L/2) + for i in range(seg): bm.faces.new((a[i], a[i+1], b[i+1], b[i])) + if ends: bm.faces.new(list(reversed(a))); bm.faces.new(b) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + me = bpy.data.meshes.new('q'); bm.to_mesh(me); o = bpy.data.objects.new('q', me); bpy.context.scene.collection.objects.link(o) + sol = o.modifiers.new('s', 'SOLIDIFY'); sol.thickness = 0.06 + bpy.context.view_layer.objects.active = o; bpy.ops.object.modifier_apply(modifier='s'); parts.append(o) + for k in range(ribs + 1): + y = cy - L/2 + k * L / ribs + bpy.ops.mesh.primitive_torus_add(location=(cx, y, z0), rotation=(math.pi/2, 0, 0), major_radius=r + 0.03, minor_radius=0.035, major_segments=24, minor_segments=6) + t = bpy.context.object + bpy.ops.object.mode_set(mode='EDIT'); tb = bmesh.from_edit_mesh(t.data) + bmesh.ops.delete(tb, geom=[v for v in tb.verts if v.co.y < -0.02], context='VERTS') + bmesh.update_edit_mesh(t.data); bpy.ops.object.mode_set(mode='OBJECT'); parts.append(t) +def fan(x, y, z, size=0.42, facing=-1): + box((x, y, z), (size*1.15, 0.06, size)) + cyl((x, y + facing*0.05, z), size*0.4, 0.04, rot=(math.pi/2, 0, 0), v=20) + for k in range(4): + box((x, y + facing*0.07, z), (size*0.72, 0.012, 0.05), rot=(0, k*math.pi/4, 0)) +def ladder(x, y, z0, h, w=0.36, axis='y'): + for s in (-w/2, w/2): + box((x + (s if axis == 'x' else 0), y + (s if axis == 'y' else 0), z0 + h/2), (0.04, 0.04, h)) + for k in range(int(h / 0.22)): + box((x, y, z0 + 0.15 + k*0.22), (w if axis == 'x' else 0.04, w if axis == 'y' else 0.04, 0.03)) +def pipe(points, r=0.035): + bpy.ops.curve.primitive_bezier_curve_add(); c = bpy.context.object; sp = c.data.splines[0] + sp.bezier_points.add(len(points) - 2) + for bp, p in zip(sp.bezier_points, points): + bp.co = p; bp.handle_left_type = bp.handle_right_type = 'AUTO' + c.data.bevel_depth = r; c.data.bevel_resolution = 2 + bpy.ops.object.convert(target='MESH'); parts.append(bpy.context.object) +def finish(out, smooth=False): + bpy.ops.object.select_all(action='DESELECT') + for o in parts: o.select_set(True) + bpy.context.view_layer.objects.active = parts[0] + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + bpy.ops.object.join(); ob = bpy.context.object + for p in ob.data.polygons: p.use_smooth = smooth + bpy.ops.export_scene.gltf(filepath=out, export_format='GLB') + print('tris', sum(len(p.vertices)-2 for p in ob.data.polygons)) diff --git a/tools/art/procedural/prep_textures.py b/tools/art/procedural/prep_textures.py new file mode 100644 index 00000000..a7ec4b47 --- /dev/null +++ b/tools/art/procedural/prep_textures.py @@ -0,0 +1,39 @@ +"""Concept image -> front/side/roof projection textures for bake_projected.py. + +Usage: python3 prep_textures.py [x0 y0 x1 y1] +Writes _front.png (object crop), _side.png (wall band), _top.png (calm roof plates). +Brightness is lifted linearly (contrast kept) to mean luminance 0.11; a gamma lift washes the +asset_pass grade out. +""" +import sys +import numpy as np +from PIL import Image, ImageFilter + +src, pre = sys.argv[1], sys.argv[2] +im = Image.open(src).convert("RGB") +if len(sys.argv) > 6: + box = tuple(int(v) for v in sys.argv[3:7]) +else: + a = np.asarray(im).astype(float) + edge = np.concatenate([a[:20].reshape(-1, 3), a[-20:].reshape(-1, 3), a[:, :20].reshape(-1, 3), a[:, -20:].reshape(-1, 3)]) + d = np.abs(a - np.median(edge, axis=0)).sum(-1) > 40 + rows, cols = np.where(d.mean(1) > 0.02)[0], np.where(d.mean(0) > 0.02)[0] + box = (cols[0], rows[0], cols[-1] + 1, rows[-1] + 1) +c = im.crop(box) + + +def lift(img, target=0.11): + x = np.asarray(img).astype(np.float32) / 255 + lum = (x @ np.array([0.2126, 0.7152, 0.0722])).mean() + return Image.fromarray((np.clip(x * min(target / max(lum, 1e-4), 4.0), 0, 1) * 255).astype(np.uint8)) + + +w, h = c.size +front = lift(c).resize((2048, 2048), Image.LANCZOS) +side = lift(c.crop((int(w * .04), int(h * .32), int(w * .40), int(h * .95)))).resize((2048, 2048), Image.LANCZOS) +# roof: large-scale colour from the wall band, fine grain at low contrast, no recognisable features +base = side.resize((24, 24), Image.BILINEAR).resize((2048, 2048), Image.BICUBIC) +grain = side.convert("L").filter(ImageFilter.GaussianBlur(3)).convert("RGB") +roof = Image.blend(base, Image.blend(base, grain, 0.5), 0.35) +front.save(pre + "_front.png"); side.save(pre + "_side.png"); roof.save(pre + "_top.png") +print("box", box) From 3c8e6bf2e7ab956074c1f12510f114ea6e672079 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 00:04:29 +0200 Subject: [PATCH 11/13] docs(production): log D-115 to D-119 and the changelog entries Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 69 ++++++++ docs/README.md | 2 +- docs/assets/AssetPass_2026-10-17.md | 98 ++++++++++- docs/production/DecisionLog.md | 245 +++++++++++++++++++++++++++- 4 files changed, 403 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c8d90e..e7beaef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de > erzeugt; MS-0 und MS-1 bleiben offen. ### Entschieden +- **D-118: Jeder Bauer kann bauen.** Steht der an eine Baustelle gebundene Bauer + nicht in Reichweite, übernimmt der eigene Bauer mit dem kleinsten Index, der dort + steht. Ersetzt die Nur-kleinster-Index-Regel aus D-085; mit einem Bauer + byte-identisch, Fingerprints und KI-Pin bleiben +- **D-119: Legion-Kraftwerk, -Fabrik, -Labor und -Kaserne prozedural.** Neu + modelliert nach neuen Konzeptbildern statt neu generiert; Hunyuan3D wurde + erprobt und verworfen, weil dessen Lizenz in der EU nicht gilt - **D-108 berichtigt und neu gefasst: jedes eigene Gebäude wird Bauanker.** Die Erstfassung vom 2026-08-17 behauptete, der Code lasse *jedes* Gebäude als Anker gelten, und wollte diesen Zustand nur aussprechen. Das war falsch: @@ -73,6 +80,38 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Sammler zeigen ihre Ladung und kippen sie ab.** Auf der Mulde des + Legion-Lasters bzw. dem Rücken des Allianz-Roboters wächst mit dem Füllstand + eine Erzladung mit leuchtenden Aetherium-Kristallen. An der Raffinerie kippt die + Mulde nach hinten (Roboter: lehnt sich zurück), die Ladung rutscht unter + leuchtendem Staub heraus, danach fährt der Sammler weiter; der eigene Sammler + wartet dafür kurz an der Raffinerie. Beim Sammeln vibriert der Motor. Die + Legion-Mulde ist ein eigenes Teil (`Bed`), `ArtPreviewCapture -captureDump` + zeigt die Kipp-Phase. Per Kamera im Spielrenderer geprüft, **nicht gespielt** +- **Befehlsleiste je Einheitentyp.** Unpassende Befehle werden ausgeblendet statt + ausgegraut: Sammler und Bauer haben keine Patrouille, Wache, Angriffsbewegung, + Verteilen oder Formationen. Sammler zeigen stattdessen einen Ladungsbalken + („Ladung 210 / 300“) und ihren Zustand (fährt zum Feld, sammelt, fährt zur + Raffinerie …), bei Mehrfachauswahl summiert. W/K/J/M wirken nur noch auf die + kampffähigen Einheiten der Auswahl +- **Befehlsleiste im Stil von Command & Conquer: Zero Hour, mit Gruppenbefehlen und + Formationen (D-117).** Ausgewählte Einheiten bekommen unten rechts eine Leiste in UI + Toolkit: Typ-Chips (Klick wählt nur diesen Typ), ein Raster aus quadratischen + Knöpfen mit gezeichneten Icons, Tastenkürzel und Tooltip. Neu: **Angriffsbewegung** + (A auf freien Boden), **Patrouille** (K), **Bewachen** (W), **Verteilen** (J) und fünf + **Formationen** — Locker, Linie, Kolonne, Keil, Block (M wechselt) —, die bei jedem + Gruppenbefehl gelten. Alles läuft als Client-Auftrag über die vorhandenen Befehle + Move, Stop und AttackTarget, das Befehlsregister bleibt unverändert. Die alte Karte + zeigt nur noch Gebäude. Per Kamera im laufenden Spiel geprüft, **nicht gespielt** +- **Zerstörte Einheiten und Gebäude bleiben als Wrack liegen (D-115).** Zu jedem + der 34 Assets erzeugt der Blender-Pass eine Wrack-Variante (verdrehter Turm, + hängendes Rohr, liegender Soldat, eingestürzte Ruine mit Trümmern, verkohlte + Textur), die im selben Prefab liegt. Beim Tod wird das Modell kurz schwarz, + wechselt aufs Wrack und qualmt; ältere Wracks versinken erst ab 60 Einheiten- + bzw. 20 Gebäudewracks, ein Neubau räumt seine Fläche. Dazu Umgebungsbewegung: + Staubfahnen hinter Fahrzeugen, Schornsteinrauch der Legion, suchende Türme im + Leerlauf, atmende und sich umschauende Infanterie, Motorvibration. Neuer + PlayMode-Test lässt acht Einheiten im Kampf sterben und prüft die Wracks - **Infanterie geht.** Die vier Infanteristen haben in Blender abgetrennte Ober- und Unterschenkel (`Leg_*`/`Shin_*`); `UnitRigAnimator` fährt daraus einen Zwei-Gelenk-Gang mit Hüftsenkung, damit die Füße am Boden bleiben, und einen @@ -297,6 +336,14 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert +- **Stimmiges Größenverhältnis (D-113).** Einheiten wurden bisher auf die Größe + ihres Graybox-Primitivs gezwungen: ein Kampfpanzer war doppelt so hoch wie ein + Soldat und fast so breit wie eine Kaserne, ein Sammler kleiner als ein leichter + Panzer. Jetzt gilt eine Regel je Klasse (`ArtViewScale`): Gebäude füllen ihren + Footprint, alle Fahrzeuge teilen einen metrischen Faktor, sodass ihre + Proportionen erhalten bleiben, Infanterie und Bauer bekommen eine lesbare + Mindesthöhe. Der Auswahlring folgt der Modellgröße. Abwehrtürme stehen jetzt + auf Panzerhöhe. Neue Größen-Aufstellung in `ArtPreviewCapture` - **Abwehrtürme in menschlichem Maßstab (D-112).** Jedes Gebäude wird auf seinen 3×3-Footprint skaliert, ein schlanker Turm maß dadurch vier Soldatenhöhen. Die Türme stehen jetzt auf einem flachen Betonsockel, der den Footprint füllt, und @@ -468,6 +515,28 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Der zweite Bauer baut (D-118).** Bisher konnte nur der Bauer mit dem + kleinsten Index eine Baustelle fortschreiben, jeder weitere stand daneben, + und beim Platzieren lief immer der erste los. Jetzt geht der ausgewählte Bauer + zur Baustelle und baut, ein Rechtsklick mit Bauern auf die eigene Baustelle + schickt sie dorthin. Die Meldung aus #135 war damals als Bauzonen-Radius + fehldiagnostiziert worden +- **Legion-Gebäude sind wieder Gebäude (D-119).** Fahrzeugfabrik, Kraftwerk und + Forschungslabor waren als Panzer generiert, die Kaserne zerfiel in zwei Hälften + mit schwebendem Wachturm. Neu: Wellblech-Montagehalle mit Rolltor und Kran, + Generatorblock mit Doppelschornstein (zwei Rauchfahnen), verschweißter + Containerstapel mit Antennen und Schüssel, Wellblechhütten mit Planenvordach und + Wachturm (`tools/art/procedural/`). Die Art liegt wie bisher nur lokal; das + Asset-Paket muss neu gebaut werden +- **Einheiten fahren gerade, machen Platz und drehen sich nicht mehr weg (D-116).** + Die Wegfindung rechnete Diagonalschritte durch einen Ganzzahlfehler + (`14 / 10` = 1) so teuer wie gerade — Einheiten fuhren Treppen statt Geraden. Jetzt + oktil 10/14. Der Kurs folgt dem Pfad statt der Abstoßung (höchstens 30° je Tick), + Einheiten halten Abstand je Typ (Infanterie dicht, Panzer weit), große Fahrzeuge + bekommen Gruppenplätze mit freiem Rand, und stehende eigene Einheiten weichen + einer gleich großen oder größeren fahrenden Einheit seitlich aus. Gemessen: + fahrende Einheiten schauen in 100 % der Frames in Fahrtrichtung, ein Panzer fährt + ohne Abweichung durch neun Soldaten. Regel-Revision V4, KI-Pin 2726 → 2721 - **Einheiten schauen in ihre Fahrtrichtung.** Die Ansicht setzte den mathematischen Sim-Kurs (0° = Osten, gegen den Uhrzeigersinn) direkt als Unity-Gier (0° = Norden, im Uhrzeigersinn) ein und spiegelte jede Einheit an der diff --git a/docs/README.md b/docs/README.md index b28832bf..55af8640 100644 --- a/docs/README.md +++ b/docs/README.md @@ -162,7 +162,7 @@ gelisteten Kernverträge. Paket ausserhalb des Repos liegen, Inhalt und Installationsablauf - [AssetImport_Tripo_2026-08-06](assets/AssetImport_Tripo_2026-08-06.md) – Importprotokoll der 34 Tripo-Assets (GLB → FBX, LODs, Texturen) -- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.4.0, Entwurf) – +- [AssetPass_2026-10-17](assets/AssetPass_2026-10-17.md) (0.7.0, Entwurf) – Blickrichtung, Teile-Animation und Optik der 34 Tripo-Assets, Werkzeuge und Grenzen - [VerticalSlice_MS1](assets/VerticalSlice_MS1.md) (0.2.0, Entwurf) – Vertical-Slice-Spezifikation der vier Erst-Assets diff --git a/docs/assets/AssetPass_2026-10-17.md b/docs/assets/AssetPass_2026-10-17.md index 4222ee83..e6038be3 100644 --- a/docs/assets/AssetPass_2026-10-17.md +++ b/docs/assets/AssetPass_2026-10-17.md @@ -1,6 +1,6 @@ # Asset-Pass: Blickrichtung, Teile-Animation, Optik -**Version:** 0.4.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 +**Version:** 0.7.0 | **Status:** Entwurf – Graybox-/Demo-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Technical Art | **Datum:** 2026-10-17 ## Zweck @@ -161,21 +161,67 @@ nimmt den laufenden Soldaten zusätzlich als Bildfolge auf Jedes Gebäude wird im Spiel auf die Breite seines 3×3-Footprints skaliert. Ein schlanker Turm maß dadurch rund vier Soldatenhöhen. Der Pass stellt die Türme jetzt auf einen flachen Betonsockel (`pad` im Rezept), der so breit ist, dass der -Turm nach der Skalierung 0,7 × Footprint hoch ist — rund 2,1 Einheiten, etwa -doppelte Soldatenhöhe und so groß wie ein Kampfpanzer. Der Sockel füllt die +Turm nach der Skalierung 0,55 × Footprint hoch ist — rund 1,7 Einheiten, also +etwa doppelte Soldatenhöhe und so hoch wie ein Kampfpanzer. Der Sockel füllt die blockierte Fläche, statt sie als unsichtbare Wand stehen zu lassen; seine UVs zeigen auf eine ruhige, ungesättigte Stelle der Textur, er braucht kein eigenes Material. **Der Footprint selbst bleibt 3×3** — ihn zu verkleinern ist eine Regeländerung (siehe Offene Punkte). -## 9. Was dieser Pass nicht löst +## 9. Größenverhältnis (D-113) + +Bis hierhin bekam jede Einheit die Größe ihres Graybox-Primitivs. Das ergab +Zufallsverhältnisse: ein Kampfpanzer doppelt so hoch wie ein Soldat und drei +Viertel so breit wie eine Kaserne, ein Sammler kürzer als ein leichter Panzer, +der Allianz-Bauroboter kleiner als ein Soldat. Jetzt gilt eine Regel +(`ArtViewScale`): + +| Klasse | Regel | Ergebnis (Felder) | +|---|---|---| +| Gebäude | Breite = Footprint (3) | 3 breit, 2,8–5,4 hoch | +| Fahrzeuge | 0,22 Felder je Meter Modellmaß, für alle gleich | Kampfpanzer 1,5–1,7, leichter Panzer 1,3, Scout 1,0, Sammler 1,5–1,8 | +| Infanterie | Mindesthöhe 0,85 (Panzerfaust 0,9), unabhängig von der Waffenbreite | Soldat 0,85 hoch | +| Bauer | Mindesthöhe 1,1 | Allianz-Roboter 1,1, Legion-Bagger 1,4 | + +Die Tripo-Modelle sind in Metern im selben Maßstab erzeugt, ihre Verhältnisse +untereinander bleiben damit erhalten. Ein Soldat reicht einem Kampfpanzer bis +etwa zur halben Höhe, ein Panzer hat ungefähr halbe Gebäudebreite: für ein RTS +leicht überzeichnet, aber lesbar. Der Auswahlring folgt jetzt der Modellgröße, +statt fest 2,4 Felder zu messen. Prüfwerkzeug: `ArtPreviewCapture -captureLineup` +stellt alle Modelle einer Fraktion in Spielgröße auf ein Feldraster und +protokolliert die Maße. + +## 10. Wracks und Umgebungsbewegung (D-115) + +`tools/art/asset_wreck.py` baut aus den getrennten Teilen der LOD0-Stufe ein +statisches Wrack-Mesh (`SM_*_Wreck.fbx`, 0,9–7,8 Tsd. Dreiecke) und eine +verkohlte Textur (`T_*_Wreck_BC.png`). Zufallswerte hängen am Asset-Namen, zwei +Läufe ergeben dasselbe Wrack. Erfahrungen: + +- **Wracks aus LOD1 zerfallen**: die reduzierte Stufe der Tripo-Mehrschalen + zeigt zerfetzte Flächen. Quelle ist deshalb LOD0. +- **Verbeulen nicht entlang der Normalen**: Hunderte loser Schalen zeigen in alle + Richtungen und reißen auf. Ein glattes Vektorfeld verbiegt das Ganze wie Blech. +- **Bruchkante nach der höchsten Ecke**, nicht nach dem Mittelpunkt, sonst ragen + große Dreiecke als Spieße über die Ruine; dünne Antennen werden nicht umgelegt. + +Der Prefab-Bau hängt das Wrack als inaktives Kind `Wreck` mit eigenem Material +`M_*_Wreck` an. `ViewWrecks` schaltet beim Tod um (kurzes Schwarzwerden, Sprung, +Aufsetzen), lässt das Wrack neun Sekunden qualmen und liegen. + +Umgebungsbewegung über `AmbientFx` (ein Staub- und ein Rauchsystem für alle): +Staubfahnen hinter fahrenden Fahrzeugen, Rauch aus `Smoke_*`-Knoten +(Legion-Raffinerie, -Kraftwerk, -HQ; Rezeptfeld `emitters`), Suchschwenk der +Türme nach 2,5 s ohne Ziel, Atmen und Umschauen der Infanterie, Motorvibration. + +## 11. Was dieser Pass nicht löst - **Die Geometrie bleibt Tripo-Qualität.** Flächen sind knubbelig; Glätten und doppelseitiges Rendern kaschieren, sie reparieren nicht. Eine Geometrie-AO war ein Fehlschlag: Auf überlappenden Schalen backen ganze Flächen schwarz. Sie ist deshalb im Rezept abgeschaltet. -- **Legion-Kraftwerk und Legion-Forschungslabor sind als Panzer generiert** - (Wanne, Kettenlaufwerk, Turm). Das ist nur durch Neugenerierung zu beheben. +- ~~Legion-Kraftwerk und Legion-Forschungslabor sind als Panzer generiert.~~ + Erledigt mit D-119, siehe Abschnitt 12. - **Infanterie geht mit starren Gliedern, ohne Skelett.** Ober- und Unterschenkel sind abgetrennte Teile; an Hüfte und Knie entstehen beim Schwingen kleine Fugen, Arme und Waffe bleiben starr. Aus RTS-Distanz liest sich das als Gehen; @@ -188,6 +234,41 @@ eine Regeländerung (siehe Offene Punkte). - **TeamMask (B-Kanal) ist leer.** Spiegelpartien (Allianz gegen Allianz) sind farblich nicht unterscheidbar. +## 12. Legion-Gebäude und Sammler (Nachtrag, D-119) + +**Vertauschte Gebäude.** Fahrzeugfabrik, Kraftwerk und Forschungslabor der Legion +waren schon im Konzeptbild Panzer: Die erste Bildgeneration (`generate.py`) gab +den Legion-Panzer als Referenzbild mit. Neue Konzeptbilder kommen aus +`generate_v2.py` (Materialtafel statt Form; Fabrik und Labor mit dem Zusatz +„Außenansicht eines Gebäudes, kein Fahrzeug“, liegen unter +`Hashkrieg_Assets/img/v2test`). Die Kaserne zerfiel als Tripo-Modell in zwei +Hälften mit schwebendem Wachturm. + +Alle vier sind jetzt prozedural in Blender gebaut (`tools/art/procedural/`, +`build_all.sh`), die Textur kommt aus dem Konzeptbild: Box-Projektion (Front aus +dem Objektausschnitt, Seiten aus einem Wandstreifen, Dach aus einer ruhigen +Plattentextur), in Cycles auf Smart-UV gebacken, linear auf mittlere Luminanz +0,11 gehoben. Eine Gamma-Anhebung ließ den Asset-Pass-Grade ausbleichen. + +Fallen: + +- **Der Splitterfilter frisst modellierte Teile.** `glb_to_unity_fbx.py` trennt + an UV-Nähten und hält dünne Leiterholme und Antennen für Generatorsplitter. + Abhilfe: `--keep-slivers`; im Rezept zusätzlich `drop_needles: false` und + `smooth_angle: 0`. +- **Hunyuan3D 2.1** (frei auf Hugging Face, ZeroGPU-Kontingent nach zwei Formen + erschöpft) liefert gute Formen, ist aber in der EU nicht lizenziert und wurde + deshalb verworfen. + +**Sammler.** Der Legion-Laster hat eine eigene Mulde `Bed` (Scharnier hinten +unten), der Allianz-Roboter einen leeren Knoten `Cargo_Anchor` auf dem Rücken. +`UnitRigAnimator` hängt daran zur Laufzeit eine Erzladung (dunkles Gestein, +16 Aetherium-Kristalle), die mit `CargoAE` wächst. Beim Entladen (Kante +`CargoAE` > 0 → 0) kippt die Mulde 50° (Roboter: Körper 11°), die Ladung rutscht +heraus, Gesamtdauer 1,85 s. Weil die Simulation in einem Tick entlädt, hält die +Client-Eskorte den eigenen Sammler 1,9 s an der Raffinerie. KI-Sammler fahren +sofort weiter. + ## Offene Punkte - **Footprint der Abwehrtürme** (Inhaberentscheidung): `BuildingFootprintCells = 3` @@ -195,8 +276,6 @@ eine Regeländerung (siehe Offene Punkte). nur für Türme berührt Platzierung, Wegfindung, KI und den Match-Fingerprint und verschiebt die Determinismus-Baselines; er bräuchte einen eigenen Regel-PR. -- Neugenerierung von Legion-Kraftwerk und Legion-Forschungslabor (Kosten pro - Generierung, Inhaberentscheidung). - Spielerische Abnahme in einer gespielten Runde steht aus; dieser Pass ist automatisiert und per Kamera geprüft, nicht gespielt. - Das Asset-Paket ([AssetPackage.md](AssetPackage.md)) muss neu gebaut werden, @@ -210,3 +289,6 @@ eine Regeländerung (siehe Offene Punkte). | 0.2.0 | 2026-10-17 | Glow über Bloom, zwei Akzentmasken, Sonne auf der Kameraseite, Radar gestaucht, Showcase in Spielgröße (D-111) | Technical Art | | 0.3.0 | 2026-10-17 | Infanterie-Gang mit starren Ober- und Unterschenkeln, Knoten `Leg_*`/`Shin_*` | Technical Art | | 0.4.0 | 2026-10-17 | Abwehrtürme auf Sockel verkleinert, Footprint-Frage als offener Punkt (D-112) | Technical Art | +| 0.5.0 | 2026-10-17 | Größenverhältnis über eine Regel je Klasse, Turm auf 0,55 × Footprint, Größen-Aufstellung (D-113) | Technical Art | +| 0.6.0 | 2026-10-17 | Wrack-Varianten aller Assets und Umgebungsbewegung (D-115) | Technical Art | +| 0.7.0 | 2026-10-17 | Legion-Kraftwerk, -Fabrik, -Labor, -Kaserne prozedural (D-119), Sammler-Ladung und Abkippen | Technical Art | diff --git a/docs/production/DecisionLog.md b/docs/production/DecisionLog.md index 67efb7c6..57f89565 100644 --- a/docs/production/DecisionLog.md +++ b/docs/production/DecisionLog.md @@ -1,6 +1,6 @@ # Decision Log -**Version:** 1.44.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 +**Version:** 1.50.0 | **Status:** aktiv (laufend) | **Verantwortungsbereich:** Game Director / Lead Technical Director / Project Owner | **Sprint:** 21 ## Zweck @@ -2019,7 +2019,9 @@ richtig so: das Register ist der MP-Vertrag. ### D-085 | verbindlich | Baumodell: Builder-Modell bleibt, Auto-Dispatch per Move-Intent -**Status:** verbindlich — Inhaberentscheidung vom 2026-08-06 (Dennis +**Status:** verbindlich, in einem Punkt ersetzt durch D-118 (nicht mehr nur der +Bauer mit kleinstem Index baut; die Baustelle geht an den eigenen Bauer in +Reichweite, der Client schickt den ausgewählten Bauer) — Inhaberentscheidung vom 2026-08-06 (Dennis Westermann) aus der Spielsitzung desselben Tages, schriftlich fixiert in [hashkrieg/10_Sprint_Baubarkeit_und_Kartenbild.md](hashkrieg/10_Sprint_Baubarkeit_und_Kartenbild.md) §3; hier protokolliert wie angeordnet. @@ -3709,6 +3711,240 @@ Determinismus-Baselines und braucht einen eigenen Regel-PR mit Baseline-Fenster. **Konsequenzen:** Rezeptfeld `pad` in `tools/art/asset_recipes.json`. Die blockierte Fläche ist jetzt sichtbar befestigt statt eine unsichtbare Wand. +### D-113 | verbindlich | Asset-Pass 2026-10-17 (Größenverhältnis der Art im Spiel) + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („mach mit dem Größenverhältnis weiter“). + +**Kontext:** `UnitViewManager` skalierte jede Einheit auf die horizontale Größe +ihres Graybox-Primitivs. Die Tabelle stammte aus der Graybox-Zeit und kannte keine +Modelle. Gemessen in einer Größen-Aufstellung: Kampfpanzer 2,3 Felder lang und +2,0–2,3 hoch bei 1,0 hohen Soldaten, Sammler kürzer als ein leichter Panzer, +Allianz-Bauroboter kleiner als ein Soldat. Die Tripo-Modelle selbst sind in +Metern im Maßstab des Art-Standards (1 Feld = 3 m) aufbereitet. + +**Alternativen:** + +1. Eine Regel je Klasse: Gebäude auf Footprint, Fahrzeuge mit einem gemeinsamen + metrischen Faktor, kleine Einheiten mit Mindesthöhe. +2. Die Graybox-Tabelle je Rolle nachjustieren. +3. Alles real-maßstäblich (1 Feld = 3 m auch für Einheiten). + +**Entscheidung:** Alternative 1 mit 0,22 Feldern je Meter für Fahrzeuge, +Mindesthöhe 0,85 (Infanterie), 0,9 (Panzerfaust), 1,1 (Bauer); Abwehrtürme +0,55 × Footprint hoch. Umgesetzt in `ArtViewScale`, der Auswahlring folgt der +Modellgröße. + +**Begründung:** Eine Regel erhält die Verhältnisse der Modelle untereinander +und gilt automatisch für jedes neue Modell; eine Tabelle je Rolle (2) wäre +wieder Handarbeit ohne Maßstab. Real-maßstäblich (3) machte Soldaten 0,6 Felder +hoch und aus der RTS-Kamera unlesbar. Der Fahrzeugfaktor liegt unter dem +Gebäudemaßstab, damit ein Panzer etwa halbe Gebäudebreite hat und Nachbarn bei +einem Kollisionsradius von einem halben Feld weniger überlappen. + +**Konsequenzen:** Reine Darstellung, keine Simulationsänderung. Lebensbalken +sitzen weiter auf fester Höhe über dem Boden; das passt jetzt besser, ist aber +nicht an die Modellhöhe gekoppelt. + +### D-115 | verbindlich | Asset-Pass 2026-10-17 (Wracks bleiben liegen, Umgebungsbewegung) + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („zu jedem Asset eine +zerstörte Einheit; zerstörte Einheiten sollen sichtbar bleiben; weitere +Bewegungen“). + +**Kontext:** Eine tote Einheit sank 0,8 s lang durchsichtig in den Boden und war +weg; [CoreGameplay.md](../vision/CoreGameplay.md) sah 20–30 s Wrack vor, gebaut +war es nie. Zerstörte Modelle gab es keine. + +**Entscheidung:** + +1. Der Blender-Pass (`tools/art/asset_wreck.py`) erzeugt zu jedem der 34 Assets + eine Wrack-Variante aus den getrennten Teilen: Fahrzeuge mit verdrehtem Turm, + hängendem Rohr, schiefer Wanne; Infanterie liegend; Gebäude oberhalb einer + gezackten Bruchkante weggebrochen, Aufbauten daneben, Trümmer im Footprint. + Verkohlte Textur, kein Leuchten. Sie liegt als inaktives Kind `Wreck` im + selben Prefab — Registry und Datenmodell bleiben unverändert. +2. Wracks **bleiben liegen**. Zwei Obergrenzen (60 Einheiten-, 20 + Gebäudewracks) lassen die ältesten versinken; ein Neubau räumt die Wracks auf + seiner Fläche. Wracks sind reine Ansicht: blockieren nichts, sind nicht + wählbar, ignorieren den Nebel (der Spieler hat sie sterben sehen) und qualmen + neun Sekunden. +3. Umgebungsbewegung: Staubfahnen hinter fahrenden Fahrzeugen, + Schornsteinrauch (Knoten `Smoke_*`), Leerlauf-Suchschwenk der Türme, Atmen und + Umschauen der Infanterie, Motorvibration stehender Fahrzeuge — über zwei + geteilte Partikelsysteme für alle Einheiten (`AmbientFx`). + +**Begründung:** Ein Wrack, das nach 30 s verschwindet, erzählt die Schlacht +nicht zu Ende; die Obergrenzen halten trotzdem jede Partie beschränkt. Das Wrack +im Prefab vermeidet eine zweite Asset-Zuordnung und einen Ladeweg. + +**Konsequenzen:** Ersetzt die 20–30 s aus CoreGameplay.md. Graybox-Primitive +und Art ohne Wrack behalten das kurze Ausblenden. + +### D-116 | verbindlich | Bewegung: oktile Wegkosten, Abstand je Einheitentyp, Platzmachen, Kurs folgt dem Pfad + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („Einheiten sollen +aufeinander reagieren, Platz machen, damit ein Panzer durchfahren kann; +möglichst natürlich; sie stehen zu dicht und drehen sich komisch weg“). + +**Kontext:** Am Code gemessen: + +1. **Fehler in der Wegfindung:** `IntegrationField` gewichtete Diagonalen mit + `cellCost * 14 / 10`, auf offenem Gelände ganzzahlig 1 — Diagonalen kosteten so + viel wie gerade Schritte. Bei Gleichstand nimmt das Flussfeld die erste Richtung + in N-, NO-, O-Reihenfolge: Ein Panzer, der 14 Felder nach Osten fahren sollte, + fuhr erst 6 Felder nach Nordost und dann zurück. Ein Test hielt das sogar als + „Baseline“ fest. +2. Der Kurs einer fahrenden Einheit war `Atan2(Pfad + Abstoßung)`; im Gedränge + kippte er bis 180° je Tick. +3. Alle Einheiten hatten Abstandsradius 0,5; seit D-113 ist ein Kampfpanzer aber + 1,6 Felder lang — geparkte Panzer standen sichtbar ineinander, Infanterie einen + ganzen Meter auseinander. +4. Gruppenziele lagen ein Feld auseinander, große Fahrzeuge ruckelten dort + endlos an Berührdistanz (Issue #42 „Zittern im Pulk“). +5. Platzmachen gab es nicht. + +**Entscheidung:** Oktile Kosten (10/14, gesättigt). Kurs = Pfadrichtung, höchstens +30° je Tick. Abstandsradius je Rolle (`MovementSpacing`: Infanterie 0,30, +Panzerfaust 0,32, Bauer/Scout 0,50, leichter Panzer 0,62, Sammler 0,70, +Kampfpanzer/Artillerie 0,78) — nur für Bewegung, der Kampfradius bleibt 0,5, damit +Reichweiten nicht wandern. Große Einheiten beanspruchen ihren Gruppenplatz samt +Nachbarfeldern. Stehende **eigene** Einheiten im Fahrweg einer gleich großen oder +größeren fahrenden Einheit weichen seitlich aus; deren Abstoßung auf den Fahrer +ist auf ein Viertel gedämpft, er hält seine Linie. Regel-Revision **V4**. + +**Begründung:** Platzmachen ist C&C-Standard und lässt sich deterministisch und +lokal (5×5-Fenster) bauen; ORCA bleibt Post-MVP (D-034). Der Kampfradius bleibt, +weil sonst eine Bewegungsentscheidung die Balance verschöbe. + +**Konsequenzen:** Gemessen: In der laufenden Partie schauen fahrende Einheiten in +100 % der Frames in Fahrtrichtung (vorher 90–100 %, Ausreißer bis 85°); ein +Kampfpanzer fährt mit 0,00 Feldern Abweichung durch neun stehende Soldaten. Der +kanonische KI-Pin bewegt sich (Tick 2726 → 2721, Endzustand +`0x4A861D9FA2E57C6F` → `0x6E15320BCC3F3EE4`, KI-Kennung unverändert +`r8.1E6E7AE3`); `RulesHash64` V4 = `0x7E2E3E8C4CF1591E`. Die vier bewachten +Baseline-Dateien bleiben unberührt. Nebenbefund: `tools/Nova.SimRunner` (das +Programm, nicht die Tests) baut auf `main` nicht, `LobbyContracts.cs` braucht +Newtonsoft — DETERMINISM_10000 ließ sich deshalb nicht nachmessen. + +### D-117 | verbindlich | Befehlsleiste im Stil von Zero Hour, Gruppenbefehle ohne neue CommandKinds + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („UI, wo man beim Anklicken +von Einheiten Aktionen ausführen kann, z. B. Patrouille, und bei mehreren +Einheiten eine Aufstellung wählen; orientiere dich an Command & Conquer +Zero Hour“). + +**Kontext:** Die Einheitenkarte war eine senkrechte IMGUI-Knopfliste ohne Icons +und Tooltips; D-083 schreibt für neue Oberflächen UI Toolkit vor und erklärt das +IMGUI-HUD zur Wegwerfversion. Das Befehlsregister ist eingefroren (D-084, +D-087): Patrouille, Bewachen und Angriffsbewegung als Sim-Befehle bräuchten neue +CommandKinds, Golden-Byte-Tests und eine API-Prüfung. Angriffsverfolgung und +Sammler-Eskorte laufen dagegen schon auf dem Client über normale Moves. + +**Alternativen:** + +1. Neue CommandKinds (Patrol, Guard, AttackMove) mit Zustand in der Simulation. +2. Client-Aufträge über die vorhandenen Kinds, Befehlsleiste in UI Toolkit. +3. Client-Aufträge, Leiste in der bestehenden IMGUI-Karte. + +**Entscheidung:** Alternative 2. Angriffsbewegung, Patrouille, Bewachen, +Verteilen und fünf Formationen (Locker, Linie, Kolonne, Keil, Block) sind +Client-Aufträge (`FormationPlanner`, `StandingOrders`), die in Move, Stop und +AttackTarget münden. Die neue `UnitCommandBar` (UI Toolkit, gezeichnete Icons, +Tastenkürzel, Tooltip, Typ-Chips) übernimmt die Einheitenauswahl; die IMGUI-Karte +zeigt nur noch Gebäude und Baustellen. **A** auf freien Boden wird +Angriffsbewegung wie in C&C. + +**Begründung:** Alternative 2 liefert alle gewünschten Befehle sofort, ohne das +Register zu öffnen; die gesendeten Moves werden aufgezeichnet, Replays und +Relay bleiben exakt. Ein Sim-Befehl wäre nur nötig, wenn Aufträge ohne den +auslösenden Client weiterlaufen müssten (Verbindungsabbruch) — das bleibt ein +späterer, eigener Schritt. Alternative 3 widerspräche D-083. + +**Konsequenzen:** Der Auftragszustand ist lokaler UI-Zustand und überlebt +keinen Spielstand. Der Feindblick der Aufträge ist nebelgebunden. Nachweis: +EditMode-Tests für Formationen und Aufträge, PlayMode-Test startet über den +Menüpfad, wählt sechs Panzer, schickt sie in Linie auf Patrouille und prüft das +Umkehren. + +### D-118 | verbindlich | Jeder Bauer kann bauen: die Baustelle geht an den Bauer, der dort steht + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („das Problem mit dem zweiten +Ingenieur oder Vorarbeiter ist immer noch nicht gelöst, der kann immer noch keine +Aktionen ausführen“). Ersetzt den Teil von D-085, nach dem nur der Bauer mit dem +kleinsten Entity-Index eine Baustelle fortschreiben darf. + +**Kontext:** `ConstructionSystem.CreateSite` bindet jede Baustelle an den eigenen +Bauer mit dem kleinsten Index, und `ProgressSites` prüft nur dessen Reichweite; +gewechselt wurde nur bei Tod oder Rollenwechsel. Der Client schickte beim +Platzieren immer denselben Bauer los, unabhängig von der Auswahl. Ein zweiter +Bauer konnte dadurch nie bauen. Die Meldung #135 wurde damals als +Bauzonen-Radius erklärt und nur mit besseren Ablehnungstexten beantwortet; die +Zuweisungsregel blieb unberührt. + +**Alternativen:** + +1. Neuer CommandKind „Bauer an Baustelle“ mit expliziter Zuweisung. +2. Übergabe in der Simulation: steht der gebundene Bauer nicht in Reichweite, geht + die Baustelle an den eigenen Bauer mit dem kleinsten Index, der in Reichweite + steht; der Client schickt bevorzugt den ausgewählten Bauer. +3. Nur Client: ausgewählten Bauer losschicken (wirkungslos, die Simulation + ignoriert ihn). + +**Entscheidung:** Alternative 2. Beim Platzieren bleibt die Bindung an den +kleinsten Index (Snapshot-Format und `ValidateAssignedBuilder` unverändert); in +`ProgressSites` übernimmt der kleinste eigene Bauer in Reichweite, wenn der +gebundene fehlt oder fern ist. Der Client schickt den ersten ausgewählten Bauer +zur Baustelle, ein Rechtsklick mit Bauern auf die eigene Baustelle schickt sie an +deren Anfahrtszelle. + +**Begründung:** Deterministisch (aufsteigender Index-Scan) und ohne neuen +Befehl; mit einem einzigen Bauer byte-identisch, deshalb bewegen sich weder +Fingerprints noch die kanonische KI-Partie (headless 775/775 grün). Alternative 1 +öffnete das eingefrorene Register (D-084, D-087) für etwas, das die Simulation +aus der Position ableiten kann. + +**Konsequenzen:** Zwei Bauer an einer Baustelle bauen nicht schneller, der +Fortschritt läuft weiter über genau einen Bauer. Die KI hält weiterhin nur einen +Bauer. Nachweis: Regressionstest mit zwei lebenden Bauern, der ferne mit kleinem +Index, der nahe mit großem (`SiteProgress_SecondBuilderInReach_TakesOverFromFarLowIndexBuilder`). + +### D-119 | verbindlich | Legion-Kraftwerk, -Fabrik, -Labor und -Kaserne prozedural statt generiert + +**Status:** Im Auftrag des Inhabers vom 2026-10-17 („Fahrzeuge sind auf einmal zu +Gebäuden geworden, vor allem bei der Fahrzeugfabrik und beim Kraftwerk, und die +Kaserne sieht auch komisch aus“; „falls Dinge unklar sind, entscheide selber“). +Löst den offenen Punkt „Neugenerierung Legion-Kraftwerk und +-Forschungslabor“ aus dem Asset-Pass-Protokoll. + +**Kontext:** Schon die Konzeptbilder von Fabrik, Kraftwerk und Labor waren +Panzer: Die erste Bildgeneration nutzte den Legion-Panzer als Referenzbild. Die +Kaserne zerfiel als Tripo-Modell in zwei Hälften mit schwebendem Wachturm. +Neue Konzeptbilder entstanden über `generate_v2.py` (Materialtafel statt Form). +Für die 3D-Form wurde Hunyuan3D 2.1 erprobt; dessen Tencent-Community-Lizenz gilt +ausdrücklich nicht in der Europäischen Union. + +**Alternativen:** + +1. Tripo-Neugenerierung aus den neuen Konzeptbildern (Kosten je Generierung, + Handarbeit im Web, gleiche Mehrschalen-Qualität). +2. Hunyuan3D-2.1-Formen (frei verfügbar, aber in der EU nicht lizenziert). +3. Prozedurale Geometrie in Blender nach dem Konzeptbild, Textur aus dem + Konzeptbild projiziert und gebacken. + +**Entscheidung:** Alternative 3 für alle vier Gebäude (`tools/art/procedural/`). +Die Hunyuan-Formen wurden verworfen und nicht verwendet. + +**Begründung:** Rechtlich sauber und reproduzierbar (Skript statt Webdienst), +saubere Geometrie ohne Splitter, und die Silhouette folgt dem Konzept genau: +Wellblech-Montagehalle mit Rolltor und Kran, Generatorblock mit Doppelschornstein, +verschweißter Containerstapel, Wellblechhütten mit Wachturm. + +**Konsequenzen:** Die vier Gebäude sind kantiger und detailärmer als Tripo-Modelle, +dafür formtreu. `glb_to_unity_fbx.py` bekommt `--keep-slivers`, weil der +Splitterfilter dünne modellierte Teile (Leitern, Antennen) an UV-Nähten für +Generatorsplitter hält. Altstand liegt unter +`Hashkrieg_Assets/3d/unity_ready/_replaced_2026-10-17`. + ## Offene Punkte - Alle Sprint-4-Review-Befunde (105, davon 9 kritisch): 7 entscheidungsbedürftige kritische Befunde sind durch D-043–D-052 entschieden. @@ -3789,6 +4025,11 @@ blockierte Fläche ist jetzt sichtbar befestigt statt eine unsichtbare Wand. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.50.0 | 2026-10-17 | D-118 und D-119 aufgenommen: jeder Bauer kann bauen (Übergabe der Baustelle an den eigenen Bauer in Reichweite, ersetzt die Nur-kleinster-Index-Regel aus D-085); Legion-Kraftwerk, -Fabrik, -Labor und -Kaserne prozedural nach Konzeptbild statt generiert, Hunyuan3D wegen fehlender EU-Lizenz verworfen | Dennis Westermann / Orchestrator | +| 1.49.0 | 2026-10-17 | D-117 aufgenommen: Befehlsleiste im Stil von Zero Hour in UI Toolkit; Angriffsbewegung, Patrouille, Bewachen, Verteilen und Formationen als Client-Aufträge über Move/Stop/AttackTarget statt neuer CommandKinds | Dennis Westermann / Orchestrator | +| 1.48.0 | 2026-10-17 | D-116 aufgenommen: oktile Wegkosten (Ganzzahlfehler 14/10 → 1 behoben), Abstand je Einheitentyp, größenabhängige Gruppenplätze, Platzmachen, pfadgeführter Kurs; Regel-Revision V4, kanonischer KI-Pin bewegt | Dennis Westermann / Orchestrator | +| 1.47.0 | 2026-10-17 | D-115 aufgenommen: Wrack-Variante je Asset im Prefab, Wracks bleiben liegen (Obergrenzen 60/20, Neubau räumt), Umgebungsbewegung über geteilte Partikelsysteme. Version 1.46.0 trägt D-114 (KI-Strang, PR #144) | Dennis Westermann / Orchestrator | +| 1.45.0 | 2026-10-17 | D-113 aufgenommen: Größenverhältnis der Art über eine Regel je Klasse (`ArtViewScale`) statt der Graybox-Tabelle; Fahrzeuge 0,22 Felder je Meter, Mindesthöhen für Infanterie und Bauer, Abwehrtürme auf Panzerhöhe | Dennis Westermann / Orchestrator | | 1.44.0 | 2026-10-17 | D-112 aufgenommen: Abwehrtürme optisch auf rund doppelte Soldatenhöhe verkleinert (Sockel füllt den 3×3-Footprint), Footprint-Verkleinerung als Regeländerung offen gelassen | Dennis Westermann / Orchestrator | | 1.43.0 | 2026-10-17 | D-111 aufgenommen: Bloom als Grundstack des Pipeline-Assets (fehlende `postProcessData` ergänzt, Kamera-Post-Processing an, Emission mit `RealtimeEmissive`), Sonne auf `Euler(50°, 35°)` von der Kameraseite statt Gegenlicht | Dennis Westermann / Orchestrator | | 1.42.0 | 2026-10-17 | D-110 aufgenommen: Blickrichtung über **eine** Abbildung Sim-Kurs → Unity-Gier (`ViewHeading`), Knoten-Vertrag `Body`/`Turret`/`Gun`/`Wheel_*`/`Arm_*`/`Spin_*`/`Scan_*` zwischen Blender-Pass und `UnitRigAnimator`, doppelseitige Art-Materialien, neue Leuchttextur `T_*_EM`; Tripo-Modelle werden aufbereitet statt neu erzeugt | Dennis Westermann / Orchestrator | From 24bb14e133fb27884e7e7c8e62a0a13eb657a682 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 15:49:11 +0200 Subject: [PATCH 12/13] fix(animation): stabilize motion and prepare beta validation --- .../EditMode/Gameplay/UnitRigAnimatorTests.cs | 194 ++++++++++++++++++ .../Gameplay/UnitRigAnimatorTests.cs.meta | 11 + .../Scripts/Gameplay/Match/UnitRigAnimator.cs | 26 ++- CHANGELOG.md | 8 + README.md | 16 +- docs/assets/AssetPackage.md | 23 ++- docs/production/DemoRunbook.md | 10 +- docs/production/Roadmap.md | 49 +++-- docs/tech/AnimationSystem.md | 15 +- 9 files changed, 319 insertions(+), 33 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta diff --git a/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs new file mode 100644 index 00000000..3ab5750a --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using NUnit.Framework; +using Nova.Gameplay.Match; +using UnityEditor; +using UnityEngine; + +namespace Nova.Gameplay.Tests +{ + public class UnitRigAnimatorTests + { + private readonly List _owned = new List(); + private Random.State _randomState; + + [SetUp] + public void SetUp() => _randomState = Random.state; + + [TearDown] + public void TearDown() + { + foreach (Object obj in _owned) Object.DestroyImmediate(obj); + _owned.Clear(); + Random.state = _randomState; + } + + private UnitRigAnimator CreateRig() + { + var root = new GameObject("TestRig"); + _owned.Add(root); + Transform body = Child(root.transform, "Body"); + Child(body, "Leg_L").localPosition = Vector3.up; + Child(body, "Spin_Test"); + Child(body, "Scan_Test"); + Child(body, "Arm_Test"); + Child(body, "Gun"); + return root.AddComponent(); + } + + private static Transform Child(Transform parent, string name) + { + var child = new GameObject(name).transform; + child.SetParent(parent, false); + return child; + } + + private UnitRigAnimator LoadArt(string category, string faction, string role, string prefix) + { + string path = $"Assets/_Project/Art/{category}/{faction}/{role}/PF_{prefix}_{faction}_{role}.prefab"; + GameObject prefab = AssetDatabase.LoadAssetAtPath(path); + if (prefab == null) Assert.Ignore($"Optional separate art bundle missing: {path}"); + var instance = Object.Instantiate(prefab); + _owned.Add(instance); + return instance.GetComponent() ?? instance.AddComponent(); + } + + private static Transform Find(UnitRigAnimator rig, string prefix) + { + foreach (Transform node in rig.GetComponentsInChildren(true)) + if (node.name.StartsWith(prefix, System.StringComparison.Ordinal)) return node; + Assert.Fail($"Expected authored animation node {prefix}"); + return null; + } + + private static void AssertContinuousSweep(UnitRigAnimator rig) + { + Transform scan = Find(rig, "Scan_"); + Quaternion rest = scan.localRotation; + Quaternion previous = rest; + float excursion = 0f; + // Cross multiple spin wraps AND a complete sweep wrap. + for (int i = 0; i < 1500; i++) + { + rig.Tick(default, 1f / 60f); + Assert.That(Quaternion.Angle(previous, scan.localRotation), Is.LessThan(0.6f), $"frame {i}"); + excursion = Mathf.Max(excursion, Quaternion.Angle(rest, scan.localRotation)); + previous = scan.localRotation; + } + Assert.That(excursion, Is.GreaterThan(45f), "A frozen dish must not pass continuity."); + } + + [Test] + public void Scan_RemainsContinuousAcrossSpinAndSweepWraps() => AssertContinuousSweep(CreateRig()); + + [Test] + public void LegionRadarPrefab_ScanRemainsContinuous() => + AssertContinuousSweep(LoadArt("Buildings", "Legion", "Radar", "BLDG")); + + [Test] + public void AllianceRadarPrefab_SpinRemainsContinuousAcrossWraps() + { + UnitRigAnimator rig = LoadArt("Buildings", "Alliance", "Radar", "BLDG"); + Transform spin = Find(rig, "Spin_"); + Quaternion previous = spin.localRotation; + for (int i = 0; i < 1500; i++) + { + rig.Tick(default, 1f / 60f); + Assert.That(Quaternion.Angle(previous, spin.localRotation), Is.InRange(0.85f, 1f), $"frame {i}"); + previous = spin.localRotation; + } + } + + private static void AssertCombatAim(UnitRigAnimator rig, bool fired) + { + Transform body = Find(rig, "Body"); + Quaternion rest = body.localRotation; + // Wait for an observable idle glance, independent of the random seed. + bool glanced = false; + for (int i = 0; i < 3600; i++) + { + rig.Tick(default, 1f / 60f); + if (Quaternion.Angle(rest, body.localRotation) > 5f) { glanced = true; break; } + } + Assert.IsTrue(glanced, "Infantry should still glance while idle."); + // Measure yaw in the root frame, including FBX axis conversion; firing pitch is allowed. + Vector3 restForward = Quaternion.Inverse(rest) * body.parent.InverseTransformDirection(rig.transform.forward); + for (int i = 0; i < (fired ? 1 : 600); i++) + { + rig.Tick(new UnitRigInput { HasTarget = !fired, Fired = fired, + TargetPosition = rig.transform.position + rig.transform.forward * 10f }, 1f / 60f); + Vector3 actual = rig.transform.InverseTransformDirection(body.TransformDirection(restForward)); + Assert.That(Mathf.Abs(Mathf.Atan2(actual.x, actual.z) * Mathf.Rad2Deg), Is.LessThan(0.05f)); + } + glanced = false; + for (int i = 0; i < 3600; i++) + { + rig.Tick(default, 1f / 60f); + Vector3 actual = rig.transform.InverseTransformDirection(body.TransformDirection(restForward)); + if (Mathf.Abs(Mathf.Atan2(actual.x, actual.z) * Mathf.Rad2Deg) > 5f) { glanced = true; break; } + } + Assert.IsTrue(glanced, "Idle glance should resume after combat."); + } + + [TestCase(false)] + [TestCase(true)] + public void Infantry_IdleLookDoesNotDeflectCombatAim(bool fired) => AssertCombatAim(CreateRig(), fired); + + [TestCase("Alliance", false)] + [TestCase("Alliance", true)] + [TestCase("Legion", false)] + [TestCase("Legion", true)] + public void InfantryPrefab_IdleLookDoesNotDeflectCombatAim(string faction, bool fired) => + AssertCombatAim(LoadArt("Units", faction, "BasicInfantry", "UNIT"), fired); + + [Test] + public void ResetPose_ReplaysFreshMotionWithoutPreviousPhasesOrLookTargets() + { + UnitRigAnimator rig = CreateRig(); + rig.ResetPose(); + Random.State replayRandom = Random.state; + Transform[] nodes = rig.GetComponentsInChildren(true); + var positions = new Vector3[nodes.Length]; + var rotations = new Quaternion[nodes.Length]; + for (int i = 0; i < 90; i++) rig.Tick(default, 1f / 60f); + for (int i = 0; i < nodes.Length; i++) + { + positions[i] = nodes[i].localPosition; + rotations[i] = nodes[i].localRotation; + } + for (int i = 0; i < 400; i++) + rig.Tick(new UnitRigInput { Speed = 2f, Working = true, Fired = true }, 1f / 60f); + rig.ResetPose(); + Random.state = replayRandom; + for (int i = 0; i < 90; i++) rig.Tick(default, 1f / 60f); + for (int i = 0; i < nodes.Length; i++) + { + Assert.That(Vector3.Distance(positions[i], nodes[i].localPosition), Is.LessThan(0.0001f), nodes[i].name); + Assert.That(Quaternion.Angle(rotations[i], nodes[i].localRotation), Is.LessThan(0.05f), nodes[i].name); + } + } + + [Test] + public void ResetPose_RestoresEmissionEvenOffscreenAndPreservesFactionTint() + { + UnitRigAnimator rig = CreateRig(); + var renderer = Find(rig, "Body").gameObject.AddComponent(); + var material = new Material(Shader.Find("Universal Render Pipeline/Lit")); + _owned.Add(material); + material.EnableKeyword("_EMISSION"); + Color emission = new Color(0.2f, 0.4f, 0.6f, 1f); + material.SetColor("_EmissionColor", emission); + renderer.sharedMaterial = material; + rig.EmissivePulse = true; + rig.ResetPose(); // Bind the authored emission before simulating a prior pulse. + var block = new MaterialPropertyBlock(); + block.SetColor("_EmissionColor", emission * 2f); + block.SetColor("_BaseColor", Color.red); + renderer.SetPropertyBlock(block); + rig.Tick(new UnitRigInput { Working = true }, 0.1f); + rig.ResetPose(); + renderer.GetPropertyBlock(block); + Assert.That(block.GetColor("_EmissionColor"), Is.EqualTo(emission)); + Assert.That(block.GetColor("_BaseColor"), Is.EqualTo(Color.red)); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta new file mode 100644 index 00000000..a66f0bd8 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/UnitRigAnimatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d57e8340d794c7ca84d89398baf85f7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs index 3eec8b91..482f6d68 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitRigAnimator.cs @@ -179,6 +179,7 @@ private struct Node private Renderer[] _glowRenderers = System.Array.Empty(); private Color[] _glowBase = System.Array.Empty(); private float _glowPhase; + private float _glowInitialPhase; private float _glowBusy; private float _size = 1f; @@ -187,6 +188,7 @@ private struct Node private float _bobPhase; private float _wheelAngle; private float _spinAngle; + private float _scanPhase; private float _armPhase; private float _armBlend; private float _lastSpeed; @@ -223,6 +225,10 @@ public void ResetPose() EnsureBound(); _turretYaw = 0f; _recoil = 0f; _bobPhase = 0f; _wheelAngle = 0f; _armShake = 0f; + _spinAngle = 0f; _scanPhase = 0f; _time = 0f; + _lookTarget = 0f; _lookTimer = 0f; _smokeTimer = 0f; + _glowPhase = _glowInitialPhase; _glowBusy = 0f; + ApplyGlow(1f, false); _armPhase = 0f; _armBlend = 0f; _lastSpeed = 0f; _pitch = 0f; _roll = 0f; _moveBlend = 0f; Restore(_body, _hasBody); Restore(_turret, _hasTurret); @@ -314,10 +320,12 @@ public void Tick(in UnitRigInput input, float dt) s.T.localRotation = Quaternion.AngleAxis(_spinAngle + s.Phase, s.Up) * s.RestRot; } + // A sweep has its own full period; scaling a wrapped spin angle jumps at 360 degrees. + _scanPhase = Mathf.Repeat(_scanPhase + _spinRate * Mathf.Deg2Rad * 0.6f * dt, Mathf.PI * 2f); for (int i = 0; i < _scans.Count; i++) { Node s = _scans[i]; - float sweep = Mathf.Sin((_spinAngle + s.Phase) * Mathf.Deg2Rad * 0.6f) * 50f; + float sweep = Mathf.Sin(_scanPhase + s.Phase * Mathf.Deg2Rad * 0.6f) * 50f; s.T.localRotation = Quaternion.AngleAxis(sweep, s.Up) * s.RestRot; } @@ -428,15 +436,20 @@ private void TickGlow(bool working, float dt) _glowBusy = Mathf.MoveTowards(_glowBusy, working ? 1f : 0f, dt * 1.5f); _glowPhase += dt * Mathf.Lerp(1.4f, 5.5f, _glowBusy); float k = 1f + Mathf.Sin(_glowPhase) * Mathf.Lerp(0.22f, 0.55f, _glowBusy) + 0.25f * _glowBusy; + ApplyGlow(k, true); + } + + private void ApplyGlow(float intensity, bool visibleOnly) + { if (_glowBlock == null) _glowBlock = new MaterialPropertyBlock(); for (int i = 0; i < _glowRenderers.Length; i++) { Renderer r = _glowRenderers[i]; - if (r == null || !r.isVisible) continue; + if (r == null || (visibleOnly && !r.isVisible)) continue; // Read-modify-write keeps the faction tint UnitViewManager put // into the same block. r.GetPropertyBlock(_glowBlock); - _glowBlock.SetColor(EmissionColorId, _glowBase[i] * k); + _glowBlock.SetColor(EmissionColorId, _glowBase[i] * intensity); r.SetPropertyBlock(_glowBlock); } } @@ -499,7 +512,9 @@ private void TickBody(in UnitRigInput input, float speed, float dt) // Standing: breathe, and now and then look around. float rest = 1f - _moveBlend; offset += _body.Up * (Mathf.Sin(_time * 1.6f) * 0.006f * _size * rest); - TickLook(dt, rest); + // Root heading already tracks combat aim; an idle glance must not turn the weapon away. + if (input.HasTarget || input.Fired) _lookYaw = 0f; + else TickLook(dt, rest); } else if (_infantryGait) { @@ -706,7 +721,8 @@ private void EnsureBound() } _glowRenderers = glowing.ToArray(); _glowBase = glowBase.ToArray(); - _glowPhase = Random.value * 6.28f; + _glowInitialPhase = Random.value * 6.28f; + _glowPhase = _glowInitialPhase; Transform bed = FindLive(transform, BedNode); _hasBed = bed != null; diff --git a/CHANGELOG.md b/CHANGELOG.md index e7beaef1..de49c0e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -515,6 +515,14 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Animationskontinuität und Pool-Reset:** Radar-Schwenks springen nicht mehr + beim Rotorumlauf; Infanterie richtet ihren Idle-Blick bei Zielkontakt oder + Schuss neutral aus. Wiederverwendete Ansichten starten mit zurückgesetzten + Phasen/Timern und Basisemission bei erhaltener Fraktionstönung. + Transform- und Art-Prefab-Regressionstests ergänzt. Roadmap auf aktives Tier 2 + und offene Beta-Vorbereitung ausgerichtet, veraltete Attack-Move-Lücken in + README/Runbook berichtigt und lokalen Art-Paket-Kandidaten dokumentiert; + aktuelle manuelle Spielabnahme und Veröffentlichung stehen aus. - **Der zweite Bauer baut (D-118).** Bisher konnte nur der Bauer mit dem kleinsten Index eine Baustelle fortschreiben, jeder weitere stand daneben, und beim Platzieren lief immer der erste los. Jetzt geht der ausgewählte Bauer diff --git a/README.md b/README.md index 0ab1a4bd..af237b8c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Hashkrieg -**Dokumentversion:** 0.23.0 | **Status:** unveröffentlichter Entwicklungsstand, spielbar | **Verantwortungsbereich:** Executive Producer / Technical Writer | **Stand:** 2026-08-10 +**Dokumentversion:** 0.23.1 | **Status:** unveröffentlichter Entwicklungsstand, spielbar | **Verantwortungsbereich:** Executive Producer / Technical Writer | **Stand:** 2026-10-18 > Ein Echtzeitstrategiespiel in der Tradition von **Command & Conquer** — Basisbau, > Ernte, Armee, Karte kontrollieren. Gebaut mit Unity und C#, offen entwickelt. @@ -148,8 +148,9 @@ automatisierte Laufzeitevidenz, keine behauptete manuelle Spielabnahme. Ehrlich und ohne Beschönigung. Der Loop läuft — aber ein Spiel ist mehr als ein funktionierender Loop: -- **Kein Attack-Move.** Truppen feuern zwar von selbst, halten unterwegs aber - nicht an, um zu kämpfen. +- **Attack-Move ist implementiert; die aktuelle Spielabnahme bleibt offen.** + Der Befehl „Angriffsbewegung“ lässt kampffähige Truppen unterwegs kämpfen + und anschließend den Marsch fortsetzen. - **Netzwerkabnahme und Betrieb stehen aus.** Der Zwei-Spieler-Pfad ist verdrahtet und headless nachgewiesen; zwei Unity-Fenster, LAN, Linux/systemd, VPS und der Live-Workflow sind noch nicht vollständig ausgeführt. @@ -280,8 +281,8 @@ Runde*. **Läuft noch nicht:** -- **Kein Attack-Move.** Truppen feuern von selbst, halten unterwegs aber nicht - zum Kämpfen an. +- **Aktuelle Attack-Move-Spielabnahme offen.** Angriffsbewegung ist umgesetzt; + der vollständige Durchlauf mit dem aktuellen Art-Stand bleibt zu prüfen. - **Netzwerk und Gefechtsdichte sind noch nicht vollständig manuell abgenommen.** A8 Stufen 2–4 sowie die 60-Einheiten-Sicht-/Gegenhörprüfung bleiben offen. @@ -453,8 +454,8 @@ Zur Bewertung, in dieser Reihenfolge: SFX-Regler, Klangbalance und Kamera-Listener gegenhören und ansehen. 3. **Strang C manuell abnehmen** — Blocker-, Energie- und Stop-Feedback im laufenden Spiel lesen und bedienen; die Implementierung ist abgeschlossen. -4. **Attack-Move** — Truppen sollen unterwegs Gegner bekämpfen, ohne jeden - Kontakt einzeln befohlen zu bekommen. +4. **Attack-Move im Match prüfen** — die implementierte Angriffsbewegung + einschließlich Kampfunterbrechung und Weiterlaufen auf aktuellem Stand abnehmen. 5. **KI-Ausbau** — der Gegner spielt, aber weiterhin schlicht; die neuen Wirtschaftsregeln müssen später auch in sein strategisches Verhalten einfließen. @@ -487,3 +488,4 @@ Projekt ein Publikum hat. | 0.15.0 | 2026-07-26 | Neu gegliedert und bebildert: Hashkrieg-Richtung nach vorn gezogen, die offene Wirtschaftsentscheidung als eigener Abschnitt sichtbar gemacht, Mitmach-Abschnitt mit Einstiegspunkten ergänzt, Projektstatus um Graybox und Fraktionsidentität aktualisiert, Lizenzlage präzisiert | Technical Writer | | 0.17.0 | 2026-08-07 | **Kernloop geschlossen:** erste vollständige Runde gespielt. Sprint „Gefecht und Rundenrahmen" (D-086, D-087) als fünfte Zeile in den Werdegang aufgenommen; Projektstatus von „fast geschlossen" auf „geschlossen" gezogen und der Kopf sagt jetzt in der ersten Zeile, dass das Spiel spielbar ist; „Was noch fehlt" komplett ersetzt (die vier alten Punkte sind erledigt) durch Einheiten-Stapelung, Wegfindung durch Gebäude, fehlendes Attack-Move und stumme Gefechte; Steuerung um Kontrollgruppen und additive Auswahl ergänzt; Nächste Schritte auf Sprint 11 umgestellt; Sprint-Feld im Kopf entfernt, weil die Sprint-7-Zählung seit der Hashkrieg-Reihe nicht mehr trägt | Technical Writer | | 0.16.0 | 2026-08-06 | Auf den Stand nach vier Sprints gezogen (D-077, D-083, D-084, D-085): Projektstatus nennt jetzt die spielende KI, Menü, bedienbares HUD und funktionierendes Bauen; §4 korrigiert den Start (Menü statt Direktstart), führt die vollständige Steuerung und ersetzt die überholte „was die Graybox nicht kann"-Liste durch den echten offenen Rest; §5 dokumentiert die 34 3D-Assets und die Drop-in-Pipeline statt „es existiert kein 3D-Asset"; Mitmach-Tabelle und Nächste Schritte auf den laufenden Sprintplan umgestellt | Technical Writer | +| 0.23.1 | 2026-10-18 | Veraltete Attack-Move-Lücken korrigiert: implementierte Angriffsbewegung von der offenen aktuellen Spielabnahme getrennt | Codex | diff --git a/docs/assets/AssetPackage.md b/docs/assets/AssetPackage.md index fe41043d..f545e87e 100644 --- a/docs/assets/AssetPackage.md +++ b/docs/assets/AssetPackage.md @@ -1,6 +1,6 @@ # Art-Asset-Paket (ausserhalb des Repositories) -**Version:** 1.1.0 | **Status:** verbindlich | **Verantwortungsbereich:** Technical Art / Producer | **Sprint:** 7 +**Version:** 1.2.0 | **Status:** verbindlich | **Verantwortungsbereich:** Technical Art / Producer | **Sprint:** 7 ## Zweck @@ -51,6 +51,8 @@ wenn das Asset selbst es nicht tut — inklusive der offenen Punkte darin. ## 3. Das Paket +### Zuletzt dokumentierter geteilter Stand (August 2026) + | | | |---|---| | Datei | `Hashkrieg_Art_MS1_2026-08-08_0904.zip` | @@ -102,6 +104,24 @@ Die `.meta`-Dateien sind Teil des Pakets und **müssen** es bleiben: Sie tragen die Unity-GUIDs. Ohne sie vergibt jeder Import neue GUIDs, und Material-, Prefab- und Registry-Referenzen brechen bei jedem Entwickler unterschiedlich. +### Lokaler Kandidat vom 18. Oktober 2026 (noch nicht hochgeladen) + +| | | +|---|---| +| Datei | `Hashkrieg_Art_MS1_2026-10-18_0013.zip` | +| Lokale Ablage | `output/art-package/` (Git-ignoriert), mit `README.txt` | +| Größe | 290.517.620 Bytes (rund 277 MiB) | +| Inhalt | 616 Dateien: 69× `.fbx`, 137× `.png`, 68× `.mat`, 34× `.prefab`, 308× `.meta` | +| SHA-256 | `326e01aeb90d25edb0dd93d091ea80c70572ee5f9f053a413dcbaa6a8922d02a` | +| Ausgangsstand | `3c8e6bf`, Arbeitsbaum beim Packen nicht sauber | + +CRC und vollständige `.meta`-Paare wurden geprüft. Alle 34 Prefabs enthalten +Wracks; die vier neu aufgebauten Legion-Gebäude einschließlich Wrack-FBX sind +enthalten. Externe GUID-Verweise ließen sich auf URP-Lit und `AssetVersion` +auflösen. Das bestätigt die Paketstruktur, noch keinen Import in einen +frischen Workspace oder die Darstellung im Match. Der geteilte Ordner wurde +nicht aktualisiert; der Kandidat ist kein veröffentlichter Release. + ## 4. Installieren 1. Paket herunterladen und im **Repository-Wurzelverzeichnis** entpacken. Die @@ -166,3 +186,4 @@ bricht sonst ab — ein Paket ohne GUIDs wäre schlimmer als gar keines (§3). |---|---|---|---| | 1.0.0 | 2026-08-06 | Erstfassung: Art-Assets als externes Paket statt im Repo; Ausschlussregeln, Paketinhalt, Installations- und Erweiterungsablauf | Producer / Technical Art | | 1.1.0 | 2026-08-08 | Paket auf den Nachschub-Stand gehoben (HQ und BattleTank ersetzt, Aetherium neu): neue Kennzahlen in §3, Paketname auf die Marke umgestellt; §5 auf das neue Build-Skript `tools/art/build_art_package.sh` umgestellt, das den Inhalt aus `.gitignore` ableitet; Freigabeweg und Link-Verzicht ausdrücklich festgehalten | Producer / Agent (Umsetzung) | +| 1.2.0 | 2026-10-18 | Lokalen Art-Paket-Kandidaten mit Prüfsumme und Strukturprüfung ergänzt, klar getrennt vom zuletzt dokumentierten geteilten August-Stand; Upload und frischer Import offen | Codex | diff --git a/docs/production/DemoRunbook.md b/docs/production/DemoRunbook.md index 443092b5..e734087a 100644 --- a/docs/production/DemoRunbook.md +++ b/docs/production/DemoRunbook.md @@ -1,6 +1,6 @@ # Demo-Runbook – erste spielbare Runde (Glutrinne-Graybox) -**Version:** 0.6.1 | **Status:** Entwurf – Graybox-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Producer / Technical Writer | **Sprint:** 16 +**Version:** 0.6.2 | **Status:** Entwurf – Graybox-Spur, kein Gate-Nachweis | **Verantwortungsbereich:** Producer / Technical Writer | **Sprint:** 16 ## Zweck @@ -94,7 +94,7 @@ Gate-Status steht ausschließlich in [MVPRecoveryPlan.md](MVPRecoveryPlan.md). | LMB Klick / Drag | Auswahl einzeln / Box | | RMB | Bewegen | | S | Stopp | -| A | Angriff auf Gegner unter dem Cursor (sonst schlichtes Move — **keine** Zielerfassung bei Ankunft) | +| A | Angriff auf Gegner unter dem Cursor; auf Boden Angriffsbewegung mit Gegnererfassung unterwegs | | H | Nächstes freies Aetherium-Feld abernten | | R | Ladung abliefern | | P | Pause / Fortsetzen | @@ -160,8 +160,9 @@ steht davor und dauert so lange, wie man ihn zeigen will. snapshot-serialisiert. - **Zielerfassung bleibt schlicht:** Bewaffnete Einheiten und fertige Verteidigungsplattformen erfassen Ziele automatisch und erwidern Feuer. Ein - A-Befehl ohne Gegner unter dem Cursor bleibt jedoch ein schlichtes Move; - Attack-Move und Zielerfassung bei der Ankunft sind nicht implementiert. + Befehl „Angriffsbewegung“ ist implementiert: kampffähige Truppen bekämpfen + sichtbare Gegner unterwegs und setzen danach den Marsch fort. Der vollständige + aktuelle Spielablauf mit Art-Assets bleibt manuell abzunehmen. - `Stop` löscht Bewegung und das aktuelle Angriffsziel. Die automatische Zielerfassung darf im nächsten Combat-Tick erneut ein Ziel setzen; ein echtes Halte-Feuer ist weiterhin nicht implementiert. Angriffe auf eigene Einheiten @@ -280,3 +281,4 @@ der Editor in der Konsole, wenn das Schreiben fehlschlägt. | 0.5.0 | 2026-08-06 | Bedienbares HUD (D-084): §2 um Bauleiste/Ghost-Platzierung, Command Card, Minimap, sichtbaren Nebel und Selektionsmarker ergänzt; §3 um MMB-Drag-Rotation, Space-Reset und die tastaturfreien Wege über Bauleiste/Command Card erweitert | Agent | | 0.6.0 | 2026-08-10 | D-102/Sprint 16.7 nachgezogen: fünf endliche Aetheriumfelder samt Markerpositionen und Reserven ersetzen die alte Zwei-Feld-Beschreibung; Angriffswege bleiben G4-Scope | Codex / Dennis Westermann | | 0.6.1 | 2026-08-10 | D-097/Paket 16.10 nachgezogen: Stop löscht das aktuelle Angriffsziel; automatische Neuerfassung im Folgetick und das weiterhin fehlende Halte-Feuer ehrlich abgegrenzt; die benachbarte überholte Behauptung fehlender Auto-Zielerfassung auf den aktuellen Combat-Stand korrigiert | Codex / Dennis Westermann | +| 0.6.2 | 2026-10-18 | Veraltete Aussage zu fehlendem Attack-Move durch den implementierten Befehlsablauf ersetzt; aktuelle manuelle Abnahme bleibt offen | Codex | diff --git a/docs/production/Roadmap.md b/docs/production/Roadmap.md index d82ccb3e..9e30a042 100644 --- a/docs/production/Roadmap.md +++ b/docs/production/Roadmap.md @@ -1,6 +1,6 @@ # Produktions-Roadmap -**Version:** 2.3.0 | **Status:** messwertbasiertes Rebaseline – ohne Kalenderzusage | **Verantwortungsbereich:** Producer / Executive Producer | **Sprint:** 7 +**Version:** 2.4.0 | **Status:** messwertbasiertes Rebaseline – ohne Kalenderzusage | **Verantwortungsbereich:** Producer / Executive Producer | **Sprint:** 7 ## Zweck @@ -11,7 +11,8 @@ noch aktive Gesamt-PT-Summe. ## Abhängigkeiten -- [MVPRecoveryPlan.md](MVPRecoveryPlan.md) – Gate-Sequenz +- [../../GOVERNANCE.md](../../GOVERNANCE.md) – aktives Tier 2 (D-076/D-091) +- [MVPRecoveryPlan.md](MVPRecoveryPlan.md) – schlafende Gate-Sequenz - [Milestones.md](Milestones.md) – MS-0/MS-1-Definition - [MVPContentManifest.md](MVPContentManifest.md) – fester MS-1-Scope - [DecisionLog.md](DecisionLog.md) – D-055, D-056, D-061 und D-063 bis D-066 @@ -27,7 +28,11 @@ D-056 schließt jetzt den Scope, liefert aber noch keine Durchsatzmessung. Historische Zahlen dürfen in älteren Berichten zur Nachvollziehbarkeit stehen, werden jedoch nicht summiert, aktualisiert oder als Prognose weitergeführt. -## 2. Re-Estimate-Punkte +## 2. Re-Estimate-Punkte (schlafend bis Tier 3) + +D-076/D-091 setzen die folgende Receipt- und Gate-Planung unter Tier 2 außer +Kraft. Sie bleibt als historische Tier-3-Vorlage erhalten und blockiert keine +aktuelle Arbeit. Belastbare Termine benötigen weiterhin gemessenen Durchsatz. | Zeitpunkt | Zulässige Aussage | Erforderliche Daten | |---|---|---| @@ -43,7 +48,7 @@ eine Integritätsvorstufe und liefert keine zulässige Durchsatzbasis. Ein Kalenderkorridor vor G4 ist verboten. -## 3. Aktive Reihenfolge +## 3. Historische Gate-Reihenfolge (schlafend bis Tier 3) `G0-A1 Integrity → G0-A2 Receipt → späteres sauberes Subject für G0-B/G0 → G1/V1–V5a → G2 → G3/V5b → G4 → G5` @@ -51,26 +56,41 @@ Jeder Schritt bleibt ergebnisorientiert. Parallelisierung darf die Reihenfolge der Statusfreigaben nicht umgehen. G0-A1 und G0-A2 werden ohne Gate-Fortschritt gemergt und können sich nicht selbst autorisieren. -## 4. Produktfragen +## 4. Aktuelle Beta-Vorbereitung (Stand 2026-10-18) + +Die folgenden Arbeiten bleiben im bestehenden MVP-Scope. „Beta-Vorbereitung" +bezeichnet die Arbeitsrichtung, keine erklärte Beta-Reife oder neue Frist. +Unter Tier 2 zählen grüne CI und eine tatsächlich gespielte Partie als Nachweis; +eine zurückgestellte Spielabnahme gemäß D-105 ist kein Meilenstein-Nachweis. + +| Priorität | Nächster überprüfbarer Schritt | Ehrlicher Stand | +|---|---|---| +| 1 | Animationskorrekturen an Radar, Infanterie und wiederverwendeten Ansichten prüfen; Bewegung, Zielen und Ernten aus Spielkamera ansehen | Transform-Animation ist vorhanden; vollständige visuelle Match-Abnahme offen | +| 2 | Eine vollständige Partie mit dem aktuellen Art-Stand von Aufbau bis Ergebnis spielen; Befehle einschließlich Attack-Move, Bau/Ernte und Gefechtsfeedback prüfen | Frühere Spielnachweise nehmen den aktuellen Stand nicht automatisch ab | +| 3 | Aktuellen Art-Bestand als separates Paket samt Metadaten und Prüfsumme bereitstellen und Import prüfen | Art liegt außerhalb Git; lokaler Kandidat dokumentiert in [AssetPackage.md](../assets/AssetPackage.md), Upload und frischer Import offen | +| 4 | Änderungen über PR, unabhängiges Review und grüne Pflicht-CI integrieren | Letzte Commits auf dem Arbeitsbranch gepusht; noch kein PR, lokale Folgekorrekturen nicht veröffentlicht | +| 5 | Bestehende Abnahmelücken zu Netzwerk und Gefechtsdichte abarbeiten | Zwei Unity-Fenster, LAN/VPS/Betrieb gemäß [Netzwerk-Abnahme A8](hashkrieg/12_Sprint_Zu_Zweit.md) sowie die [60-Einheiten-Sicht-/Gegenhörprüfung](hashkrieg/12B_Sprint_Sichtbares_Gefecht.md) bleiben offen | + +## 5. Produktfragen Q-018 (Preispunkt) und Q-019 (Telemetrie) bleiben offen. D-056 stellt Telemetrie aus MS-1 zurück; weder Preis noch Backend werden in eine G0–G5- -Schätzung eingerechnet. Steam, Cloud, Kampagne und Online sind ebenfalls -Post-MVP. +Schätzung eingerechnet. Steam, Cloud und Kampagne bleiben Post-MVP. Die +historische D-056-Einordnung von Online ersetzt nicht die Prüfung des inzwischen +implementierten Zwei-Spieler-Pfads; dessen offene Abnahme steht in §4. ## Offene Punkte -- Kein belastbarer Durchsatz vor G2. -- Kein belastbarer Kalenderkorridor vor G4. +- Keine aktuelle Beta-Abnahme und kein belastbarer Kalenderkorridor. +- Vollständiger Match-Durchlauf und Art-Paket-Import auf aktuellem Stand offen. - Post-MVP-Scope ist absichtlich nicht geplant. ## Nächste Schritte -1. G0-A1 ohne Gate-Fortschritt mergen und G0-A2 separat implementieren. -2. Erst am nachfolgenden sauberen Subject autorisierte G0–G2-Messdaten - sammeln. -3. Nach G2 eine neue Aufwandsspanne mit Annahmen dokumentieren. -4. Nach G4 erstmals einen G5-/MS-1-Kalenderkorridor erstellen. +1. Animationskorrekturen mit lokalen Unity-Tests prüfen und im Match ansehen. +2. Aktuelles Art-Paket und einen vollständigen Spielablauf gemeinsam abnehmen. +3. Änderungen per PR integrieren; Defekte und tatsächlichen Durchsatz erfassen. +4. Erst auf dieser Grundlage Aufwand neu bewerten; keine Kalenderzusage ableiten. ## Änderungsverlauf @@ -82,3 +102,4 @@ Post-MVP. | 2.1.0 | 2026-07-24 | D-063-autorisierte Schema-1.2-Evidence als einzige zulässige Durchsatzbasis festgelegt | Producer / Executive Producer | | 2.2.0 | 2026-07-24 | D-064: Durchsatzbasis auf subject-unabhängig autorisierte Schema-1.3-Evidence nach zweistufigem G0-A-Bootstrap begrenzt | Producer / Executive Producer | | 2.3.0 | 2026-07-25 | D-066: Durchsatzbasis auf abgeschlossene zweiphasige Receipt-Autorisierung nach G0-A1/G0-A2 begrenzt | Producer / Executive Producer | +| 2.4.0 | 2026-10-18 | Receipt-Sequenz gemäß D-076/D-091 als schlafend gekennzeichnet; aktuelle Beta-Vorbereitung mit offenen Integrations-, Art- und Spielabnahmen ergänzt | Codex | diff --git a/docs/tech/AnimationSystem.md b/docs/tech/AnimationSystem.md index 45a21ada..585b855d 100644 --- a/docs/tech/AnimationSystem.md +++ b/docs/tech/AnimationSystem.md @@ -1,6 +1,6 @@ # Animation System -**Version:** 0.3.0 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 +**Version:** 0.3.1 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 ## Zweck @@ -55,7 +55,7 @@ Fahrzeuge, Mechs ohne Humanoid-Rig, Gebäude und Lufteinheiten erhalten **kein S - **Luft:** Banking/Pitch aus Steuerkurve der Luft-Steering-Schicht (D-034), Schwebetrudeln im Idle; kein Rig. - **Evolvierte-Gebäude (D-011):** Keim → Reifung über Wachstums-Shader (Vertex-Displacement/Blend-Shapes, Wachstumsgrad 0–1 aus Sim-Reifestufe) statt Konstruktions-Stufen; Regenerations-Visualisierung als Pulsieren statt Reparatur-Funken. -### Umsetzungsstand (2026-10-17, D-110) +### Umsetzungsstand (2026-10-18, D-110) Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne `AnimationSetSO` und ohne Animations-LOD; beides bleibt Ziel dieses Dokuments. @@ -77,6 +77,16 @@ Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne Bein-Knoten statt über ein Mecanim-Rig. Der Emissionspuls schreibt wie die Fraktionstönung über `MaterialPropertyBlock`. +- **Korrekturen am 18. Oktober:** Radar-Schwenks (`Scan_*`) haben eine eigene + kontinuierliche Phase, unabhängig vom Umlauf der `Spin_*`-Knoten. Infanterie + unterdrückt den Idle-Blick bei sichtbarem Ziel oder Schuss, damit die vom + View-Manager gesetzte Gefechtsrichtung erhalten bleibt. `ResetPose` setzt + Phasen und Timer zurück und stellt die Basisemission auch außerhalb des + Sichtfelds wieder her; die Fraktionstönung bleibt im Property-Block erhalten. + EditMode-Regressionstests prüfen Transform-Verläufe und vorhandene Radar-/ + Infanterie-Prefabs. Die visuelle Qualität im vollständigen Match bleibt separat + abzunehmen; diese Korrekturen ersetzen weder Rigging noch Animations-LOD. + ## Animations-LOD (3 Stufen, Pflicht) Konfigurierbar pro Einheitentyp im `AnimationSetSO` (Schwellen in Kamerahöhe/Metern und Einheitenanzahl): @@ -170,3 +180,4 @@ Der `RenderSnapshot` wird vom View-Adapter aus dem serialisierbaren Sim-State (D | 0.2.0 | 2026-07-24 | MS-1-Animationsumfang, synthetischen 500er-Lasttest, CPU-P95-Budget und Unity-Pin gemäß D-056/D-058/D-060/D-061 festgelegt | Lead Graphics Engineer | | 0.2.1 | 2026-07-24 | MG/Rocket-Module als MS-1-Pflicht klargestellt und nur Flak zurückgestellt | Lead Graphics Engineer | | 0.3.0 | 2026-10-17 | Umsetzungsstand der rig-losen Sparte (D-110): `UnitRigAnimator`, Knoten-Vertrag, View-Eingaben und Abweichungen vom Entwurf | Technical Art | +| 0.3.1 | 2026-10-18 | Kontinuierlicher Radar-Schwenk, Gefechtsausrichtung der Infanterie und vollständiger Pool-Reset beschrieben; visuelle Match-Abnahme bleibt offen | Codex | From 36c3e9a9ae36154315f88e17c3678ca57675ad4f Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 18 Oct 2026 15:57:36 +0200 Subject: [PATCH 13/13] fix(animation): stop remote builders from appearing to work --- .../PlayMode/BuilderWorkPresentationTests.cs | 95 +++++++++++++++++++ .../BuilderWorkPresentationTests.cs.meta | 2 + .../Scripts/Gameplay/Match/UnitViewManager.cs | 17 +++- CHANGELOG.md | 5 + docs/production/GrayboxLog.md | 47 ++++++++- docs/production/Roadmap.md | 7 +- docs/tech/AnimationSystem.md | 10 +- 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs create mode 100644 Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta diff --git a/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs new file mode 100644 index 00000000..83ce5768 --- /dev/null +++ b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs @@ -0,0 +1,95 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Core; +using Nova.Gameplay.Match; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.State; +using EntityId = Nova.Core.EntityId; + +namespace Nova.PlayMode.Tests +{ + public sealed class BuilderWorkPresentationTests + { + [UnityTest] + public IEnumerator BuilderArms_WorkAtSite_AndRestWhenStoppedOutsideReach() + { + yield return SceneManager.LoadSceneAsync("Assets/_Project/Scenes/Bootstrap.unity", LoadSceneMode.Single); + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady); + MatchRunner runner = bootstrap.Runner; + var views = Object.FindAnyObjectByType(); + Assert.NotNull(views); + + EntityId builder = EntityId.Invalid; + foreach (UnitState unit in runner.Entities.RawUnits) + if (unit.IsActive && unit.PlayerId == MatchBootstrap.LocalSlot && unit.Role == UnitRole.Builder) + { builder = unit.Id; break; } + Assert.IsTrue(builder.IsValid, "canonical opening must contain an own builder"); + // Fixture position beside the canonical refinery footprint (8,4)..(10,6). + // Subsequent placement, movement and Stop use the real command intake. + runner.Entities.GetUnitRef(builder).Transform = new Transform2D(SimFixed.FromInt(7), SimFixed.FromInt(5)); + yield return new WaitForSeconds(0.4f); + Assert.IsTrue(views.TryGetView(builder, out GameObject view) && view != null); + Assert.NotNull(view.GetComponent(), "requires the current art bundle"); + Transform arm = null; + foreach (Transform node in view.GetComponentsInChildren(true)) + if (node.name.StartsWith("Arm_", System.StringComparison.Ordinal)) { arm = node; break; } + Assert.NotNull(arm, "builder prefab needs an authored Arm_ node"); + Quaternion rest = arm.localRotation; + uint rawBuilder = UnitCommandStateView.ToRawEntityId(builder); + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new PlaceBuildingPayload(4, 8, 4)), out _)); + + float maxWorkAngle = 0f; + float until = Time.time + 1.8f; + while (Time.time < until) + { + yield return null; + maxWorkAngle = Mathf.Max(maxWorkAngle, Quaternion.Angle(rest, arm.localRotation)); + } + uint siteRaw = 0; + foreach (UnitState unit in runner.Entities.RawUnits) + if (unit.IsActive && unit.PlayerId == MatchBootstrap.LocalSlot + && runner.Construction.IsActiveSite(unit.Id)) + { siteRaw = UnitCommandStateView.ToRawEntityId(unit.Id); break; } + Assert.AreNotEqual(0u, siteRaw, "placement must create a real construction site"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int nearProgress, out uint assigned)); + Assert.AreEqual(rawBuilder, assigned); + Assert.Greater(nearProgress, 0, "in-reach builder must progress construction"); + Assert.Greater(maxWorkAngle, 3f, "working builder's authored arm must visibly swing"); + + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new MovePayload(new[] { rawBuilder }, + SimFixed.FromInt(7), SimFixed.FromInt(12))), out _)); + until = Time.time + 10f; + while (Time.time < until + && SimFixed.WorldToGrid(runner.Entities.GetUnitRef(builder).Transform.PositionY) < 9) + yield return null; + Assert.GreaterOrEqual(SimFixed.WorldToGrid(runner.Entities.GetUnitRef(builder).Transform.PositionY), 9, + "builder must actually leave footprint reach through movement"); + Assert.AreEqual(CommandIngressResult.Accepted, + runner.Ingress.TrySubmitIntent(CommandIntent.Create(new StopPayload(new[] { rawBuilder })), out _)); + yield return new WaitForSeconds(0.9f); // command latency and arm-blend settling + Assert.IsFalse(runner.Entities.GetUnitRef(builder).IsMoving, "Stop must land before checking idle work"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int farProgress, out assigned)); + Assert.AreEqual(rawBuilder, assigned, "assignment intentionally survives departure"); + uint tickBefore = runner.Session.CurrentTick; + float maxIdleAngle = 0f; + until = Time.time + 1.2f; + while (Time.time < until) + { + yield return null; + maxIdleAngle = Mathf.Max(maxIdleAngle, Quaternion.Angle(rest, arm.localRotation)); + } + Assert.Greater(runner.Session.CurrentTick, tickBefore, "the match must keep ticking during the pause check"); + Assert.IsTrue(runner.Construction.TryGetSite(siteRaw, out _, out int finalProgress, out _)); + Assert.AreEqual(farProgress, finalProgress, "out-of-reach builder must not advance the site"); + Assert.Less(maxIdleAngle, 0.1f, "assigned but out-of-reach builder must not fake construction with its arms"); + } + } +} diff --git a/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta new file mode 100644 index 00000000..07d06b8f --- /dev/null +++ b/Assets/Tests/PlayMode/BuilderWorkPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 44d55401fb944e5099777f33d7f6099f diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index fb06e0d9..830fde15 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -1174,7 +1174,22 @@ private void MarkWorkingBuilders(EntityManager entities) if (!construction.TryGetSite(UnitCommandStateView.ToRawEntityId(id), out _, out _, out uint builderRaw)) continue; if (builderRaw == 0u) continue; EntityId builder = UnitCommandStateView.ToEntityId(builderRaw); - if (builder.IsValid && builder.Index < _buildingThisFrame.Length) _buildingThisFrame[builder.Index] = true; + if (!builder.IsValid || builder.Index >= _buildingThisFrame.Length + || !entities.TryGetUnit(builder, out UnitState builderUnit) + || builderUnit.Role != UnitRole.Builder + || !entities.TryGetUnit(id, out UnitState siteUnit) + || builderUnit.PlayerId != siteUnit.PlayerId) continue; + + // Site entities sit at the footprint centre, as in the site HUD. + // Assignment survives a move away; only an in-reach builder actually works. + int half = SimDefinitions.BuildingFootprintCells / 2; + int originX = SimFixed.WorldToGrid(siteUnit.Transform.PositionX) - half; + int originY = SimFixed.WorldToGrid(siteUnit.Transform.PositionY) - half; + int builderX = Math.Max(0, SimFixed.WorldToGrid(builderUnit.Transform.PositionX)); + int builderY = Math.Max(0, SimFixed.WorldToGrid(builderUnit.Transform.PositionY)); + if (ConstructionSiteStatus.IsInReachOfFootprint( + builderX, builderY, originX, originY, SimDefinitions.BuildingFootprintCells)) + _buildingThisFrame[builder.Index] = true; } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bda9c43..47e6376e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -536,6 +536,11 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt ### Behoben +- **Builder-Arme folgen dem tatsächlichen Baubereich:** Eine Baustellenzuweisung + allein löst keine Arbeitsanimation mehr aus. Nur ein lebender eigener Builder + in Reichweite des Baustellen-Footprints arbeitet sichtbar; nach Wegfahren und + Stop bleiben seine Arme ruhig, während der Bau pausiert. PlayMode-Regression + prüft echte Baustelle, Befehle, Baufortschritt und Armbewegung gemeinsam. - **Animationskontinuität und Pool-Reset:** Radar-Schwenks springen nicht mehr beim Rotorumlauf; Infanterie richtet ihren Idle-Blick bei Zielkontakt oder Schuss neutral aus. Wiederverwendete Ansichten starten mit zurückgesetzten diff --git a/docs/production/GrayboxLog.md b/docs/production/GrayboxLog.md index a95687c8..bbeca018 100644 --- a/docs/production/GrayboxLog.md +++ b/docs/production/GrayboxLog.md @@ -1,6 +1,6 @@ # Graybox-Log -**Version:** 0.5.0 | **Status:** Entwurf – append-only Sitzungsprotokoll (trägt D-067, noch nicht ratifiziert) | **Verantwortungsbereich:** Orchestrator / Technical Writer | **Sprint:** 7 +**Version:** 0.6.0 | **Status:** Entwurf – append-only Sitzungsprotokoll (trägt D-067, noch nicht ratifiziert) | **Verantwortungsbereich:** Orchestrator / Technical Writer | **Sprint:** 7 ## Zweck @@ -649,6 +649,50 @@ Zweitsystem. - Erster **menschlicher** Play-Durchlauf steht weiter aus (Runbook §4 ist auf GB-005 aktualisiert). +## Sitzung GB-006 – 2026-10-18 – Buildstart und Animations-Teilprüfung + +**Ausgangsstand:** `3c8e6bf` mit den anschließend als `24bb14e` gesicherten +Animationskorrekturen; lokaler macOS-Build `3c8e6bf-dirty`, Unity 6000.5.4f1. +Der Build wurde auf Inhaberwunsch vor dem Commit erstellt und gestartet. +Das später integrierte KI-Update aus `main` (`6f36b7e`) gehört nicht zu dieser +laufenden Spielinstanz. + +### Beobachtet + +- Der macOS-Player wurde erfolgreich gebaut (663.446.516 Bytes laut Unity) + und gestartet. In der laufenden Partie waren Art-HQ, Builder, Baustelle, + später fertige Raffinerie, Sammler sowie Ressourcen- und Befehlsanzeigen + sichtbar. Der Build-Stempel war lesbar. +- Der Agent beobachtete die bereits bediente Partie, ohne eigene Spielbefehle + einzuspeisen. Standbilder belegen die Darstellung, keine durchgehende + Animationsqualität oder vollständige Spielerfahrung. +- Weitere Abfragen der nativen Spielansicht scheiterten zweimal am Timeout. + Deshalb keine behauptete vollständige visuelle Spielabnahme, kein Sieg-/ + Niederlagen-Durchlauf und kein Meilenstein-Nachweis. + +### Nachfolgende technische Prüfung + +- Branch samt Animationskorrekturen veröffentlicht als + [Draft-PR #154](https://github.com/VibecodingGermany/HashKrieg/pull/154). + Konflikte mit D-114 auf `main` unter Erhalt von Fraktionswahl und Seed-Auswahl + aufgelöst; unabhängiges Read-only-Review ohne Befund. Danach 785 + Simulationstests und 729 Unity-EditMode-Tests erfolgreich. +- Der nächste Animationsbefund wurde in einer laufenden Testszene reproduziert: + Ein entfernter, gestoppter Builder schwenkte trotz pausierter Baustelle seine + Arme bis 14°. Eine reine Baustellenzuweisung darf deshalb nicht als aktives + Arbeiten angezeigt werden. Die View-Korrektur nutzt die vorhandene + Footprint-Reichweitenhilfe; Simulationsregeln bleiben unverändert. + Gegenprobe: alter Code reproduziert den Fehler, korrigierter Code besteht + den Test; alle sieben gezielten PlayMode-Tests sind anschließend grün. + +### Offen + +Eine vollständige Partie mit dem integrierten Stand, beide Fraktionen, +längere Gefechte und eine menschliche Beurteilung von Gang, Rückstoß und +Arbeitsübergängen bleiben ausstehend. Der laufende Build wird nicht nachträglich +als Build eines späteren Commits bezeichnet. Aktuelle Reihenfolge: +[Roadmap](Roadmap.md), Tier-2-Abnahme: [Governance](../../GOVERNANCE.md). + ## Offene Punkte - D-067 und D-068 sind Entwürfe. Ohne Ratifizierung existiert der @@ -688,3 +732,4 @@ Zweitsystem. | 0.4.0 | 2026-08-05 | Sitzung GB-004 (Demo-Beweis, Harvester-Footprint-Fix, URP-Verdrahtung, 34 Tripo-Assets integriert) angehängt – Kopfzeile war ohne Tabellenzeile geblieben, hier nachgezogen | Technical Writer | | 0.5.0 | 2026-08-06 | Sitzung GB-005 (spielbarer Core-Loop nach D-077) angehängt – Befund Overlay/Maßstab, Sieg bei HQ-Verlust, KI-Slot aktiv, Verifikation 420/425/2 grün, `-quit`-Fallstrick erneut protokolliert | Agent | | 0.4.0 | 2026-08-05 | Sitzung GB-004 (Wirtschaftsfix nach D-068-Regeln, volle Tastenbelegung, URP-Zuordnung, Tripo-Asset-Integration 34/34, PlayMode-Sichtbeweis mit Screenshots) angehängt – einschließlich getrennter Hash-Zuordnung und fünf offenen Befunden | Technical Writer | +| 0.6.0 | 2026-10-18 | GB-006 ergänzt: gestarteter macOS-Testbuild, ehrliche visuelle Teilprüfung, Integration mit main und reproduzierter Builder-Animationsbefund | Codex | diff --git a/docs/production/Roadmap.md b/docs/production/Roadmap.md index 9e30a042..aa0dc469 100644 --- a/docs/production/Roadmap.md +++ b/docs/production/Roadmap.md @@ -1,6 +1,6 @@ # Produktions-Roadmap -**Version:** 2.4.0 | **Status:** messwertbasiertes Rebaseline – ohne Kalenderzusage | **Verantwortungsbereich:** Producer / Executive Producer | **Sprint:** 7 +**Version:** 2.4.1 | **Status:** messwertbasiertes Rebaseline – ohne Kalenderzusage | **Verantwortungsbereich:** Producer / Executive Producer | **Sprint:** 7 ## Zweck @@ -65,10 +65,10 @@ eine zurückgestellte Spielabnahme gemäß D-105 ist kein Meilenstein-Nachweis. | Priorität | Nächster überprüfbarer Schritt | Ehrlicher Stand | |---|---|---| -| 1 | Animationskorrekturen an Radar, Infanterie und wiederverwendeten Ansichten prüfen; Bewegung, Zielen und Ernten aus Spielkamera ansehen | Transform-Animation ist vorhanden; vollständige visuelle Match-Abnahme offen | +| 1 | Animationskorrekturen an Radar, Infanterie und wiederverwendeten Ansichten prüfen; Bewegung, Zielen und Ernten aus Spielkamera ansehen | Radar-/Ziel-/Pool-Korrekturen und Builder-Reichweite automatisiert geprüft; visuelle Teilprüfung in [GB-006](GrayboxLog.md), vollständige Match-Abnahme offen | | 2 | Eine vollständige Partie mit dem aktuellen Art-Stand von Aufbau bis Ergebnis spielen; Befehle einschließlich Attack-Move, Bau/Ernte und Gefechtsfeedback prüfen | Frühere Spielnachweise nehmen den aktuellen Stand nicht automatisch ab | | 3 | Aktuellen Art-Bestand als separates Paket samt Metadaten und Prüfsumme bereitstellen und Import prüfen | Art liegt außerhalb Git; lokaler Kandidat dokumentiert in [AssetPackage.md](../assets/AssetPackage.md), Upload und frischer Import offen | -| 4 | Änderungen über PR, unabhängiges Review und grüne Pflicht-CI integrieren | Letzte Commits auf dem Arbeitsbranch gepusht; noch kein PR, lokale Folgekorrekturen nicht veröffentlicht | +| 4 | Änderungen über PR, unabhängiges Review und grüne Pflicht-CI integrieren | [Draft-PR #154](https://github.com/VibecodingGermany/HashKrieg/pull/154) offen, mit main abgeglichen; CI auf `6f36b7e` grün, vollständige Spielabnahme weiter offen | | 5 | Bestehende Abnahmelücken zu Netzwerk und Gefechtsdichte abarbeiten | Zwei Unity-Fenster, LAN/VPS/Betrieb gemäß [Netzwerk-Abnahme A8](hashkrieg/12_Sprint_Zu_Zweit.md) sowie die [60-Einheiten-Sicht-/Gegenhörprüfung](hashkrieg/12B_Sprint_Sichtbares_Gefecht.md) bleiben offen | ## 5. Produktfragen @@ -103,3 +103,4 @@ implementierten Zwei-Spieler-Pfads; dessen offene Abnahme steht in §4. | 2.2.0 | 2026-07-24 | D-064: Durchsatzbasis auf subject-unabhängig autorisierte Schema-1.3-Evidence nach zweistufigem G0-A-Bootstrap begrenzt | Producer / Executive Producer | | 2.3.0 | 2026-07-25 | D-066: Durchsatzbasis auf abgeschlossene zweiphasige Receipt-Autorisierung nach G0-A1/G0-A2 begrenzt | Producer / Executive Producer | | 2.4.0 | 2026-10-18 | Receipt-Sequenz gemäß D-076/D-091 als schlafend gekennzeichnet; aktuelle Beta-Vorbereitung mit offenen Integrations-, Art- und Spielabnahmen ergänzt | Codex | +| 2.4.1 | 2026-10-18 | PR- und Integrationsstand sowie Animationsprüfung und visuelle Teilprüfung GB-006 nachgeführt | Codex | diff --git a/docs/tech/AnimationSystem.md b/docs/tech/AnimationSystem.md index 585b855d..af01ebfe 100644 --- a/docs/tech/AnimationSystem.md +++ b/docs/tech/AnimationSystem.md @@ -1,6 +1,6 @@ # Animation System -**Version:** 0.3.1 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 +**Version:** 0.3.2 | **Status:** Entwurf – MS-1-Override verbindlich | **Verantwortungsbereich:** Lead Graphics Engineer | **Sprint:** 3 ## Zweck @@ -87,6 +87,13 @@ Die rig-lose Sparte ist in einer ersten, schlanken Form gebaut — ohne Infanterie-Prefabs. Die visuelle Qualität im vollständigen Match bleibt separat abzunehmen; diese Korrekturen ersetzen weder Rigging noch Animations-LOD. +- **Builder-Arbeit:** Eine Baustellenzuweisung zählt nur bei einem lebenden + Builder desselben Besitzers in Reichweite des tatsächlichen Footprints als + Arbeitszustand. Die View nutzt dieselbe bereits vorhandene Reichweitenhilfe + wie das Baustellen-HUD; eine pausierte Baustelle animiert den entfernten, + stehenden Builder nicht weiter. Ein PlayMode-Test verbindet Baufortschritt, + Bewegung/Stop über den Befehlsweg und die Armtransforms des Art-Prefabs. + ## Animations-LOD (3 Stufen, Pflicht) Konfigurierbar pro Einheitentyp im `AnimationSetSO` (Schwellen in Kamerahöhe/Metern und Einheitenanzahl): @@ -181,3 +188,4 @@ Der `RenderSnapshot` wird vom View-Adapter aus dem serialisierbaren Sim-State (D | 0.2.1 | 2026-07-24 | MG/Rocket-Module als MS-1-Pflicht klargestellt und nur Flak zurückgestellt | Lead Graphics Engineer | | 0.3.0 | 2026-10-17 | Umsetzungsstand der rig-losen Sparte (D-110): `UnitRigAnimator`, Knoten-Vertrag, View-Eingaben und Abweichungen vom Entwurf | Technical Art | | 0.3.1 | 2026-10-18 | Kontinuierlicher Radar-Schwenk, Gefechtsausrichtung der Infanterie und vollständiger Pool-Reset beschrieben; visuelle Match-Abnahme bleibt offen | Codex | +| 0.3.2 | 2026-10-18 | Builder-Arbeit an tatsächliche Baustellenreichweite gebunden; PlayMode-Nachweis für Bauen und pausierte Fernzuweisung beschrieben | Codex |