(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
+15
View File
@@ -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
+145 -9
View File
@@ -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
}