(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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user