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 { /// /// 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 /// ), 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. /// [DisallowMultipleComponent] [RequireComponent(typeof(NetworkObject))] public class CookingStation : NetworkBehaviour, IInteractable { #region Types /// /// 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. /// public struct SlotView { public ushort rawId; public bool cooked; } #endregion #region Serialized Fields [Header("Slots")] /// /// The cooking spots on this station; pure visual anchors driven by the replicated state. /// [SerializeField] private CookingSlot[] slots; [Header("Fuel")] /// /// 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. /// [SerializeField] private int maxLogs = 5; /// /// Log meshes revealed in order to match the loaded log count (first for one log, and so on). /// [SerializeField] private GameObject[] logMeshes; [Header("Fire Visuals")] /// /// Flame particles toggled on/off as the fire starts and stops burning. /// [SerializeField] private ParticleSystem fireParticles; /// /// Light enabled while burning and disabled when the fire pauses or dies. /// [SerializeField] private Light fireLight; #endregion #region Networked State /// /// One SlotView per cooking slot, in the same order as . The server writes /// it; every client renders models from it. /// private readonly SyncList slotViews = new SyncList(); /// /// Whole logs currently loaded, replicated so all clients show the right log meshes. /// private readonly SyncVar logCount = new SyncVar(); #endregion #region Server State /// /// Server-only: the raw item cooking in each slot (null = empty), parallel to . /// private ItemData[] rawItems; /// /// Server-only: accumulated lit cooking time per slot. /// private float[] elapsed; /// /// Server-only: remaining burn seconds of each loaded log, oldest first. /// private readonly List logBurn = new List(); #endregion #region View State /// /// Log count shown last, so the meshes are only refreshed on change. /// private int shownLogs = -1; #endregion #region Unity Lifecycle /// /// Falls back to child slots when the array is unassigned, and sizes the server-side arrays. /// private void Awake() { if (slots == null || slots.Length == 0) slots = GetComponentsInChildren(); rawItems = new ItemData[slots.Length]; elapsed = new float[slots.Length]; } /// /// 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. /// 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 /// /// Subscribes to the replicated state so visuals follow it on every machine. /// public override void OnStartNetwork() { base.OnStartNetwork(); slotViews.OnChange += OnSlotViewChanged; logCount.OnChange += OnLogCountChanged; } /// /// Server-side: seeds one empty SlotView per slot so clients receive a sized collection. /// 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; } /// /// Client-side: catches up on the current state (models, logs, fire) on join. /// public override void OnStartClient() { base.OnStartClient(); for (int i = 0; i < slotViews.Count; i++) ApplySlotView(i); UpdateLogVisuals(); SetFireVisuals(ClientIsBurning()); } /// /// Unsubscribes — mirrors OnStartNetwork exactly. /// public override void OnStopNetwork() { base.OnStopNetwork(); slotViews.OnChange -= OnSlotViewChanged; logCount.OnChange -= OnLogCountChanged; } #endregion #region IInteractable /// /// 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. /// 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; } } /// /// 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. /// 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 /// /// 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. /// 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); } /// /// 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. /// 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); } /// /// 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. /// 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 /// /// Server-side: places the food on the first free slot, or refunds it to the requester. /// [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 }; } /// /// Server-side: loads one log if there is room, or refunds the fuel. /// [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; } /// /// Server-side: clears the first cooked slot and grants its result to the requester. /// [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 /// /// True only while the fire genuinely burns: it has fuel and at least one item is still cooking. /// 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; } /// /// 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. /// 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; } /// /// Advances every cooking slot by the lit time and flips it to cooked (replicated) on completion. /// 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; } } } /// /// First slot with no food (server view), or -1 when every slot is occupied. /// private int ServerFirstEmptySlot() { for (int i = 0; i < rawItems.Length; i++) if (rawItems[i] == null) return i; return -1; } /// /// First slot holding finished food (server view), or -1 when none is ready. /// private int ServerFirstCookedSlot() { for (int i = 0; i < rawItems.Length; i++) if (rawItems[i] != null && slotViews[i].cooked) return i; return -1; } /// /// Grants an item back to the requesting player's (client-authoritative) inventory. /// private void GrantBack(NetworkConnection conn, ItemData item, int quantity) { if (item == null) return; PlayerInventory inv = ResolveInventory(conn); if (inv != null) inv.GrantItemFromServer(item, quantity); } /// /// Returns the PlayerInventory on the player object owned by the given connection. /// private PlayerInventory ResolveInventory(NetworkConnection conn) { NetworkObject playerObject = conn != null ? conn.FirstObject : null; return playerObject != null ? playerObject.GetComponent() : null; } #endregion #region Replication Handlers /// /// Re-applies a slot's model when its view changes, and fires the local "cooked" feedback. /// 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; } } /// /// Refreshes the log meshes and the fire visuals when the log count changes. /// private void OnLogCountChanged(int prev, int next, bool asServer) { UpdateLogVisuals(); SetFireVisuals(ClientIsBurning()); } #endregion #region View Helpers /// /// Shows the raw or cooked model (or clears it) for a slot from its replicated view. /// 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); } /// /// Reveals the first logCount meshes and hides the rest; only touches the scene on change. /// 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); } } /// /// Toggles the flame particles and the fire light to match the burning state. /// 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; } /// /// True (from replicated state) while the fire has fuel and at least one item is still cooking. /// 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 /// /// Returns the item the local player currently holds (selected hotbar slot), or null. /// private ItemData HeldItem() { PlayerInventory inv = PlayerInventory.Instance; if (inv == null) return null; InventorySlot slot = inv.GetSelectedSlot(); return slot != null && !slot.IsEmpty ? slot.ItemData : null; } /// /// First slot with no food (replicated view), or -1 when every slot is occupied. /// private int FirstEmptySlotIndex() { for (int i = 0; i < slotViews.Count; i++) if (slotViews[i].rawId == 0) return i; return -1; } /// /// First slot holding finished food (replicated view), or -1 when none is ready. /// private int FirstCookedSlotIndex() { for (int i = 0; i < slotViews.Count; i++) if (slotViews[i].rawId != 0 && slotViews[i].cooked) return i; return -1; } /// /// The cooked item a slot will yield (from its replicated raw id), or null. /// 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 } }