Files
Emberwild/Assets/GAME/Script/Cooking/CookingStation.cs
T
2026-06-22 16:18:34 +02:00

637 lines
22 KiB
C#

using System.Collections.Generic;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
using Ashwild.Harvesting;
using Ashwild.Interaction;
using Ashwild.Inventory;
using Ashwild.Network;
using Ashwild.Player;
namespace Ashwild.Cooking
{
/// <summary>
/// A campfire-style cooking station and the single IInteractable for the whole fire. Food is
/// dropped onto its slots (even on a cold fire, to pre-load it), cooks only while the fire
/// actually burns, swaps to its cooked model, and is taken back — all by interacting, Raft-style.
/// What the player holds picks the action: cookable food is placed, fuel (logs) feeds the fire,
/// anything else (or empty hands) collects a cooked item.
///
/// Multiplayer: the station is **server-authoritative** so two players can't clobber each other.
/// The server owns the cooking simulation (fuel burn + timers) and replicates a compact view
/// (per-slot raw id + cooked flag, and the log count) to every client. Interactions are requested
/// through ServerRpcs; the inventory itself stays client-authoritative (the player removes the
/// held item locally and the server grants results/refunds via PlayerInventory.GrantItemFromServer),
/// mirroring the Pickable/Harvestable pattern. Fuel is counted in whole logs (capped by
/// <see cref="maxLogs"/>), each carrying its own burn time; the fire only burns — VFX, light, fuel
/// drain and cooking timers — while at least one item is still cooking on it.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(NetworkObject))]
public class CookingStation : NetworkBehaviour, IInteractable
{
#region Types
/// <summary>
/// Replicated per-slot view: which raw item sits here (0 = empty) and whether it is cooked.
/// Clients render the model from this; the cooking timer itself stays server-side.
/// </summary>
public struct SlotView
{
public ushort rawId;
public bool cooked;
}
#endregion
#region Serialized Fields
[Header("Slots")]
/// <summary>
/// The cooking spots on this station; pure visual anchors driven by the replicated state.
/// </summary>
[SerializeField] private CookingSlot[] slots;
[Header("Fuel")]
/// <summary>
/// Maximum number of logs the fire can hold at once; bounds both how many fuel items can be
/// loaded and how many log meshes can be shown.
/// </summary>
[SerializeField] private int maxLogs = 5;
/// <summary>
/// Log meshes revealed in order to match the loaded log count (first for one log, and so on).
/// </summary>
[SerializeField] private GameObject[] logMeshes;
[Header("Fire Visuals")]
/// <summary>
/// Flame particles toggled on/off as the fire starts and stops burning.
/// </summary>
[SerializeField] private ParticleSystem fireParticles;
/// <summary>
/// Light enabled while burning and disabled when the fire pauses or dies.
/// </summary>
[SerializeField] private Light fireLight;
#endregion
#region Networked State
/// <summary>
/// One SlotView per cooking slot, in the same order as <see cref="slots"/>. The server writes
/// it; every client renders models from it.
/// </summary>
private readonly SyncList<SlotView> slotViews = new SyncList<SlotView>();
/// <summary>
/// Whole logs currently loaded, replicated so all clients show the right log meshes.
/// </summary>
private readonly SyncVar<int> logCount = new SyncVar<int>();
#endregion
#region Server State
/// <summary>
/// Server-only: the raw item cooking in each slot (null = empty), parallel to <see cref="slots"/>.
/// </summary>
private ItemData[] rawItems;
/// <summary>
/// Server-only: accumulated lit cooking time per slot.
/// </summary>
private float[] elapsed;
/// <summary>
/// Server-only: remaining burn seconds of each loaded log, oldest first.
/// </summary>
private readonly List<float> logBurn = new List<float>();
#endregion
#region View State
/// <summary>
/// Log count shown last, so the meshes are only refreshed on change.
/// </summary>
private int shownLogs = -1;
#endregion
#region Unity Lifecycle
/// <summary>
/// Falls back to child slots when the array is unassigned, and sizes the server-side arrays.
/// </summary>
private void Awake()
{
if (slots == null || slots.Length == 0)
slots = GetComponentsInChildren<CookingSlot>();
rawItems = new ItemData[slots.Length];
elapsed = new float[slots.Length];
}
/// <summary>
/// Server-only cooking simulation: drains fuel and advances timers while the fire burns.
/// Clients render purely from the replicated state, so they do nothing here.
/// </summary>
private void Update()
{
if (!base.IsServerInitialized) return;
if (rawItems == null || slotViews.Count != rawItems.Length) return;
if (ServerIsBurning())
{
float dt = Time.deltaTime;
ConsumeFuel(dt);
TickCooking(dt);
}
}
#endregion
#region Network Lifecycle
/// <summary>
/// Subscribes to the replicated state so visuals follow it on every machine.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
slotViews.OnChange += OnSlotViewChanged;
logCount.OnChange += OnLogCountChanged;
}
/// <summary>
/// Server-side: seeds one empty SlotView per slot so clients receive a sized collection.
/// </summary>
public override void OnStartServer()
{
base.OnStartServer();
slotViews.Clear();
for (int i = 0; i < slots.Length; i++)
slotViews.Add(new SlotView { rawId = 0, cooked = false });
logCount.Value = 0;
}
/// <summary>
/// Client-side: catches up on the current state (models, logs, fire) on join.
/// </summary>
public override void OnStartClient()
{
base.OnStartClient();
for (int i = 0; i < slotViews.Count; i++) ApplySlotView(i);
UpdateLogVisuals();
SetFireVisuals(ClientIsBurning());
}
/// <summary>
/// Unsubscribes — mirrors OnStartNetwork exactly.
/// </summary>
public override void OnStopNetwork()
{
base.OnStopNetwork();
slotViews.OnChange -= OnSlotViewChanged;
logCount.OnChange -= OnLogCountChanged;
}
#endregion
#region IInteractable
/// <summary>
/// Describes the next action from what the local player holds: place cookable food, feed a log
/// when there is room, or collect the first cooked item waiting. Reads replicated state.
/// </summary>
public string InteractionPrompt
{
get
{
ItemData held = HeldItem();
if (held != null && held.IsCookable && FirstEmptySlotIndex() >= 0)
return $"Poser {held.ItemName}";
if (held != null && held.IsFuel && logCount.Value < maxLogs)
return $"Ajouter {held.ItemName}";
int cookedSlot = FirstCookedSlotIndex();
if (cookedSlot >= 0)
{
ItemData cooked = CookedItemAt(cookedSlot);
if (cooked != null) return $"Récupérer {cooked.ItemName}";
}
return string.Empty;
}
}
/// <summary>
/// Performs the action chosen by the held item: place, add a log, or collect. Runs on the
/// interacting client; the actual mutation is requested from the server.
/// </summary>
public void Interact()
{
ItemData held = HeldItem();
if (held != null && held.IsCookable && FirstEmptySlotIndex() >= 0)
{
PlaceFood(held);
return;
}
if (held != null && held.IsFuel && logCount.Value < maxLogs)
{
AddFuel(held);
return;
}
Retrieve();
}
#endregion
#region Client Actions
/// <summary>
/// Removes one held food item locally and asks the server to place it; the server picks the
/// slot authoritatively and refunds the item if there is no room left by the time it arrives.
/// </summary>
private void PlaceFood(ItemData raw)
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(raw) : (ushort)0;
if (id == 0)
{
Debug.LogError($"[CookingStation] '{raw.ItemName}' is not in the ItemDatabase — cannot cook. Run Rebuild Item Database.", this);
return;
}
inv.RemoveItem(inv.SelectedHotbarIndex, 1);
RequestPlaceFoodServerRpc(id);
PlayerEvents.RaiseFoodPlacedToCook(raw);
}
/// <summary>
/// Removes one held fuel item locally and asks the server to add a log; refunded if the fire
/// is already full when the request arrives.
/// </summary>
private void AddFuel(ItemData fuel)
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(fuel) : (ushort)0;
if (id == 0)
{
Debug.LogError($"[CookingStation] '{fuel.ItemName}' is not in the ItemDatabase — cannot add fuel. Run Rebuild Item Database.", this);
return;
}
inv.RemoveItem(inv.SelectedHotbarIndex, 1);
RequestAddFuelServerRpc(id);
PlayerEvents.RaiseFuelAdded(fuel);
}
/// <summary>
/// Asks the server for the first cooked item, after a local CanFit pre-check so a full
/// inventory keeps the food on the fire instead of losing it.
/// </summary>
private void Retrieve()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
int slot = FirstCookedSlotIndex();
if (slot < 0) return;
ItemData cooked = CookedItemAt(slot);
if (cooked != null && !inv.CanFit(cooked, 1))
{
Debug.LogWarning($"[CookingStation] '{name}' cannot give '{cooked.ItemName}' — inventory full.", this);
return;
}
RequestRetrieveServerRpc();
}
#endregion
#region Server RPCs
/// <summary>
/// Server-side: places the food on the first free slot, or refunds it to the requester.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestPlaceFoodServerRpc(ushort rawId, NetworkConnection conn = null)
{
ItemData raw = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(rawId) : null;
if (raw == null || !raw.IsCookable) { GrantBack(conn, raw, 1); return; }
int slot = ServerFirstEmptySlot();
if (slot < 0) { GrantBack(conn, raw, 1); return; }
rawItems[slot] = raw;
elapsed[slot] = 0f;
slotViews[slot] = new SlotView { rawId = rawId, cooked = false };
}
/// <summary>
/// Server-side: loads one log if there is room, or refunds the fuel.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestAddFuelServerRpc(ushort fuelId, NetworkConnection conn = null)
{
ItemData fuel = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(fuelId) : null;
if (fuel == null || !fuel.IsFuel) { GrantBack(conn, fuel, 1); return; }
if (logBurn.Count >= maxLogs) { GrantBack(conn, fuel, 1); return; }
logBurn.Add(fuel.FuelSeconds);
logCount.Value = logBurn.Count;
}
/// <summary>
/// Server-side: clears the first cooked slot and grants its result to the requester.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestRetrieveServerRpc(NetworkConnection conn = null)
{
int slot = ServerFirstCookedSlot();
if (slot < 0) return;
ItemData cooked = rawItems[slot] != null ? rawItems[slot].CookedResult : null;
rawItems[slot] = null;
elapsed[slot] = 0f;
slotViews[slot] = new SlotView { rawId = 0, cooked = false };
GrantBack(conn, cooked, 1);
}
#endregion
#region Server Simulation
/// <summary>
/// True only while the fire genuinely burns: it has fuel and at least one item is still cooking.
/// </summary>
private bool ServerIsBurning()
{
if (logBurn.Count == 0) return false;
for (int i = 0; i < rawItems.Length; i++)
if (rawItems[i] != null && !slotViews[i].cooked) return true;
return false;
}
/// <summary>
/// Burns the given time off the oldest log, removing logs as they are spent (rolling leftover
/// time onto the next), and replicates the new log count.
/// </summary>
private void ConsumeFuel(float deltaTime)
{
bool changed = false;
while (deltaTime > 0f && logBurn.Count > 0)
{
if (logBurn[0] > deltaTime)
{
logBurn[0] -= deltaTime;
deltaTime = 0f;
}
else
{
deltaTime -= logBurn[0];
logBurn.RemoveAt(0);
changed = true;
}
}
if (changed) logCount.Value = logBurn.Count;
}
/// <summary>
/// Advances every cooking slot by the lit time and flips it to cooked (replicated) on completion.
/// </summary>
private void TickCooking(float deltaTime)
{
for (int i = 0; i < rawItems.Length; i++)
{
if (rawItems[i] == null || slotViews[i].cooked) continue;
elapsed[i] += deltaTime;
if (elapsed[i] >= rawItems[i].CookTime)
{
SlotView v = slotViews[i];
v.cooked = true;
slotViews[i] = v;
}
}
}
/// <summary>
/// First slot with no food (server view), or -1 when every slot is occupied.
/// </summary>
private int ServerFirstEmptySlot()
{
for (int i = 0; i < rawItems.Length; i++)
if (rawItems[i] == null) return i;
return -1;
}
/// <summary>
/// First slot holding finished food (server view), or -1 when none is ready.
/// </summary>
private int ServerFirstCookedSlot()
{
for (int i = 0; i < rawItems.Length; i++)
if (rawItems[i] != null && slotViews[i].cooked) return i;
return -1;
}
/// <summary>
/// Grants an item back to the requesting player's (client-authoritative) inventory.
/// </summary>
private void GrantBack(NetworkConnection conn, ItemData item, int quantity)
{
if (item == null) return;
PlayerInventory inv = ResolveInventory(conn);
if (inv != null) inv.GrantItemFromServer(item, quantity);
}
/// <summary>
/// Returns the PlayerInventory on the player object owned by the given connection.
/// </summary>
private PlayerInventory ResolveInventory(NetworkConnection conn)
{
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
}
#endregion
#region Replication Handlers
/// <summary>
/// Re-applies a slot's model when its view changes, and fires the local "cooked" feedback.
/// </summary>
private void OnSlotViewChanged(SyncListOperation op, int index, SlotView oldItem, SlotView newItem, bool asServer)
{
switch (op)
{
case SyncListOperation.Add:
case SyncListOperation.Insert:
case SyncListOperation.Set:
ApplySlotView(index);
if (op == SyncListOperation.Set && !oldItem.cooked && newItem.cooked && newItem.rawId != 0)
{
ItemData cooked = CookedItemAt(index);
if (cooked != null) PlayerEvents.RaiseFoodCookedReady(cooked);
}
SetFireVisuals(ClientIsBurning());
break;
case SyncListOperation.Complete:
for (int i = 0; i < slotViews.Count; i++) ApplySlotView(i);
SetFireVisuals(ClientIsBurning());
break;
}
}
/// <summary>
/// Refreshes the log meshes and the fire visuals when the log count changes.
/// </summary>
private void OnLogCountChanged(int prev, int next, bool asServer)
{
UpdateLogVisuals();
SetFireVisuals(ClientIsBurning());
}
#endregion
#region View Helpers
/// <summary>
/// Shows the raw or cooked model (or clears it) for a slot from its replicated view.
/// </summary>
private void ApplySlotView(int index)
{
if (slots == null || index < 0 || index >= slots.Length || slots[index] == null) return;
SlotView v = slotViews[index];
if (v.rawId == 0)
{
slots[index].ClearModel();
return;
}
ItemData raw = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(v.rawId) : null;
if (raw == null)
{
slots[index].ClearModel();
return;
}
GameObject prefab = v.cooked
? (raw.CookedResult != null ? raw.CookedResult.WorldPrefab : null)
: raw.WorldPrefab;
slots[index].ShowModel(prefab);
}
/// <summary>
/// Reveals the first logCount meshes and hides the rest; only touches the scene on change.
/// </summary>
private void UpdateLogVisuals()
{
if (logMeshes == null) return;
int count = logCount.Value;
if (count == shownLogs) return;
shownLogs = count;
for (int i = 0; i < logMeshes.Length; i++)
{
if (logMeshes[i] == null) continue;
bool visible = i < count;
if (logMeshes[i].activeSelf != visible)
logMeshes[i].SetActive(visible);
}
}
/// <summary>
/// Toggles the flame particles and the fire light to match the burning state.
/// </summary>
private void SetFireVisuals(bool burning)
{
if (fireParticles != null)
{
if (burning && !fireParticles.isPlaying) fireParticles.Play();
else if (!burning && fireParticles.isPlaying) fireParticles.Stop();
}
if (fireLight != null) fireLight.enabled = burning;
}
/// <summary>
/// True (from replicated state) while the fire has fuel and at least one item is still cooking.
/// </summary>
private bool ClientIsBurning()
{
if (logCount.Value <= 0) return false;
for (int i = 0; i < slotViews.Count; i++)
if (slotViews[i].rawId != 0 && !slotViews[i].cooked) return true;
return false;
}
#endregion
#region Internal Helpers
/// <summary>
/// Returns the item the local player currently holds (selected hotbar slot), or null.
/// </summary>
private ItemData HeldItem()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return null;
InventorySlot slot = inv.GetSelectedSlot();
return slot != null && !slot.IsEmpty ? slot.ItemData : null;
}
/// <summary>
/// First slot with no food (replicated view), or -1 when every slot is occupied.
/// </summary>
private int FirstEmptySlotIndex()
{
for (int i = 0; i < slotViews.Count; i++)
if (slotViews[i].rawId == 0) return i;
return -1;
}
/// <summary>
/// First slot holding finished food (replicated view), or -1 when none is ready.
/// </summary>
private int FirstCookedSlotIndex()
{
for (int i = 0; i < slotViews.Count; i++)
if (slotViews[i].rawId != 0 && slotViews[i].cooked) return i;
return -1;
}
/// <summary>
/// The cooked item a slot will yield (from its replicated raw id), or null.
/// </summary>
private ItemData CookedItemAt(int index)
{
if (index < 0 || index >= slotViews.Count) return null;
ItemData raw = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(slotViews[index].rawId) : null;
return raw != null ? raw.CookedResult : null;
}
#endregion
}
}