Added Furnance and smelting

This commit is contained in:
2026-08-11 17:12:34 +03:00
parent 2a30d4d8cf
commit 67e0bcfaaf
82 changed files with 25148 additions and 7186 deletions
@@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
public enum FurnaceState : byte
{
Idle,
Smelting
}
public sealed class FurnaceController : NetworkWorkstationController
{
[Header("Storage")]
[SerializeField, Min(1f)] private float metalCapacity = 100f;
[Header("Production")]
[SerializeField] private Transform outputPoint;
[SerializeField] private List<SmeltingRecipe> recipes = new();
[SerializeField, Min(0f)] private float outputSpacing = 0.35f;
private readonly NetworkVariable<float> storedMetal = new(
0f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<int> selectedRecipeIndex = new(
-1,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<FurnaceState> furnaceState = new(
FurnaceState.Idle,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<double> processStartedAt = new(
0d,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<double> processEndsAt = new(
0d,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
public override bool IsOperating => furnaceState.Value == FurnaceState.Smelting;
public FurnaceState State => furnaceState.Value;
public float StoredMetal => storedMetal.Value;
public float MetalCapacity => metalCapacity;
public float MetalNormalized => metalCapacity > 0f ? storedMetal.Value / metalCapacity : 0f;
public int SelectedRecipeIndex => selectedRecipeIndex.Value;
public IReadOnlyList<SmeltingRecipe> Recipes => recipes;
public SmeltingRecipe SelectedRecipe => TryGetRecipe(selectedRecipeIndex.Value, out SmeltingRecipe recipe)
? recipe
: null;
public bool CanStartSelectedRecipe =>
!IsOperating &&
SelectedRecipe != null &&
SelectedRecipe.OutputItem != null &&
SelectedRecipe.OutputItem.WorldPrefab != null &&
storedMetal.Value >= SelectedRecipe.MetalCost;
public float ProcessProgress
{
get
{
if (!IsOperating)
return 0f;
double duration = processEndsAt.Value - processStartedAt.Value;
if (duration <= 0d)
return 1f;
double now = NetworkManager != null
? NetworkManager.ServerTime.Time
: Time.timeAsDouble;
return Mathf.Clamp01((float)((now - processStartedAt.Value) / duration));
}
}
public float RemainingSeconds
{
get
{
if (!IsOperating)
return 0f;
double now = NetworkManager != null
? NetworkManager.ServerTime.Time
: Time.timeAsDouble;
return Mathf.Max(0f, (float)(processEndsAt.Value - now));
}
}
public event Action Changed;
public override void OnNetworkSpawn()
{
storedMetal.OnValueChanged += HandleStoredMetalChanged;
selectedRecipeIndex.OnValueChanged += HandleSelectedRecipeChanged;
furnaceState.OnValueChanged += HandleStateChanged;
processStartedAt.OnValueChanged += HandleProcessTimeChanged;
processEndsAt.OnValueChanged += HandleProcessTimeChanged;
if (IsServer)
{
storedMetal.Value = Mathf.Clamp(storedMetal.Value, 0f, metalCapacity);
selectedRecipeIndex.Value = recipes.Count > 0 ? 0 : -1;
furnaceState.Value = FurnaceState.Idle;
processStartedAt.Value = 0d;
processEndsAt.Value = 0d;
}
Changed?.Invoke();
}
public override void OnNetworkDespawn()
{
storedMetal.OnValueChanged -= HandleStoredMetalChanged;
selectedRecipeIndex.OnValueChanged -= HandleSelectedRecipeChanged;
furnaceState.OnValueChanged -= HandleStateChanged;
processStartedAt.OnValueChanged -= HandleProcessTimeChanged;
processEndsAt.OnValueChanged -= HandleProcessTimeChanged;
}
private void Update()
{
if (!IsServer || !IsSpawned || !IsOperating)
return;
if (NetworkManager.ServerTime.Time >= processEndsAt.Value)
CompleteSmeltingOnServer();
}
public bool TryConsumeMetalSource(FurnaceMetalSource source)
{
if (!IsServer || !IsSpawned || source == null || storedMetal.Value >= metalCapacity)
return false;
NetworkItem item = source.GetComponent<NetworkItem>();
if (item == null || !item.IsSpawned || item.NetworkObject == null)
return false;
if (source.TryGetComponent(out NetworkHeldInteractable heldObject) && heldObject.IsHeld)
return false;
if (source.TryGetComponent(out NetworkHandItemInteractable handItem) && handItem.IsHeld)
return false;
if (!source.TryMarkConsumed())
return false;
storedMetal.Value = Mathf.Min(metalCapacity, storedMetal.Value + source.MetalAmount);
item.NetworkObject.Despawn(true);
return true;
}
public bool TryAddMetal(float amount)
{
if (!IsServer || !IsSpawned || amount <= 0f || storedMetal.Value >= metalCapacity)
return false;
storedMetal.Value = Mathf.Min(metalCapacity, storedMetal.Value + amount);
return true;
}
public void SelectRecipe(int recipeIndex)
{
if (!IsSpawned)
return;
if (IsServer)
SelectRecipeOnServer(recipeIndex);
else
RequestSelectRecipeRpc(recipeIndex);
}
public void StartSmelting()
{
if (!IsSpawned)
return;
if (IsServer)
TryStartSmeltingOnServer();
else
RequestStartSmeltingRpc();
}
public void SelectAndStartRecipe(int recipeIndex)
{
if (!IsSpawned)
return;
if (IsServer)
{
if (SelectRecipeOnServer(recipeIndex))
TryStartSmeltingOnServer();
}
else
{
RequestSelectAndStartRecipeRpc(recipeIndex);
}
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestSelectRecipeRpc(int recipeIndex) =>
SelectRecipeOnServer(recipeIndex);
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestStartSmeltingRpc() =>
TryStartSmeltingOnServer();
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestSelectAndStartRecipeRpc(int recipeIndex)
{
if (SelectRecipeOnServer(recipeIndex))
TryStartSmeltingOnServer();
}
private bool SelectRecipeOnServer(int recipeIndex)
{
if (!IsServer || IsOperating || !TryGetRecipe(recipeIndex, out _))
return false;
selectedRecipeIndex.Value = recipeIndex;
return true;
}
private bool TryStartSmeltingOnServer()
{
if (!IsServer || !CanStartSelectedRecipe)
return false;
SmeltingRecipe recipe = SelectedRecipe;
storedMetal.Value -= recipe.MetalCost;
double now = NetworkManager.ServerTime.Time;
processStartedAt.Value = now;
processEndsAt.Value = now + recipe.ProcessingDuration;
furnaceState.Value = FurnaceState.Smelting;
return true;
}
private void CompleteSmeltingOnServer()
{
if (!TryGetRecipe(selectedRecipeIndex.Value, out SmeltingRecipe recipe) ||
recipe.OutputItem == null ||
recipe.OutputItem.WorldPrefab == null)
{
Debug.LogError($"{name} cannot complete smelting: output prefab is missing.", this);
StopProcessingOnServer();
return;
}
Transform spawnTransform = outputPoint != null ? outputPoint : transform;
for (int index = 0; index < recipe.OutputAmount; index++)
{
Vector3 sideOffset = spawnTransform.right * outputSpacing * index;
NetworkObject output = Instantiate(
recipe.OutputItem.WorldPrefab,
spawnTransform.position + sideOffset,
spawnTransform.rotation);
output.Spawn();
}
StopProcessingOnServer();
}
private void StopProcessingOnServer()
{
furnaceState.Value = FurnaceState.Idle;
processStartedAt.Value = 0d;
processEndsAt.Value = 0d;
}
private bool TryGetRecipe(int index, out SmeltingRecipe recipe)
{
bool isValid = index >= 0 && index < recipes.Count && recipes[index] != null;
recipe = isValid ? recipes[index] : null;
return isValid;
}
private void HandleStoredMetalChanged(float _, float __) => Changed?.Invoke();
private void HandleSelectedRecipeChanged(int _, int __) => Changed?.Invoke();
private void HandleStateChanged(FurnaceState _, FurnaceState __) => Changed?.Invoke();
private void HandleProcessTimeChanged(double _, double __) => Changed?.Invoke();
[ContextMenu("Debug/Add 25 Metal (Server Play Mode)")]
private void DebugAddMetal() => TryAddMetal(25f);
[ContextMenu("Debug/Start Selected Recipe (Server Play Mode)")]
private void DebugStartSelectedRecipe() => StartSmelting();
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41af49db79eeaf84ea8bc7c3dd75ae0f
@@ -0,0 +1,88 @@
using UnityEngine;
public sealed class FurnaceMetalLevelView : MonoBehaviour
{
[SerializeField] private FurnaceController furnace;
[SerializeField] private Transform metalVisual;
[SerializeField] private Transform emptyLocalPosition;
[SerializeField] private Transform fullLocalPosition;
[SerializeField, Min(0f)] private float movementSpeed = 1.5f;
private Vector3 targetLocalPosition;
private void Reset()
{
furnace = GetComponentInParent<FurnaceController>();
metalVisual = transform;
}
private void OnEnable()
{
if (furnace == null)
furnace = GetComponentInParent<FurnaceController>();
if (furnace != null)
furnace.Changed += RefreshTarget;
RefreshTarget();
}
private void OnDisable()
{
if (furnace != null)
furnace.Changed -= RefreshTarget;
}
private void Update()
{
if (metalVisual == null)
return;
metalVisual.localPosition = movementSpeed <= 0f
? targetLocalPosition
: Vector3.MoveTowards(
metalVisual.localPosition,
targetLocalPosition,
movementSpeed * Time.deltaTime);
}
private void RefreshTarget()
{
float normalized = furnace != null ? furnace.MetalNormalized : 0f;
targetLocalPosition = Vector3.Lerp(
GetLocalPoint(emptyLocalPosition),
GetLocalPoint(fullLocalPosition),
normalized);
}
private Vector3 GetLocalPoint(Transform point)
{
if (point == null || metalVisual == null)
return metalVisual != null ? metalVisual.localPosition : Vector3.zero;
Transform space = metalVisual.parent;
return space != null
? space.InverseTransformPoint(point.position)
: point.position;
}
[ContextMenu("Preview/Empty")]
private void PreviewEmpty() => ApplyPreview(0f);
[ContextMenu("Preview/Half")]
private void PreviewHalf() => ApplyPreview(0.5f);
[ContextMenu("Preview/Full")]
private void PreviewFull() => ApplyPreview(1f);
private void ApplyPreview(float normalized)
{
if (metalVisual == null)
return;
metalVisual.localPosition = Vector3.Lerp(
GetLocalPoint(emptyLocalPosition),
GetLocalPoint(fullLocalPosition),
normalized);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d51e4e087bfc8f14d8df1b4da69a6d56
@@ -0,0 +1,31 @@
using UnityEngine;
[DisallowMultipleComponent]
[RequireComponent(typeof(Collider))]
public sealed class FurnaceScrapReceiver : MonoBehaviour
{
[SerializeField] private FurnaceController furnace;
private void Reset()
{
furnace = GetComponentInParent<FurnaceController>();
Collider receiverCollider = GetComponent<Collider>();
receiverCollider.isTrigger = true;
}
private void Awake()
{
if (furnace == null)
furnace = GetComponentInParent<FurnaceController>();
}
private void OnTriggerEnter(Collider other)
{
if (furnace == null || !furnace.IsServer || !furnace.IsSpawned)
return;
FurnaceMetalSource source = other.GetComponentInParent<FurnaceMetalSource>();
if (source != null)
furnace.TryConsumeMetalSource(source);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a72230fcd966f84d967ab39b124447d
@@ -0,0 +1,25 @@
using UnityEngine;
[CreateAssetMenu(fileName = "SmeltingRecipe", menuName = "FORT-BO/Workstations/Smelting Recipe")]
public sealed class SmeltingRecipe : ScriptableObject
{
[SerializeField] private string recipeId;
[SerializeField] private string displayName;
[SerializeField] private ItemDefinition outputItem;
[SerializeField, Min(1)] private int outputAmount = 1;
[SerializeField, Min(0.01f)] private float metalCost = 25f;
[SerializeField, Min(0.05f)] private float processingDuration = 3f;
public string RecipeId => recipeId;
public string DisplayName => string.IsNullOrWhiteSpace(displayName) ? name : displayName;
public ItemDefinition OutputItem => outputItem;
public int OutputAmount => outputAmount;
public float MetalCost => metalCost;
public float ProcessingDuration => processingDuration;
private void OnValidate()
{
recipeId = recipeId?.Trim().ToLowerInvariant();
displayName = displayName?.Trim();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dbdeccc1573d9204f858f7c114972d91
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 46d9fb1e1a0fed4479d8298a3ccff544
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,72 @@
using UnityEngine;
public sealed class FurnacePanelUIController : MonoBehaviour, IGlobalUISectionController
{
[SerializeField] private FurnaceController furnace;
[SerializeField] private FurnacePanelUIView view;
private void Reset()
{
furnace = GetComponentInParent<FurnaceController>();
view = GetComponent<FurnacePanelUIView>();
}
private void OnEnable()
{
if (furnace == null)
furnace = GetComponentInParent<FurnaceController>();
if (view == null)
view = GetComponent<FurnacePanelUIView>();
if (furnace != null)
furnace.Changed += Refresh;
GlobalUIController.Instance?.Register(this);
Refresh();
}
private void OnDisable()
{
if (furnace != null)
furnace.Changed -= Refresh;
GlobalUIController.Instance?.Unregister(this);
}
private void Update()
{
if (furnace != null && furnace.IsOperating)
Refresh();
}
public void SelectRecipe(int recipeIndex)
{
furnace?.SelectRecipe(recipeIndex);
}
public void SelectAndStartRecipe(int recipeIndex)
{
furnace?.SelectAndStartRecipe(recipeIndex);
}
public void StartSmelting()
{
furnace?.StartSmelting();
}
public void Refresh()
{
if (furnace == null || view == null)
return;
view.Render(
furnace.StoredMetal,
furnace.MetalCapacity,
furnace.SelectedRecipe,
furnace.State,
furnace.ProcessProgress,
furnace.RemainingSeconds,
furnace.CanStartSelectedRecipe);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6cce45d5e80938340961575a1e91eb41
@@ -0,0 +1,51 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public sealed class FurnacePanelUIView : MonoBehaviour
{
[SerializeField] private TMP_Text metalText;
[SerializeField] private TMP_Text selectedRecipeText;
[SerializeField] private TMP_Text statusText;
[SerializeField] private TMP_Text remainingTimeText;
[SerializeField] private Image metalImage;
[SerializeField] private Image progressImage;
[SerializeField] private Button startButton;
public void Render(
float storedMetal,
float metalCapacity,
SmeltingRecipe selectedRecipe,
FurnaceState state,
float processProgress,
float remainingSeconds,
bool canStart)
{
if (metalText != null)
metalText.text = $"METAL {storedMetal:0.#} / {metalCapacity:0.#}";
if (metalImage != null)
metalImage.fillAmount = metalCapacity > 0f ? storedMetal / metalCapacity : 0f;
if (selectedRecipeText != null)
{
selectedRecipeText.text = selectedRecipe != null
? $"{selectedRecipe.DisplayName} | {selectedRecipe.MetalCost:0.#} metal"
: "NO RECIPE";
}
if (statusText != null)
statusText.text = state == FurnaceState.Smelting ? "SMELTING" : "READY";
if (progressImage != null)
progressImage.fillAmount = processProgress;
if (remainingTimeText != null)
remainingTimeText.text = state == FurnaceState.Smelting
? $"{remainingSeconds:0.0}s"
: string.Empty;
if (startButton != null)
startButton.interactable = canStart;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 871599a5b7d016b45a74a2005a6e22e5