637 lines
22 KiB
C#
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
|
|
}
|
|
}
|