(Feat) Add build cost

This commit is contained in:
2026-07-14 14:25:02 +02:00
parent c9e923975d
commit 3657b870d8
27 changed files with 1270 additions and 990 deletions
+56
View File
@@ -0,0 +1,56 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Ashwild.UI
{
/// <summary>
/// One resource line of a build's cost, shown near the crosshair while placing: an icon plus an
/// "owned / required" count (e.g. 2/3). A pure, data-agnostic view — it is handed an icon, the two
/// numbers and an "affordable" flag and renders exactly that (tinting the count to signal a
/// shortfall), never resolving any domain data itself. CrosshairManager instantiates one of these
/// per cost line into its holder.
/// </summary>
[DisallowMultipleComponent]
public class BuildCostEntryUI : MonoBehaviour
{
#region Serialized Fields
[Header("References")]
[Tooltip("Icon of the required resource.")]
[SerializeField] private Image icon;
[Tooltip("Owned / required count for one placement (e.g. 2/3).")]
[SerializeField] private TMP_Text amountLabel;
[Header("Affordability Colours")]
[Tooltip("Amount colour when the player holds enough of this resource.")]
[SerializeField] private Color affordColor = Color.white;
[Tooltip("Amount colour when the player is short of this resource.")]
[SerializeField] private Color missingColor = new Color(1f, 0.35f, 0.35f);
#endregion
#region Public API
/// <summary>
/// Fills the line: sets the icon, the "owned / required" count, and tints it by affordability.
/// </summary>
public void Set(Sprite resourceIcon, int owned, int amount, bool affordable)
{
if (icon != null)
{
icon.sprite = resourceIcon;
icon.enabled = resourceIcon != null;
}
if (amountLabel != null)
{
amountLabel.text = $"{owned}/{amount}";
amountLabel.color = affordable ? affordColor : missingColor;
}
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b197776c5c48d19409d8b73a5f77bcfb
+96 -2
View File
@@ -1,7 +1,9 @@
using System.Collections.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Ashwild.Building;
using Ashwild.Interaction;
using Ashwild.Player;
@@ -39,6 +41,18 @@ namespace Ashwild.UI
/// </summary>
[SerializeField] private TMP_Text promptLabel;
[Header("Build Cost")]
/// <summary>
/// Container the per-resource cost entries are parented under while a build is being placed.
/// Its own layout (e.g. a HorizontalLayoutGroup) arranges them.
/// </summary>
[SerializeField] private RectTransform costHolder;
/// <summary>
/// Prefab instantiated once per cost line (icon + amount) into the holder. Pooled and reused.
/// </summary>
[SerializeField] private BuildCostEntryUI costEntryPrefab;
[Header("States")]
/// <summary>
/// Look used when nothing is hovered (the plain dot).
@@ -101,6 +115,18 @@ namespace Ashwild.UI
/// </summary>
private string currentPrompt;
/// <summary>
/// True while a build's cost is displayed: the prompt label is suppressed and the holder takes
/// over, so a hovered interactable's prompt never fights the cost readout mid-placement.
/// </summary>
private bool isShowingCost;
/// <summary>
/// The instantiated cost entries, pooled and reused across placements — activated up to the
/// current cost's line count and hidden beyond it.
/// </summary>
private readonly List<BuildCostEntryUI> costEntries = new List<BuildCostEntryUI>();
#endregion
#region Unity Lifecycle
@@ -126,6 +152,7 @@ namespace Ashwild.UI
private void OnEnable()
{
PlayerEvents.InteractableHoverChanged += HandleHoverChanged;
PlayerEvents.BuildCostChanged += HandleBuildCostChanged;
ApplyState(idleState, isHover: false, animated: false);
SetLabel(null, animated: false);
}
@@ -136,6 +163,7 @@ namespace Ashwild.UI
private void OnDisable()
{
PlayerEvents.InteractableHoverChanged -= HandleHoverChanged;
PlayerEvents.BuildCostChanged -= HandleBuildCostChanged;
}
/// <summary>
@@ -151,7 +179,7 @@ namespace Ashwild.UI
if (prompt != currentPrompt)
{
currentPrompt = prompt;
SetLabel(prompt, animated: true);
if (!isShowingCost) SetLabel(prompt, animated: true);
}
}
@@ -180,7 +208,24 @@ namespace Ashwild.UI
currentPrompt = hovering ? target.InteractionPrompt : null;
ApplyState(hovering ? hoverState : idleState, hovering, animated: true);
SetLabel(currentPrompt, animated: true);
if (!isShowingCost) SetLabel(currentPrompt, animated: true);
}
/// <summary>
/// Shows the active build's cost (hiding the prompt label and filling the holder) while placing,
/// or clears it and restores the normal prompt when the list is null/empty (placement ended).
/// </summary>
private void HandleBuildCostChanged(IReadOnlyList<BuildCostView> cost)
{
if (cost == null || cost.Count == 0)
{
ClearCost();
return;
}
isShowingCost = true;
SetLabel(null, animated: true);
PopulateCost(cost);
}
#endregion
@@ -243,6 +288,55 @@ namespace Ashwild.UI
a => promptLabel.alpha = a, target, labelFadeDuration);
}
/// <summary>
/// Fills the holder with one entry per cost line, reusing pooled entries and only instantiating
/// when the current build needs more lines than any previous one. Entries beyond this build's
/// count are hidden. Logs and no-ops when the holder or prefab reference is missing.
/// </summary>
private void PopulateCost(IReadOnlyList<BuildCostView> cost)
{
if (costHolder == null || costEntryPrefab == null)
{
Debug.LogError("[CrosshairManager] Cost holder or entry prefab not assigned — cannot show the build cost.", this);
return;
}
for (int i = 0; i < cost.Count; i++)
{
BuildCostEntryUI entry;
if (i < costEntries.Count)
{
entry = costEntries[i];
}
else
{
entry = Instantiate(costEntryPrefab, costHolder);
costEntries.Add(entry);
}
entry.gameObject.SetActive(true);
entry.Set(cost[i].Icon, cost[i].Owned, cost[i].Amount, cost[i].Affordable);
}
for (int i = cost.Count; i < costEntries.Count; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
}
/// <summary>
/// Hides every cost entry and restores the prompt for whatever is currently hovered. No-op when
/// no cost is being shown.
/// </summary>
private void ClearCost()
{
if (!isShowingCost) return;
isShowingCost = false;
for (int i = 0; i < costEntries.Count; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
SetLabel(currentPrompt, animated: true);
}
/// <summary>
/// Kills every cached tween so none survive a disable/destroy.
/// </summary>
+84 -20
View File
@@ -6,6 +6,11 @@ using Ashwild.Player;
namespace Ashwild.UI
{
/// <summary>
/// Drives the on-screen item/recipe notification stack. Incoming notifications are queued and
/// released one at a time on a short delay, so a burst (e.g. one pickup unlocking several recipes)
/// reveals gradually instead of popping all at once, capped by a max on-screen count.
/// </summary>
public class NotificationUI : MonoBehaviour
{
[Header("References")]
@@ -35,7 +40,30 @@ namespace Ashwild.UI
[SerializeField] private string discoveryLabel = "NEW";
[SerializeField] private Color discoveryColor = new Color(1f, 0.85f, 0.3f);
[Header("Queue")]
[Tooltip("Minimum delay between two notifications appearing, so a burst reveals one at a time.")]
[SerializeField] private float spawnDelay = 0.5f;
[Tooltip("Maximum notifications visible on screen at once; the rest wait in the queue.")]
[SerializeField] private int maxActiveNotifications = 5;
[Tooltip("Maximum notifications waiting in the queue; beyond this the oldest pending one is dropped.")]
[SerializeField] private int maxQueuedNotifications = 20;
private readonly List<NotificationItemUI> activeNotifications = new List<NotificationItemUI>();
private readonly Queue<PendingNotification> pendingQueue = new Queue<PendingNotification>();
private float spawnCooldown;
/// <summary>
/// A notification waiting to be shown. Holds either an item gain/loss (item + signed quantity,
/// still mergeable while queued) or a recipe discovery (icon + name).
/// </summary>
private class PendingNotification
{
public bool isDiscovery;
public ItemData item;
public int signedQuantity;
public Sprite icon;
public string label;
}
private void OnEnable()
{
@@ -65,16 +93,18 @@ namespace Ashwild.UI
// Food placed on a cooking station or fuel fed to it both leave the inventory by one.
private void OnItemSpent(ItemData item) => Push(item, -1);
/// <summary>
/// Records an item gain/loss. Merges instantly into a matching active notification, then into a
/// matching one still queued, otherwise enqueues a new one to be released on the spawn delay.
/// </summary>
private void Push(ItemData item, int signedQuantity)
{
if (item == null || signedQuantity == 0) return;
// Clean up destroyed notifications
activeNotifications.RemoveAll(n => n == null);
bool gain = signedQuantity > 0;
// Merge only with a notification for the same item AND same direction
for (int i = 0; i < activeNotifications.Count; i++)
{
if (activeNotifications[i] != null && !activeNotifications[i].IsDiscovery
@@ -86,31 +116,62 @@ namespace Ashwild.UI
}
}
// Create new notification
GameObject go = Instantiate(notificationPrefab, container);
NotificationItemUI notification = go.GetComponent<NotificationItemUI>();
foreach (PendingNotification pending in pendingQueue)
{
if (!pending.isDiscovery && pending.item == item && (pending.signedQuantity > 0) == gain)
{
pending.signedQuantity += signedQuantity;
return;
}
}
float targetY = startOffsetY + activeNotifications.Count * (notificationHeight + spacing);
RectTransform rt = notification.RectTransform;
rt.anchoredPosition = new Vector2(0f, targetY - 30f);
notification.Initialize(item, signedQuantity, gain ? gainColor : lossColor, displayDuration, slideInDuration, slideInEase);
// Slide in from below
rt.DOAnchorPosY(targetY, slideInDuration).SetEase(slideInEase);
activeNotifications.Add(notification);
Enqueue(new PendingNotification { isDiscovery = false, item = item, signedQuantity = signedQuantity });
}
/// <summary>
/// Pushes a one-time "NEW" notification for a newly unlocked recipe: its result icon and name
/// with the configured badge in the discovery colour. Never merges with anything.
/// Enqueues a one-time "NEW" notification for a newly unlocked recipe (its result icon and name
/// with the discovery badge). Never merges; released on the spawn delay like the rest.
/// </summary>
private void PushDiscovery(Sprite icon, string recipeName)
{
activeNotifications.RemoveAll(n => n == null);
Enqueue(new PendingNotification { isDiscovery = true, icon = icon, label = recipeName });
}
/// <summary>
/// Adds a pending notification to the queue, dropping the oldest waiting one when the queue is
/// full so the most recent notifications are never lost.
/// </summary>
private void Enqueue(PendingNotification pending)
{
if (pendingQueue.Count >= maxQueuedNotifications)
pendingQueue.Dequeue();
pendingQueue.Enqueue(pending);
}
/// <summary>
/// Ticks the spawn cooldown and releases at most one queued notification per delay, while the
/// on-screen count is under the cap.
/// </summary>
private void Update()
{
if (spawnCooldown > 0f)
spawnCooldown -= Time.deltaTime;
if (pendingQueue.Count == 0 || spawnCooldown > 0f) return;
activeNotifications.RemoveAll(n => n == null);
if (activeNotifications.Count >= maxActiveNotifications) return;
Spawn(pendingQueue.Dequeue());
spawnCooldown = spawnDelay;
}
/// <summary>
/// Instantiates and slides in a notification for a released pending entry, wiring it as either
/// an item gain/loss or a recipe discovery.
/// </summary>
private void Spawn(PendingNotification pending)
{
GameObject go = Instantiate(notificationPrefab, container);
NotificationItemUI notification = go.GetComponent<NotificationItemUI>();
@@ -119,7 +180,10 @@ namespace Ashwild.UI
RectTransform rt = notification.RectTransform;
rt.anchoredPosition = new Vector2(0f, targetY - 30f);
notification.InitializeDiscovery(icon, recipeName, discoveryLabel, discoveryColor, displayDuration, slideInDuration);
if (pending.isDiscovery)
notification.InitializeDiscovery(pending.icon, pending.label, discoveryLabel, discoveryColor, displayDuration, slideInDuration);
else
notification.Initialize(pending.item, pending.signedQuantity, pending.signedQuantity > 0 ? gainColor : lossColor, displayDuration, slideInDuration, slideInEase);
rt.DOAnchorPosY(targetY, slideInDuration).SetEase(slideInEase);