(Feat) Add build cost
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Building
|
||||
{
|
||||
/// <summary>
|
||||
/// One resource line of a buildable's price: an authored item and how many of it a single
|
||||
/// placement consumes. Mirrors CraftingIngredient — plain authoring data, no behaviour.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct BuildCost
|
||||
{
|
||||
public ItemData item;
|
||||
public int quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2915c8306391c7e40a840a6b4ade5a41
|
||||
@@ -0,0 +1,26 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Ashwild.Building
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight, data-agnostic view payload for one line of a build's cost, handed to the crosshair
|
||||
/// so it can render the price without ever touching BuildableData/ItemData. Carries the icon, how
|
||||
/// many the local player currently holds, the required amount, and whether the line is affordable —
|
||||
/// the manager decides the numbers, the view just renders "owned / required" and colours it.
|
||||
/// </summary>
|
||||
public readonly struct BuildCostView
|
||||
{
|
||||
public readonly Sprite Icon;
|
||||
public readonly int Owned;
|
||||
public readonly int Amount;
|
||||
public readonly bool Affordable;
|
||||
|
||||
public BuildCostView(Sprite icon, int owned, int amount, bool affordable)
|
||||
{
|
||||
Icon = icon;
|
||||
Owned = owned;
|
||||
Amount = amount;
|
||||
Affordable = affordable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d5f455513b7a8345b86ee9f8f2fcaa0
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using FishNet.Object;
|
||||
using UnityEngine;
|
||||
using Ashwild.Inventory;
|
||||
using Ashwild.Player;
|
||||
|
||||
namespace Ashwild.Building
|
||||
@@ -51,6 +52,10 @@ namespace Ashwild.Building
|
||||
[Tooltip("Degrees the ghost yaws per scroll notch while placing.")]
|
||||
[SerializeField] private float rotationStep = 90f;
|
||||
|
||||
[Header("Placement Mode")]
|
||||
[Tooltip("Continuous build: after confirming a placement, immediately re-arm another ghost of the same structure (to chain walls/floors) instead of returning to idle. Off = place one at a time.")]
|
||||
[SerializeField] private bool continuousBuild = false;
|
||||
|
||||
[Header("Snapping")]
|
||||
[Tooltip("Radius around the aim in which a matching existing socket pulls the ghost in.")]
|
||||
[SerializeField] private float snapRadius = 1.25f;
|
||||
@@ -125,6 +130,12 @@ namespace Ashwild.Building
|
||||
|
||||
private readonly Collider[] snapResults = new Collider[16];
|
||||
|
||||
/// <summary>
|
||||
/// Reused buffer for the active build's cost lines, rebuilt and pushed to the crosshair each
|
||||
/// time the price or its affordability changes — so no per-refresh allocation.
|
||||
/// </summary>
|
||||
private readonly List<BuildCostView> costView = new List<BuildCostView>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
@@ -156,6 +167,7 @@ namespace Ashwild.Building
|
||||
PlayerEvents.HotbarScroll += HandleRotate;
|
||||
PlayerEvents.BuildRotatePressed += HandleRotateKey;
|
||||
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
|
||||
PlayerEvents.InventorySlotChanged += HandleInventoryChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,6 +181,7 @@ namespace Ashwild.Building
|
||||
PlayerEvents.HotbarScroll -= HandleRotate;
|
||||
PlayerEvents.BuildRotatePressed -= HandleRotateKey;
|
||||
PlayerEvents.DemolishModeChanged -= HandleDemolishModeChanged;
|
||||
PlayerEvents.InventorySlotChanged -= HandleInventoryChanged;
|
||||
|
||||
EndPlacement();
|
||||
ClearDemoTarget();
|
||||
@@ -267,6 +280,7 @@ namespace Ashwild.Building
|
||||
|
||||
PlayerEvents.RaiseBuildMenuToggleRequested();
|
||||
PlayerEvents.RaiseBuildPlacingChanged(true);
|
||||
PublishCostView();
|
||||
BuildRegistry.Instance.RequestSpawnGhost(id, token);
|
||||
}
|
||||
|
||||
@@ -303,8 +317,11 @@ namespace Ashwild.Building
|
||||
|
||||
/// <summary>
|
||||
/// Left-click: demolishes the aimed build while in demolition mode, otherwise commits the
|
||||
/// ghost's pose to the registry (spawned for everyone) and ends the placement. Placement is
|
||||
/// blocked on an invalid spot and on the very frame the ghost was attached.
|
||||
/// ghost's pose to the registry (spawned for everyone) and consumes its resource cost from the
|
||||
/// local inventory (client-authoritative, exactly like crafting). In continuous build mode it
|
||||
/// then re-arms a fresh ghost of the same structure (keeping the current yaw, so pieces chain
|
||||
/// edge-to-edge); otherwise it ends the placement. Blocked on an invalid spot, on the very frame
|
||||
/// the ghost was attached, and when the player cannot afford the cost.
|
||||
/// </summary>
|
||||
private void HandleConfirm()
|
||||
{
|
||||
@@ -318,10 +335,19 @@ namespace Ashwild.Building
|
||||
if (PlayerEvents.InputLocked) return;
|
||||
if (Time.frameCount == placementBeganFrame) return;
|
||||
if (ghostValidity != null && !ghostValidity.IsValid) return;
|
||||
if (active != null && !active.CanAfford(PlayerInventory.Instance)) return;
|
||||
|
||||
if (BuildRegistry.Instance != null)
|
||||
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
|
||||
|
||||
ConsumeCost();
|
||||
|
||||
if (continuousBuild && active != null)
|
||||
{
|
||||
RearmGhost();
|
||||
return;
|
||||
}
|
||||
|
||||
EndPlacement();
|
||||
}
|
||||
|
||||
@@ -435,6 +461,43 @@ namespace Ashwild.Building
|
||||
/// idle. Safe to call when nothing is in flight.
|
||||
/// </summary>
|
||||
private void EndPlacement()
|
||||
{
|
||||
DespawnGhost();
|
||||
|
||||
if (active != null)
|
||||
{
|
||||
active = null;
|
||||
activeId = 0;
|
||||
PlayerEvents.RaiseBuildPlacingChanged(false);
|
||||
PlayerEvents.RaiseBuildCostChanged(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Continuous build: after committing a piece, drops the spent ghost and asks the registry for a
|
||||
/// fresh one of the same structure so the player keeps placing without reopening the menu. Keeps
|
||||
/// <see cref="active"/>/<see cref="activeId"/> and the current yaw, and stays "placing" on the
|
||||
/// bus. The token bump makes any in-flight ghost from the old request reject itself in AttachGhost.
|
||||
/// </summary>
|
||||
private void RearmGhost()
|
||||
{
|
||||
DespawnGhost();
|
||||
|
||||
if (BuildRegistry.Instance == null)
|
||||
{
|
||||
EndPlacement();
|
||||
return;
|
||||
}
|
||||
|
||||
int token = ++spawnToken;
|
||||
BuildRegistry.Instance.RequestSpawnGhost(activeId, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Despawns the networked ghost we own and clears its cached components, without touching the
|
||||
/// active buildable or the bus. Safe to call when no ghost is live.
|
||||
/// </summary>
|
||||
private void DespawnGhost()
|
||||
{
|
||||
if (ghost != null)
|
||||
{
|
||||
@@ -444,13 +507,6 @@ namespace Ashwild.Building
|
||||
}
|
||||
ghostValidity = null;
|
||||
ghostSnaps = null;
|
||||
|
||||
if (active != null)
|
||||
{
|
||||
active = null;
|
||||
activeId = 0;
|
||||
PlayerEvents.RaiseBuildPlacingChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -469,6 +525,86 @@ namespace Ashwild.Building
|
||||
|
||||
#endregion
|
||||
|
||||
#region Build Cost
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the crosshair cost display when the inventory changes mid-placement (a resource was
|
||||
/// spent or picked up), so its affordability colouring stays live. No-op while idle.
|
||||
/// </summary>
|
||||
private void HandleInventoryChanged(int slotIndex)
|
||||
{
|
||||
if (active == null) return;
|
||||
PublishCostView();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the active build's cost lines into the reused buffer — each with the local player's
|
||||
/// current affordability — and pushes them to the crosshair via the bus. Clears the display when
|
||||
/// idle or when the build is free (no cost lines).
|
||||
/// </summary>
|
||||
private void PublishCostView()
|
||||
{
|
||||
BuildCost[] cost = active != null ? active.Cost : null;
|
||||
if (cost == null || cost.Length == 0)
|
||||
{
|
||||
PlayerEvents.RaiseBuildCostChanged(null);
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
costView.Clear();
|
||||
foreach (BuildCost line in cost)
|
||||
{
|
||||
if (line.item == null) continue;
|
||||
int owned = inv != null ? inv.CountItem(line.item) : 0;
|
||||
bool affordable = owned >= line.quantity;
|
||||
costView.Add(new BuildCostView(line.item.Icon, owned, line.quantity, affordable));
|
||||
}
|
||||
PlayerEvents.RaiseBuildCostChanged(costView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spends the active build's cost from the local inventory after a committed placement. Client-
|
||||
/// authoritative like crafting — the inventory change refreshes the cost view through
|
||||
/// <see cref="HandleInventoryChanged"/>. No-op for a free build or when offline.
|
||||
/// </summary>
|
||||
private void ConsumeCost()
|
||||
{
|
||||
BuildCost[] cost = active != null ? active.Cost : null;
|
||||
if (cost == null || cost.Length == 0) return;
|
||||
|
||||
PlayerInventory inv = PlayerInventory.Instance;
|
||||
if (inv == null) return;
|
||||
|
||||
foreach (BuildCost line in cost)
|
||||
{
|
||||
if (line.item == null) continue;
|
||||
inv.RemoveItemByData(line.item, line.quantity);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
|
||||
/// <summary>
|
||||
/// Whether confirming a placement immediately re-arms another ghost of the same structure
|
||||
/// (continuous build) instead of returning to idle. Settable so a key or menu toggle can flip it.
|
||||
/// </summary>
|
||||
public bool ContinuousBuild
|
||||
{
|
||||
get => continuousBuild;
|
||||
set => continuousBuild = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flips continuous build on/off — wire this to a key or a menu toggle so the player can switch
|
||||
/// between chaining placements and placing one at a time.
|
||||
/// </summary>
|
||||
public void ToggleContinuousBuild() => continuousBuild = !continuousBuild;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Demolition
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using Ashwild.Inventory;
|
||||
|
||||
namespace Ashwild.Building
|
||||
{
|
||||
@@ -30,6 +31,10 @@ namespace Ashwild.Building
|
||||
[Tooltip("The real structure spawned once the placement is confirmed.")]
|
||||
[SerializeField] private GameObject builtPrefab;
|
||||
|
||||
[Header("Cost")]
|
||||
[Tooltip("Resources consumed from the builder's inventory on each placement. Leave empty for a free build.")]
|
||||
[SerializeField] private BuildCost[] cost;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
@@ -39,6 +44,24 @@ namespace Ashwild.Building
|
||||
public string Description => description;
|
||||
public GameObject GhostPrefab => ghostPrefab;
|
||||
public GameObject BuiltPrefab => builtPrefab;
|
||||
public BuildCost[] Cost => cost;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the given inventory holds enough of every cost line to place this structure once.
|
||||
/// A null inventory or an empty cost list counts as affordable (free build).
|
||||
/// </summary>
|
||||
public bool CanAfford(PlayerInventory inventory)
|
||||
{
|
||||
if (cost == null || cost.Length == 0) return true;
|
||||
if (inventory == null) return false;
|
||||
|
||||
for (int i = 0; i < cost.Length; i++)
|
||||
{
|
||||
if (cost[i].item == null) continue;
|
||||
if (!inventory.HasItem(cost[i].item, cost[i].quantity)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ namespace Ashwild.EditorTools
|
||||
/// </summary>
|
||||
private enum LightingPreset { Studio, Soft, Dramatic, Flat }
|
||||
|
||||
/// <summary>
|
||||
/// Which asset kind the Target object picker is filtered to — prefabs/scene objects or raw
|
||||
/// meshes. Materials, scripts and other assets are intentionally excluded from both.
|
||||
/// </summary>
|
||||
private enum TargetKind { Prefab, Mesh }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
@@ -46,6 +52,8 @@ namespace Ashwild.EditorTools
|
||||
private string exportFileName = string.Empty;
|
||||
private bool nameEditedByUser;
|
||||
|
||||
private TargetKind targetKind = TargetKind.Prefab;
|
||||
|
||||
private bool autoRotate;
|
||||
private IVisualElementScheduledItem rotateSchedule;
|
||||
|
||||
@@ -172,15 +180,30 @@ namespace Ashwild.EditorTools
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Target card: the object field (prefab / scene object / mesh) plus quick Frame and auto-rotate
|
||||
/// controls that act on the loaded subject.
|
||||
/// Target card: a Prefab/Mesh kind selector that filters the object picker to only those assets
|
||||
/// (so materials, scripts and the like never clutter it), the object field itself, plus quick
|
||||
/// Frame and auto-rotate controls that act on the loaded subject.
|
||||
/// </summary>
|
||||
private VisualElement BuildTargetCard()
|
||||
{
|
||||
VisualElement card = Card("Target");
|
||||
|
||||
ObjectField field = new ObjectField("Object") { objectType = typeof(UnityEngine.Object), allowSceneObjects = true };
|
||||
ObjectField field = new ObjectField("Object")
|
||||
{
|
||||
objectType = TargetObjectType(),
|
||||
allowSceneObjects = targetKind == TargetKind.Prefab
|
||||
};
|
||||
field.RegisterValueChangedCallback(evt => LoadTarget(evt.newValue));
|
||||
|
||||
EnumField kind = new EnumField("Kind", targetKind);
|
||||
kind.RegisterValueChangedCallback(evt =>
|
||||
{
|
||||
targetKind = (TargetKind)evt.newValue;
|
||||
field.value = null;
|
||||
field.objectType = TargetObjectType();
|
||||
field.allowSceneObjects = targetKind == TargetKind.Prefab;
|
||||
});
|
||||
card.Add(kind);
|
||||
card.Add(field);
|
||||
|
||||
VisualElement buttons = new VisualElement();
|
||||
@@ -198,6 +221,12 @@ namespace Ashwild.EditorTools
|
||||
return card;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Unity type the object picker is restricted to for the current kind — GameObject for
|
||||
/// prefabs/scene objects, Mesh for raw meshes — so nothing else appears in the picker.
|
||||
/// </summary>
|
||||
private Type TargetObjectType() => targetKind == TargetKind.Mesh ? typeof(Mesh) : typeof(GameObject);
|
||||
|
||||
/// <summary>
|
||||
/// Camera card: projection, field of view and framing padding.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using UnityEngine;
|
||||
using Ashwild.Audio;
|
||||
using Ashwild.Building;
|
||||
using Ashwild.Harvesting;
|
||||
using Ashwild.Interaction;
|
||||
using Ashwild.Inventory;
|
||||
@@ -135,6 +137,7 @@ namespace Ashwild.Player
|
||||
public static event Action<bool> BuildMenuOpenChanged; // the construction menu opened (true) or closed (false)
|
||||
public static event Action<bool> BuildPlacingChanged; // a build ghost started (true) / stopped (false) being positioned
|
||||
public static event Action<bool> DemolishModeChanged; // the hammer entered (true) / left (false) demolition mode (right-click held)
|
||||
public static event Action<IReadOnlyList<BuildCostView>> BuildCostChanged; // the active build's cost to display near the crosshair (null/empty = clear)
|
||||
|
||||
// ============================================================
|
||||
// Cooking events
|
||||
@@ -305,6 +308,17 @@ namespace Ashwild.Player
|
||||
BuildPlacingChanged?.Invoke(placing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the active build's cost lines for the crosshair to render (or a null/empty list to
|
||||
/// clear it). Owner-side only; BuildManager re-raises it live as the inventory changes so the
|
||||
/// affordability colouring stays current while a piece is being positioned.
|
||||
/// </summary>
|
||||
public static void RaiseBuildCostChanged(IReadOnlyList<BuildCostView> cost)
|
||||
{
|
||||
Log(nameof(BuildCostChanged));
|
||||
BuildCostChanged?.Invoke(cost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles demolition mode (right-click held with the hammer out). Owner-side only; the
|
||||
/// hammer raises it and BuildManager drives the world-facing targeting/destroy from it.
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user