(Feat) Add Build

This commit is contained in:
2026-07-22 12:56:13 +02:00
parent 3657b870d8
commit 0052b0d98e
244 changed files with 35752 additions and 3112 deletions
+80 -52
View File
@@ -3,13 +3,41 @@ using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// Placement validity for a build ghost. Each frame BuildManager calls Evaluate with the
/// obstruction mask it owns (configured in one place, not per ghost prefab), which box-overlaps
/// the ghost's footprint against those layers (other structures, props, players — never the
/// ground it rests on) and shows the result by adding a valid or invalid overlay material on top
/// of each renderer's base materials (not by tinting them). BuildManager reads <see cref="IsValid"/>
/// to allow or block the build. The footprint collider is only a bounds source: the overlap test
/// reads its dimensions directly rather than relying on physics contacts.
/// Why a placement is or is not allowed. Produced in one place (BuildManager.EvaluatePlacement) and
/// consumed by both the ghost's look and the confirm gate, so the preview can never disagree with
/// what pressing the button actually does — the two used to be decided separately and could drift.
///
/// Splitting the failure into distinct reasons is the point: a single "invalid" state left the
/// player staring at a red ghost with no way to tell a blocked spot from an unpayable one.
/// </summary>
public enum PlacementVerdict
{
/// <summary>Buildable here, right now.</summary>
Valid,
/// <summary>The footprint overlaps something on the obstruction layers.</summary>
Blocked,
/// <summary>The structure requires a socket connection (PlacementSupport.SnapOnly) and has none.</summary>
Unsupported,
/// <summary>The spot itself is fine, but the player cannot pay the cost.</summary>
Unaffordable
}
/// <summary>
/// The visual state of a build ghost — a pure view over a <see cref="PlacementVerdict"/>.
///
/// It decides nothing: BuildManager owns the layer masks, runs the obstruction query and produces
/// the verdict, then hands it here to be rendered. (It used to run the overlap itself using a mask
/// passed in from outside, which mixed deciding with displaying and put the physics query away from
/// the data that configures it.)
///
/// The look is applied by ADDING an overlay material on top of each renderer's authored materials
/// rather than replacing them, so the model keeps its own look underneath the tint. The footprint
/// BoxCollider is exposed purely as a bounds source — it stays disabled on the prefab, because the
/// obstruction mask includes the layer the ghost itself sits on and an enabled collider would make
/// the preview report itself as blocked.
/// </summary>
[DisallowMultipleComponent]
public class BuildGhost : MonoBehaviour
@@ -17,47 +45,54 @@ namespace Ashwild.Building
#region Serialized Fields
[Header("Footprint")]
[Tooltip("Box whose bounds define the volume tested for obstructions. Sized in the prefab.")]
[Tooltip("Box whose bounds define the volume tested for obstructions. Sized in the prefab, and kept disabled — it is read as dimensions, never used for physics contacts.")]
[SerializeField] private BoxCollider footprint;
[Header("Overlay Materials")]
[Tooltip("Renderers the overlay is added onto. Auto-filled from children if left empty.")]
[SerializeField] private Renderer[] renderers;
[Tooltip("Material added on top of the base ones when the spot is buildable (green).")]
[Tooltip("Added on top of the base materials when the spot is buildable (green).")]
[SerializeField] private Material validOverlay;
[Tooltip("Material added on top of the base ones when the spot is blocked (red).")]
[Tooltip("Added for every reason the build is refused — blocked, unsupported or unaffordable.")]
[SerializeField] private Material invalidOverlay;
#endregion
#region State
/// <summary>
/// Whether the ghost is currently on a buildable spot (no obstruction overlap).
/// </summary>
public bool IsValid { get; private set; }
/// <summary>
/// Each renderer's original materials, so the overlay can be appended without losing them.
/// </summary>
private Material[][] baseMaterials;
/// <summary>
/// The validity reflected by the currently applied overlay, so we only swap on a real change.
/// The overlay currently applied, so we only rebuild the material lists when it actually changes.
/// </summary>
private bool lastValid;
private Material lastOverlay;
private bool overlayApplied;
private readonly Collider[] overlapResults = new Collider[8];
#endregion
#region Public API
/// <summary>
/// The footprint volume, for BuildManager to read its centre and size when testing obstructions.
/// Null on a ghost authored without one, which BuildManager treats as "never obstructed".
/// </summary>
public BoxCollider Footprint => footprint;
#endregion
#region Unity Lifecycle
/// <summary>
/// Auto-collects renderers when none were assigned and caches their base materials.
/// Auto-collects renderers when none were assigned, caches their base materials, then shows the
/// valid overlay immediately so the ghost reads as a semi-transparent preview from the very first
/// frame — on every client, not just the owner. On a remote copy nobody runs the evaluation, so
/// without this the ghost would sit in its opaque base materials until (and unless) a validity
/// update lands; the owner's own evaluation overrides this on its next frame anyway.
/// </summary>
private void Awake()
{
@@ -67,53 +102,46 @@ namespace Ashwild.Building
baseMaterials = new Material[renderers.Length][];
for (int i = 0; i < renderers.Length; i++)
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
SetOverlay(validOverlay);
}
#endregion
#region Public API
#region Display
/// <summary>
/// Re-tests the footprint against the obstruction layers (passed in by BuildManager, which
/// owns the mask so it is configured in one place), swaps the overlay material when validity
/// changed, and returns the result. A ghost with no footprint is always considered valid.
/// Shows the look matching a verdict. The single entry point the owner drives every frame.
/// Every refusal shares the one invalid material — the verdict still distinguishes the reasons for
/// the confirm gate (and for anything that wants to surface them later), the preview just does not
/// colour-code them.
/// </summary>
public bool Evaluate(LayerMask obstructionMask)
{
IsValid = !IsObstructed(obstructionMask);
if (!overlayApplied || IsValid != lastValid)
{
ApplyOverlay(IsValid ? validOverlay : invalidOverlay);
lastValid = IsValid;
overlayApplied = true;
}
return IsValid;
}
#endregion
#region Internal
public void SetState(PlacementVerdict verdict)
=> SetOverlay(verdict == PlacementVerdict.Valid ? validOverlay : invalidOverlay);
/// <summary>
/// Box-overlaps the footprint against the obstruction layers, ignoring triggers.
/// Applies the shared valid/blocked look on a remote copy from the placer's replicated spot
/// validity (routed through BuildRegistry). Remotes only ever see these two states — the placer's
/// private "unaffordable" and "unsupported" reasons are never sent — so a teammate sees green on
/// a buildable spot and red otherwise, matching where the placer can actually build.
/// </summary>
private bool IsObstructed(LayerMask obstructionMask)
public void SetRemoteValidity(bool valid) => SetOverlay(valid ? validOverlay : invalidOverlay);
/// <summary>
/// Sets the current overlay, rebuilding the renderers' material lists only when it actually
/// changes — the single funnel every state path goes through, so the change-guard stays in one place.
/// </summary>
private void SetOverlay(Material overlay)
{
if (footprint == null) return false;
Transform t = footprint.transform;
Vector3 center = t.TransformPoint(footprint.center);
Vector3 halfExtents = Vector3.Scale(footprint.size, t.lossyScale) * 0.5f;
int count = Physics.OverlapBoxNonAlloc(center, halfExtents, overlapResults, t.rotation, obstructionMask, QueryTriggerInteraction.Ignore);
return count > 0;
if (overlayApplied && overlay == lastOverlay) return;
ApplyOverlay(overlay);
lastOverlay = overlay;
overlayApplied = true;
}
/// <summary>
/// Rebuilds each renderer's material list as "base materials + overlay", so the overlay
/// draws on top of the model instead of replacing it. Skips when no overlay is assigned.
/// Rebuilds each renderer's material list as "base materials + overlay", so the overlay draws on
/// top of the model instead of replacing it. Skips when no overlay is assigned.
/// </summary>
private void ApplyOverlay(Material overlay)
{
+165 -27
View File
@@ -67,6 +67,9 @@ namespace Ashwild.Building
[Tooltip("Layers that block a build (structures, props, players). Must NOT include the ground.")]
[SerializeField] private LayerMask obstructionMask;
[Tooltip("Metres shaved off each side of the footprint before testing obstructions. Connected pieces meet exactly flush, so without a small inset every snapped build would report its own neighbour as blocking. Keep it well under the thinnest structure.")]
[SerializeField] private float obstructionPadding = 0.03f;
[Header("Demolition")]
[Tooltip("Layers the demolition aim can hit to target a committed structure. Narrow this to the structures' layer so walls, roofs, etc. are the only demolishable hits.")]
[SerializeField] private LayerMask buildMask = ~0;
@@ -74,6 +77,10 @@ namespace Ashwild.Building
[Tooltip("Overlay material added on top of the aimed build while it is the demolition target (e.g. a translucent red 'about to break' pass). Should be a transparent/additive material since it draws over the model.")]
[SerializeField] private Material demolitionMaterial;
[Header("Hammer Wear")]
[Tooltip("Durability (uses) spent on the equipped hammer for each successful placement or demolition. Requires the hammer's ItemData to be uses-tracked (Has Uses + Max Uses). A depleted hammer blocks building/demolishing until repaired. Set to 0 for a hammer that never wears.")]
[SerializeField] private int hammerUsesPerAction = 1;
#endregion
#region State
@@ -98,9 +105,17 @@ namespace Ashwild.Building
/// The live networked ghost we own and drive, or null while idle or awaiting its spawn.
/// </summary>
private GameObject ghost;
private BuildGhost ghostValidity;
private BuildGhost ghostView;
private BuildSnapPoint[] ghostSnaps;
/// <summary>
/// Whether the ghost's current pose came from a socket connection rather than the free/grid aim.
/// Kept as state rather than recomputed because the pose and the validity check happen in two
/// steps, and because a frame whose aim ray hits nothing leaves the ghost — and so its
/// connection — exactly as it was.
/// </summary>
private bool ghostIsConnected;
/// <summary>
/// The ghost's current yaw for grid placement, stepped by the scroll wheel.
/// </summary>
@@ -128,7 +143,20 @@ namespace Ashwild.Building
/// </summary>
private int spawnToken;
/// <summary>
/// Last spot validity pushed to teammates (-1 = none sent yet, 0 = invalid, 1 = valid), so the
/// networked ghost overlay is only replicated when it actually flips — never every frame.
/// </summary>
private int lastSentValidity = -1;
private readonly Collider[] snapResults = new Collider[16];
private readonly Collider[] obstructionResults = new Collider[8];
/// <summary>
/// Floor for the padded half-extents, so a very thin piece (a floor slab a few centimetres deep)
/// cannot have the inset collapse its footprint to zero — or invert it — and stop testing at all.
/// </summary>
private const float MinHalfExtent = 0.01f;
/// <summary>
/// Reused buffer for the active build's cost lines, rebuilt and pushed to the crosshair each
@@ -163,7 +191,7 @@ namespace Ashwild.Building
{
PlayerEvents.BuildMenuOpenChanged += HandleMenuOpenChanged;
PlayerEvents.AttackPressed += HandleConfirm;
PlayerEvents.SecondaryUsePressed += HandleCancel;
PlayerEvents.BuildCancelRequested += HandleCancel;
PlayerEvents.HotbarScroll += HandleRotate;
PlayerEvents.BuildRotatePressed += HandleRotateKey;
PlayerEvents.DemolishModeChanged += HandleDemolishModeChanged;
@@ -177,7 +205,7 @@ namespace Ashwild.Building
{
PlayerEvents.BuildMenuOpenChanged -= HandleMenuOpenChanged;
PlayerEvents.AttackPressed -= HandleConfirm;
PlayerEvents.SecondaryUsePressed -= HandleCancel;
PlayerEvents.BuildCancelRequested -= HandleCancel;
PlayerEvents.HotbarScroll -= HandleRotate;
PlayerEvents.BuildRotatePressed -= HandleRotateKey;
PlayerEvents.DemolishModeChanged -= HandleDemolishModeChanged;
@@ -205,7 +233,7 @@ namespace Ashwild.Building
if (PlayerEvents.InputLocked) return;
UpdateGhostPose();
if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask);
EvaluateGhost();
}
#endregion
@@ -306,22 +334,107 @@ namespace Ashwild.Building
}
ghost = go;
ghostValidity = go.GetComponentInChildren<BuildGhost>(true);
ghostView = go.GetComponentInChildren<BuildGhost>(true);
ghostSnaps = go.GetComponentsInChildren<BuildSnapPoint>(true);
ResolveAim();
lastSentValidity = -1;
ghostIsConnected = false;
placementBeganFrame = Time.frameCount;
UpdateGhostPose();
if (ghostValidity != null) ghostValidity.Evaluate(obstructionMask);
EvaluateGhost();
}
/// <summary>
/// Pushes the current verdict into the ghost's look each frame. No-op when no ghost is live.
/// </summary>
private void EvaluateGhost()
{
if (ghostView == null) return;
PlacementVerdict verdict = EvaluatePlacement();
ghostView.SetState(verdict);
ReplicateValidity(verdict == PlacementVerdict.Valid);
}
/// <summary>
/// The single answer to "can the active structure be committed at the ghost's current pose?",
/// used both to tint the preview and to gate the confirm — so what the player sees and what the
/// button does can never disagree. It is recomputed on demand rather than cached, since a cached
/// verdict would be one frame stale depending on the input/Update order.
///
/// The reasons are tested most-spatial first: a structure that needs a connection and has none is
/// reported before an obstruction, and both before affordability, so the player is always told
/// about the problem that moving the mouse can actually fix.
///
/// A CONNECTED ghost skips the obstruction test entirely: the socket already decided where the
/// piece belongs, and real modular geometry interpenetrates far more than any tolerance can absorb
/// (a wall's head runs up into the ceiling it supports, a frame overlaps the slab it sits in).
/// Shrinking the footprint was tried and is not enough — the only rule that holds is that an
/// authored connection IS the validity check. Free placement still tests obstructions normally,
/// so the padding below continues to matter there.
/// </summary>
private PlacementVerdict EvaluatePlacement()
{
if (active == null) return PlacementVerdict.Blocked;
if (active.Support == PlacementSupport.SnapOnly && !ghostIsConnected)
return PlacementVerdict.Unsupported;
if (!ghostIsConnected && IsFootprintObstructed()) return PlacementVerdict.Blocked;
if (!active.CanAfford(PlayerInventory.Instance)) return PlacementVerdict.Unaffordable;
return PlacementVerdict.Valid;
}
/// <summary>
/// Box-overlaps the ghost's footprint against the obstruction layers, ignoring triggers. The box
/// is shrunk by <see cref="obstructionPadding"/> on every side: snapped pieces meet exactly flush
/// by construction, so an un-inset test would report the very neighbour the player is building
/// against. The inset is small enough that a real intruder inside the volume — a chest, a player,
/// another structure — is still caught. A ghost without a footprint is never obstructed.
/// </summary>
private bool IsFootprintObstructed()
{
BoxCollider footprint = ghostView != null ? ghostView.Footprint : null;
if (footprint == null) return false;
Transform t = footprint.transform;
Vector3 center = t.TransformPoint(footprint.center);
Vector3 halfExtents = Vector3.Scale(footprint.size, t.lossyScale) * 0.5f;
halfExtents = Vector3.Max(halfExtents - Vector3.one * obstructionPadding, Vector3.one * MinHalfExtent);
int count = Physics.OverlapBoxNonAlloc(center, halfExtents, obstructionResults, t.rotation, obstructionMask, QueryTriggerInteraction.Ignore);
return count > 0;
}
/// <summary>
/// Pushes the ghost's spot validity to teammates through BuildRegistry, but only when it flips —
/// so a remote ghost shows the same green/red as here without a per-frame RPC stream. Affordability
/// stays local (the placer's private inventory), so only IsValid crosses the wire.
/// </summary>
private void ReplicateValidity(bool valid)
{
int state = valid ? 1 : 0;
if (state == lastSentValidity) return;
lastSentValidity = state;
if (BuildRegistry.Instance != null && ghost != null)
BuildRegistry.Instance.SetGhostValidity(ghost.GetComponent<NetworkObject>(), valid);
}
/// <summary>
/// Left-click: demolishes the aimed build while in demolition mode, otherwise commits the
/// 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.
/// ghost's pose to the registry (spawned for everyone), spends a use of the equipped hammer and
/// consumes the 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.
///
/// Placement itself is gated by the one verdict the preview is already showing, so the button can
/// never accept what the ghost paints as refused. The remaining guards are input/session
/// conditions rather than placement rules — input locked, the very frame the ghost was attached,
/// no registry in the session, and a hammer out of durability.
/// </summary>
private void HandleConfirm()
{
@@ -334,11 +447,11 @@ namespace Ashwild.Building
if (ghost == null) return;
if (PlayerEvents.InputLocked) return;
if (Time.frameCount == placementBeganFrame) return;
if (ghostValidity != null && !ghostValidity.IsValid) return;
if (active != null && !active.CanAfford(PlayerInventory.Instance)) return;
if (EvaluatePlacement() != PlacementVerdict.Valid) return;
if (BuildRegistry.Instance == null) return;
if (!TryConsumeHammerUse()) return;
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
BuildRegistry.Instance.RequestBuild(activeId, ghost.transform.position, ghost.transform.rotation);
ConsumeCost();
@@ -352,7 +465,9 @@ namespace Ashwild.Building
}
/// <summary>
/// Right-click: cancels the current placement (even while the ghost is still spawning).
/// The hammer's cancel request (a right-click tap while placing): cancels the current placement
/// (even while the ghost is still spawning). Routed through the bus so this stays the single
/// place that ends a placement — the hammer is the sole arbiter of the click.
/// </summary>
private void HandleCancel()
{
@@ -388,7 +503,7 @@ namespace Ashwild.Building
/// <summary>
/// Poses the ghost each frame: quantise the aim hit to the grid with the current yaw, then
/// let a nearby matching socket pull it into an exact connection (sockets win over the grid).
/// Leaves the ghost where it was when the ray hits nothing in range.
/// Leaves the ghost where it was — connection included — when the ray hits nothing in range.
/// </summary>
private void UpdateGhostPose()
{
@@ -397,7 +512,7 @@ namespace Ashwild.Building
return;
ghost.transform.SetPositionAndRotation(SnapToGrid(hit.point), Quaternion.Euler(0f, yaw, 0f));
TrySnapToSockets();
ghostIsConnected = TrySnapToSockets();
}
/// <summary>
@@ -419,11 +534,14 @@ namespace Ashwild.Building
/// coincides. Because it commits to the single nearest ghost-socket↔world-socket pair (not the
/// socket nearest the aim), the ghost picks one connection instead of hovering between two.
/// Rotation stays the player's grid yaw, so pieces meet edge-to-edge once oriented.
///
/// Returns whether a connection was actually made, which is what lets the caller trust the pose
/// and skip the obstruction test for it.
/// </summary>
private void TrySnapToSockets()
private bool TrySnapToSockets()
{
if (ghostSnaps == null || ghostSnaps.Length == 0) return;
if (snapPointMask == 0) return;
if (ghostSnaps == null || ghostSnaps.Length == 0) return false;
if (snapPointMask == 0) return false;
BuildSnapPoint bestGhost = null;
BuildSnapPoint bestWorld = null;
@@ -451,9 +569,10 @@ namespace Ashwild.Building
}
}
if (bestGhost == null || bestWorld == null) return;
if (bestGhost == null || bestWorld == null) return false;
ghost.transform.position += bestWorld.transform.position - bestGhost.transform.position;
return true;
}
/// <summary>
@@ -505,8 +624,10 @@ namespace Ashwild.Building
BuildRegistry.Instance.RequestDespawnGhost(ghost.GetComponent<NetworkObject>());
ghost = null;
}
ghostValidity = null;
ghostView = null;
ghostSnaps = null;
lastSentValidity = -1;
ghostIsConnected = false;
}
/// <summary>
@@ -638,14 +759,31 @@ namespace Ashwild.Building
}
/// <summary>
/// Left-click while demolishing: asks the registry to remove the aimed build for everyone. The
/// target stays until the next frame re-evaluates, so a miss simply retargets.
/// Left-click while demolishing: spends a hammer use and asks the registry to remove the aimed
/// build for everyone (which refunds resources by remaining health). Blocked when the hammer is
/// out of durability. The target stays until the next frame re-evaluates, so a miss simply
/// retargets.
/// </summary>
private void TryDemolish()
{
if (demoTarget == null) return;
if (BuildRegistry.Instance != null)
BuildRegistry.Instance.RequestDemolish(demoTarget.gameObject);
if (!TryConsumeHammerUse()) return;
demoTarget.RequestDemolish();
}
/// <summary>
/// Spends the configured durability on the equipped hammer for one build action, returning
/// whether the action may proceed. A depleted hammer returns false so the placement/demolition
/// is blocked (like any worn-out tool); a hammer with no durability authored, a zero cost, or no
/// inventory simply passes without wear. The hammer is the selected hotbar item while building,
/// so spending the selected tool's use targets it.
/// </summary>
private bool TryConsumeHammerUse()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return true;
return inv.ConsumeSelectedToolUse(hammerUsesPerAction);
}
/// <summary>
@@ -1,16 +0,0 @@
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// A committed structure in the world, replicated in the BuildRegistry's SyncList: which
/// buildable (network id) sits at which pose. Every machine instantiates the matching
/// BuiltPrefab locally from this record — the structures themselves are never NetworkObjects (§3).
/// </summary>
public struct BuildRecord
{
public ushort buildableId;
public Vector3 position;
public Quaternion rotation;
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 1554bdf5a966a1947bbe0d2855967590
+65 -204
View File
@@ -1,25 +1,29 @@
using System.Collections.Generic;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// Owns the whole networked side of building, grouped here by cohesion rather than split into
/// two tiny scripts:
/// The networked side of building: it commits placements and owns the live ghost preview.
///
/// 1. Committed structures — a SyncList of BuildRecords (id + pose). They accumulate to high
/// counts, so they are data-driven, not NetworkObjects (§3): every machine (late joiners
/// included) instantiates the matching BuiltPrefab locally from the synced list.
/// 2. The live ghost preview — spawned as a real NetworkObject owned by the placer, so FishNet's
/// NetworkTransform replicates its motion to everyone smoothly. A ghost is one-per-player and
/// transient, the case where a NetworkObject beats a data registry — the deliberate exception
/// to §3's "no NetworkObjects for world state" (which targets high-count scatter, not this).
/// Every committed structure — a wall as much as a chest or a campfire — is spawned as a real
/// NetworkObject owned by the server. That is the whole design: FishNet already replicates spawns,
/// catches late joiners up, and tears objects down on despawn, so there is no build list to keep,
/// no index to align, and no rebuild path to write. Each build carries its own state on its own
/// <see cref="BuiltStructure"/>, which is what lets a stateful structure (chest, cooking station)
/// be placed by exactly the same code as a plain wall instead of needing a parallel mechanism.
///
/// BuildManager (the local build UX) drives the owned ghost; on confirm this registry commits the
/// record and despawns the ghost. One instance per scene, a scene NetworkObject like the others.
/// This replaced an earlier data-driven registry (a SyncList of pose records that every client
/// instantiated locally). That saved FishNet's per-object bookkeeping but not the GameObjects —
/// they were instantiated locally anyway — while forcing a second, incompatible path for anything
/// with real state. One mechanism is worth the bookkeeping.
///
/// The ghost is likewise a NetworkObject, but owned by the placer so FishNet's NetworkTransform
/// replicates its motion smoothly; it is transient and one per player.
///
/// BuildManager (the local build UX) drives the owned ghost; on confirm this registry spawns the
/// structure and despawns the ghost. One instance per scene, a scene NetworkObject like the others.
/// </summary>
[RequireComponent(typeof(NetworkObject))]
public class BuildRegistry : NetworkBehaviour
@@ -27,50 +31,31 @@ namespace Ashwild.Building
#region State
/// <summary>
/// The single scene instance, so BuildManager can request ghost spawns/despawns and build
/// commits.
/// The single scene instance, so BuildManager can request ghost spawns/despawns and build commits.
/// </summary>
public static BuildRegistry Instance { get; private set; }
/// <summary>
/// Every committed structure, server-written and replicated to all clients.
/// </summary>
private readonly SyncList<BuildRecord> builds = new SyncList<BuildRecord>();
/// <summary>
/// The local instances rendered from <see cref="builds"/>, kept index-aligned with it.
/// </summary>
private readonly List<GameObject> spawned = new List<GameObject>();
/// <summary>
/// How close two sockets must be to count as connected — they snap to the exact same point,
/// so a tiny threshold is enough and no layer/radius has to be configured.
/// </summary>
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
#endregion
#region Network Lifecycle
/// <summary>
/// Starts listening to the replicated build list on every machine.
/// Claims the singleton on every machine.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
Instance = this;
builds.OnChange += OnBuildsChanged;
}
/// <summary>
/// Unsubscribes and tears down the local instances when the session ends.
/// Releases the singleton when the session ends. Committed structures are NetworkObjects, so
/// FishNet despawns them itself — there is nothing to tear down here.
/// </summary>
public override void OnStopNetwork()
{
base.OnStopNetwork();
if (Instance == this) Instance = null;
builds.OnChange -= OnBuildsChanged;
ClearAll();
}
#endregion
@@ -78,60 +63,45 @@ namespace Ashwild.Building
#region Commit Build
/// <summary>
/// Called by BuildManager when the local player confirms a placement: commit this buildable
/// at this pose so the real structure appears for every player.
/// Called by BuildManager when the local player confirms a placement: commit this buildable at
/// this pose so the real structure appears for every player.
/// </summary>
public void RequestBuild(ushort buildableId, Vector3 position, Quaternion rotation)
=> RequestBuildServerRpc(buildableId, position, rotation);
/// <summary>
/// Server-side: validates the id and appends the record, which replicates to all clients.
/// Server-side: validates the id, instantiates the built prefab, seeds its identity and health,
/// then spawns it for everyone. The appear pop is sent separately to current observers only, so a
/// late joiner streaming in an existing base does not watch all of it pop at once.
///
/// The structure is spawned server-owned (no connection passed): builds are shared world state
/// and several players interact with the same chest or fire, so handing ownership to whoever
/// placed it would tie everyone's access to that player's connection.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestBuildServerRpc(ushort buildableId, Vector3 position, Quaternion rotation, NetworkConnection conn = null)
{
if (BuildableDatabase.Instance == null || BuildableDatabase.Instance.GetBuildable(buildableId) == null)
return;
// TODO: validate + consume the requester's resources (conn → PlayerInventory) before committing.
builds.Add(new BuildRecord { buildableId = buildableId, position = position, rotation = rotation });
}
#endregion
#region Demolish Build
/// <summary>
/// Called by BuildManager when the local player demolishes a build they aimed at: resolves the
/// hit instance to its index in the replicated list and asks the server to remove it. The index
/// is a valid shared key because <see cref="spawned"/> stays aligned with <see cref="builds"/>
/// on every machine. No-op when the object is not one of our committed builds.
/// </summary>
public void RequestDemolish(GameObject builtInstance)
{
if (builtInstance == null) return;
int index = spawned.IndexOf(builtInstance);
if (index < 0)
BuildableData data = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetBuildable(buildableId) : null;
if (data == null || data.BuiltPrefab == null)
{
Debug.LogWarning($"[BuildRegistry] '{builtInstance.name}' is not a committed build — nothing to demolish.", this);
Debug.LogWarning($"[BuildRegistry] Buildable id {buildableId} is unknown or has no BuiltPrefab — nothing spawned.", this);
return;
}
RequestDemolishServerRpc(index);
}
/// <summary>
/// Server-side: removes the record at this index, which replicates the removal (and the local
/// teardown + occupancy refresh) to every client. Range-guarded against a stale index from a
/// build that another player demolished during the round-trip.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestDemolishServerRpc(int index, NetworkConnection conn = null)
{
if (index < 0 || index >= builds.Count) return;
// TODO: validate + consume the requester's resources (conn → PlayerInventory) before committing.
GameObject go = Instantiate(data.BuiltPrefab, position, rotation);
// TODO: refund the builder's resources (conn → PlayerInventory) once build cost lands.
builds.RemoveAt(index);
BuiltStructure structure = go.GetComponent<BuiltStructure>();
if (structure == null)
{
Debug.LogError($"[BuildRegistry] BuiltPrefab '{data.BuiltPrefab.name}' has no BuiltStructure — add one (it carries the build's identity, health and demolition).", this);
Destroy(go);
return;
}
structure.InitialiseOnServer(buildableId, data.MaxHealth);
base.ServerManager.Spawn(go);
structure.PlaySpawnBumpForObservers();
}
#endregion
@@ -193,147 +163,38 @@ namespace Ashwild.Building
base.ServerManager.Despawn(ghost, DespawnType.Destroy);
}
#endregion
#region Sync Local Instances
/// <summary>
/// Mirrors the replicated build list into local instances. The host processes only the
/// server callback (asServer) so a build is never instantiated twice on the same machine.
/// Owner → replicates the ghost's current spot validity to every other client so teammates see
/// the same green (clear) / red (blocked) preview. Only the owner drives the evaluation locally,
/// so without this a remote ghost stays on its default look; BuildManager calls this only when the
/// value actually changes, so the traffic is a rare per-ghost blip, not a per-frame stream.
/// </summary>
private void OnBuildsChanged(SyncListOperation op, int index, BuildRecord oldItem, BuildRecord newItem, bool asServer)
public void SetGhostValidity(NetworkObject ghost, bool valid)
{
if (!asServer && base.IsServerInitialized) return;
switch (op)
{
case SyncListOperation.Add:
case SyncListOperation.Insert:
{
GameObject go = CreateInstance(newItem);
spawned.Insert(index, go);
MarkOccupancy(go);
break;
}
case SyncListOperation.Set:
DestroyAt(index);
spawned[index] = CreateInstance(newItem);
break;
case SyncListOperation.RemoveAt:
DestroyAt(index);
spawned.RemoveAt(index);
RecomputeOccupancy();
break;
case SyncListOperation.Clear:
ClearAll();
break;
case SyncListOperation.Complete:
RebuildAll();
break;
}
if (ghost != null) SetGhostValidityServerRpc(ghost, valid);
}
/// <summary>
/// Instantiates a record's BuiltPrefab at its pose, or null when the id or prefab is missing.
/// Server-side relay: forwards the placer's validity to all observers.
/// </summary>
private GameObject CreateInstance(BuildRecord record)
[ServerRpc(RequireOwnership = false)]
private void SetGhostValidityServerRpc(NetworkObject ghost, bool valid, NetworkConnection conn = null)
{
BuildableData data = BuildableDatabase.Instance != null ? BuildableDatabase.Instance.GetBuildable(record.buildableId) : null;
if (data == null || data.BuiltPrefab == null)
{
Debug.LogWarning($"[BuildRegistry] Buildable id {record.buildableId} introuvable ou sans BuiltPrefab — rien de spawné.", this);
return null;
}
GameObject go = Instantiate(data.BuiltPrefab, record.position, record.rotation);
if (go.GetComponent<BuiltStructure>() == null) go.AddComponent<BuiltStructure>();
return go;
if (ghost == null) return;
ObserversSetGhostValidity(ghost, valid);
}
/// <summary>
/// Destroys the local instance at an index without touching the list bookkeeping.
/// Applies the replicated validity on every client except the placer, whose own local evaluation
/// already drives the richer verdict (and knows about affordability, which is never sent). A
/// missing ghost is ignored — it may have been despawned mid-flight.
/// </summary>
private void DestroyAt(int index)
[ObserversRpc]
private void ObserversSetGhostValidity(NetworkObject ghost, bool valid)
{
if (index < 0 || index >= spawned.Count) return;
if (spawned[index] != null) Destroy(spawned[index]);
}
/// <summary>
/// Destroys every local instance and clears the list.
/// </summary>
private void ClearAll()
{
foreach (GameObject go in spawned)
if (go != null) Destroy(go);
spawned.Clear();
}
/// <summary>
/// Rebuilds all local instances from the current list — used for the late-join catch-up —
/// then recomputes which sockets are connected so late joiners see the same occupancy.
/// </summary>
private void RebuildAll()
{
ClearAll();
for (int i = 0; i < builds.Count; i++)
spawned.Add(CreateInstance(builds[i]));
foreach (GameObject go in spawned)
MarkOccupancy(go);
}
/// <summary>
/// Recomputes occupancy from scratch after a removal: clears every socket, then re-marks the
/// connected pairs across what remains. Without this a demolished piece would leave its former
/// neighbours' sockets stuck "occupied", so nothing could ever be snapped back into the gap.
/// </summary>
private void RecomputeOccupancy()
{
foreach (GameObject go in spawned)
{
if (go == null) continue;
foreach (BuildSnapPoint sp in go.GetComponentsInChildren<BuildSnapPoint>(true))
sp.SetOccupied(false);
}
foreach (GameObject go in spawned)
MarkOccupancy(go);
}
/// <summary>
/// Marks the sockets a piece shares a position with — and the matching sockets on the pieces
/// it touches — as occupied, so placement snapping skips them. Occupancy is derived purely
/// from geometry (two connected sockets sit on the exact same point), so it stays consistent
/// on every client without anything extra crossing the wire.
/// </summary>
private void MarkOccupancy(GameObject instance)
{
if (instance == null) return;
BuildSnapPoint[] newSockets = instance.GetComponentsInChildren<BuildSnapPoint>(true);
if (newSockets.Length == 0) return;
foreach (GameObject other in spawned)
{
if (other == null || other == instance) continue;
foreach (BuildSnapPoint os in other.GetComponentsInChildren<BuildSnapPoint>(true))
{
foreach (BuildSnapPoint ns in newSockets)
{
if (ns.Category != os.Category) continue;
if ((ns.transform.position - os.transform.position).sqrMagnitude > OccupancyThresholdSqr) continue;
ns.SetOccupied(true);
os.SetOccupied(true);
}
}
}
if (ghost == null || ghost.IsOwner) return;
BuildGhost bg = ghost.GetComponent<BuildGhost>();
if (bg != null) bg.SetRemoteValidity(valid);
}
#endregion
+31 -7
View File
@@ -24,8 +24,9 @@ namespace Ashwild.Building
/// of the ghost, so the ghost never snaps to itself.
///
/// Once another piece connects here the socket is marked occupied — the placement snapping skips
/// occupied sockets (so two pieces never stack on the same connection) and its gizmo turns from
/// blue (free) to red (occupied) in the Scene view.
/// occupied sockets (so two pieces never stack on the same connection) and its gizmo turns red
/// in the Scene view; while free it takes the colour of its category, so a glance at a prefab
/// tells which sockets can ever link together.
/// </summary>
[DisallowMultipleComponent]
public class BuildSnapPoint : MonoBehaviour
@@ -61,19 +62,42 @@ namespace Ashwild.Building
#region Gizmos
private static readonly Color OccupiedColor = new Color(1f, 0.3f, 0.3f, 0.9f);
private static readonly Color FloorColor = new Color(0.3f, 0.8f, 1f, 0.9f);
private static readonly Color WallColor = new Color(0.4f, 1f, 0.5f, 0.9f);
private static readonly Color RoofColor = new Color(1f, 0.75f, 0.25f, 0.9f);
private static readonly Color PillarColor = new Color(0.8f, 0.5f, 1f, 0.9f);
private static readonly Color CustomColor = new Color(1f, 1f, 1f, 0.9f);
/// <summary>
/// Draws the socket in the editor: a sphere (blue when free, red once occupied) plus a ray
/// along its forward (Z) axis — the direction the connecting piece attaches. Two sockets link
/// only when their forwards face each other, so orient each empty's blue arrow outward toward
/// where the neighbour should sit.
/// Draws the socket in the editor: a sphere plus a ray along its forward (Z) axis — the
/// direction the connecting piece attaches. Two sockets link only when their forwards face
/// each other, so orient each empty's arrow outward toward where the neighbour should sit.
/// </summary>
private void OnDrawGizmos()
{
Gizmos.color = occupied ? new Color(1f, 0.3f, 0.3f, 0.9f) : new Color(0.3f, 0.8f, 1f, 0.9f);
Gizmos.color = occupied ? OccupiedColor : GetCategoryColor(category);
Gizmos.DrawWireSphere(transform.position, 0.12f);
Gizmos.DrawRay(transform.position, transform.forward * 0.35f);
}
/// <summary>
/// Maps a connection category to its Scene-view colour. Sockets that can link share a
/// category, so same-coloured gizmos are exactly the ones that may ever snap together —
/// mismatched colours on two pieces mean they will never connect.
/// </summary>
private static Color GetCategoryColor(SnapCategory value)
{
switch (value)
{
case SnapCategory.Floor: return FloorColor;
case SnapCategory.Wall: return WallColor;
case SnapCategory.Roof: return RoofColor;
case SnapCategory.Pillar: return PillarColor;
default: return CustomColor;
}
}
#endregion
}
}
@@ -3,6 +3,25 @@ using Ashwild.Inventory;
namespace Ashwild.Building
{
/// <summary>
/// What a buildable is allowed to rest on. This is the one placement rule that genuinely differs
/// per structure — a foundation sits on terrain, a wall or a ceiling only makes sense attached to
/// something — so it is authored here rather than configured globally on BuildManager (which keeps
/// owning the project-wide layer masks: what blocks a build does not vary per build).
///
/// <see cref="GroundOrSnap"/> is deliberately the zero value: Unity deserializes a missing field to
/// 0, so buildables authored before this rule existed keep their old, permissive behaviour instead
/// of silently becoming unplaceable.
/// </summary>
public enum PlacementSupport
{
/// <summary>Free placement anywhere the aim lands, or connected to a socket. Foundations, floors.</summary>
GroundOrSnap,
/// <summary>Only valid while connected to a matching socket. Walls, ceilings, roofs.</summary>
SnapOnly
}
/// <summary>
/// Authoring data for one buildable structure shown in the construction menu (a wall, a
/// floor, a door, ...). Mirrors the ScriptableObject-for-data / MonoBehaviour-for-logic
@@ -35,6 +54,14 @@ namespace Ashwild.Building
[Tooltip("Resources consumed from the builder's inventory on each placement. Leave empty for a free build.")]
[SerializeField] private BuildCost[] cost;
[Header("Placement")]
[Tooltip("Where this structure may be committed. Walls, ceilings and roofs should be Snap Only so they cannot be dropped in mid-air; foundations and floors stay Ground Or Snap.")]
[SerializeField] private PlacementSupport support = PlacementSupport.GroundOrSnap;
[Header("Health")]
[Tooltip("Hit points the placed structure starts with. Damage subtracts from this (server-authoritative in BuildRegistry); at 0 the build is destroyed. A manual demolition refunds the cost in proportion to the health left.")]
[SerializeField] private float maxHealth = 100f;
#endregion
#region Public API
@@ -45,6 +72,8 @@ namespace Ashwild.Building
public GameObject GhostPrefab => ghostPrefab;
public GameObject BuiltPrefab => builtPrefab;
public BuildCost[] Cost => cost;
public float MaxHealth => maxHealth;
public PlacementSupport Support => support;
/// <summary>
/// Whether the given inventory holds enough of every cost line to place this structure once.
+310 -18
View File
@@ -1,34 +1,106 @@
using System.Collections.Generic;
using Ashwild.Inventory;
using DG.Tweening;
using FishNet.Connection;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using UnityEngine;
namespace Ashwild.Building
{
/// <summary>
/// Runtime marker added to every committed structure instance the BuildRegistry spawns, so the
/// demolition aim can recognise a build from a raycast hit (GetComponentInParent) and hand its
/// root back to the registry to remove. It also owns the demolition highlight: while it is the
/// hammer's target the demolition material is added on top of every renderer (an extra overlay
/// pass, like the ghost), and removed again when the aim leaves — so the player sees exactly which
/// build will break. Pure local visual + a pure tag: nothing about it ever crosses the wire.
/// The one component every committed structure carries — wall, floor, chest, campfire alike. Since
/// builds are spawned as real NetworkObjects, each one is self-contained: it knows what it is, owns
/// its own server-authoritative health, and handles its own damage and demolition. There is no
/// central list of builds and no index to keep aligned; the object IS the record.
///
/// It groups everything that changes together when a placed build's behaviour changes:
/// 1. Identity — <see cref="BuildableId"/>, replicated so any machine can resolve the BuildableData
/// behind this instance (the refund needs its cost, UI needs its name).
/// 2. Health and damage — server-authoritative. <see cref="TakeDamage"/> is what an attacker calls;
/// the server applies it and despawns the build for everyone when it dies (no refund — it broke).
/// 3. Demolition — <see cref="RequestDemolish"/> refunds a share of the cost proportional to the
/// health left, then despawns.
/// 4. Presentation — the appear bump and the demolition highlight, both purely local.
/// 5. Snap occupancy — every live instance registers here, so connected sockets can be marked
/// without anyone holding a master list of builds.
///
/// Health never crosses the wire: only its observable effects do (the build disappearing, resources
/// refunded), which replicate through the despawn and the inventory RPC respectively.
/// </summary>
[DisallowMultipleComponent]
public class BuiltStructure : MonoBehaviour
[RequireComponent(typeof(NetworkObject))]
public class BuiltStructure : NetworkBehaviour
{
#region Serialized Fields
[Header("Appear Bump")]
[Tooltip("How long the pop lasts — short and snappy reads as an 'appear' effect.")]
[SerializeField] private float bumpDuration = DefaultBumpDuration;
[Tooltip("Fraction of the authored scale the bump starts from (0.7 = starts at 70%, then springs up).")]
[SerializeField] private float startScaleFactor = DefaultStartScaleFactor;
[Tooltip("OutBack gives the springy overshoot that sells the 'boom'.")]
[SerializeField] private Ease bumpEase = Ease.OutBack;
#endregion
#region Constants
/// <summary>
/// Fallback bump values, used both as the field initializers and as the guard defaults in
/// <see cref="PlaySpawnBump"/> — so a build whose prefab predates these serialized fields
/// (Unity deserializes the missing values to 0) still pops instead of snapping in.
/// </summary>
private const float DefaultBumpDuration = 0.22f;
private const float DefaultStartScaleFactor = 0.7f;
/// <summary>
/// How close two sockets must be to count as connected — they snap to the exact same point, so a
/// tiny threshold is enough and no layer or radius has to be configured.
/// </summary>
private const float OccupancyThresholdSqr = 0.05f * 0.05f;
#endregion
#region State
/// <summary>
/// Every child renderer, cached so the highlight can swap and restore their materials.
/// Every live built structure on this machine. Maintained here rather than in a central registry
/// so occupancy works off the objects themselves — the network spawns and despawns them, and the
/// list follows automatically.
/// </summary>
private static readonly List<BuiltStructure> Live = new List<BuiltStructure>();
/// <summary>
/// Which buildable this instance was placed from, so the refund can look up its cost. Written by
/// the server before the spawn, so it is already correct on the first frame everywhere.
/// </summary>
private readonly SyncVar<ushort> buildableId = new SyncVar<ushort>();
/// <summary>
/// Server-only remaining health, seeded from the buildable's authored max at spawn.
/// </summary>
private float health;
private Renderer[] renderers;
/// <summary>
/// Each renderer's original materials, kept so the swap is fully reversible.
/// </summary>
private Material[][] baseMaterials;
private bool highlighted;
private Tween bumpTween;
#endregion
#region Public API
public ushort BuildableId => buildableId.Value;
/// <summary>
/// Whether the demolition material is currently applied, so swaps stay idempotent.
/// The authoring data behind this instance, or null when the database cannot resolve its id.
/// </summary>
private bool highlighted;
public BuildableData Data => BuildableDatabase.Instance != null
? BuildableDatabase.Instance.GetBuildable(buildableId.Value)
: null;
#endregion
@@ -46,14 +118,234 @@ namespace Ashwild.Building
baseMaterials[i] = renderers[i] != null ? renderers[i].sharedMaterials : new Material[0];
}
/// <summary>
/// Kills the appear tween so it never targets a destroyed transform (e.g. the build was
/// demolished mid-pop).
/// </summary>
private void OnDestroy() => bumpTween?.Kill();
#endregion
#region Public API
#region Network Lifecycle
/// <summary>
/// Adds the demolition material on top of each renderer's authored materials (an extra draw
/// pass), marking this build as the hammer's current target without hiding the model. Mirrors
/// the ghost overlay. No-op when already highlighted or no material was provided.
/// Joins the live set and marks the sockets this piece connects to. Runs on every machine as the
/// object spawns — including on a late joiner receiving builds placed long ago, which is exactly
/// when occupancy has to be rebuilt for them too.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
Live.Add(this);
MarkOccupancy();
}
/// <summary>
/// Leaves the live set and recomputes occupancy across what remains. Without the recompute a
/// demolished piece would leave its former neighbours' sockets stuck "occupied", so nothing could
/// ever be snapped back into the gap.
/// </summary>
public override void OnStopNetwork()
{
base.OnStopNetwork();
Live.Remove(this);
RecomputeOccupancy();
}
#endregion
#region Server Setup
/// <summary>
/// Seeds identity and health on the server, before the object is spawned, so both are already in
/// place when clients first see it. Called by BuildRegistry as part of committing a placement.
/// </summary>
public void InitialiseOnServer(ushort id, float maxHealth)
{
buildableId.Value = id;
health = maxHealth > 0f ? maxHealth : 1f;
}
#endregion
#region Appear Bump
/// <summary>
/// Tells everyone currently watching to play the appear pop. Sent by the server right after a
/// fresh placement. Because it only reaches present observers, a late joiner streaming in dozens
/// of existing structures never receives it — so their base does not pop into existence all at
/// once, which is precisely the distinction the old "only on Add, never on rebuild" rule made.
/// </summary>
[ObserversRpc]
public void PlaySpawnBumpForObservers() => PlaySpawnBump();
/// <summary>
/// Runs the appear bump from the shrunk start scale up to the transform's current (authored)
/// scale. Captures the target at call time so a non-uniform or non-unit prefab scale is preserved.
/// Non-positive serialized values fall back to the constants so the pop still plays.
/// </summary>
public void PlaySpawnBump()
{
bumpTween?.Kill();
float duration = bumpDuration > 0f ? bumpDuration : DefaultBumpDuration;
float factor = (startScaleFactor > 0f && startScaleFactor < 1f) ? startScaleFactor : DefaultStartScaleFactor;
Vector3 targetScale = transform.localScale;
transform.localScale = targetScale * factor;
bumpTween = transform.DOScale(targetScale, duration).SetEase(bumpEase);
}
#endregion
#region Damage
/// <summary>
/// Called by an attacker (weapon, explosion, ...) to hurt this build. Health is server-
/// authoritative, so this only forwards the hit; the server applies it and despawns the structure
/// for everyone when it dies. A build destroyed by damage is NOT refunded, unlike a manual
/// demolition — it broke, its resources are lost. Safe to call from any client.
/// </summary>
public void TakeDamage(float amount)
{
if (amount <= 0f) return;
TakeDamageServerRpc(amount);
}
/// <summary>
/// Server-side: subtracts the damage and despawns the build once it reaches zero.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void TakeDamageServerRpc(float amount)
{
if (amount <= 0f) return;
health -= amount;
if (health <= 0f) Despawn();
}
#endregion
#region Demolition
/// <summary>
/// Called by BuildManager when the local player demolishes this build: asks the server to refund
/// and remove it. Any client may request it — co-op is friendly, not anti-cheat.
/// </summary>
public void RequestDemolish() => DemolishServerRpc();
/// <summary>
/// Server-side: refunds the demolisher a share of the cost proportional to the health left, then
/// despawns the structure for everyone.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void DemolishServerRpc(NetworkConnection conn = null)
{
RefundResources(conn);
Despawn();
}
/// <summary>
/// Grants the demolishing player back a share of this build's cost proportional to its remaining
/// health: a full-health build refunds its whole cost, a half-health one refunds half, and so on
/// (per line, floored — a partial unit is not returned). No-op for a free build, when the player's
/// inventory cannot be resolved, or when the fraction rounds every line down to nothing.
/// </summary>
private void RefundResources(NetworkConnection conn)
{
BuildableData data = Data;
if (data == null || data.Cost == null || data.Cost.Length == 0) return;
PlayerInventory inventory = ResolveInventory(conn);
if (inventory == null) return;
float max = data.MaxHealth;
float fraction = max > 0f ? Mathf.Clamp01(health / max) : 1f;
foreach (BuildCost line in data.Cost)
{
if (line.item == null) continue;
int refund = Mathf.FloorToInt(line.quantity * fraction);
if (refund > 0) inventory.GrantItemFromServer(line.item, refund);
}
}
/// <summary>
/// Returns the PlayerInventory on the player object owned by the given connection, mirroring
/// CookingStation so refunds land in the requester's own inventory.
/// </summary>
private static PlayerInventory ResolveInventory(NetworkConnection conn)
{
NetworkObject playerObject = conn != null ? conn.FirstObject : null;
return playerObject != null ? playerObject.GetComponent<PlayerInventory>() : null;
}
/// <summary>
/// Removes this structure for every player. Server-side only.
/// </summary>
private void Despawn()
{
if (base.IsServerInitialized) base.ServerManager.Despawn(base.NetworkObject, DespawnType.Destroy);
}
#endregion
#region Snap Occupancy
/// <summary>
/// Marks the sockets this piece shares a position with — and the matching sockets on the pieces it
/// touches — as occupied, so placement snapping skips them. Occupancy is derived purely from
/// geometry (two connected sockets sit on the exact same point), so it stays consistent on every
/// client without anything extra crossing the wire.
/// </summary>
private void MarkOccupancy()
{
BuildSnapPoint[] mine = GetComponentsInChildren<BuildSnapPoint>(true);
if (mine.Length == 0) return;
foreach (BuiltStructure other in Live)
{
if (other == null || other == this) continue;
foreach (BuildSnapPoint os in other.GetComponentsInChildren<BuildSnapPoint>(true))
{
foreach (BuildSnapPoint ms in mine)
{
if (ms.Category != os.Category) continue;
if ((ms.transform.position - os.transform.position).sqrMagnitude > OccupancyThresholdSqr) continue;
ms.SetOccupied(true);
os.SetOccupied(true);
}
}
}
}
/// <summary>
/// Recomputes occupancy from scratch across every live structure: clears every socket, then
/// re-marks the connected pairs across what remains. Run after a removal.
/// </summary>
private static void RecomputeOccupancy()
{
foreach (BuiltStructure structure in Live)
{
if (structure == null) continue;
foreach (BuildSnapPoint sp in structure.GetComponentsInChildren<BuildSnapPoint>(true))
sp.SetOccupied(false);
}
foreach (BuiltStructure structure in Live)
if (structure != null) structure.MarkOccupancy();
}
#endregion
#region Demolition Highlight
/// <summary>
/// Adds the demolition material on top of each renderer's authored materials (an extra draw pass),
/// marking this build as the hammer's current target without hiding the model. Mirrors the ghost
/// overlay. No-op when already highlighted or no material was provided.
/// </summary>
public void Highlight(Material demolitionMaterial)
{
@@ -81,10 +81,12 @@ namespace Ashwild.Crafting
#region Event Handlers
/// <summary>
/// Marks an acquired item as discovered. Guarded to the owning instance so a remote puppet
/// that briefly subscribed before being gated off can never drive discovery on the wrong copy.
/// Marks an acquired item as discovered. Transfers count too — pulling an item a co-op partner
/// left in a chest is a genuine first acquisition and must unlock its recipes, even though the
/// HUD stays silent about it. Guarded to the owning instance so a remote puppet that briefly
/// subscribed before being gated off can never drive discovery on the wrong copy.
/// </summary>
private void HandleItemAdded(ItemData item, int quantity)
private void HandleItemAdded(ItemData item, int quantity, bool isTransfer)
{
if (Instance != this) return;
Discover(item);
@@ -0,0 +1,121 @@
using System.Diagnostics;
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
using Ashwild.Building;
using Debug = UnityEngine.Debug;
namespace Ashwild.EditorTools
{
/// <summary>
/// A load test for the decision to spawn every build as a NetworkObject: places a grid of structures
/// and reports how long it took and what it cost in memory, so the "can FishNet carry a whole base"
/// question can be answered with numbers instead of estimates.
///
/// Deliberately single-instance. Measuring a second client's join bandwidth would mean two peers, and
/// the session is wired to FishySteamworks — that costs far more setup than the risk it covers. Join
/// traffic is a one-shot cost of roughly a spawn packet per object anyway; what actually matters day
/// to day is the steady state, and that is exactly what this measures. Watch the profiler's frame time
/// after the spawn, not just the numbers logged here.
///
/// Caveat when reading the elapsed time: this places everything in a single frame, which no player
/// ever does. Treat it as a worst case for the spawn burst, not as a gameplay measurement.
/// </summary>
public static class BuildStressTest
{
#region Constants
private const float Spacing = 4f;
private const float GroundHeight = 0f;
#endregion
#region Menu
/// <summary>
/// Places 250 structures — a realistic co-op base.
/// </summary>
[MenuItem("Tools/Ashwild/Stress Test/Spawn 250 Builds")]
public static void Spawn250() => SpawnGrid(250);
/// <summary>
/// Places 1000 structures — an ambitious long-save base, the level the NetworkObject decision was
/// judged against.
/// </summary>
[MenuItem("Tools/Ashwild/Stress Test/Spawn 1000 Builds")]
public static void Spawn1000() => SpawnGrid(1000);
#endregion
#region Internal Helpers
/// <summary>
/// Commits <paramref name="count"/> builds of the first usable buildable on a grid around the
/// origin, then logs the elapsed time and the memory delta. Requires play mode with a live session,
/// since committing goes through the registry's server RPC exactly as a real placement would.
/// </summary>
private static void SpawnGrid(int count)
{
if (!Application.isPlaying)
{
Debug.LogError("[BuildStressTest] Enter play mode and host a session first — builds are spawned through the server.");
return;
}
if (BuildRegistry.Instance == null)
{
Debug.LogError("[BuildStressTest] No BuildRegistry in the session — is the scene's BuildRegistry present and the session started?");
return;
}
if (!TryResolveBuildable(out ushort id, out string label)) return;
int side = Mathf.CeilToInt(Mathf.Sqrt(count));
long memoryBefore = Profiler.GetTotalAllocatedMemoryLong();
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < count; i++)
{
Vector3 position = new Vector3((i % side) * Spacing, GroundHeight, (i / side) * Spacing);
BuildRegistry.Instance.RequestBuild(id, position, Quaternion.identity);
}
watch.Stop();
long memoryDelta = Profiler.GetTotalAllocatedMemoryLong() - memoryBefore;
Debug.Log($"[BuildStressTest] Requested {count}× '{label}' in {watch.ElapsedMilliseconds} ms " +
$"({memoryDelta / 1024f / 1024f:F1} MB allocated this frame). " +
"Spawns complete over the next frames — check the profiler's steady-state frame time now.");
}
/// <summary>
/// Picks the first buildable that can actually be spawned, so the test never fails halfway on a
/// half-authored asset. Reports clearly when the database is empty or unbuilt.
/// </summary>
private static bool TryResolveBuildable(out ushort id, out string label)
{
id = 0;
label = string.Empty;
BuildableDatabase database = BuildableDatabase.Instance;
if (database == null || database.Buildables == null || database.Buildables.Length == 0)
{
Debug.LogError("[BuildStressTest] BuildableDatabase is empty — run Tools ▸ Ashwild ▸ Rebuild Buildable Database.");
return false;
}
foreach (BuildableData buildable in database.Buildables)
{
if (buildable == null || buildable.BuiltPrefab == null) continue;
id = database.GetId(buildable);
label = buildable.name;
return true;
}
Debug.LogError("[BuildStressTest] No buildable with a BuiltPrefab found.");
return false;
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfaebf3f0f14ca94e803a73cc00965f7
@@ -0,0 +1,109 @@
using UnityEditor;
using UnityEngine;
using Ashwild.Building;
using FishNet.Object;
namespace Ashwild.EditorTools
{
/// <summary>
/// One-shot migration for the move to server-spawned builds: every committed structure is now a real
/// NetworkObject, so each buildable's BuiltPrefab needs a NetworkObject and a BuiltStructure on its
/// root. Prefabs authored before that change have neither (or only BuiltStructure, which is now a
/// NetworkBehaviour and cannot function alone), and a missing NetworkObject makes the prefab silently
/// unspawnable — the exact failure that used to leave a placed chest deactivated.
///
/// Safe to re-run: it only touches prefabs that are actually missing a component, and reports what it
/// changed. Kept as an explicit menu action rather than an automatic postprocessor because it rewrites
/// authored assets, which should never happen behind the designer's back.
/// </summary>
public static class BuiltPrefabMigrator
{
#region Menu
/// <summary>
/// Scans every BuildableData, ensures its BuiltPrefab carries NetworkObject + BuiltStructure, and
/// asks FishNet to rescan its spawnable prefab collection afterwards.
/// </summary>
[MenuItem("Tools/Ashwild/Migrate Built Prefabs To Network Objects")]
public static void Migrate()
{
string[] guids = AssetDatabase.FindAssets("t:BuildableData");
int migrated = 0;
int alreadyFine = 0;
int missingPrefab = 0;
foreach (string guid in guids)
{
BuildableData buildable = AssetDatabase.LoadAssetAtPath<BuildableData>(AssetDatabase.GUIDToAssetPath(guid));
if (buildable == null) continue;
if (buildable.BuiltPrefab == null)
{
Debug.LogWarning($"[BuiltPrefabMigrator] '{buildable.name}' has no BuiltPrefab — skipped.", buildable);
missingPrefab++;
continue;
}
if (EnsureNetworked(buildable.BuiltPrefab)) migrated++;
else alreadyFine++;
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
RefreshNetworkPrefabRegistry();
Debug.Log($"[BuiltPrefabMigrator] Done — {migrated} prefab(s) migrated, {alreadyFine} already correct, {missingPrefab} buildable(s) without a BuiltPrefab.");
}
#endregion
#region Internal Helpers
/// <summary>
/// Adds whatever the prefab root is missing and saves it. The NetworkObject goes on first so that
/// BuiltStructure's RequireComponent is already satisfied when it is added. Returns true when the
/// prefab was actually changed.
/// </summary>
private static bool EnsureNetworked(GameObject prefab)
{
string path = AssetDatabase.GetAssetPath(prefab);
if (string.IsNullOrEmpty(path)) return false;
GameObject root = PrefabUtility.LoadPrefabContents(path);
bool changed = false;
if (root.GetComponent<NetworkObject>() == null)
{
root.AddComponent<NetworkObject>();
changed = true;
}
if (root.GetComponent<BuiltStructure>() == null)
{
root.AddComponent<BuiltStructure>();
changed = true;
}
if (changed)
{
PrefabUtility.SaveAsPrefabAsset(root, path);
Debug.Log($"[BuiltPrefabMigrator] Migrated '{path}'.", AssetDatabase.LoadAssetAtPath<GameObject>(path));
}
PrefabUtility.UnloadPrefabContents(root);
return changed;
}
/// <summary>
/// Forces FishNet to rescan DefaultPrefabObjects so the migrated prefabs are spawnable. Invoked
/// through the menu item because the generator API is internal to the FishNet assembly.
/// </summary>
private static void RefreshNetworkPrefabRegistry()
{
const string menu = "Tools/Fish-Networking/Utility/Refresh Default Prefabs";
if (!EditorApplication.ExecuteMenuItem(menu))
Debug.LogWarning($"[BuiltPrefabMigrator] Could not run '{menu}' — run it by hand so the built prefabs are registered as spawnable.");
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1d18a05426439d47937c24568677913
@@ -438,3 +438,32 @@
.ash-recipe-name {
margin-bottom: 12px;
}
/* ── Prefab generator (shown while a prefab slot is empty) ─ */
.ash-generator {
margin-top: 10px;
padding: 10px;
border-radius: 8px;
background-color: rgba(64, 110, 220, 0.07);
border-width: 1px;
border-color: rgb(74, 90, 130);
}
.ash-generator__title {
-unity-font-style: bold;
font-size: 11px;
color: rgb(150, 175, 240);
margin-bottom: 8px;
}
.ash-generator .ash-btn {
margin-top: 8px;
}
.ash-generator__hint {
margin-top: 8px;
font-size: 10px;
color: rgb(130, 132, 140);
-unity-font-style: italic;
white-space: normal;
}
@@ -311,7 +311,7 @@ namespace Ashwild.EditorTools
else if (selectedAsset is CraftingRecipe recipe)
rightPane.Add(new RecipeEditorView(recipe, RefreshRow).Root);
else if (selectedAsset is BuildableData buildable)
rightPane.Add(new BuildableEditorView(buildable, RefreshRow).Root);
rightPane.Add(new BuildableEditorView(buildable, RefreshRow, ShowSelection).Root);
}
/// <summary>
@@ -1,20 +1,91 @@
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using Ashwild.Building;
using Ashwild.GrassClearer;
using FishNet.Component.Transforming;
using FishNet.Object;
namespace Ashwild.EditorTools
{
/// <summary>
/// Editor-only factory that creates BuildableData assets for the Ashwild Database window.
/// Kept separate from the window so buildable authoring stays a single-purpose, testable helper —
/// mirrors RecipeAssetFactory for recipes and ItemAssetFactory for items.
/// Which socket sets to generate on a buildable. Combinable, because one piece usually offers several
/// kinds of connection at once: a floor slab chains to other slabs by its edges AND hosts walls on
/// those same edges, so it is authored as <see cref="FloorEdges"/> | <see cref="WallMounts"/>.
///
/// Each flag describes a connection the piece OFFERS, not what the piece is called, so the sets
/// compose without overlapping. The pairs that mate are <see cref="WallMounts"/> (faces up, on a
/// floor) with <see cref="WallBody"/>'s foot (faces down, on the wall) — opposing forwards of the
/// same category, which is exactly what BuildManager requires to connect two sockets.
///
/// Lives here rather than in the runtime assembly because it is purely an authoring-time hint: the
/// generated prefabs only ever carry plain BuildSnapPoints.
/// </summary>
[System.Flags]
public enum BuildSnapLayout
{
None = 0,
/// <summary>Four horizontal edge sockets (category Floor) so slabs chain edge to edge.</summary>
FloorEdges = 1 << 0,
/// <summary>Four upward-facing sockets on the top edges (category Wall) where a wall plants its foot.</summary>
WallMounts = 1 << 1,
/// <summary>This piece IS a wall: a downward foot plus two end sockets (category Wall) for wall-to-wall runs.</summary>
WallBody = 1 << 2,
/// <summary>A single upward socket at the centre of the top face (category Roof) where a roof lands.</summary>
RoofMount = 1 << 3,
/// <summary>This piece IS a roof/ceiling: a single downward socket at its centre, mating a RoofMount.</summary>
RoofBody = 1 << 4,
/// <summary>Up/down caps (category Pillar) so pillars stack vertically.</summary>
PillarCaps = 1 << 5
}
/// <summary>
/// Editor-only factory that creates BuildableData assets and, on demand, generates the two prefabs a
/// buildable needs from a single source mesh or prefab — mirroring ItemAssetFactory for items.
///
/// The generated pair follows the authored convention exactly (see Ghost_WoodCelling / WoodCelling_Prefab):
/// - Built prefab — root on the Build layer carrying the source as a child, a non-trigger BoxCollider
/// fitted to the renderers, BuiltStructure (bump / highlight / damage entry) and ClearGrassOnPlace.
/// - Ghost prefab — the same root plus a NetworkObject and a client-authoritative NetworkTransform
/// (BuildRegistry spawns the ghost as a real NetworkObject, §3's deliberate low-count exception),
/// and a BuildGhost wired to the footprint collider and the two overlay materials.
///
/// Both get a "Snap" container of BuildSnapPoints on the BuildSnap layer, positioned from the source's
/// bounds with each socket's forward pointing OUT of the piece — the orientation BuildManager requires,
/// since two sockets only connect when their forwards oppose. The built copy's sockets are triggers so
/// the placement overlap finds them; the ghost's are not, so a ghost never snaps to itself.
///
/// The layout is a best guess from the mesh proportions and is meant to be adjusted by hand afterwards;
/// generation exists to kill the repetitive part, not to replace authoring judgement.
/// </summary>
public static class BuildableAssetFactory
{
#region Constants
private const string BuildablesFolder = "Assets/GAME/ScriptableObjects/Buildables";
private const string BuiltPrefabFolder = "Assets/GAME/Prefabs/Structure/Build/Build";
private const string GhostPrefabFolder = "Assets/GAME/Prefabs/Structure/Build/Ghost";
private const string ValidOverlayPath = "Assets/GAME/Shaders/Build/GAME_BuildGhost_Good.mat";
private const string InvalidOverlayPath = "Assets/GAME/Shaders/Build/GAME_BuildGhost_Not.mat";
private const string BuildLayerName = "Build";
private const string SnapLayerName = "BuildSnap";
private const string GhostLayerName = "BuildGhost";
private const string SnapContainerName = "Snap";
private const float SnapColliderRadius = 0.5f;
private const string RefreshPrefabsMenu = "Tools/Fish-Networking/Utility/Refresh Default Prefabs";
private static readonly Vector3 FallbackSize = new Vector3(1f, 1f, 1f);
#endregion
@@ -44,10 +115,512 @@ namespace Ashwild.EditorTools
return buildable;
}
/// <summary>
/// Renames the asset file so it matches the authored display name — a buildable called "Wood Wall"
/// should not sit on disk as "NewBuildable 2". Invalid path characters are stripped and the result
/// is uniquified, so a clash with an existing file never overwrites it. No-op when the name is
/// blank or already matches. Returns true when the file was actually renamed.
/// </summary>
public static bool RenameAssetToDisplayName(BuildableData buildable)
{
if (buildable == null) return false;
string desired = SanitiseFileName(buildable.DisplayName);
if (string.IsNullOrEmpty(desired) || desired == buildable.name) return false;
string path = AssetDatabase.GetAssetPath(buildable);
if (string.IsNullOrEmpty(path)) return false;
string folder = Path.GetDirectoryName(path).Replace('\\', '/');
string unique = Path.GetFileNameWithoutExtension(AssetDatabase.GenerateUniqueAssetPath($"{folder}/{desired}.asset"));
string error = AssetDatabase.RenameAsset(path, unique);
if (!string.IsNullOrEmpty(error))
{
Debug.LogWarning($"[BuildableAssetFactory] Could not rename '{buildable.name}' to '{unique}' — {error}.", buildable);
return false;
}
AssetDatabase.SaveAssets();
return true;
}
/// <summary>
/// Strips characters the file system rejects (and collapses surrounding whitespace) so a display
/// name typed freely by a designer can safely become an asset file name.
/// </summary>
private static string SanitiseFileName(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return string.Empty;
char[] invalid = Path.GetInvalidFileNameChars();
StringBuilder builder = new StringBuilder(raw.Length);
foreach (char c in raw.Trim())
if (System.Array.IndexOf(invalid, c) < 0) builder.Append(c);
return builder.ToString().Trim();
}
#endregion
#region Prefab Generation
/// <summary>
/// Generates the missing prefabs for a buildable from one source object (an FBX model or an
/// existing prefab), wires them onto the asset and returns true when at least one was created.
/// Only the slots asked for are built, so a designer can regenerate just the ghost after tweaking
/// the model without losing hand-edits on the built prefab.
///
/// The source is nested as a child rather than flattened, so it stays a live prefab link the
/// designer can keep editing. Because the ghost is a NetworkObject it must live in
/// DefaultPrefabObjects; FishNet's generator picks it up on import, and we force a refresh
/// afterwards so the registration is deterministic rather than dependent on import timing.
/// </summary>
public static bool GeneratePrefabs(BuildableData buildable, GameObject source, BuildSnapLayout layout, bool generateBuilt, bool generateGhost)
{
if (buildable == null)
{
Debug.LogError("[BuildableAssetFactory] Cannot generate prefabs — no BuildableData selected.");
return false;
}
if (source == null)
{
Debug.LogError($"[BuildableAssetFactory] Cannot generate prefabs for '{buildable.name}' — no source mesh or prefab chosen.", buildable);
return false;
}
if (!generateBuilt && !generateGhost) return false;
if (!EnsureFolder(BuiltPrefabFolder) || !EnsureFolder(GhostPrefabFolder)) return false;
Bounds bounds = MeasureSource(source);
bool created = false;
if (generateBuilt) created |= BuildBuiltPrefab(buildable, source, bounds, layout) != null;
if (generateGhost) created |= BuildGhostPrefab(buildable, source, bounds, layout) != null;
if (created)
{
ApplySupport(buildable, layout);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
RefreshNetworkPrefabRegistry();
}
return created;
}
/// <summary>
/// Builds and saves the committed-structure prefab: the source on the Build layer under a root
/// carrying a fitted non-trigger BoxCollider (what the demolition ray and obstruction tests hit),
/// BuiltStructure and ClearGrassOnPlace, plus trigger snap sockets the placement overlap can find.
///
/// Committed structures are spawned by the server as real NetworkObjects, so the root gets one —
/// without it BuiltStructure (a NetworkBehaviour) cannot function and the prefab is not spawnable.
/// </summary>
private static GameObject BuildBuiltPrefab(BuildableData buildable, GameObject source, Bounds bounds, BuildSnapLayout layout)
{
GameObject root = BuildRoot(buildable.name, source, ResolveLayer(BuildLayerName));
FitBoxCollider(root, bounds, true);
root.AddComponent<NetworkObject>();
root.AddComponent<BuiltStructure>();
root.AddComponent<ClearGrassOnPlace>();
AddSnapSockets(root, bounds, layout, true);
return SaveAndAssign(root, $"{BuiltPrefabFolder}/{buildable.name}.prefab", buildable, "builtPrefab");
}
/// <summary>
/// Builds and saves the placement-preview prefab: the same root plus the networking pair the
/// registry needs (NetworkObject + client-authoritative NetworkTransform, since the placer owns
/// and drives its own ghost) and a BuildGhost wired to the footprint collider and the shared
/// valid/invalid overlay materials. Its snap colliders are left as non-triggers, matching the
/// authored ghost, so the ghost never registers as a snap target for itself.
///
/// The child renderers are assigned explicitly rather than left to BuildGhost's Awake fallback, so
/// the overlay targets are visible (and editable) in the prefab exactly like the authored ghost.
/// </summary>
private static GameObject BuildGhostPrefab(BuildableData buildable, GameObject source, Bounds bounds, BuildSnapLayout layout)
{
GameObject root = BuildRoot($"Ghost_{buildable.name}", source, ResolveGhostLayer());
BoxCollider footprint = FitBoxCollider(root, bounds, false);
root.AddComponent<NetworkObject>();
NetworkTransform netTransform = root.AddComponent<NetworkTransform>();
SerializedObject transformSo = new SerializedObject(netTransform);
transformSo.FindProperty("_clientAuthoritative").boolValue = true;
transformSo.ApplyModifiedPropertiesWithoutUndo();
BuildGhost ghost = root.AddComponent<BuildGhost>();
SerializedObject ghostSo = new SerializedObject(ghost);
ghostSo.FindProperty("footprint").objectReferenceValue = footprint;
ghostSo.FindProperty("validOverlay").objectReferenceValue = LoadOverlay(ValidOverlayPath);
ghostSo.FindProperty("invalidOverlay").objectReferenceValue = LoadOverlay(InvalidOverlayPath);
AssignRenderers(ghostSo.FindProperty("renderers"), root);
ghostSo.ApplyModifiedPropertiesWithoutUndo();
AddSnapSockets(root, bounds, layout, false);
DisableColliders(root);
return SaveAndAssign(root, $"{GhostPrefabFolder}/Ghost_{buildable.name}.prefab", buildable, "ghostPrefab");
}
/// <summary>
/// Fills a serialized Renderer array with every renderer found under the root, including inactive
/// ones — the ghost swaps materials on all of them, so a renderer left out would keep its opaque
/// look while the rest of the piece turns translucent.
/// </summary>
private static void AssignRenderers(SerializedProperty arrayProperty, GameObject root)
{
Renderer[] renderers = root.GetComponentsInChildren<Renderer>(true);
arrayProperty.arraySize = renderers.Length;
for (int i = 0; i < renderers.Length; i++)
arrayProperty.GetArrayElementAtIndex(i).objectReferenceValue = renderers[i];
}
#endregion
#region Snap Sockets
/// <summary>
/// Creates the "Snap" container at the source's bounds centre and fills it with every socket set
/// the layout asks for — they compose, so a floor slab gets its edge ring AND its wall mounts from
/// one pass. Nothing is added for <see cref="BuildSnapLayout.None"/>, leaving a piece that can only
/// be free-placed.
/// </summary>
private static void AddSnapSockets(GameObject root, Bounds bounds, BuildSnapLayout layout, bool trigger)
{
if (layout == BuildSnapLayout.None) return;
GameObject container = new GameObject(SnapContainerName) { layer = root.layer };
container.transform.SetParent(root.transform, false);
container.transform.localPosition = bounds.center;
Vector3 half = bounds.extents;
if (layout.HasFlag(BuildSnapLayout.FloorEdges)) AddFloorEdges(container, half, trigger);
if (layout.HasFlag(BuildSnapLayout.RoofMount)) AddRoofMount(container, half, trigger);
if (layout.HasFlag(BuildSnapLayout.RoofBody)) AddRoofBody(container, half, trigger);
if (layout.HasFlag(BuildSnapLayout.WallMounts)) AddWallMounts(container, half, trigger);
if (layout.HasFlag(BuildSnapLayout.WallBody)) AddWallBody(container, half, trigger);
if (layout.HasFlag(BuildSnapLayout.PillarCaps)) AddPillarCaps(container, half, trigger);
}
/// <summary>
/// Adds the four horizontal edge sockets of a slab, at the midpoint of each side and at
/// mid-thickness, each facing straight out of the piece so a neighbour clicks in edge-to-edge.
/// </summary>
private static void AddFloorEdges(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "Floor_North", new Vector3(0f, 0f, half.z), Vector3.forward, SnapCategory.Floor, trigger);
CreateSocket(container, "Floor_South", new Vector3(0f, 0f, -half.z), Vector3.back, SnapCategory.Floor, trigger);
CreateSocket(container, "Floor_East", new Vector3(half.x, 0f, 0f), Vector3.right, SnapCategory.Floor, trigger);
CreateSocket(container, "Floor_West", new Vector3(-half.x, 0f, 0f), Vector3.left, SnapCategory.Floor, trigger);
}
/// <summary>
/// Adds the single socket a roof lands on: centred on the top face and facing up, because a roof
/// or ceiling sits centred over the piece rather than hooking onto its sides. Deliberately ONE
/// socket, not a ring — four of them would let a roof snap offset to an edge, and two adjacent
/// pieces would each offer a competing target for the same roof.
/// </summary>
private static void AddRoofMount(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "RoofMount", new Vector3(0f, half.y, 0f), Vector3.up, SnapCategory.Roof, trigger);
}
/// <summary>
/// Adds the counterpart a roof or ceiling piece needs: one socket under its centre facing DOWN,
/// which mates the upward RoofMount offered by whatever it rests on. Without this set a RoofMount
/// has nothing to pair with — two sockets connect only when their forwards oppose.
/// </summary>
private static void AddRoofBody(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "Roof_Base", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Roof, trigger);
}
/// <summary>
/// Adds the sockets a floor offers to walls: one per top edge, facing UP. A wall's foot faces down,
/// so the two oppose and connect — which also means a wall mount can never be mistaken for a floor
/// edge socket (those face sideways), even though a slab carries both rings at once.
/// </summary>
private static void AddWallMounts(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "WallMount_North", new Vector3(0f, half.y, half.z), Vector3.up, SnapCategory.Wall, trigger);
CreateSocket(container, "WallMount_South", new Vector3(0f, half.y, -half.z), Vector3.up, SnapCategory.Wall, trigger);
CreateSocket(container, "WallMount_East", new Vector3(half.x, half.y, 0f), Vector3.up, SnapCategory.Wall, trigger);
CreateSocket(container, "WallMount_West", new Vector3(-half.x, half.y, 0f), Vector3.up, SnapCategory.Wall, trigger);
}
/// <summary>
/// Adds a standing panel's own sockets: a foot at the base facing DOWN (which mates a floor's
/// upward wall mount) plus one socket on each end of its long axis facing outward, so walls chain
/// into runs. The long axis is taken as whichever horizontal dimension is larger.
/// </summary>
private static void AddWallBody(GameObject container, Vector3 half, bool trigger)
{
bool thinAlongZ = half.z <= half.x;
Vector3 longAxis = thinAlongZ ? Vector3.right : Vector3.forward;
float longHalf = thinAlongZ ? half.x : half.z;
CreateSocket(container, "Wall_Foot", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Wall, trigger);
CreateSocket(container, "Wall_SideA", longAxis * longHalf, longAxis, SnapCategory.Wall, trigger);
CreateSocket(container, "Wall_SideB", -longAxis * longHalf, -longAxis, SnapCategory.Wall, trigger);
}
/// <summary>
/// Adds a pillar's two caps so pillars stack vertically: the top faces up and the bottom faces
/// down, which is exactly the opposing pair the snapping requires.
/// </summary>
private static void AddPillarCaps(GameObject container, Vector3 half, bool trigger)
{
CreateSocket(container, "Cap_Top", new Vector3(0f, half.y, 0f), Vector3.up, SnapCategory.Pillar, trigger);
CreateSocket(container, "Cap_Bottom", new Vector3(0f, -half.y, 0f), Vector3.down, SnapCategory.Pillar, trigger);
}
/// <summary>
/// Creates one socket: a child on the BuildSnap layer oriented so its forward is
/// <paramref name="outward"/>, carrying a BuildSnapPoint of the given category and the sphere
/// collider the placement overlap detects it by. Sockets are triggers on the built piece (the
/// snap targets the placement scan looks for) and plain colliders on the ghost, matching the
/// authored prefabs — the ghost is excluded from its own scan by parentage, not by collider type.
/// </summary>
private static void CreateSocket(GameObject container, string name, Vector3 localPosition, Vector3 outward, SnapCategory category, bool trigger)
{
GameObject socket = new GameObject(name) { layer = ResolveLayer(SnapLayerName) };
socket.transform.SetParent(container.transform, false);
socket.transform.localPosition = localPosition;
socket.transform.localRotation = SocketRotation(outward);
BuildSnapPoint point = socket.AddComponent<BuildSnapPoint>();
SerializedObject so = new SerializedObject(point);
so.FindProperty("category").enumValueIndex = (int)category;
so.ApplyModifiedPropertiesWithoutUndo();
SphereCollider collider = socket.AddComponent<SphereCollider>();
collider.radius = SnapColliderRadius;
collider.isTrigger = trigger;
}
/// <summary>
/// Turns an outward direction into a socket rotation. A vertical direction needs a non-parallel
/// reference up, otherwise LookRotation degenerates and the cap sockets come out unrotated.
/// </summary>
private static Quaternion SocketRotation(Vector3 outward)
{
Vector3 up = Mathf.Abs(Vector3.Dot(outward, Vector3.up)) > 0.99f ? Vector3.forward : Vector3.up;
return Quaternion.LookRotation(outward, up);
}
/// <summary>
/// Derives the buildable's placement rule from the sockets it was just given: a piece authored as
/// a wall body or a roof body only makes sense attached to something, so it becomes SnapOnly and
/// can no longer be dropped in mid-air. Everything else — foundations, floor slabs — stays free to
/// place on the ground. Doing it here means the designer never has to remember to set the two
/// fields consistently: the socket layout already says what kind of piece this is.
/// </summary>
private static void ApplySupport(BuildableData buildable, BuildSnapLayout layout)
{
bool needsConnection = layout.HasFlag(BuildSnapLayout.WallBody) || layout.HasFlag(BuildSnapLayout.RoofBody);
SerializedObject so = new SerializedObject(buildable);
so.FindProperty("support").enumValueIndex = (int)(needsConnection ? PlacementSupport.SnapOnly : PlacementSupport.GroundOrSnap);
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(buildable);
}
/// <summary>
/// Suggests a layout for a source model, so picking a mesh in the window pre-ticks a sensible set
/// the designer can then adjust. Measures the mesh, so it is only worth calling when the source
/// actually changes.
/// </summary>
public static BuildSnapLayout InferLayout(GameObject source)
{
return source == null ? BuildSnapLayout.None : InferLayout(MeasureSource(source).size);
}
/// <summary>
/// Picks a socket layout from the mesh's proportions: a square-ish tall block stacks as a pillar,
/// a tall panel thin on one horizontal axis is a wall body, and anything flat enough to stand on
/// is a floor — which gets BOTH its edge ring (slab to slab) and its wall mounts, since a floor
/// piece almost always has to host walls too. That combination is the reason the layout is a set
/// of flags rather than a single choice.
/// </summary>
private static BuildSnapLayout InferLayout(Vector3 size)
{
const BuildSnapLayout slab = BuildSnapLayout.FloorEdges | BuildSnapLayout.WallMounts;
float minHorizontal = Mathf.Min(size.x, size.z);
float maxHorizontal = Mathf.Max(size.x, size.z);
if (maxHorizontal <= Mathf.Epsilon) return slab;
if (size.y <= 0.5f * minHorizontal) return slab;
if (size.y >= 1.5f * maxHorizontal && maxHorizontal <= 1.6f * minHorizontal) return BuildSnapLayout.PillarCaps;
if (minHorizontal <= 0.35f * maxHorizontal && size.y >= 0.6f * maxHorizontal) return BuildSnapLayout.WallBody;
return slab;
}
#endregion
#region Internal Helpers
/// <summary>
/// Measures the source's combined renderer bounds in its own local space by instantiating it once
/// at the origin, so socket positions and the footprint are derived from the real mesh rather than
/// guessed. Falls back to a unit box when the source has no renderers, so generation still yields a
/// usable (if arbitrary) footprint instead of a degenerate zero-size collider.
/// </summary>
private static Bounds MeasureSource(GameObject source)
{
GameObject probe = (GameObject)PrefabUtility.InstantiatePrefab(source);
if (probe == null) probe = Object.Instantiate(source);
probe.transform.position = Vector3.zero;
probe.transform.rotation = Quaternion.identity;
Renderer[] renderers = probe.GetComponentsInChildren<Renderer>();
Bounds bounds = renderers.Length > 0 ? renderers[0].bounds : new Bounds(Vector3.zero, FallbackSize);
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);
Object.DestroyImmediate(probe);
return bounds;
}
/// <summary>
/// Creates the generated root with the source nested as a child at the origin, so the designer's
/// authored model stays a prefab link rather than being flattened into the copy.
///
/// The whole nested subtree is forced onto <paramref name="layer"/>. Source models routinely ship
/// on their own layer (a wall model authored on Build, say), and leaving that alone would make the
/// generated piece behave inconsistently — worst of all on a ghost, whose visual must not sit on a
/// layer the obstruction test scans.
/// </summary>
private static GameObject BuildRoot(string name, GameObject source, int layer)
{
GameObject root = new GameObject(name) { layer = layer };
GameObject visual = (GameObject)PrefabUtility.InstantiatePrefab(source);
if (visual == null) visual = Object.Instantiate(source);
visual.transform.SetParent(root.transform, false);
visual.transform.localPosition = Vector3.zero;
SetLayerRecursively(visual, layer);
return root;
}
/// <summary>
/// Puts a GameObject and every descendant on one layer.
/// </summary>
private static void SetLayerRecursively(GameObject go, int layer)
{
go.layer = layer;
foreach (Transform child in go.transform)
SetLayerRecursively(child.gameObject, layer);
}
/// <summary>
/// Disables every collider under the ghost, including any the source model brought with it.
///
/// This is what keeps a ghost from reporting itself as blocked: BuildGhost box-overlaps its
/// footprint against the obstruction mask, and an enabled collider anywhere in the preview — the
/// source model's own BoxCollider is the usual culprit, since it is authored on the Build layer —
/// lands inside that very box, so the spot reads as occupied everywhere and the ghost never turns
/// green. A ghost needs no physics at all: its footprint is read as raw dimensions, and its snap
/// sockets are found through GetComponentsInChildren, never by an overlap query.
/// </summary>
private static void DisableColliders(GameObject root)
{
foreach (Collider collider in root.GetComponentsInChildren<Collider>(true))
collider.enabled = false;
}
/// <summary>
/// The layer generated ghosts go on: a dedicated "BuildGhost" layer when the project defines one
/// (the clean setup — it keeps previews out of every mask aimed at real structures), otherwise the
/// Build layer, matching the original authored ghosts. Silent by design, since the dedicated layer
/// is optional.
/// </summary>
private static int ResolveGhostLayer()
{
int layer = LayerMask.NameToLayer(GhostLayerName);
return layer >= 0 ? layer : ResolveLayer(BuildLayerName);
}
/// <summary>
/// Adds the root's BoxCollider sized to the measured bounds. It is left enabled on the built piece
/// (its physical body, and what the demolition ray hits) but DISABLED on the ghost: the obstruction
/// mask is the Build layer the ghost itself sits on, so an enabled footprint would overlap itself
/// and report every spot as blocked. BuildGhost only ever reads the collider's centre/size, never
/// its physics contacts, so a disabled collider still serves as the bounds source.
/// </summary>
private static BoxCollider FitBoxCollider(GameObject root, Bounds bounds, bool enabled)
{
BoxCollider box = root.AddComponent<BoxCollider>();
box.center = bounds.center;
box.size = bounds.size;
box.enabled = enabled;
return box;
}
/// <summary>
/// Saves a built GameObject as a prefab under a unique path, destroys the scene instance, wires the
/// saved prefab onto the given BuildableData field and marks the asset dirty.
/// </summary>
private static GameObject SaveAndAssign(GameObject root, string desiredPath, BuildableData buildable, string field)
{
string prefabPath = AssetDatabase.GenerateUniqueAssetPath(desiredPath);
GameObject prefab = PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
Object.DestroyImmediate(root);
if (prefab == null)
{
Debug.LogError($"[BuildableAssetFactory] Failed to save prefab at '{prefabPath}'.", buildable);
return null;
}
SerializedObject so = new SerializedObject(buildable);
so.FindProperty(field).objectReferenceValue = prefab;
so.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(buildable);
Debug.Log($"[BuildableAssetFactory] Built '{prefabPath}' and assigned it to {buildable.name}.{field}.", prefab);
return prefab;
}
/// <summary>
/// Loads one of the shared ghost overlay materials, warning (rather than failing generation) when
/// it has been moved — a ghost without overlays still works, it just renders opaque.
/// </summary>
private static Material LoadOverlay(string path)
{
Material material = AssetDatabase.LoadAssetAtPath<Material>(path);
if (material == null)
Debug.LogWarning($"[BuildableAssetFactory] Ghost overlay material not found at '{path}' — assign it by hand on the generated ghost.");
return material;
}
/// <summary>
/// Forces FishNet to rescan DefaultPrefabObjects so the freshly saved ghost is spawnable straight
/// away. Invoked through the menu item rather than the generator API, which is internal to the
/// FishNet assembly and so unreachable from this one.
/// </summary>
private static void RefreshNetworkPrefabRegistry()
{
if (!EditorApplication.ExecuteMenuItem(RefreshPrefabsMenu))
Debug.LogWarning($"[BuildableAssetFactory] Could not run '{RefreshPrefabsMenu}' — run it by hand so the ghost is registered as a spawnable network prefab.");
}
/// <summary>
/// Resolves a layer index by name, falling back to the Default layer (0) with a warning when the
/// project is missing the expected layer so generation never silently lands on a wrong one.
/// </summary>
private static int ResolveLayer(string layerName)
{
int layer = LayerMask.NameToLayer(layerName);
if (layer >= 0) return layer;
Debug.LogWarning($"[BuildableAssetFactory] Layer '{layerName}' not found — using Default. Add it in the Tags & Layers settings.");
return 0;
}
/// <summary>
/// Ensures a project-relative asset folder exists, creating any missing segments. Returns
/// false (and logs) when the path cannot be created.
@@ -1,18 +1,23 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using Ashwild.Building;
using Ashwild.Inventory;
namespace Ashwild.EditorTools
{
/// <summary>
/// The custom editor for a BuildableData shown in the right pane of the Ashwild Database. Replaces
/// the default ScriptableObject inspector with a hero header (large clickable icon, editable name,
/// "Buildable" badge) and cards for the identity (description) and the ghost/built prefabs the
/// construction menu and placement ghost consume. All fields bind live to the asset; changing the
/// name or icon notifies the list so the row updates without a full rebuild.
/// "Buildable" badge) and cards for the identity (description), the ghost/built prefabs, the resource
/// cost, and the placed structure's health. The cost card reuses the recipe editor's interactive chip
/// strip — one clickable item card (with a /+ quantity stepper and a remove button) per cost line,
/// joined by "+", followed by a dashed add tile — so authoring a price feels identical to authoring a
/// recipe. All fields bind live to the asset; changing the name or icon notifies the list so the row
/// updates without a full rebuild.
/// </summary>
public class BuildableEditorView
{
@@ -21,12 +26,21 @@ namespace Ashwild.EditorTools
public VisualElement Root { get; }
private const int IconPickerControlId = 0x41534842;
private const int CostPickerControlId = 0x41534843;
private readonly BuildableData buildable;
private readonly SerializedObject so;
private readonly SerializedProperty costProp;
private readonly Action onMetaChanged;
private readonly Action onAssetChanged;
private VisualElement iconPreview;
private VisualElement costStrip;
private Action<ItemData> costPickHandler;
private bool costPickCommitsOnCloseOnly;
private GameObject generationSource;
private BuildSnapLayout generationLayout = BuildSnapLayout.None;
#endregion
@@ -34,18 +48,28 @@ namespace Ashwild.EditorTools
/// <summary>
/// Builds the full editor tree for a buildable. <paramref name="onMetaChanged"/> is raised when
/// the display name or icon changes so the list can refresh that row.
/// the display name or icon changes so the list can refresh that row;
/// <paramref name="onAssetChanged"/> re-renders the whole view after a structural change (prefab
/// generation, asset rename) that the live bindings alone cannot reflect.
/// </summary>
public BuildableEditorView(BuildableData buildable, Action onMetaChanged)
public BuildableEditorView(BuildableData buildable, Action onMetaChanged, Action onAssetChanged)
{
this.buildable = buildable;
this.onMetaChanged = onMetaChanged;
this.onAssetChanged = onAssetChanged;
so = new SerializedObject(buildable);
costProp = so.FindProperty("cost");
Root = new VisualElement();
Root.Add(BuildHero());
Root.Add(BuildIdentityCard());
Root.Add(BuildPrefabsCard());
Root.Add(BuildCostCard());
Root.Add(BuildPlacementCard());
Root.Add(BuildHealthCard());
Root.Add(BuildPickerProxy());
RefreshCost();
}
#endregion
@@ -82,6 +106,7 @@ namespace Ashwild.EditorTools
nameField.AddToClassList("ash-hero__name");
nameField.BindProperty(so.FindProperty("displayName"));
nameField.RegisterValueChangedCallback(_ => onMetaChanged?.Invoke());
nameField.RegisterCallback<FocusOutEvent>(_ => SyncAssetName());
info.Add(AshwildUI.EditableNameRow(nameField));
VisualElement typeRow = new VisualElement();
@@ -93,6 +118,19 @@ namespace Ashwild.EditorTools
return hero;
}
/// <summary>
/// Renames the asset file to match the display name once the designer leaves the name field, so a
/// buildable titled "Wood Wall" stops living on disk as "NewBuildable 2". Deliberately fired on
/// focus-out rather than on every keystroke — renaming per character would spam the AssetDatabase
/// and leave a trail of half-typed file names. Re-renders the view so the header reflects the new
/// asset identity.
/// </summary>
private void SyncAssetName()
{
if (BuildableAssetFactory.RenameAssetToDisplayName(buildable))
onAssetChanged?.Invoke();
}
/// <summary>
/// Shows the buildable's icon in the hero preview, or a neutral placeholder when none is set.
/// </summary>
@@ -141,7 +179,7 @@ namespace Ashwild.EditorTools
#endregion
#region Cards
#region Identity & Prefab Cards
/// <summary>
/// Identity card: the description shown on the menu card, plus the icon field (kept in sync
@@ -170,7 +208,12 @@ namespace Ashwild.EditorTools
/// <summary>
/// Prefabs card: the semi-transparent ghost spawned while positioning and the real structure
/// spawned once placement is confirmed.
/// spawned once placement is confirmed. When either slot is still empty the card also offers the
/// one-click generator below the fields, so the common path (drop a mesh in, press Generate) never
/// leaves the window. The generator's visibility is resolved when the view is built rather than
/// bound to the two fields: BindProperty raises a change event on its initial bind, so re-rendering
/// from those callbacks would loop the view rebuild endlessly. Generating re-renders explicitly,
/// and a hand-assigned prefab is picked up the next time the buildable is selected.
/// </summary>
private VisualElement BuildPrefabsCard()
{
@@ -184,9 +227,367 @@ namespace Ashwild.EditorTools
built.BindProperty(so.FindProperty("builtPrefab"));
card.Add(built);
if (buildable.GhostPrefab == null || buildable.BuiltPrefab == null)
card.Add(BuildGeneratorBlock());
return card;
}
/// <summary>
/// The generator shown while a prefab slot is empty: pick a source model or prefab, tick which
/// socket sets the piece should offer, and press the button to author the missing prefab(s) —
/// collider, gameplay components, networking and snap sockets included. Only the empty slots are
/// generated, so regenerating a ghost never clobbers a built prefab that has already been hand-tuned.
///
/// The layout is a mask rather than a single choice because one piece usually offers several kinds
/// of connection: a floor both chains to other floors and hosts walls. Choosing a source pre-ticks
/// the set inferred from the mesh proportions, which the designer is free to change.
/// </summary>
private VisualElement BuildGeneratorBlock()
{
VisualElement block = new VisualElement();
block.AddToClassList("ash-generator");
Label heading = new Label("Generate from a source model");
heading.AddToClassList("ash-generator__title");
block.Add(heading);
MaskField layoutField = null;
ObjectField sourceField = new ObjectField("Source Mesh / Prefab")
{
objectType = typeof(GameObject),
allowSceneObjects = false,
tooltip = "The authored model (FBX) or prefab to wrap. It is nested as a child, never flattened, so you can keep editing it."
};
sourceField.RegisterValueChangedCallback(evt =>
{
generationSource = evt.newValue as GameObject;
generationLayout = BuildableAssetFactory.InferLayout(generationSource);
layoutField?.SetValueWithoutNotify((int)generationLayout);
});
block.Add(sourceField);
layoutField = new MaskField(
"Snap Layout",
new List<string> { "Floor Edges", "Wall Mounts", "Wall Body", "Roof Mount", "Roof Body", "Pillar Caps" },
(int)generationLayout)
{
tooltip = "Which sockets to place — combinable. A floor slab usually wants Floor Edges (chain to other slabs) AND Wall Mounts (walls stand on its edges); a wall piece wants Wall Body."
};
layoutField.RegisterValueChangedCallback(evt => generationLayout = (BuildSnapLayout)evt.newValue);
block.Add(layoutField);
Button generate = new Button(GenerateMissingPrefabs) { text = DescribeGeneration() };
generate.AddToClassList("ash-btn");
generate.AddToClassList("ash-btn--primary");
block.Add(generate);
Label hint = new Label("Sockets are placed from the mesh bounds: floor edges face outward, wall mounts face up, a wall's foot faces down, and the roof mount is a single socket centred on the top face. Treat the layout as a starting point — nudge them in the prefab afterwards.");
hint.AddToClassList("ash-generator__hint");
block.Add(hint);
return block;
}
/// <summary>
/// Labels the generate button with exactly what it will create, so the designer can tell at a
/// glance whether pressing it touches one slot or both.
/// </summary>
private string DescribeGeneration()
{
bool needsBuilt = buildable.BuiltPrefab == null;
bool needsGhost = buildable.GhostPrefab == null;
if (needsBuilt && needsGhost) return "Generate Built + Ghost Prefabs";
return needsBuilt ? "Generate Built Prefab" : "Generate Ghost Prefab";
}
/// <summary>
/// Runs the generation for whichever slots are empty and re-renders the view so the new prefabs
/// appear in their fields and the generator collapses away.
/// </summary>
private void GenerateMissingPrefabs()
{
bool generated = BuildableAssetFactory.GeneratePrefabs(
buildable,
generationSource,
generationLayout,
buildable.BuiltPrefab == null,
buildable.GhostPrefab == null);
if (generated) onAssetChanged?.Invoke();
}
/// <summary>
/// Health card: the hit points the placed structure starts with, edited as a plain float field.
/// An empty cost makes the build free; likewise this drives the proportional demolition refund.
/// </summary>
private VisualElement BuildHealthCard()
{
VisualElement card = AshwildUI.Card("Health");
FloatField maxHealth = new FloatField("Max Health");
maxHealth.BindProperty(so.FindProperty("maxHealth"));
card.Add(maxHealth);
return card;
}
/// <summary>
/// Placement card: what this structure may rest on. Generating the prefabs sets it from the snap
/// layout, so this is the override for a piece the layout cannot classify — or for a buildable
/// authored before the rule existed, which defaults to the permissive Ground Or Snap.
/// </summary>
private VisualElement BuildPlacementCard()
{
VisualElement card = AshwildUI.Card("Placement");
EnumField support = new EnumField("Support")
{
tooltip = "Ground Or Snap: free placement anywhere the aim lands, or connected. Snap Only: valid solely while connected to a matching socket — use it for walls, ceilings and roofs so they cannot be dropped in mid-air."
};
support.BindProperty(so.FindProperty("support"));
card.Add(support);
return card;
}
#endregion
#region Cost Card
/// <summary>
/// Cost card: an interactive chip strip mirroring the recipe editor. Each cost line is a clickable
/// item card with a /+ quantity stepper and a remove button, the lines are joined by "+", and a
/// dashed add tile appends a new line. An empty strip means a free build.
/// </summary>
private VisualElement BuildCostCard()
{
VisualElement card = AshwildUI.Card("Cost");
costStrip = new VisualElement();
costStrip.AddToClassList("ash-equation");
card.Add(costStrip);
return card;
}
/// <summary>
/// Repaints the whole cost strip: one interactive card per cost line (joined by "+"), then the
/// dashed add tile. Shows only the add tile when the build is free.
/// </summary>
private void RefreshCost()
{
costStrip.Clear();
int count = costProp.arraySize;
for (int i = 0; i < count; i++)
{
if (i > 0) costStrip.Add(Operator("+"));
costStrip.Add(BuildCostCardChip(i));
}
if (count > 0) costStrip.Add(Operator("+"));
costStrip.Add(BuildAddTile());
}
/// <summary>
/// Builds the interactive card for the cost line at <paramref name="index"/>: a clickable icon
/// (change item), the name, a /+ quantity stepper, and a remove button.
/// </summary>
private VisualElement BuildCostCardChip(int index)
{
SerializedProperty element = costProp.GetArrayElementAtIndex(index);
ItemData item = element.FindPropertyRelative("item").objectReferenceValue as ItemData;
int quantity = element.FindPropertyRelative("quantity").intValue;
VisualElement card = MakeCard(item, () => OpenItemPicker(item, true, picked => SetCostItem(index, picked)));
card.Add(Stepper(quantity, delta => AdjustCostQuantity(index, delta)));
Button remove = new Button(() => RemoveCost(index)) { text = "✕" };
remove.AddToClassList("ash-rchip__remove");
card.Add(remove);
return card;
}
/// <summary>
/// Builds the dashed "+" tile that appends a new cost line once an item is picked.
/// </summary>
private VisualElement BuildAddTile()
{
VisualElement tile = new VisualElement();
tile.AddToClassList("ash-rchip");
tile.AddToClassList("ash-rchip--add");
tile.tooltip = "Add a cost line";
tile.RegisterCallback<ClickEvent>(_ => OpenItemPicker(null, false, AddCost));
Label plus = new Label("+");
plus.AddToClassList("ash-rchip__plus");
tile.Add(plus);
return tile;
}
/// <summary>
/// Builds the shared card body (icon + name) for an item, with the icon wired to open a picker.
/// The icon shows the item's sprite, or a neutral placeholder when unset.
/// </summary>
private VisualElement MakeCard(ItemData item, Action onIconClicked)
{
VisualElement card = new VisualElement();
card.AddToClassList("ash-rchip");
VisualElement icon = new VisualElement();
icon.AddToClassList("ash-rchip__icon");
icon.tooltip = "Click to choose an item";
if (item != null && item.Icon != null) icon.style.backgroundImage = new StyleBackground(item.Icon);
icon.RegisterCallback<ClickEvent>(_ => onIconClicked());
card.Add(icon);
Label name = new Label(item != null ? item.ItemName : "Choose…");
name.AddToClassList("ash-rchip__name");
card.Add(name);
return card;
}
/// <summary>
/// Builds a /+ quantity stepper showing the current value; <paramref name="onDelta"/> receives
/// -1 or +1.
/// </summary>
private VisualElement Stepper(int quantity, Action<int> onDelta)
{
VisualElement stepper = new VisualElement();
stepper.AddToClassList("ash-rchip__stepper");
Button minus = new Button(() => onDelta(-1)) { text = "" };
minus.AddToClassList("ash-rchip__step");
stepper.Add(minus);
Label value = new Label(quantity.ToString());
value.AddToClassList("ash-rchip__qty");
stepper.Add(value);
Button plus = new Button(() => onDelta(1)) { text = "+" };
plus.AddToClassList("ash-rchip__step");
stepper.Add(plus);
return stepper;
}
/// <summary>
/// Builds a large "+" operator glyph between cost cards.
/// </summary>
private static Label Operator(string glyph)
{
Label op = new Label(glyph);
op.AddToClassList("ash-equation__op");
return op;
}
#endregion
#region Cost Mutations
/// <summary>
/// Sets the item of an existing cost line and repaints.
/// </summary>
private void SetCostItem(int index, ItemData item)
{
if (index < 0 || index >= costProp.arraySize) return;
costProp.GetArrayElementAtIndex(index).FindPropertyRelative("item").objectReferenceValue = item;
so.ApplyModifiedProperties();
RefreshCost();
}
/// <summary>
/// Changes a cost line's quantity by a delta, clamped to a minimum of one, and repaints.
/// </summary>
private void AdjustCostQuantity(int index, int delta)
{
if (index < 0 || index >= costProp.arraySize) return;
SerializedProperty quantity = costProp.GetArrayElementAtIndex(index).FindPropertyRelative("quantity");
quantity.intValue = Mathf.Max(1, quantity.intValue + delta);
so.ApplyModifiedProperties();
RefreshCost();
}
/// <summary>
/// Appends a new cost line (quantity 1) for the picked item and repaints; ignores a null pick
/// (e.g. the picker was cancelled).
/// </summary>
private void AddCost(ItemData item)
{
if (item == null) return;
int index = costProp.arraySize;
costProp.arraySize++;
SerializedProperty element = costProp.GetArrayElementAtIndex(index);
element.FindPropertyRelative("item").objectReferenceValue = item;
element.FindPropertyRelative("quantity").intValue = 1;
so.ApplyModifiedProperties();
RefreshCost();
}
/// <summary>
/// Removes the cost line at the given index and repaints.
/// </summary>
private void RemoveCost(int index)
{
if (index < 0 || index >= costProp.arraySize) return;
costProp.DeleteArrayElementAtIndex(index);
so.ApplyModifiedProperties();
RefreshCost();
}
#endregion
#region Item Picker
/// <summary>
/// Builds the hidden IMGUI proxy that relays Unity's object-picker commands to the active cost
/// pick handler (the editor window is UI Toolkit, which can't receive those commands directly).
/// </summary>
private VisualElement BuildPickerProxy()
{
IMGUIContainer proxy = new IMGUIContainer(HandleItemPickerCommands);
proxy.style.position = Position.Absolute;
proxy.style.width = 1;
proxy.style.height = 1;
return proxy;
}
/// <summary>
/// Opens Unity's item picker seeded with the current item. When <paramref name="commitLive"/>
/// is true the pick applies on every highlight (live preview, e.g. changing an existing line);
/// otherwise it applies only when the picker closes (e.g. adding a new line — commit once).
/// </summary>
private void OpenItemPicker(ItemData seed, bool commitLive, Action<ItemData> onPicked)
{
costPickHandler = onPicked;
costPickCommitsOnCloseOnly = !commitLive;
EditorGUIUtility.ShowObjectPicker<ItemData>(seed, false, string.Empty, CostPickerControlId);
}
/// <summary>
/// Forwards the picker's selection to the active handler, honouring the live-vs-on-close policy.
/// </summary>
private void HandleItemPickerCommands()
{
Event evt = Event.current;
if (evt == null || evt.type != EventType.ExecuteCommand) return;
if (EditorGUIUtility.GetObjectPickerControlID() != CostPickerControlId) return;
bool updated = evt.commandName == "ObjectSelectorUpdated";
bool closed = evt.commandName == "ObjectSelectorClosed";
if (!updated && !closed) return;
if (updated && costPickCommitsOnCloseOnly) return;
costPickHandler?.Invoke(EditorGUIUtility.GetObjectPickerObject() as ItemData);
}
#endregion
}
}
@@ -0,0 +1,213 @@
using System;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace Ashwild.EditorTools
{
/// <summary>
/// Editor-only capture helper: an on/off toggle under Tools ▸ Ashwild that, when enabled, forces the
/// Game view into a borderless fullscreen window as soon as Play Mode starts — so the running game
/// looks exactly like a shipped build (no editor chrome, no toolbar) and is clean to record in OBS.
/// The preference is checkable in the menu and persists per-machine in EditorPrefs; entering fullscreen
/// spawns a dedicated popup Game view on the main display and it is torn down the moment play stops.
/// </summary>
[InitializeOnLoad]
public static class FullscreenOnPlay
{
#region Constants
/// <summary>
/// The checkable menu entry that flips the feature on and off.
/// </summary>
private const string MenuPath = "Tools/Fullscreen On Play (OBS)";
/// <summary>
/// EditorPrefs key holding the toggle state. Per-user, per-machine — a personal recording setting,
/// deliberately not shared through the project.
/// </summary>
private const string PrefKey = "Ashwild.FullscreenOnPlay.Enabled";
#endregion
#region State
/// <summary>
/// The live borderless Game view spawned for fullscreen playback, or null when not in fullscreen.
/// Held so it can be closed again the instant Play Mode ends.
/// </summary>
private static EditorWindow fullscreenView;
#endregion
#region Initialization
/// <summary>
/// Hooks the Play Mode state stream once, on editor load and every domain reload, so the fullscreen
/// window follows play/stop regardless of how the user triggered it.
/// </summary>
static FullscreenOnPlay()
{
EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged;
EditorApplication.playModeStateChanged += HandlePlayModeStateChanged;
}
#endregion
#region Menu
/// <summary>
/// Flips the toggle and, as a convenience, applies it immediately when already in Play Mode
/// (goes fullscreen right away, or drops back to the windowed Game view).
/// </summary>
[MenuItem(MenuPath, false, 200)]
private static void Toggle()
{
bool enabled = !IsEnabled;
EditorPrefs.SetBool(PrefKey, enabled);
Menu.SetChecked(MenuPath, enabled);
if (!EditorApplication.isPlaying) return;
if (enabled) EnterFullscreen();
else ExitFullscreen();
}
/// <summary>
/// Keeps the menu checkmark in sync with the stored preference every time the menu is opened.
/// </summary>
[MenuItem(MenuPath, true)]
private static bool ToggleValidate()
{
Menu.SetChecked(MenuPath, IsEnabled);
return true;
}
#endregion
#region Play Mode Hook
/// <summary>
/// Enters fullscreen when play begins (only if the toggle is on) and always tears it down as play
/// ends — so a leftover popup can never survive back into edit mode.
/// </summary>
private static void HandlePlayModeStateChanged(PlayModeStateChange state)
{
switch (state)
{
case PlayModeStateChange.EnteredPlayMode:
if (IsEnabled) EnterFullscreen();
break;
case PlayModeStateChange.ExitingPlayMode:
ExitFullscreen();
break;
}
}
#endregion
#region Fullscreen Control
/// <summary>
/// Spawns a dedicated Game view, strips its toolbar and shows it as a borderless popup sized to the
/// main display — the closest the editor gets to a real build's fullscreen. The position is
/// re-asserted next editor tick because ShowPopup can clamp the initial rect. Reuses nothing from
/// the docked layout, so the user's window arrangement is left untouched.
/// </summary>
private static void EnterFullscreen()
{
if (fullscreenView != null) return;
Type gameViewType = typeof(EditorWindow).Assembly.GetType("UnityEditor.GameView");
if (gameViewType == null)
{
Debug.LogError("[FullscreenOnPlay] Could not resolve UnityEditor.GameView — fullscreen capture unavailable on this Unity version.");
return;
}
fullscreenView = ScriptableObject.CreateInstance(gameViewType) as EditorWindow;
if (fullscreenView == null)
{
Debug.LogError("[FullscreenOnPlay] Failed to create a Game view instance — fullscreen capture aborted.");
return;
}
fullscreenView.titleContent = new GUIContent("Game (Fullscreen)");
SetShowToolbar(fullscreenView, false);
Rect fullscreen = MainDisplayRect();
fullscreenView.ShowPopup();
fullscreenView.position = fullscreen;
fullscreenView.minSize = fullscreen.size;
fullscreenView.maxSize = fullscreen.size;
fullscreenView.Focus();
EditorApplication.delayCall += ReassertPosition;
Debug.Log("[FullscreenOnPlay] Game view is fullscreen for capture. To exit: press your Play shortcut (Ctrl/Cmd+P), or Alt+Tab back to the Unity editor and press Stop.");
}
/// <summary>
/// Closes the fullscreen popup if one is open. Safe to call when nothing is showing (a no-op),
/// so both the play-stop hook and the live toggle can lean on it unconditionally.
/// </summary>
private static void ExitFullscreen()
{
EditorApplication.delayCall -= ReassertPosition;
if (fullscreenView == null) return;
fullscreenView.Close();
fullscreenView = null;
}
/// <summary>
/// Re-applies the fullscreen rect one tick after showing, defeating any clamp ShowPopup applied
/// before the window was fully realized. Guards against the window having been closed meanwhile.
/// </summary>
private static void ReassertPosition()
{
EditorApplication.delayCall -= ReassertPosition;
if (fullscreenView == null) return;
fullscreenView.position = MainDisplayRect();
fullscreenView.Focus();
}
#endregion
#region Internal Helpers
/// <summary>
/// The main display's bounds expressed in editor points (pixels ÷ pixelsPerPoint), so the window
/// covers the whole monitor correctly even under OS display scaling on high-DPI screens.
/// </summary>
private static Rect MainDisplayRect()
{
float pixelsPerPoint = Mathf.Max(1f, EditorGUIUtility.pixelsPerPoint);
Resolution res = Screen.currentResolution;
return new Rect(0f, 0f, res.width / pixelsPerPoint, res.height / pixelsPerPoint);
}
/// <summary>
/// Hides (or shows) the Game view's own toolbar via its internal 'showToolbar' property so the
/// capture is pure game with no editor UI. Silently degrades if the property is missing on a
/// future Unity version — a visible toolbar is a cosmetic loss, not a failure.
/// </summary>
private static void SetShowToolbar(EditorWindow gameView, bool visible)
{
PropertyInfo prop = gameView.GetType().GetProperty(
"showToolbar", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop != null && prop.CanWrite)
prop.SetValue(gameView, visible);
}
/// <summary>
/// Whether the fullscreen-on-play toggle is currently enabled.
/// </summary>
private static bool IsEnabled => EditorPrefs.GetBool(PrefKey, false);
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71eb5c7505bea6d4f862da3606a758f3
+25 -17
View File
@@ -55,7 +55,8 @@ namespace Ashwild.Inventory
{
// Visual setup that does not need the player.
for (int i = 0; i < hotbarSlot.Length; i++)
hotbarSlot[i].Initialize(i, InventoryUI.OnSwapRequested, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
hotbarSlot[i].Initialize(SlotContainer.Inventory, i,
InventoryUI.HandleSlotDrop, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
transform.localScale = Vector3.one * idleScale;
if (canvasGroup != null)
@@ -154,15 +155,27 @@ namespace Ashwild.Inventory
ZoomOut();
}
private void OnSlotClicked(int index, bool rightClick)
/// <summary>
/// Right-click on a hotbar slot: a quick deposit into the open chest, otherwise the slot context
/// menu (only while the inventory window is open — the hotbar must not pop a menu during play).
/// </summary>
private void OnSlotClicked(SlotContainer container, int index, bool rightClick)
{
if (rightClick && PlayerEvents.IsInventoryOpen)
if (!rightClick) return;
if (PlayerEvents.IsChestOpen)
{
if (contextMenu != null)
contextMenu.Show(index);
else
inventory.UseItem(index);
if (InventoryUI.Instance != null)
InventoryUI.Instance.RequestQuickTransfer(SlotContainer.Inventory, index);
return;
}
if (!PlayerEvents.IsInventoryOpen) return;
if (contextMenu != null)
contextMenu.Show(index);
else
inventory.UseItem(index);
}
private void OnSlotChanged(int index)
@@ -173,11 +186,12 @@ namespace Ashwild.Inventory
/// <summary>
/// Shows the shared description panel for a hovered hotbar slot, but only while the inventory
/// is open — during normal play the hotbar must not pop a description. The duration bar
/// reflects the item's remaining uses/durability.
/// is open and no chest is open (chest mode hides the description entirely) — during normal play
/// the hotbar must not pop a description. The duration bar reflects the item's remaining uses.
/// </summary>
private void OnSlotHoverEnter(int index)
private void OnSlotHoverEnter(SlotContainer container, int index)
{
if (PlayerEvents.IsChestOpen) return;
if (hoverDescription == null || inventory == null || !PlayerEvents.IsInventoryOpen) return;
InventorySlot slot = inventory.GetSlot(index);
@@ -187,13 +201,7 @@ namespace Ashwild.Inventory
return;
}
ItemData item = slot.ItemData;
bool hasDuration = item.HasUses;
float fill = hasDuration ? (float)slot.CurrentUses / item.MaxUses : 0f;
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
hoverDescription.Show(new ItemDescriptionView(
item.Icon, item.ItemName, item.Description, hasDuration, fill, durationText));
hoverDescription.Show(ItemDescriptionView.From(slot));
}
/// <summary>
@@ -9,6 +9,7 @@ namespace Ashwild.Inventory
[SerializeField] private int defaultCategoryIndex;
private int currentIndex = -1;
private bool locked;
private void Awake()
{
@@ -22,12 +23,13 @@ namespace Ashwild.Inventory
panels[i].SetActive(false);
}
/// <summary>
/// Opens the category strip on the inventory category every time — the window must always land on
/// the inventory, never stay on craft from a previous session (and chest mode needs the grid).
/// </summary>
public void Open()
{
if (currentIndex < 0)
SelectCategoryImmediate(defaultCategoryIndex);
else
SelectCategoryImmediate(currentIndex);
SelectCategoryImmediate(defaultCategoryIndex);
}
public void Close()
@@ -36,8 +38,15 @@ namespace Ashwild.Inventory
panels[i].SetActive(false);
}
/// <summary>
/// Blocks category switching while set — used in chest mode, where the left grid must stay the
/// inventory so items can be deposited/withdrawn.
/// </summary>
public void SetLocked(bool value) => locked = value;
public void SelectCategory(int index)
{
if (locked) return;
if (index == currentIndex) return;
if (index < 0 || index >= panels.Length) return;
@@ -55,17 +55,6 @@ namespace Ashwild.Inventory
currentUses = 0;
}
public int AddQuantity(int amount)
{
if (itemData == null || !itemData.IsStackable || itemData.HasUses)
return amount;
int space = itemData.MaxStackSize - quantity;
int toAdd = amount < space ? amount : space;
quantity += toAdd;
return amount - toAdd;
}
public void RemoveQuantity(int amount)
{
quantity -= amount;
+188 -54
View File
@@ -2,24 +2,37 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Ashwild.Player;
using Ashwild.Storage;
using Ashwild.UI;
namespace Ashwild.Inventory
{
/// <summary>
/// The inventory window. It is a local UI panel (opening/closing is purely local — the item
/// data itself lives in the networked <see cref="PlayerInventory"/>), so it is driven by the
/// GameUIManager panel stack like any other panel. The controller object stays active so it can
/// build its grid when the local player spawns; Show/Hide only toggle the visual window.
/// The inventory window and the manager of its right-side modules. It is a local UI panel
/// (opening/closing is purely local — the item data lives in the networked <see cref="PlayerInventory"/>),
/// driven by the GameUIManager panel stack. The left grid + hotbar are always the player's inventory;
/// the right area swaps between two modules: the hover description (normal browsing) and the chest
/// module (<see cref="ChestPanelUI"/>) when a chest is opened. Opening a chest reuses this same window
/// — the inventory and hotbar cells double as the deposit source, so nothing is rebuilt — and every
/// drop is routed here into the right operation, chest transfers going through the chest's
/// server-authoritative RPCs so two players sharing a chest stay in sync.
/// </summary>
public class InventoryUI : UIPanel
{
/// <summary>
/// Marks this panel as the inventory: input is locked and the cursor shows, but the world
/// keeps running (unlike the pause menu).
/// keeps running (unlike the pause menu). A chest reuses this same window/kind.
/// </summary>
public override PanelKind Kind => PanelKind.Inventory;
/// <summary>
/// The single inventory window in the scene, reached by a chest's Interact() and by slot cells
/// routing their drops here.
/// </summary>
public static InventoryUI Instance { get; private set; }
#region Serialized Fields
[Header("References")]
[SerializeField] private GameObject inventoryPanel;
[SerializeField] private Transform slotContainer;
@@ -33,8 +46,12 @@ namespace Ashwild.Inventory
[Header("Context Menu")]
[SerializeField] private SlotContextMenu contextMenu;
[Header("Hover Description")]
[Header("Right Modules")]
[Tooltip("Holder of the hover-description module — shown while browsing, hidden while a chest is open.")]
[SerializeField] private GameObject descriptionModule;
[SerializeField] private HoverDescriptionUI hoverDescription;
[Tooltip("The chest module shown in place of the description while a chest is open.")]
[SerializeField] private ChestPanelUI chestPanel;
[Header("Categories")]
[SerializeField] private InventoryCategoryManager categoryManager;
@@ -42,10 +59,32 @@ namespace Ashwild.Inventory
[Header("Hotbar")]
[SerializeField] private HotbarUI hotbarUI;
#endregion
#region State
private SlotUI[] slotUIs;
private PlayerInventory inventory;
private bool bound;
/// <summary>
/// The chest bound while the window is in chest mode (null during normal inventory browsing).
/// </summary>
private Chest boundChest;
#endregion
#region Unity Lifecycle
/// <summary>
/// Registers the singleton (in addition to the base panel setup).
/// </summary>
protected override void Awake()
{
base.Awake();
Instance = this;
}
/// <summary>
/// Waits for the networked local player to spawn before building the inventory grid.
/// </summary>
@@ -64,7 +103,7 @@ namespace Ashwild.Inventory
private void Start()
{
// Setup shared ghost for all SlotUIs (hotbar + inventory) — does not need the player.
// Setup shared ghost for all SlotUIs (hotbar + inventory + chest) — does not need the player.
SlotUI.SetupGhost(ghostObject, ghostIcon, ghostQuantityText);
inventoryPanel.SetActive(false);
@@ -73,6 +112,20 @@ namespace Ashwild.Inventory
BuildInventory();
}
/// <summary>
/// Clears the listener and the singleton on teardown.
/// </summary>
private void OnDestroy()
{
if (inventory != null)
inventory.onSlotChanged.RemoveListener(RefreshSlot);
if (Instance == this) Instance = null;
}
#endregion
#region Build
/// <summary>
/// Builds the grid as soon as the local player has spawned on the network.
/// </summary>
@@ -96,7 +149,8 @@ namespace Ashwild.Inventory
int slotIndex = inventory.HotbarSize + i;
GameObject slotGO = Instantiate(slotPrefab, slotContainer);
SlotUI slotUI = slotGO.GetComponent<SlotUI>();
slotUI.Initialize(slotIndex, OnSwapRequested, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
slotUI.Initialize(SlotContainer.Inventory, slotIndex,
HandleSlotDrop, OnSlotClicked, OnSlotHoverEnter, OnSlotHoverExit);
slotUIs[i] = slotUI;
}
@@ -104,29 +158,59 @@ namespace Ashwild.Inventory
RefreshAll();
}
private void OnDestroy()
#endregion
#region Panel Lifecycle
/// <summary>
/// Binds a chest and opens this window in chest mode through the panel stack. Called from a
/// chest's Interact(); the chest is picked up in Show().
/// </summary>
public void OpenChest(Chest chest)
{
if (inventory != null)
inventory.onSlotChanged.RemoveListener(RefreshSlot);
if (chest == null) return;
boundChest = chest;
if (UIManager.Instance != null)
UIManager.Instance.OpenPanel(this);
}
/// <summary>
/// Opens the inventory window (called by the GameUIManager panel stack).
/// Opens the inventory window. Enters chest mode when a chest was bound (right side shows the
/// chest module, category is forced to and locked on the inventory), otherwise normal browsing
/// (right side shows the description). Always lands on the inventory category, never craft.
/// </summary>
public override void Show()
{
inventoryPanel.SetActive(true);
RefreshAll();
bool chestMode = boundChest != null;
if (categoryManager != null)
{
categoryManager.Open();
categoryManager.SetLocked(chestMode);
}
if (chestMode)
{
if (descriptionModule != null) descriptionModule.SetActive(false);
if (chestPanel != null) chestPanel.Bind(boundChest);
PlayerEvents.RaiseChestOpenChanged(true);
}
else
{
if (chestPanel != null) chestPanel.Hide();
if (descriptionModule != null) descriptionModule.SetActive(true);
}
RefreshAll();
if (hotbarUI != null)
hotbarUI.OnInventoryOpen();
}
/// <summary>
/// Closes the inventory window and tears down its transient UI (ghost, context menu).
/// Closes the window and tears down its transient UI (ghost, context menu, chest binding).
/// </summary>
public override void Hide()
{
@@ -140,11 +224,16 @@ namespace Ashwild.Inventory
hoverDescription.Hide();
if (categoryManager != null)
{
categoryManager.SetLocked(false);
categoryManager.Close();
}
if (hotbarUI != null)
hotbarUI.OnInventoryClose();
CloseChestMode();
inventoryPanel.SetActive(false);
}
@@ -156,54 +245,99 @@ namespace Ashwild.Inventory
if (ghostObject != null)
ghostObject.SetActive(false);
CloseChestMode();
inventoryPanel.SetActive(false);
}
// Called by any SlotUI (inventory or hotbar) when a drag-drop completes
public static void OnSwapRequested(int fromIndex, int toIndex)
/// <summary>
/// Leaves chest mode: hides the chest module, releases the chest and puts the description module
/// back. Shared by both close paths so they can never drift apart — HideInstant used to forget
/// the description and left the right side blank on the next open.
/// </summary>
private void CloseChestMode()
{
PlayerInventory inv = PlayerInventory.Instance;
InventorySlot fromSlot = inv.GetSlot(fromIndex);
InventorySlot toSlot = inv.GetSlot(toIndex);
if (boundChest == null) return;
// Same stackable item: merge
if (!fromSlot.IsEmpty && !toSlot.IsEmpty
&& fromSlot.ItemData == toSlot.ItemData
&& toSlot.CanAccept(fromSlot.ItemData))
{
int leftover = toSlot.AddQuantity(fromSlot.Quantity);
if (leftover <= 0)
fromSlot.Clear();
else
fromSlot.Set(fromSlot.ItemData, leftover);
inv.onSlotChanged?.Invoke(fromIndex);
inv.onSlotChanged?.Invoke(toIndex);
}
else
{
// Swap
inv.SwapSlots(fromIndex, toIndex);
}
if (chestPanel != null) chestPanel.Hide();
boundChest = null;
if (descriptionModule != null) descriptionModule.SetActive(true);
PlayerEvents.RaiseChestOpenChanged(false);
}
private void OnSlotClicked(int index, bool rightClick)
#endregion
#region Transfer Routing
/// <summary>
/// Routes a slot drop to whoever owns the slots: the inventory itself when both ends are local,
/// otherwise the chest (which reconciles it server-side). Both paths end up in the same
/// <see cref="SlotTransfer.Move"/> rules, so a chest drag behaves exactly like an inventory drag.
/// Static so every cell (inventory, hotbar, chest) reports here without a per-cell reference.
/// </summary>
public static void HandleSlotDrop(SlotContainer fromContainer, int fromIndex, SlotContainer toContainer, int toIndex)
{
if (rightClick)
if (fromContainer == SlotContainer.Inventory && toContainer == SlotContainer.Inventory)
{
if (contextMenu != null)
contextMenu.Show(index);
else
inventory.UseItem(index);
PlayerInventory inv = PlayerInventory.Instance;
if (inv != null) inv.MoveSlot(fromIndex, toIndex);
return;
}
Chest chest = Instance != null ? Instance.boundChest : null;
if (chest == null) return;
// Paint the chest cell as emptied now: its clear replicates on the SyncList (end of tick)
// while the grant is an immediate TargetRpc, so without this the stack visibly lands in the
// inventory before it leaves the chest.
if (fromContainer == SlotContainer.Chest && Instance.chestPanel != null)
Instance.chestPanel.PredictEmptied(fromIndex);
chest.RequestMove(fromContainer, fromIndex, toContainer, toIndex);
}
/// <summary>
/// Builds the description payload for the hovered slot and shows the side panel. Empty slots
/// keep the panel hidden. The duration bar reflects the item's remaining uses/durability.
/// Quick-transfers a stack to the other container (auto-placed), for right-clicks. Exposed so the
/// hotbar — which cannot reach the private chest binding — can route its own cells here.
/// </summary>
private void OnSlotHoverEnter(int index)
public void RequestQuickTransfer(SlotContainer container, int index)
{
if (boundChest != null)
boundChest.RequestQuickTransfer(container, index);
}
#endregion
#region Event Handlers
/// <summary>
/// Right-click on an inventory cell: a quick deposit while a chest is open, otherwise the slot
/// context menu.
/// </summary>
private void OnSlotClicked(SlotContainer container, int index, bool rightClick)
{
if (!rightClick) return;
if (boundChest != null)
{
boundChest.RequestQuickTransfer(SlotContainer.Inventory, index);
return;
}
if (contextMenu != null)
contextMenu.Show(index);
else if (inventory != null)
inventory.UseItem(index);
}
/// <summary>
/// Builds the description payload for the hovered slot and shows the side panel. Suppressed in
/// chest mode (the description module is hidden) and for empty slots. The duration bar reflects
/// the item's remaining uses/durability.
/// </summary>
private void OnSlotHoverEnter(SlotContainer container, int index)
{
if (boundChest != null) return;
if (hoverDescription == null || inventory == null) return;
InventorySlot slot = inventory.GetSlot(index);
@@ -213,13 +347,7 @@ namespace Ashwild.Inventory
return;
}
ItemData item = slot.ItemData;
bool hasDuration = item.HasUses;
float fill = hasDuration ? (float)slot.CurrentUses / item.MaxUses : 0f;
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
hoverDescription.Show(new ItemDescriptionView(
item.Icon, item.ItemName, item.Description, hasDuration, fill, durationText));
hoverDescription.Show(ItemDescriptionView.From(slot));
}
/// <summary>
@@ -231,6 +359,10 @@ namespace Ashwild.Inventory
hoverDescription.Hide();
}
#endregion
#region Refresh
private void RefreshSlot(int index)
{
if (slotUIs == null) return;
@@ -250,5 +382,7 @@ namespace Ashwild.Inventory
slotUIs[i].UpdateVisual(inventory.GetSlot(slotIndex));
}
}
#endregion
}
}
@@ -39,5 +39,21 @@ namespace Ashwild.Inventory
DurationFill = durationFill;
DurationText = durationText;
}
/// <summary>
/// Builds the payload for a hovered slot, including the uses bar for items that track
/// durability. Shared by the inventory grid and the hotbar so both describe an item
/// identically — they used to each build this by hand and could drift apart.
/// </summary>
public static ItemDescriptionView From(InventorySlot slot)
{
ItemData item = slot.ItemData;
bool hasDuration = item.HasUses;
float fill = hasDuration && item.MaxUses > 0 ? (float)slot.CurrentUses / item.MaxUses : 0f;
string durationText = hasDuration ? $"{slot.CurrentUses} / {item.MaxUses}" : string.Empty;
return new ItemDescriptionView(item.Icon, item.ItemName, item.Description,
hasDuration, fill, durationText);
}
}
}
+138 -76
View File
@@ -130,15 +130,16 @@ namespace Ashwild.Inventory
/// <summary>
/// Server-side: sends a granted item to this inventory's owning client, where it is added.
/// Called after the server authorises a pickup.
/// Called after the server authorises a pickup, a cooking result or a chest withdrawal.
/// <paramref name="uses"/> restores a specific remaining-uses value, used when a partially used
/// instance is picked back up off the ground (negative = grant at full uses).
/// <paramref name="preferredIndex"/> is the slot the player actually aimed at (a chest item
/// dragged onto a precise inventory cell); it keeps the grant from auto-filling the first free
/// slot — which is the hotbar — when the player picked a destination. Negative = auto-place.
/// <paramref name="isTransfer"/> marks a container-to-container move (chest) so the HUD does not
/// announce it as a gain.
/// </summary>
public void GrantItemFromServer(ItemData item, int quantity) => GrantItemFromServer(item, quantity, -1);
/// <summary>
/// Server-side grant that also restores a specific remaining-uses value, used when a partially
/// used instance is picked back up off the ground. A negative value means "grant at full uses".
/// </summary>
public void GrantItemFromServer(ItemData item, int quantity, int uses)
public void GrantItemFromServer(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
{
if (item == null) return;
@@ -149,15 +150,15 @@ namespace Ashwild.Inventory
return;
}
TargetGrantItem(Owner, id, quantity, uses);
TargetGrantItem(Owner, id, quantity, uses, preferredIndex, isTransfer);
}
/// <summary>
/// Runs on the owning client: resolves the granted item and adds it locally, restoring its
/// remaining uses when one was carried across the drop.
/// remaining uses and honouring the slot the player aimed at when one was requested.
/// </summary>
[TargetRpc]
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses)
private void TargetGrantItem(NetworkConnection conn, ushort itemId, int quantity, int uses, int preferredIndex, bool isTransfer)
{
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(itemId) : null;
if (item == null)
@@ -166,7 +167,7 @@ namespace Ashwild.Inventory
return;
}
AddItem(item, quantity, uses);
AddItem(item, quantity, uses, preferredIndex, isTransfer);
}
#endregion
@@ -182,11 +183,92 @@ namespace Ashwild.Inventory
public InventorySlot GetSelectedSlot() => slots[selectedHotbarIndex];
/// <summary>
/// Adds an item, stacking into existing slots first. Uses-tracked items never stack: each unit
/// takes its own slot with its own uses bar. The optional uses value restores a partially used
/// instance (negative = full); it only applies to uses-tracked items.
/// Reads a slot as a container-agnostic snapshot, including its remaining uses. This is how the
/// shared <see cref="SlotTransfer"/> rules see an inventory slot.
/// </summary>
public bool AddItem(ItemData item, int quantity = 1, int uses = -1)
public SlotContent ReadSlot(int index)
{
InventorySlot slot = GetSlot(index);
if (slot == null || slot.IsEmpty) return SlotContent.Empty;
return SlotContent.Of(slot.ItemData, slot.Quantity, slot.CurrentUses);
}
/// <summary>
/// Writes a snapshot back into a slot and notifies the UI. Always goes through the uses-carrying
/// Set overload, so a half-worn tool stays half-worn instead of silently repairing itself.
/// </summary>
public void WriteSlot(int index, SlotContent content)
{
InventorySlot slot = GetSlot(index);
if (slot == null) return;
if (content.IsEmpty) slot.Clear();
else slot.Set(content.Item, content.Quantity, content.Uses);
NotifySlotChanged(index);
}
/// <summary>
/// Moves a stack between two of this inventory's slots — the drag-and-drop operation. Delegates
/// the decision (move / merge / swap) to the shared rules, so it behaves exactly like a chest
/// transfer.
/// </summary>
public void MoveSlot(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
if (GetSlot(fromIndex) == null || GetSlot(toIndex) == null) return;
SlotContent from = ReadSlot(fromIndex);
SlotContent to = ReadSlot(toIndex);
if (!SlotTransfer.Move(ref from, ref to)) return;
WriteSlot(fromIndex, from);
WriteSlot(toIndex, to);
}
/// <summary>
/// Splits a stack in half into the first empty slot — half stays, half moves. No-op on an empty
/// slot, a single unit, or a full inventory. It lives here rather than in the context menu so the
/// split goes through the same write path as every other change; done by hand from the UI it
/// bypassed NotifySlotChanged and the bus event never fired.
/// </summary>
public void SplitSlot(int index)
{
SlotContent source = ReadSlot(index);
if (source.IsEmpty || source.Quantity <= 1) return;
for (int i = 0; i < inventorySize; i++)
{
if (!slots[i].IsEmpty) continue;
int moved = source.Quantity / 2;
SlotContent half = SlotContent.Of(source.Item, moved, source.Uses);
source.Quantity -= moved;
WriteSlot(index, source);
WriteSlot(i, half);
return;
}
}
/// <summary>
/// Adds an item without the player picking a destination (a pickup, a craft result, a chest
/// withdrawal): merges into matching stacks first, then fills empty slots. Uses-tracked items
/// never stack, so each unit claims its own slot with its own uses bar; the optional uses value
/// restores a partially used instance (negative = full).
///
/// <paramref name="preferredIndex"/> is the slot the player actually aimed at, tried first so a
/// targeted transfer lands where they dropped it instead of the default scan silently claiming a
/// hotbar slot. It is only a hint: whatever does not fit there falls through to normal placement,
/// so a slot that got occupied in the meantime degrades gracefully rather than losing items.
///
/// <paramref name="isTransfer"/> marks a stack that merely moved between the player's own
/// containers (a chest withdrawal, a refund) rather than being acquired from the world, so the
/// HUD does not announce a "gain" for shuffling items around. Craft discovery still counts it —
/// pulling an item a co-op partner left in a chest is a genuine first acquisition.
/// </summary>
public bool AddItem(ItemData item, int quantity = 1, int uses = -1, int preferredIndex = -1, bool isTransfer = false)
{
if (item == null)
{
@@ -194,66 +276,64 @@ namespace Ashwild.Inventory
return false;
}
int remaining = quantity;
SlotContent incoming = SlotContent.Of(item, quantity, uses);
if (!item.HasUses)
{
for (int i = 0; i < inventorySize && remaining > 0; i++)
{
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
{
remaining = slots[i].AddQuantity(remaining);
NotifySlotChanged(i);
}
}
}
for (int i = 0; i < inventorySize && remaining > 0; i++)
{
if (slots[i].IsEmpty)
{
if (item.HasUses)
{
slots[i].Set(item, 1, uses);
remaining -= 1;
}
else
{
int toPlace = (item.IsStackable && remaining > item.MaxStackSize) ? item.MaxStackSize : remaining;
slots[i].Set(item, toPlace);
remaining -= toPlace;
}
NotifySlotChanged(i);
}
}
if (preferredIndex >= 0 && preferredIndex < inventorySize)
StackInto(preferredIndex, ref incoming);
int added = quantity - remaining;
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
if (!slots[i].IsEmpty) StackInto(i, ref incoming);
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
if (slots[i].IsEmpty) StackInto(i, ref incoming);
int added = quantity - (incoming.IsEmpty ? 0 : incoming.Quantity);
if (added > 0)
{
onItemAdded?.Invoke(item, added);
PlayerEvents.RaiseItemAdded(item, added);
PlayerEvents.RaiseItemAdded(item, added, isTransfer);
}
return remaining <= 0;
return incoming.IsEmpty;
}
/// <summary>
/// Returns whether the given quantity of an item would fit without mutating anything.
/// Pushes as much of the incoming stack as one slot accepts, through the shared auto-placement
/// rule (never swaps), and writes the slot back when it changed.
/// </summary>
private void StackInto(int index, ref SlotContent incoming)
{
SlotContent target = ReadSlot(index);
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
WriteSlot(index, target);
}
/// <summary>
/// Returns whether the given quantity would fit, without mutating anything. Dry-runs the exact
/// same auto-placement rule the real add uses, against copies of the slots — so this answer can
/// never drift from what <see cref="AddItem"/> would actually do, which a hand-written capacity
/// calculation eventually would.
/// </summary>
public bool CanFit(ItemData item, int quantity = 1)
{
if (item == null) return false;
int remaining = quantity;
for (int i = 0; i < inventorySize && remaining > 0; i++)
SlotContent incoming = SlotContent.Of(item, quantity, -1);
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
{
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
remaining -= Mathf.Min(remaining, item.MaxStackSize - slots[i].Quantity);
if (slots[i].IsEmpty) continue;
SlotContent target = ReadSlot(i);
SlotTransfer.TryStack(ref incoming, ref target);
}
for (int i = 0; i < inventorySize && remaining > 0; i++)
for (int i = 0; i < inventorySize && !incoming.IsEmpty; i++)
{
if (slots[i].IsEmpty)
remaining -= Mathf.Min(remaining, item.IsStackable ? item.MaxStackSize : 1);
if (!slots[i].IsEmpty) continue;
SlotContent target = SlotContent.Empty;
SlotTransfer.TryStack(ref incoming, ref target);
}
return remaining <= 0;
return incoming.IsEmpty;
}
public void RemoveItem(int index, int quantity = 1)
@@ -263,24 +343,6 @@ namespace Ashwild.Inventory
NotifySlotChanged(index);
}
public void SwapSlots(int indexA, int indexB)
{
if (indexA < 0 || indexA >= inventorySize) return;
if (indexB < 0 || indexB >= inventorySize) return;
ItemData tempData = slots[indexA].ItemData;
int tempQty = slots[indexA].Quantity;
if (slots[indexB].IsEmpty) slots[indexA].Clear();
else slots[indexA].Set(slots[indexB].ItemData, slots[indexB].Quantity);
if (tempData == null) slots[indexB].Clear();
else slots[indexB].Set(tempData, tempQty);
NotifySlotChanged(indexA);
NotifySlotChanged(indexB);
}
/// <summary>
/// Consumes one use of a consumable: applies its restores once (per bite, for multi-use food)
/// then either spends a use or removes one unit. A multi-use item is kept at 0 uses (red,
@@ -0,0 +1,42 @@
namespace Ashwild.Inventory
{
/// <summary>
/// A container-agnostic snapshot of what a slot holds: the item, how many, and the remaining uses
/// of this concrete instance (-1 when the item does not track uses). It is the common currency
/// between every container — the player's inventory, a chest, or a stack in flight over the network —
/// so the transfer rules can be written once without knowing where a slot lives.
///
/// Carrying <see cref="Uses"/> is what keeps a half-worn tool half-worn when it changes slot: writing
/// a slot back without it silently repairs the item to full.
/// </summary>
public struct SlotContent
{
public ItemData Item;
public int Quantity;
public int Uses;
/// <summary>
/// True when this snapshot holds nothing — no item, or a quantity that ran out.
/// </summary>
public bool IsEmpty => Item == null || Quantity <= 0;
/// <summary>
/// The empty snapshot, used to clear a slot.
/// </summary>
public static SlotContent Empty => new SlotContent { Item = null, Quantity = 0, Uses = -1 };
/// <summary>
/// Builds a snapshot, normalising the uses of an item that does not track them to -1.
/// </summary>
public static SlotContent Of(ItemData item, int quantity, int uses)
{
if (item == null || quantity <= 0) return Empty;
return new SlotContent
{
Item = item,
Quantity = quantity,
Uses = item.HasUses ? uses : -1
};
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c5314c94623af7498a750b916e5c22f
@@ -226,7 +226,7 @@ namespace Ashwild.Inventory
}
}
InventoryUI.OnSwapRequested(currentSlotIndex, targetSlot);
inv.MoveSlot(currentSlotIndex, targetSlot);
}
private void OnMoveToInventory()
@@ -238,7 +238,7 @@ namespace Ashwild.Inventory
{
if (inv.GetSlot(i).IsEmpty)
{
InventoryUI.OnSwapRequested(currentSlotIndex, i);
inv.MoveSlot(currentSlotIndex, i);
return;
}
}
@@ -249,7 +249,7 @@ namespace Ashwild.Inventory
InventorySlot slot = inv.GetSlot(i);
if (!slot.IsEmpty && slot.ItemData == sourceSlot.ItemData && slot.CanAccept(sourceSlot.ItemData))
{
InventoryUI.OnSwapRequested(currentSlotIndex, i);
inv.MoveSlot(currentSlotIndex, i);
return;
}
}
@@ -273,24 +273,7 @@ namespace Ashwild.Inventory
private void OnSplit()
{
if (currentSlotIndex < 0) return;
PlayerInventory inv = PlayerInventory.Instance;
InventorySlot slot = inv.GetSlot(currentSlotIndex);
if (slot.IsEmpty || slot.Quantity <= 1) return;
int halfQty = slot.Quantity / 2;
int remaining = slot.Quantity - halfQty;
for (int i = 0; i < inv.InventorySize; i++)
{
if (inv.GetSlot(i).IsEmpty)
{
slot.Set(slot.ItemData, remaining);
inv.GetSlot(i).Set(slot.ItemData, halfQty);
inv.onSlotChanged?.Invoke(currentSlotIndex);
inv.onSlotChanged?.Invoke(i);
return;
}
}
PlayerInventory.Instance.SplitSlot(currentSlotIndex);
}
}
}
@@ -0,0 +1,105 @@
using UnityEngine;
namespace Ashwild.Inventory
{
/// <summary>
/// The single source of truth for what happens when a stack meets another stack. Both rules below
/// are pure — they know nothing about containers, networking or authority — so the exact same
/// decision runs for an inventory-to-inventory drag (client-side), a chest-to-chest drag
/// (server-side) and an inventory-to-chest drag (server-side, against the payload in flight).
/// That is why a chest transfer behaves identically to moving an item inside the inventory.
///
/// There are exactly two operations, because the player expresses exactly two intents:
/// <see cref="Move"/> when they aim at a precise destination, and <see cref="TryStack"/> when they
/// let the game find a free spot (quick transfer, deposit-all, pickups).
/// </summary>
public static class SlotTransfer
{
/// <summary>
/// Moves a stack onto a destination the player explicitly aimed at. Empty destination = the whole
/// stack moves; same mergeable item = merge and leave the remainder in the source; anything else
/// (different item, non-stackable, uses-tracked, or a full stack) = swap the two slots. Uses ride
/// along with the content in every branch, so a worn item stays worn.
/// Returns whether anything actually changed.
/// </summary>
public static bool Move(ref SlotContent source, ref SlotContent target)
{
if (source.IsEmpty) return false;
if (target.IsEmpty)
{
target = source;
source = SlotContent.Empty;
return true;
}
if (CanMerge(source, target))
{
int space = target.Item.MaxStackSize - target.Quantity;
int moved = Mathf.Min(source.Quantity, space);
target.Quantity += moved;
source.Quantity -= moved;
if (source.Quantity <= 0) source = SlotContent.Empty;
return moved > 0;
}
SlotContent temp = source;
source = target;
target = temp;
return true;
}
/// <summary>
/// Auto-placement: pushes as much of <paramref name="incoming"/> into the target as it can accept,
/// and never swaps — the player did not pick this slot, so displacing what is already there would
/// be wrong. An empty target takes one instance of a uses-tracked item (each keeps its own uses
/// bar) or up to a full stack otherwise. Returns whether the target changed, leaving the leftover
/// in <paramref name="incoming"/> for the caller to keep placing.
/// </summary>
public static bool TryStack(ref SlotContent incoming, ref SlotContent target)
{
if (incoming.IsEmpty) return false;
ItemData item = incoming.Item;
if (target.IsEmpty)
{
int toPlace = item.HasUses || !item.IsStackable
? 1
: Mathf.Min(incoming.Quantity, item.MaxStackSize);
target = SlotContent.Of(item, toPlace, incoming.Uses);
incoming.Quantity -= toPlace;
if (incoming.Quantity <= 0) incoming = SlotContent.Empty;
return true;
}
if (!CanMerge(incoming, target)) return false;
int space = target.Item.MaxStackSize - target.Quantity;
int moved = Mathf.Min(incoming.Quantity, space);
if (moved <= 0) return false;
target.Quantity += moved;
incoming.Quantity -= moved;
if (incoming.Quantity <= 0) incoming = SlotContent.Empty;
return true;
}
/// <summary>
/// Two stacks merge only when they are the same stackable item that does not track uses and the
/// target still has room. Uses-tracked items never merge: each instance owns its uses bar, so
/// merging them would silently destroy one item's wear.
/// </summary>
private static bool CanMerge(SlotContent source, SlotContent target)
{
ItemData item = source.Item;
return target.Item == item
&& item.IsStackable
&& !item.HasUses
&& target.Quantity < item.MaxStackSize;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ca211917655c26a4189545ea5e5ec0a8
+143 -56
View File
@@ -6,8 +6,26 @@ using System;
namespace Ashwild.Inventory
{
/// <summary>
/// Which container a slot cell maps to, so a drop can be routed to the right operation: a plain
/// inventory swap, or a deposit/withdraw/move when a chest is involved.
/// </summary>
public enum SlotContainer
{
Inventory,
Chest
}
/// <summary>
/// One draggable slot cell, used everywhere the same way: the inventory grid, the hotbar and the
/// chest module. It is a dumb view — it renders whatever it is handed and reports its drags, clicks
/// and hovers back to a manager as a (container, index) pair; it never mutates any model itself.
/// The manager turns a drop into the matching operation (swap, deposit, withdraw, move).
/// </summary>
public class SlotUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Image iconImage;
[SerializeField] private TextMeshProUGUI quantityText;
@@ -23,137 +41,197 @@ namespace Ashwild.Inventory
[Tooltip("Icon (and bar) tint applied when the item is depleted — a broken repairable tool / empty container.")]
[SerializeField] private Color depletedTint = Color.red;
#endregion
#region State
private SlotContainer container;
private int slotIndex;
private Action<int, int> onSwapRequested;
private Action<int, bool> onClicked;
private Action<int> onHoverEnter;
private bool hasItem;
private Action<SlotContainer, int, SlotContainer, int> onDrop;
private Action<SlotContainer, int, bool> onClicked;
private Action<SlotContainer, int> onHoverEnter;
private Action onHoverExit;
public int SlotIndex => slotIndex;
public SlotContainer Container => container;
// Static drag state shared across all slots
// Static drag state shared across every cell (inventory, hotbar and chest grids).
private static SlotUI draggedSlot;
private static GameObject ghostObject;
private static Image ghostIcon;
private static TextMeshProUGUI ghostQuantity;
#endregion
#region Setup
/// <summary>
/// Wires the single shared drag ghost used by every slot cell in the scene.
/// </summary>
public static void SetupGhost(GameObject ghost, Image icon, TextMeshProUGUI qty)
{
ghostObject = ghost;
ghostIcon = icon;
ghostQuantity = qty;
ghostObject.SetActive(false);
if (ghostObject != null) ghostObject.SetActive(false);
}
public void Initialize(int index, Action<int, int> swapCallback, Action<int, bool> clickCallback,
Action<int> hoverEnterCallback = null, Action hoverExitCallback = null)
/// <summary>
/// Binds this cell to a container and index and the manager callbacks it reports interactions to.
/// Drops carry both the dragged and the target (container, index) so the manager can route them.
/// </summary>
public void Initialize(SlotContainer slotContainer, int index,
Action<SlotContainer, int, SlotContainer, int> dropCallback,
Action<SlotContainer, int, bool> clickCallback,
Action<SlotContainer, int> hoverEnterCallback = null,
Action hoverExitCallback = null)
{
container = slotContainer;
slotIndex = index;
onSwapRequested = swapCallback;
onDrop = dropCallback;
onClicked = clickCallback;
onHoverEnter = hoverEnterCallback;
onHoverExit = hoverExitCallback;
}
#endregion
#region Rendering
/// <summary>
/// Redraws the slot: icon, quantity, and the uses bar. Uses-tracked items (tools, multi-bite
/// food) show a fill bar for their remaining uses and turn red once depleted.
/// Redraws the cell from an inventory slot (the local, client-authoritative container).
/// </summary>
public void UpdateVisual(InventorySlot slot)
{
if (slot == null || slot.IsEmpty)
{
iconImage.gameObject.SetActive(false);
quantityText.gameObject.SetActive(false);
if (usageBarRoot != null) usageBarRoot.SetActive(false);
RenderEmpty();
return;
}
else
{
iconImage.gameObject.SetActive(true);
iconImage.sprite = slot.ItemData.Icon;
bool showQty = slot.Quantity > 1;
quantityText.gameObject.SetActive(showQty);
if (showQty)
quantityText.text = slot.Quantity.ToString();
UpdateUsageBar(slot);
}
ItemData item = slot.ItemData;
bool hasUses = item.HasUses;
float fill = hasUses && item.MaxUses > 0 ? (float)slot.CurrentUses / item.MaxUses : 0f;
RenderItem(item.Icon, slot.Quantity, hasUses, hasUses && slot.IsDepleted, fill);
}
/// <summary>
/// Shows the uses bar and tints the icon red when the item is depleted; hides the bar entirely
/// for items that do not track uses. Preserves the current icon alpha so a mid-drag fade stays.
/// Redraws the cell from a resolved chest view (item + quantity + remaining uses). A null item
/// renders the empty look. Uses -1 for items that do not track uses.
/// </summary>
private void UpdateUsageBar(InventorySlot slot)
public void UpdateVisual(ItemData item, int quantity, int uses)
{
bool hasUses = slot.ItemData.HasUses;
if (item == null)
{
RenderEmpty();
return;
}
if (usageBarRoot != null)
usageBarRoot.SetActive(hasUses);
bool hasUses = item.HasUses;
bool depleted = hasUses && uses <= 0;
float fill = hasUses && item.MaxUses > 0 ? (float)Mathf.Max(0, uses) / item.MaxUses : 0f;
RenderItem(item.Icon, quantity, hasUses, depleted, fill);
}
Color tint = (hasUses && slot.IsDepleted) ? depletedTint : normalTint;
/// <summary>
/// Draws an item: icon, quantity (hidden for single stacks) and the uses bar. Preserves the
/// current icon alpha so a mid-drag fade survives a refresh, and tints the icon red when
/// depleted. Shared by both the inventory and the chest render paths.
/// </summary>
private void RenderItem(Sprite icon, int quantity, bool hasUses, bool depleted, float fill)
{
hasItem = true;
iconImage.gameObject.SetActive(true);
iconImage.sprite = icon;
bool showQty = quantity > 1;
quantityText.gameObject.SetActive(showQty);
if (showQty) quantityText.text = quantity.ToString();
if (usageBarRoot != null) usageBarRoot.SetActive(hasUses);
Color tint = depleted ? depletedTint : normalTint;
tint.a = iconImage.color.a;
iconImage.color = tint;
if (hasUses && usageBarFill != null)
usageBarFill.fillAmount = (float)slot.CurrentUses / slot.ItemData.MaxUses;
if (hasUses && usageBarFill != null) usageBarFill.fillAmount = fill;
}
/// <summary>
/// Clears the cell to its empty look.
/// </summary>
private void RenderEmpty()
{
hasItem = false;
iconImage.gameObject.SetActive(false);
quantityText.gameObject.SetActive(false);
if (usageBarRoot != null) usageBarRoot.SetActive(false);
}
/// <summary>
/// Toggles the selection highlight (used by the hotbar for the active slot).
/// </summary>
public void SetSelected(bool selected)
{
if (highlightImage != null)
highlightImage.gameObject.SetActive(selected);
}
// Drag & Drop
#endregion
#region Drag & Drop
/// <summary>
/// Starts dragging this cell's stack (left button, non-empty only), building the shared ghost
/// from what the cell currently shows — container-agnostic, so inventory and chest cells drag
/// identically.
/// </summary>
public void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
InventorySlot slot = PlayerInventory.Instance.GetSlot(slotIndex);
if (slot == null || slot.IsEmpty) return;
if (!hasItem) return;
draggedSlot = this;
// Show ghost
if (ghostObject != null)
{
ghostObject.SetActive(true);
ghostIcon.sprite = slot.ItemData.Icon;
ghostIcon.sprite = iconImage.sprite;
ghostIcon.gameObject.SetActive(true);
bool showQty = slot.Quantity > 1;
bool showQty = quantityText.gameObject.activeSelf;
ghostQuantity.gameObject.SetActive(showQty);
if (showQty)
ghostQuantity.text = slot.Quantity.ToString();
if (showQty) ghostQuantity.text = quantityText.text;
ghostObject.transform.position = eventData.position;
}
// Make source icon semi-transparent
Color c = iconImage.color;
c.a = 0.4f;
iconImage.color = c;
}
/// <summary>
/// Moves the ghost with the pointer.
/// </summary>
public void OnDrag(PointerEventData eventData)
{
if (draggedSlot != this) return;
if (ghostObject != null)
ghostObject.transform.position = eventData.position;
if (ghostObject != null) ghostObject.transform.position = eventData.position;
}
/// <summary>
/// Ends the drag: hides the ghost and restores the source cell's opacity.
/// </summary>
public void OnEndDrag(PointerEventData eventData)
{
if (draggedSlot != this) return;
// Hide ghost
if (ghostObject != null)
ghostObject.SetActive(false);
if (ghostObject != null) ghostObject.SetActive(false);
// Restore icon opacity
Color c = iconImage.color;
c.a = 1f;
iconImage.color = c;
@@ -161,37 +239,46 @@ namespace Ashwild.Inventory
draggedSlot = null;
}
/// <summary>
/// Drop target: reports the dragged and target (container, index) so the manager routes the
/// transfer (swap, deposit, withdraw or move).
/// </summary>
public void OnDrop(PointerEventData eventData)
{
if (draggedSlot == null || draggedSlot == this) return;
onSwapRequested?.Invoke(draggedSlot.SlotIndex, slotIndex);
onDrop?.Invoke(draggedSlot.container, draggedSlot.slotIndex, container, slotIndex);
}
/// <summary>
/// Reports a right-click so the manager can act (context menu in normal mode, quick transfer
/// while a chest is open).
/// </summary>
public void OnPointerClick(PointerEventData eventData)
{
if (eventData.dragging) return;
if (eventData.button == PointerEventData.InputButton.Right)
onClicked?.Invoke(slotIndex, true);
onClicked?.Invoke(container, slotIndex, true);
}
/// <summary>
/// Reports the hovered slot index so the manager can show its description panel. Suppressed
/// while a drag is in progress, where the panel would only get in the way.
/// Reports the hovered cell so the manager can show its description panel. Suppressed while a
/// drag is in progress, where the panel would only get in the way.
/// </summary>
public void OnPointerEnter(PointerEventData eventData)
{
if (draggedSlot != null) return;
onHoverEnter?.Invoke(slotIndex);
onHoverEnter?.Invoke(container, slotIndex);
}
/// <summary>
/// Reports that the pointer left the slot so the manager can hide the description panel.
/// Reports that the pointer left the cell so the manager can hide the description panel.
/// </summary>
public void OnPointerExit(PointerEventData eventData)
{
onHoverExit?.Invoke();
}
#endregion
}
}
@@ -30,6 +30,10 @@ namespace Ashwild.Network
[Tooltip("The single source of truth for the gameplay scene loaded over the network.")]
[SerializeField] private string gameSceneName = "TestScene";
[Tooltip("The single source of truth for the main menu scene, loaded on return-to-menu — both " +
"the deliberate Quit and an involuntary disconnect (the host left / connection lost).")]
[SerializeField] private string menuSceneName = "MenuScene";
[Tooltip("Max seconds to wait for the loading curtain to fully fade in before loading the game " +
"scene anyway. Safety net so a missing/disabled LoadingScreen can never soft-lock the host.")]
[SerializeField] private float curtainWaitTimeout = 3f;
@@ -60,6 +64,11 @@ namespace Ashwild.Network
/// </summary>
public string GameSceneName => gameSceneName;
/// <summary>
/// The main menu scene name (single source of truth, read by anything that needs it).
/// </summary>
public string MenuSceneName => menuSceneName;
/// <summary>
/// Hard cap on total players per room (host included). Single source of truth for the
/// player limit — the Steam invite lobby reads this to mirror the cap.
@@ -82,6 +91,12 @@ namespace Ashwild.Network
/// </summary>
private bool curtainShown;
/// <summary>
/// True while a return-to-menu transition is in flight, so the client-disconnect handler does
/// not fire a second, redundant return on top of the deliberate one it triggers itself.
/// </summary>
private bool returningToMenu;
/// <summary>
/// Round-robin cursor over the available spawn points.
/// </summary>
@@ -244,16 +259,21 @@ namespace Ashwild.Network
/// the host launch: it raises the curtain, waits for it to fully fade in (so the menu-load freeze
/// is hidden), tears the session down, loads the menu, then signals MenuReady to drop the curtain.
/// Orchestrated here — not on the per-scene GameUIManager — because this object persists across
/// the scene swap while the caller is destroyed by the load.
/// the scene swap while the caller is destroyed by the load. Reused both for the deliberate Quit
/// button and for an involuntary disconnect (the host left / connection lost); the re-entrancy
/// guard makes the two paths converge on a single transition.
/// </summary>
public void ReturnToMenu(string menuSceneName)
public void ReturnToMenu()
{
if (string.IsNullOrWhiteSpace(menuSceneName))
{
Debug.LogError("[NetworkSession] ReturnToMenu called with no menu scene name.", this);
Debug.LogError("[NetworkSession] ReturnToMenu called but no menu scene name is assigned.", this);
return;
}
if (returningToMenu) return;
returningToMenu = true;
PlayerEvents.RaiseReturningToMenu();
curtainShown = false;
StartCoroutine(ReturnToMenuRoutine(menuSceneName));
@@ -295,6 +315,13 @@ namespace Ashwild.Network
/// <summary>
/// Local client transport changed state. For a pure client (join) this drives the
/// joined/stopped bus events; for a host the server handler already covers it.
///
/// On Stopped it also rescues a client dropped while in-game — the host quit or the connection
/// was lost — by driving it back to the menu through the same curtained transition as the Quit
/// button. Without this the client would sit stranded in the now-dead game scene. The guard
/// skips this when a deliberate return is already in flight (the Quit button's own teardown
/// stops the client too) and when we never made it into the game scene (a failed join, which
/// the session-error flow already handles), so the menu is never reloaded needlessly.
/// </summary>
private void HandleClientState(ClientConnectionStateArgs args)
{
@@ -311,6 +338,12 @@ namespace Ashwild.Network
else if (args.ConnectionState == LocalConnectionState.Stopped)
{
PlayerEvents.RaiseSessionStopped();
if (!returningToMenu && UnityEngine.SceneManagement.SceneManager.GetActiveScene().name == gameSceneName)
{
Log("Client disconnected while in-game (host left / connection lost) — returning to menu.");
ReturnToMenu();
}
}
}
@@ -439,6 +472,7 @@ namespace Ashwild.Network
Debug.LogError($"[NetworkSession] Could not load menu scene '{menuSceneName}' — is it in Build Settings?", this);
}
returningToMenu = false;
PlayerEvents.RaiseMenuReady();
}
+6 -2
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using Ashwild.Interaction;
using Ashwild.Inventory;
using Ashwild.Player;
namespace Ashwild.Network
{
@@ -57,11 +58,14 @@ namespace Ashwild.Network
public void Interact() => Pickup();
/// <summary>
/// Asks the registry (server) to claim this pickup for the local player. Aborts if already
/// claimed or the local inventory is full.
/// Asks the registry (server) to claim this pickup for the local player. Aborts while the player
/// is building (the hammer's placement/demolition would otherwise let an interact press snatch an
/// item mid-build), if already claimed, or if the local inventory is full.
/// </summary>
public void Pickup()
{
if (PlayerEvents.IsBuilding) return;
if (itemData == null)
{
Debug.LogError($"[Pickable] '{name}' has no ItemData assigned.", this);
@@ -29,6 +29,10 @@ namespace Ashwild.Player
public static bool IsPlacingBuild { get; private set; }
// True while the build hammer is in demolition mode (right-click held, menu closed, not placing).
public static bool IsDemolishing { get; private set; }
// Composite "the player is actively building" flag — true whenever the construction menu is open,
// a ghost is being positioned, or the hammer is demolishing. Recomputed from those three states
// (never set directly) and used to widen the crosshair and to suppress item pickup while building.
public static bool IsBuilding { get; private set; }
public static bool IsPaused { get; private set; }
// True when a networked session is live (host or client).
@@ -103,7 +107,11 @@ namespace Ashwild.Player
public static event Action<int> InventorySlotChanged;
public static event Action<int> SelectedHotbarSlotChanged;
public static event Action<ItemData, int> ItemAdded;
// (item, quantity, isTransfer) — isTransfer is true when the stack merely moved between the
// player's own containers (a chest withdrawal, a refund) instead of being newly acquired from
// the world. Consumers that announce a gain (notifications) skip transfers; consumers that only
// track "the player has held this" (craft discovery) treat both the same.
public static event Action<ItemData, int, bool> ItemAdded;
public static event Action<ItemData, int> ItemDropped;
public static event Action<ItemData> ItemConsumed;
public static event Action<ItemData, int> ItemPickedUp;
@@ -134,9 +142,11 @@ namespace Ashwild.Player
// ============================================================
public static event Action BuildMenuToggleRequested; // the build hammer asks to open/close the construction menu
public static event Action BuildCancelRequested; // the build hammer asks to cancel/exit the current ghost placement (right-click while placing)
public static event Action<bool> BuildMenuOpenChanged; // the construction menu opened (true) or closed (false)
public static event Action<bool> BuildPlacingChanged; // a build ghost started (true) / stopped (false) being positioned
public static event Action<bool> DemolishModeChanged; // the hammer entered (true) / left (false) demolition mode (right-click held)
public static event Action<bool> BuildModeChanged; // the composite build mode turned on (true) / off (false) — see IsBuilding
public static event Action<IReadOnlyList<BuildCostView>> BuildCostChanged; // the active build's cost to display near the crosshair (null/empty = clear)
// ============================================================
@@ -256,7 +266,7 @@ namespace Ashwild.Player
public static void RaiseInventorySlotChanged(int i) { InventorySlotChanged?.Invoke(i); }
public static void RaiseSelectedHotbarSlotChanged(int i) { Log(nameof(SelectedHotbarSlotChanged)); SelectedHotbarSlotChanged?.Invoke(i); }
public static void RaiseItemAdded(ItemData d, int q) { Log(nameof(ItemAdded)); ItemAdded?.Invoke(d, q); }
public static void RaiseItemAdded(ItemData d, int q, bool isTransfer = false) { Log(nameof(ItemAdded)); ItemAdded?.Invoke(d, q, isTransfer); }
public static void RaiseItemDropped(ItemData d, int q) { Log(nameof(ItemDropped)); ItemDropped?.Invoke(d, q); }
public static void RaiseItemConsumed(ItemData d) { Log(nameof(ItemConsumed)); ItemConsumed?.Invoke(d); }
public static void RaiseItemPickedUp(ItemData d, int q) { Log(nameof(ItemPickedUp)); ItemPickedUp?.Invoke(d, q); }
@@ -295,17 +305,20 @@ namespace Ashwild.Player
public static void RaiseUnequipAnimComplete() { UnequipAnimComplete?.Invoke(); }
public static void RaiseBuildMenuToggleRequested() { Log(nameof(BuildMenuToggleRequested)); BuildMenuToggleRequested?.Invoke(); }
public static void RaiseBuildCancelRequested() { Log(nameof(BuildCancelRequested)); BuildCancelRequested?.Invoke(); }
public static void RaiseBuildMenuOpenChanged(bool open)
{
IsBuildMenuOpen = open;
Log(nameof(BuildMenuOpenChanged));
BuildMenuOpenChanged?.Invoke(open);
RefreshBuildMode();
}
public static void RaiseBuildPlacingChanged(bool placing)
{
IsPlacingBuild = placing;
Log(nameof(BuildPlacingChanged));
BuildPlacingChanged?.Invoke(placing);
RefreshBuildMode();
}
/// <summary>
@@ -328,6 +341,21 @@ namespace Ashwild.Player
IsDemolishing = on;
Log(nameof(DemolishModeChanged));
DemolishModeChanged?.Invoke(on);
RefreshBuildMode();
}
/// <summary>
/// Recomputes the composite build-mode flag from its three sub-states and raises BuildModeChanged
/// only when it actually flips, so consumers (crosshair spacing, pickup gating) react exactly once
/// per transition instead of on every sub-state change.
/// </summary>
private static void RefreshBuildMode()
{
bool building = IsBuildMenuOpen || IsPlacingBuild || IsDemolishing;
if (building == IsBuilding) return;
IsBuilding = building;
Log(nameof(BuildModeChanged));
BuildModeChanged?.Invoke(building);
}
public static void RaiseFoodPlacedToCook(ItemData raw) { Log(nameof(FoodPlacedToCook)); FoodPlacedToCook?.Invoke(raw); }
@@ -413,6 +441,7 @@ namespace Ashwild.Player
IsBuildMenuOpen = false;
IsPlacingBuild = false;
IsDemolishing = false;
IsBuilding = false;
IsPaused = false;
HoveredInteractable = null;
}
@@ -4,12 +4,15 @@ using Ashwild.Inventory;
namespace Ashwild.Player
{
/// <summary>
/// Held-item logic for the build hammer, placed on its hand prefab. It turns the single
/// right-click into two gestures without any extra keybind: a quick <b>tap</b> opens/closes the
/// construction menu through the bus, while <b>holding</b> right-click past a short threshold puts
/// the hammer into demolition mode for as long as it is held (releasing leaves it). The hammer
/// stays a pure input source — it only raises bus requests; BuildManager owns the menu and the
/// world-facing targeting/destroy, so this never reaches for either.
/// Held-item logic for the build hammer, placed on its hand prefab. It is the single arbiter of the
/// right-click gesture in a build context, so the menu can never fight a placement over the same
/// click. One right-click is a <b>tap</b>, resolved on release as a clean build-mode toggle: while
/// placing a ghost it cancels the placement (back to idle, no menu), otherwise it opens/closes the
/// construction menu. <b>Holding</b> right-click past a short threshold instead enters demolition
/// mode for as long as it is held. The tap is gated only by hard UI locks (dead, inventory, chest,
/// pause) — not by the build menu being open (so a tap closes it) nor by a placement being active
/// (so a tap cancels it). The hammer stays a pure input source: it only raises bus requests;
/// BuildManager owns the menu and the world-facing targeting/destroy, so this never reaches for either.
/// </summary>
[DisallowMultipleComponent]
public class BuildHammerBehaviour : MonoBehaviour, IHeldItemBehaviour
@@ -109,16 +112,18 @@ namespace Ashwild.Player
#region Event Handlers
/// <summary>
/// The single right-click gesture, split by press and release: a press is tracked (unless
/// input is locked or a ghost is being placed, where right-click means "cancel" and is left to
/// BuildManager); its release either ends demolition mode (if the hold engaged it) or, for a
/// quick tap, toggles the construction menu under the cooldown.
/// The single right-click gesture, split by press and release. A press is tracked unless a hard
/// UI lock is up; the build menu being open or a placement being active do NOT block it, because
/// this gesture is what drives those states. The release resolves the gesture in priority order:
/// a hold that engaged demolition leaves demolition; a tap while placing a ghost cancels it
/// (the single arbiter — BuildManager no longer listens to the raw click, so the menu can't
/// reopen on the same click); otherwise a tap toggles the construction menu.
/// </summary>
private void HandleSecondaryUse(bool held)
{
if (held)
{
if (PlayerEvents.InputLocked || PlayerEvents.IsPlacingBuild) return;
if (HardLocked) return;
secondaryDown = true;
secondaryDownTime = Time.time;
return;
@@ -127,14 +132,24 @@ namespace Ashwild.Player
if (!secondaryDown) return;
secondaryDown = false;
if (demoActive) ExitDemolition();
else ToggleMenu();
if (demoActive) { ExitDemolition(); return; }
if (PlayerEvents.IsPlacingBuild) { PlayerEvents.RaiseBuildCancelRequested(); return; }
ToggleMenu();
}
#endregion
#region Internal Helpers
/// <summary>
/// A hard UI lock (dead, inventory, chest, pause) where right-click must do nothing. Unlike the
/// bus's InputLocked, it deliberately ignores the build menu being open and a placement being
/// active — both are build states this gesture is meant to drive (close the menu, cancel the
/// placement), not locks that should swallow the click.
/// </summary>
private static bool HardLocked =>
PlayerEvents.IsDead || PlayerEvents.IsInventoryOpen || PlayerEvents.IsChestOpen || PlayerEvents.IsPaused;
/// <summary>
/// Requests the construction menu to open/close, gated by the toggle cooldown.
/// </summary>
+182 -212
View File
@@ -10,8 +10,8 @@ namespace Ashwild.Storage
{
/// <summary>
/// A networked storage chest and the single IInteractable that opens it. Interacting opens the
/// shared <see cref="ChestUI"/> (the player's inventory on the left, this chest on the right); all
/// item moves are requested through server RPCs.
/// inventory window in chest mode (the player's inventory on the left, this chest's module on the
/// right); all item moves are requested through server RPCs.
///
/// Multiplayer: the chest is **server-authoritative** so two players browsing the same chest can
/// never clobber or duplicate items. The server owns the rich model (<see cref="InventorySlot"/>[])
@@ -72,7 +72,7 @@ namespace Ashwild.Storage
#region Events
/// <summary>
/// Fired when a slot's replicated view changes so the open <see cref="ChestUI"/> can refresh
/// Fired when a slot's replicated view changes so the open <see cref="ChestPanelUI"/> can refresh
/// just that cell. An index of -1 means the whole collection changed (a full refresh).
/// </summary>
public event Action<int> SlotViewChanged;
@@ -154,16 +154,17 @@ namespace Ashwild.Storage
public string InteractionPrompt => $"Ouvrir {DisplayName}";
/// <summary>
/// Opens the shared chest window bound to this chest. Runs on the interacting client only.
/// Opens the inventory window in chest mode, bound to this chest. Runs on the interacting client
/// only.
/// </summary>
public void Interact()
{
if (ChestUI.Instance == null)
if (InventoryUI.Instance == null)
{
Debug.LogError("[Chest] No ChestUI found in the scene — cannot open the chest.", this);
Debug.LogError("[Chest] No InventoryUI found in the scene — cannot open the chest.", this);
return;
}
ChestUI.Instance.Open(this);
InventoryUI.Instance.OpenChest(this);
}
#endregion
@@ -198,52 +199,94 @@ namespace Ashwild.Storage
#region Client Requests
/// <summary>
/// Deposits the whole stack in the given inventory slot into a specific chest slot. When the
/// target chest slot holds a different item this becomes a swap, so it first checks the
/// displaced stack still fits in the inventory (server refunds anything the target can't hold).
/// Pass a negative chest slot to let the server auto-place into the first fitting slot.
/// The one drag-and-drop operation for anything involving this chest: chest→inventory,
/// inventory→chest and chest→chest, in either direction. It never decides *what* happens — the
/// shared <see cref="SlotTransfer.Move"/> rules do — so dropping a stack on a chest cell behaves
/// exactly like dropping it on an inventory cell (move, merge or swap).
///
/// For a cross-container move the player's slot is read and cleared locally (the inventory is
/// client-authoritative) and sent along as a payload; the server reconciles it against the chest
/// slot and grants whatever comes back into that same slot, which is why an item never lands
/// somewhere unexpected like the first free hotbar cell.
/// </summary>
public void RequestDepositToSlot(int inventoryIndex, int chestSlot)
public void RequestMove(SlotContainer fromContainer, int fromIndex, SlotContainer toContainer, int toIndex)
{
if (fromContainer == SlotContainer.Chest && toContainer == SlotContainer.Chest)
{
if (fromIndex != toIndex) MoveWithinServerRpc(fromIndex, toIndex);
return;
}
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
bool chestIsSource = fromContainer == SlotContainer.Chest;
int chestIndex = chestIsSource ? fromIndex : toIndex;
int inventoryIndex = chestIsSource ? toIndex : fromIndex;
if (chestIndex < 0 || chestIndex >= slotViews.Count) return;
if (inv.GetSlot(inventoryIndex) == null) return;
SlotContent payload = inv.ReadSlot(inventoryIndex);
bool chestSlotFilled = TryGetSlot(chestIndex, out _, out _, out _);
// Nothing to move: the side the player dragged from is empty.
if (chestIsSource ? !chestSlotFilled : payload.IsEmpty) return;
ushort id = 0;
if (!payload.IsEmpty)
{
id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(payload.Item) : (ushort)0;
if (id == 0)
{
Debug.LogError($"[Chest] '{payload.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
return;
}
inv.WriteSlot(inventoryIndex, SlotContent.Empty);
}
MoveCrossServerRpc(chestIndex, inventoryIndex, chestIsSource, id, payload.Quantity, payload.Uses);
}
/// <summary>
/// Quick transfer (right-click): sends a stack to the other container without the player picking a
/// destination, so it is auto-placed into the first slot that accepts it. Deliberately a different
/// operation from <see cref="RequestMove"/> — here the player expressed no target.
/// </summary>
public void RequestQuickTransfer(SlotContainer fromContainer, int index)
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
InventorySlot slot = inv.GetSlot(inventoryIndex);
if (slot == null || slot.IsEmpty) return;
ItemData item = slot.ItemData;
int quantity = slot.Quantity;
int uses = item.HasUses ? slot.CurrentUses : -1;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(item) : (ushort)0;
if (id == 0)
if (fromContainer == SlotContainer.Chest)
{
Debug.LogError($"[Chest] '{item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
if (!TryGetSlot(index, out ItemData item, out int quantity, out _)) return;
if (!inv.CanFit(item, quantity))
{
Debug.LogWarning($"[Chest] Inventaire plein — impossible de retirer '{item.ItemName}'.", this);
return;
}
QuickWithdrawServerRpc(index);
return;
}
if (chestSlot >= 0 && chestSlot < slotViews.Count)
SlotContent content = inv.ReadSlot(index);
if (content.IsEmpty) return;
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0;
if (id == 0)
{
ChestSlotView target = slotViews[chestSlot];
if (target.rawId != 0)
{
ItemData targetItem = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(target.rawId) : null;
bool sameStackable = targetItem == item && item.IsStackable && !item.HasUses;
if (!sameStackable && targetItem != null && !inv.CanFit(targetItem, target.quantity))
{
Debug.LogWarning($"[Chest] Inventaire plein — impossible d'échanger avec '{targetItem.ItemName}'.", this);
return;
}
}
Debug.LogError($"[Chest] '{content.Item.ItemName}' is not in the ItemDatabase — cannot store. Run Rebuild Item Database.", this);
return;
}
inv.RemoveItem(inventoryIndex, quantity);
RequestDepositServerRpc(chestSlot, id, quantity, uses);
inv.WriteSlot(index, SlotContent.Empty);
QuickDepositServerRpc(id, content.Quantity, content.Uses, index);
}
/// <summary>
/// Deposits every non-empty inventory slot into the chest, auto-placing each. Safe: the server
/// refunds anything that does not fit back to the same player.
/// Stores every inventory stack in the chest, auto-placing each. Safe: the server refunds whatever
/// does not fit back to the slot it came from.
/// </summary>
public void RequestDepositAll()
{
@@ -251,50 +294,18 @@ namespace Ashwild.Storage
if (inv == null) return;
for (int i = 0; i < inv.InventorySize; i++)
{
InventorySlot slot = inv.GetSlot(i);
if (slot == null || slot.IsEmpty) continue;
RequestDepositToSlot(i, -1);
}
RequestQuickTransfer(SlotContainer.Inventory, i);
}
/// <summary>
/// Withdraws a chest slot's whole stack into the inventory, after a local CanFit pre-check so a
/// full inventory leaves the items in the chest instead of losing them.
/// </summary>
public void RequestWithdraw(int chestSlot)
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
if (!TryGetSlot(chestSlot, out ItemData item, out int quantity, out _)) return;
if (!inv.CanFit(item, quantity))
{
Debug.LogWarning($"[Chest] Inventaire plein — impossible de retirer '{item.ItemName}'.", this);
return;
}
RequestWithdrawServerRpc(chestSlot, quantity);
}
/// <summary>
/// Withdraws every non-empty chest slot into the inventory. Each slot is CanFit-checked as it
/// goes. Because the grants come back asynchronously, the checks do not yet account for items
/// still in flight, so a chest larger than the free inventory space may leave some items behind.
/// Takes every chest stack into the inventory. Each slot is CanFit-checked as it goes; because the
/// grants come back asynchronously the checks do not account for items still in flight, so a chest
/// larger than the free inventory space may leave some items behind.
/// </summary>
public void RequestWithdrawAll()
{
for (int i = 0; i < slotViews.Count; i++)
if (slotViews[i].rawId != 0)
RequestWithdraw(i);
}
/// <summary>
/// Moves/merges one chest slot into another (a drag within the chest grid).
/// </summary>
public void RequestMoveWithin(int fromSlot, int toSlot)
{
RequestMoveWithinServerRpc(fromSlot, toSlot);
RequestQuickTransfer(SlotContainer.Chest, i);
}
#endregion
@@ -302,110 +313,80 @@ namespace Ashwild.Storage
#region Server RPCs
/// <summary>
/// Server-side deposit. Places the incoming stack in the requested slot — filling an empty
/// slot, merging into a matching stack, or swapping with a different item (the displaced stack
/// is granted back to the player) — and refunds any leftover. A negative slot auto-places.
/// Server-side chest-to-chest move: both slots are ours, so the shared rules run straight on the
/// authoritative model.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestDepositServerRpc(int chestSlot, ushort id, int quantity, int uses, NetworkConnection conn = null)
private void MoveWithinServerRpc(int fromIndex, int toIndex)
{
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
if (item == null || quantity <= 0) return;
if (fromIndex < 0 || fromIndex >= slots.Length) return;
if (toIndex < 0 || toIndex >= slots.Length) return;
if (chestSlot < 0 || chestSlot >= slots.Length)
SlotContent from = ReadServerSlot(fromIndex);
SlotContent to = ReadServerSlot(toIndex);
if (!SlotTransfer.Move(ref from, ref to)) return;
WriteServerSlot(fromIndex, from);
WriteServerSlot(toIndex, to);
}
/// <summary>
/// Server-side inventory↔chest move, both directions. The player's slot arrives as a payload (empty
/// when they dragged onto an empty cell); the same rules that drive an inventory-to-inventory drag
/// decide between move, merge and swap, then whatever ends up on the player's side is granted back
/// into the exact slot they used. An out-of-range chest index refunds the payload rather than
/// swallowing it.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void MoveCrossServerRpc(int chestIndex, int inventoryIndex, bool chestIsSource, ushort id, int quantity, int uses, NetworkConnection conn = null)
{
ItemData payloadItem = id != 0 && ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
SlotContent playerContent = SlotContent.Of(payloadItem, quantity, uses);
if (chestIndex < 0 || chestIndex >= slots.Length)
{
ServerDepositAuto(item, quantity, uses, conn);
GrantBack(conn, playerContent, inventoryIndex);
return;
}
InventorySlot target = slots[chestSlot];
SlotContent chestContent = ReadServerSlot(chestIndex);
if (target.IsEmpty)
{
int place = PlaceableCount(item, quantity);
target.Set(item, place, uses);
WriteView(chestSlot);
GrantBack(conn, item, quantity - place, uses);
}
else if (!item.HasUses && target.ItemData == item && target.CanAccept(item))
{
int leftover = target.AddQuantity(quantity);
WriteView(chestSlot);
GrantBack(conn, item, leftover, uses);
}
else
{
ItemData displaced = target.ItemData;
int displacedQty = target.Quantity;
int displacedUses = displaced != null && displaced.HasUses ? target.CurrentUses : -1;
if (chestIsSource) SlotTransfer.Move(ref chestContent, ref playerContent);
else SlotTransfer.Move(ref playerContent, ref chestContent);
int place = PlaceableCount(item, quantity);
target.Set(item, place, uses);
WriteView(chestSlot);
GrantBack(conn, item, quantity - place, uses);
GrantBack(conn, displaced, displacedQty, displacedUses);
}
WriteServerSlot(chestIndex, chestContent);
GrantBack(conn, playerContent, inventoryIndex);
}
/// <summary>
/// Server-side withdraw. Removes the whole stack from the chest slot and grants it to the
/// requesting player.
/// Server-side quick deposit: auto-places the incoming stack and refunds any leftover to the slot
/// it was taken from.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestWithdrawServerRpc(int chestSlot, int quantity, NetworkConnection conn = null)
private void QuickDepositServerRpc(ushort id, int quantity, int uses, int originIndex, NetworkConnection conn = null)
{
if (chestSlot < 0 || chestSlot >= slots.Length) return;
ItemData item = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetItem(id) : null;
SlotContent incoming = SlotContent.Of(item, quantity, uses);
if (incoming.IsEmpty) return;
InventorySlot s = slots[chestSlot];
if (s.IsEmpty || quantity <= 0) return;
ItemData item = s.ItemData;
int take = Mathf.Min(quantity, s.Quantity);
int uses = item.HasUses ? s.CurrentUses : -1;
s.RemoveQuantity(take);
WriteView(chestSlot);
GrantBack(conn, item, take, uses);
AutoPlace(ref incoming);
GrantBack(conn, incoming, originIndex);
}
/// <summary>
/// Server-side move within the chest: merges into a matching stack, otherwise swaps the two
/// slots (uses preserved). Never touches any inventory.
/// Server-side quick withdraw: empties the chest slot and lets the player's inventory auto-place it.
/// </summary>
[ServerRpc(RequireOwnership = false)]
private void RequestMoveWithinServerRpc(int fromSlot, int toSlot, NetworkConnection conn = null)
private void QuickWithdrawServerRpc(int chestIndex, NetworkConnection conn = null)
{
if (fromSlot < 0 || fromSlot >= slots.Length) return;
if (toSlot < 0 || toSlot >= slots.Length) return;
if (fromSlot == toSlot) return;
if (chestIndex < 0 || chestIndex >= slots.Length) return;
InventorySlot a = slots[fromSlot];
if (a.IsEmpty) return;
InventorySlot b = slots[toSlot];
SlotContent content = ReadServerSlot(chestIndex);
if (content.IsEmpty) return;
if (!b.IsEmpty && !a.ItemData.HasUses && b.ItemData == a.ItemData && b.CanAccept(a.ItemData))
{
int leftover = b.AddQuantity(a.Quantity);
if (leftover <= 0) a.Clear();
else a.Set(a.ItemData, leftover);
}
else
{
ItemData ai = a.ItemData;
int aq = a.Quantity;
int au = ai != null && ai.HasUses ? a.CurrentUses : -1;
ItemData bi = b.ItemData;
int bq = b.Quantity;
int bu = bi != null && bi.HasUses ? b.CurrentUses : -1;
if (bi == null) a.Clear(); else a.Set(bi, bq, bu);
if (ai == null) b.Clear(); else b.Set(ai, aq, au);
}
WriteView(fromSlot);
WriteView(toSlot);
WriteServerSlot(chestIndex, SlotContent.Empty);
GrantBack(conn, content, -1);
}
#endregion
@@ -413,84 +394,73 @@ namespace Ashwild.Storage
#region Server Helpers
/// <summary>
/// Auto-places an incoming stack: merges into matching non-full slots first, then fills empty
/// slots (one instance per slot for uses-tracked items), and refunds any leftover.
/// Auto-places a stack into the chest through the shared rule: merge into matching stacks first,
/// then fill empty slots. Never swaps — the player picked no destination. Whatever does not fit is
/// left in <paramref name="incoming"/> for the caller to refund.
/// </summary>
private void ServerDepositAuto(ItemData item, int quantity, int uses, NetworkConnection conn)
private void AutoPlace(ref SlotContent incoming)
{
int remaining = quantity;
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
if (!slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
if (!item.HasUses)
{
for (int i = 0; i < slots.Length && remaining > 0; i++)
{
if (!slots[i].IsEmpty && slots[i].ItemData == item && slots[i].CanAccept(item))
{
remaining = slots[i].AddQuantity(remaining);
WriteView(i);
}
}
}
for (int i = 0; i < slots.Length && remaining > 0; i++)
{
if (!slots[i].IsEmpty) continue;
if (item.HasUses)
{
slots[i].Set(item, 1, uses);
remaining -= 1;
}
else
{
int place = item.IsStackable ? Mathf.Min(remaining, item.MaxStackSize) : 1;
slots[i].Set(item, place);
remaining -= place;
}
WriteView(i);
}
GrantBack(conn, item, remaining, uses);
for (int i = 0; i < slots.Length && !incoming.IsEmpty; i++)
if (slots[i].IsEmpty) StackIntoSlot(i, ref incoming);
}
/// <summary>
/// How many units of an item can sit in a single fresh slot: one for uses-tracked or
/// non-stackable items, otherwise up to the max stack size.
/// Pushes as much of the incoming stack as one chest slot accepts, writing it back when it changed.
/// </summary>
private int PlaceableCount(ItemData item, int quantity)
private void StackIntoSlot(int index, ref SlotContent incoming)
{
int cap = item.HasUses ? 1 : (item.IsStackable ? item.MaxStackSize : 1);
return Mathf.Min(quantity, cap);
SlotContent target = ReadServerSlot(index);
if (!SlotTransfer.TryStack(ref incoming, ref target)) return;
WriteServerSlot(index, target);
}
/// <summary>
/// Pushes the server model of a slot into its replicated view.
/// Reads a chest slot from the authoritative model as a container-agnostic snapshot.
/// </summary>
private void WriteView(int index)
private SlotContent ReadServerSlot(int index)
{
InventorySlot s = slots[index];
ChestSlotView v;
if (s.IsEmpty)
{
v = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 };
}
else
{
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(s.ItemData) : (ushort)0;
v = new ChestSlotView { rawId = id, quantity = s.Quantity, uses = s.ItemData.HasUses ? s.CurrentUses : -1 };
}
slotViews[index] = v;
if (s.IsEmpty) return SlotContent.Empty;
return SlotContent.Of(s.ItemData, s.Quantity, s.CurrentUses);
}
/// <summary>
/// Grants an item back to the requesting player's (client-authoritative) inventory. No-op for
/// a null item or a non-positive quantity.
/// Writes a snapshot into the authoritative model and pushes it into the replicated view in one
/// step, so the two can never drift apart. Carries the remaining uses, so a worn tool stored in a
/// chest comes back out just as worn.
/// </summary>
private void GrantBack(NetworkConnection conn, ItemData item, int quantity, int uses)
private void WriteServerSlot(int index, SlotContent content)
{
if (item == null || quantity <= 0) return;
InventorySlot s = slots[index];
if (content.IsEmpty)
{
s.Clear();
slotViews[index] = new ChestSlotView { rawId = 0, quantity = 0, uses = -1 };
return;
}
s.Set(content.Item, content.Quantity, content.Uses);
ushort id = ItemDatabase.Instance != null ? ItemDatabase.Instance.GetId(content.Item) : (ushort)0;
slotViews[index] = new ChestSlotView { rawId = id, quantity = content.Quantity, uses = content.Uses };
}
/// <summary>
/// Grants a stack to the requesting player's (client-authoritative) inventory. No-op when empty.
/// <paramref name="preferredIndex"/> is the slot the player used, so a refund or a swapped-out
/// stack returns exactly there instead of auto-filling the first free slot (the hotbar). Negative
/// means auto-place. Always flagged as a transfer: taking a stack out of a chest is shuffling
/// items between the player's own containers, not a gain, so the HUD must not announce it.
/// </summary>
private void GrantBack(NetworkConnection conn, SlotContent content, int preferredIndex)
{
if (content.IsEmpty) return;
PlayerInventory inv = ResolveInventory(conn);
if (inv != null) inv.GrantItemFromServer(item, quantity, uses);
if (inv != null) inv.GrantItemFromServer(content.Item, content.Quantity, content.Uses, preferredIndex, isTransfer: true);
}
/// <summary>
+192
View File
@@ -0,0 +1,192 @@
using UnityEngine;
using TMPro;
using Ashwild.Inventory;
namespace Ashwild.Storage
{
/// <summary>
/// The chest module of the inventory window: the right-side grid that shows the opened chest's
/// contents, mirror of the hover-description module it replaces while a chest is open. It is a pure
/// view driven by <see cref="InventoryUI"/> — it builds its grid of <see cref="SlotUI"/> cells from
/// the bound chest's replicated view and refreshes them live, but it never routes transfers itself:
/// every cell reports its drop to <see cref="InventoryUI.HandleSlotDrop"/>, which turns it into the
/// matching server-authoritative deposit/withdraw/move on the <see cref="Chest"/>.
/// </summary>
[DisallowMultipleComponent]
public class ChestPanelUI : MonoBehaviour
{
#region Serialized Fields
[Header("Window")]
[Tooltip("Root holder toggled on/off as the module is shown or hidden.")]
[SerializeField] private GameObject root;
[SerializeField] private TextMeshProUGUI titleText;
[Header("Grid")]
[Tooltip("Parent the chest cells are laid out under.")]
[SerializeField] private Transform slotContainer;
[Tooltip("Prefab carrying a SlotUI — the same cell used by the inventory grid and the hotbar.")]
[SerializeField] private GameObject slotPrefab;
#endregion
#region State
private SlotUI[] slots;
private Chest boundChest;
/// <summary>
/// The chest currently shown (null while the module is hidden). Read by InventoryUI to route
/// transfers.
/// </summary>
public Chest Chest => boundChest;
#endregion
#region Unity Lifecycle
/// <summary>
/// Parks the module hidden so it only appears once a chest is opened.
/// </summary>
private void Awake()
{
if (root != null) root.SetActive(false);
}
/// <summary>
/// Unbinds the chest event if the module is torn down while open.
/// </summary>
private void OnDestroy()
{
if (boundChest != null) boundChest.SlotViewChanged -= HandleChestSlotChanged;
}
#endregion
#region Show / Hide
/// <summary>
/// Binds a chest and shows the module: (re)builds the grid to the chest's slot count, subscribes
/// to its replicated view so cells refresh live, sets the title and draws every cell.
/// </summary>
public void Bind(Chest chest)
{
if (boundChest != null) boundChest.SlotViewChanged -= HandleChestSlotChanged;
boundChest = chest;
if (root != null) root.SetActive(true);
BuildGrid();
if (boundChest != null)
{
boundChest.SlotViewChanged += HandleChestSlotChanged;
if (titleText != null) titleText.text = boundChest.DisplayName;
}
RefreshAll();
}
/// <summary>
/// Hides the module, unbinds the chest and releases it.
/// </summary>
public void Hide()
{
if (boundChest != null) boundChest.SlotViewChanged -= HandleChestSlotChanged;
boundChest = null;
if (root != null) root.SetActive(false);
}
#endregion
#region Grid Building
/// <summary>
/// Builds (or rebuilds when the size differs) the grid to match the bound chest's slot count, so
/// opening a small chest then a large one lays out the right number of cells.
/// </summary>
private void BuildGrid()
{
int count = boundChest != null ? boundChest.SlotCount : 0;
if (slots != null && slots.Length == count) return;
if (slots != null)
foreach (SlotUI s in slots)
if (s != null) Destroy(s.gameObject);
slots = new SlotUI[count];
for (int i = 0; i < count; i++)
{
SlotUI slot = Instantiate(slotPrefab, slotContainer).GetComponent<SlotUI>();
slot.Initialize(SlotContainer.Chest, i, InventoryUI.HandleSlotDrop, HandleChestSlotClicked);
slots[i] = slot;
}
}
#endregion
#region Interaction
/// <summary>
/// Right-click on a chest cell quick-transfers its whole stack into the inventory (auto-placed).
/// </summary>
private void HandleChestSlotClicked(SlotContainer container, int index, bool rightClick)
{
if (!rightClick || boundChest == null) return;
PredictEmptied(index);
boundChest.RequestQuickTransfer(SlotContainer.Chest, index);
}
/// <summary>
/// Draws a cell as emptied straight away, while its move is still in flight to the server.
///
/// Emptying the chest travels on the SyncList (flushed at end of tick) but the matching grant is
/// a TargetRpc (sent immediately), so the item would otherwise appear in the inventory a moment
/// *before* leaving the chest — a visible double. This only repaints the cell: the authoritative
/// model is untouched, so if the server disagrees (a co-op partner grabbed the stack first) the
/// next replicated update simply paints the item back.
/// </summary>
public void PredictEmptied(int index)
{
if (slots == null || index < 0 || index >= slots.Length) return;
slots[index].UpdateVisual((ItemData)null, 0, -1);
}
#endregion
#region Refresh
/// <summary>
/// Refreshes a single cell, or the whole grid when the collection was (re)seeded (index -1).
/// </summary>
private void HandleChestSlotChanged(int index)
{
if (slots == null) return;
if (index < 0) { RefreshAll(); return; }
if (index < slots.Length) RefreshSlot(index);
}
/// <summary>
/// Redraws every chest cell.
/// </summary>
private void RefreshAll()
{
if (slots == null) return;
for (int i = 0; i < slots.Length; i++) RefreshSlot(i);
}
/// <summary>
/// Draws one cell from the chest's replicated view, or empty when the slot holds nothing.
/// </summary>
private void RefreshSlot(int index)
{
if (boundChest != null && boundChest.TryGetSlot(index, out ItemData item, out int qty, out int uses))
slots[index].UpdateVisual(item, qty, uses);
else
slots[index].UpdateVisual((ItemData)null, 0, -1);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7b3438aa0c3c5264cbd59c336e56d645
-245
View File
@@ -1,245 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using Ashwild.Inventory;
namespace Ashwild.Storage
{
/// <summary>
/// Which container a chest-window cell belongs to, so a drop can be routed to the right transfer.
/// </summary>
public enum ChestSlotContainer
{
Inventory,
Chest
}
/// <summary>
/// One draggable cell in the chest window, used for both sides — the player's inventory (left) and
/// the chest (right). Unlike the inventory's SlotUI it carries which container and index it maps to,
/// so a drop hands the pair to the owning <see cref="ChestUI"/>, which turns it into a deposit,
/// withdraw, move-within or inventory swap. It only renders and reports drags; every real mutation
/// goes through the chest's server-authoritative RPCs.
/// </summary>
public class ChestSlotUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler, IPointerClickHandler
{
#region Serialized Fields
[Header("References")]
[SerializeField] private Image iconImage;
[SerializeField] private TextMeshProUGUI quantityText;
[Header("Uses Bar")]
[Tooltip("Root of the uses/durability bar — shown only for items that track uses.")]
[SerializeField] private GameObject usageBarRoot;
[Tooltip("Filled image whose fillAmount maps to the remaining uses (Image Type = Filled).")]
[SerializeField] private Image usageBarFill;
[SerializeField] private Color normalTint = Color.white;
[SerializeField] private Color depletedTint = Color.red;
#endregion
#region State
private ChestUI owner;
private ChestSlotContainer container;
private int index;
public ChestSlotContainer Container => container;
public int Index => index;
// Static drag state shared across every chest-window cell (both grids).
private static ChestSlotUI draggedSlot;
private static GameObject ghostObject;
private static Image ghostIcon;
private static TextMeshProUGUI ghostQuantity;
#endregion
#region Setup
/// <summary>
/// Wires the single shared drag ghost used by every cell in the window.
/// </summary>
public static void SetupGhost(GameObject ghost, Image icon, TextMeshProUGUI qty)
{
ghostObject = ghost;
ghostIcon = icon;
ghostQuantity = qty;
if (ghostObject != null) ghostObject.SetActive(false);
}
/// <summary>
/// Binds this cell to a container and index within the owning window.
/// </summary>
public void Initialize(ChestUI ownerUI, ChestSlotContainer slotContainer, int slotIndex)
{
owner = ownerUI;
container = slotContainer;
index = slotIndex;
}
#endregion
#region Rendering
/// <summary>
/// Redraws the cell from whichever container it belongs to (the local inventory or the chest's
/// replicated view).
/// </summary>
public void Refresh()
{
if (container == ChestSlotContainer.Inventory)
{
PlayerInventory inv = PlayerInventory.Instance;
InventorySlot slot = inv != null ? inv.GetSlot(index) : null;
if (slot == null || slot.IsEmpty)
{
RenderEmpty();
return;
}
Render(slot.ItemData, slot.Quantity, slot.ItemData.HasUses ? slot.CurrentUses : -1);
}
else
{
if (owner != null && owner.Chest != null
&& owner.Chest.TryGetSlot(index, out ItemData item, out int qty, out int uses))
Render(item, qty, uses);
else
RenderEmpty();
}
}
/// <summary>
/// Clears the cell to its empty look.
/// </summary>
private void RenderEmpty()
{
iconImage.gameObject.SetActive(false);
quantityText.gameObject.SetActive(false);
if (usageBarRoot != null) usageBarRoot.SetActive(false);
}
/// <summary>
/// Draws an item's icon, quantity and uses bar. Keeps the mid-drag fade on the cell currently
/// being dragged, but otherwise forces full opacity.
/// </summary>
private void Render(ItemData item, int quantity, int uses)
{
iconImage.gameObject.SetActive(true);
iconImage.sprite = item.Icon;
bool showQty = quantity > 1;
quantityText.gameObject.SetActive(showQty);
if (showQty) quantityText.text = quantity.ToString();
bool hasUses = item.HasUses;
if (usageBarRoot != null) usageBarRoot.SetActive(hasUses);
bool depleted = hasUses && uses <= 0;
Color tint = depleted ? depletedTint : normalTint;
tint.a = draggedSlot == this ? iconImage.color.a : 1f;
iconImage.color = tint;
if (hasUses && usageBarFill != null && item.MaxUses > 0)
usageBarFill.fillAmount = (float)Mathf.Max(0, uses) / item.MaxUses;
}
#endregion
#region Drag & Drop
/// <summary>
/// Starts dragging this cell's stack (left button, non-empty only), showing the shared ghost.
/// </summary>
public void OnBeginDrag(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left) return;
if (!HasItem()) return;
draggedSlot = this;
if (ghostObject != null)
{
ghostObject.SetActive(true);
ghostIcon.sprite = iconImage.sprite;
ghostIcon.gameObject.SetActive(true);
bool showQty = quantityText.gameObject.activeSelf;
ghostQuantity.gameObject.SetActive(showQty);
if (showQty) ghostQuantity.text = quantityText.text;
ghostObject.transform.position = eventData.position;
}
Color c = iconImage.color;
c.a = 0.4f;
iconImage.color = c;
}
/// <summary>
/// Moves the ghost with the pointer.
/// </summary>
public void OnDrag(PointerEventData eventData)
{
if (draggedSlot != this) return;
if (ghostObject != null) ghostObject.transform.position = eventData.position;
}
/// <summary>
/// Ends the drag: hides the ghost and restores the source cell's opacity.
/// </summary>
public void OnEndDrag(PointerEventData eventData)
{
if (draggedSlot != this) return;
if (ghostObject != null) ghostObject.SetActive(false);
Color c = iconImage.color;
c.a = 1f;
iconImage.color = c;
draggedSlot = null;
}
/// <summary>
/// Drop target: hands the dragged cell and this cell to the window to route the transfer.
/// </summary>
public void OnDrop(PointerEventData eventData)
{
if (draggedSlot == null || draggedSlot == this) return;
if (owner != null) owner.HandleTransfer(draggedSlot, this);
}
/// <summary>
/// Right-click: quick-transfers this cell to the other container (deposit or withdraw).
/// </summary>
public void OnPointerClick(PointerEventData eventData)
{
if (eventData.dragging) return;
if (eventData.button == PointerEventData.InputButton.Right && owner != null)
owner.HandleQuickTransfer(this);
}
#endregion
#region Internal Helpers
/// <summary>
/// Whether this cell currently holds an item in its container.
/// </summary>
private bool HasItem()
{
if (container == ChestSlotContainer.Inventory)
{
PlayerInventory inv = PlayerInventory.Instance;
InventorySlot s = inv != null ? inv.GetSlot(index) : null;
return s != null && !s.IsEmpty;
}
return owner != null && owner.Chest != null && owner.Chest.TryGetSlot(index, out _, out _, out _);
}
#endregion
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 2052b2fcd1679c04e8720a51067c8784
-320
View File
@@ -1,320 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Ashwild.Inventory;
using Ashwild.UI;
namespace Ashwild.Storage
{
/// <summary>
/// The chest window: the local player's whole inventory on the left, the opened chest's contents
/// on the right. It is a local UI panel — opened by interacting with a <see cref="Chest"/>, closed
/// with Escape — driven by the GameUIManager panel stack like the inventory. The inventory grid is
/// client-authoritative data; the chest grid renders the chest's replicated view and every move is
/// routed through the chest's server-authoritative RPCs, so two players sharing a chest stay in sync.
///
/// Register this panel in <c>GameUIManager.panels</c> so the stack can open/close it. The controller
/// object stays active; Show/Hide only toggle the visual window and (un)bind the change events.
/// </summary>
public class ChestUI : UIPanel
{
/// <summary>
/// Marks this as a local window: input is locked and the cursor shows, but the world keeps
/// running (never pauses), exactly like the inventory.
/// </summary>
public override PanelKind Kind => PanelKind.Chest;
/// <summary>
/// The single chest window in the scene, reached by a chest's Interact().
/// </summary>
public static ChestUI Instance { get; private set; }
#region Serialized Fields
[Header("Window")]
[Tooltip("Visual window toggled on open/close; the controller object itself stays active.")]
[SerializeField] private GameObject windowRoot;
[SerializeField] private TextMeshProUGUI titleText;
[Header("Grids")]
[Tooltip("Parent the player-inventory cells are laid out under (left side).")]
[SerializeField] private Transform inventoryContainer;
[Tooltip("Parent the chest cells are laid out under (right side).")]
[SerializeField] private Transform chestContainer;
[Tooltip("Prefab with a ChestSlotUI, instantiated for every cell on both sides.")]
[SerializeField] private GameObject slotPrefab;
[Header("Drag Ghost")]
[SerializeField] private GameObject ghostObject;
[SerializeField] private Image ghostIcon;
[SerializeField] private TextMeshProUGUI ghostQuantityText;
[Header("Transfer Buttons")]
[SerializeField] private Button depositAllButton;
[SerializeField] private Button withdrawAllButton;
#endregion
#region State
private ChestSlotUI[] inventorySlots;
private ChestSlotUI[] chestSlots;
private Chest boundChest;
/// <summary>
/// The chest currently displayed on the right side (null while closed).
/// </summary>
public Chest Chest => boundChest;
#endregion
#region Unity Lifecycle
/// <summary>
/// Registers the singleton (in addition to the base panel setup).
/// </summary>
protected override void Awake()
{
base.Awake();
Instance = this;
}
/// <summary>
/// Sets up the shared ghost, parks the window closed and wires the transfer-all buttons.
/// </summary>
private void Start()
{
ChestSlotUI.SetupGhost(ghostObject, ghostIcon, ghostQuantityText);
if (windowRoot != null) windowRoot.SetActive(false);
if (depositAllButton != null) depositAllButton.onClick.AddListener(HandleDepositAll);
if (withdrawAllButton != null) withdrawAllButton.onClick.AddListener(HandleWithdrawAll);
}
/// <summary>
/// Clears the singleton and drops the button listeners on teardown.
/// </summary>
private void OnDestroy()
{
if (depositAllButton != null) depositAllButton.onClick.RemoveListener(HandleDepositAll);
if (withdrawAllButton != null) withdrawAllButton.onClick.RemoveListener(HandleWithdrawAll);
if (Instance == this) Instance = null;
}
#endregion
#region Open / Panel Lifecycle
/// <summary>
/// Binds a chest and opens the window through the panel stack (cursor + input lock handled by
/// the GameUIManager). Called from a chest's Interact().
/// </summary>
public void Open(Chest chest)
{
if (chest == null) return;
boundChest = chest;
if (UIManager.Instance != null)
UIManager.Instance.OpenPanel(this);
}
/// <summary>
/// Opens the window: builds the grids for the bound chest, binds change events and refreshes.
/// </summary>
public override void Show()
{
if (windowRoot != null) windowRoot.SetActive(true);
EnsureInventoryGrid();
BuildChestGrid();
BindChanges();
if (titleText != null && boundChest != null) titleText.text = boundChest.DisplayName;
RefreshInventory();
RefreshChest();
}
/// <summary>
/// Closes the window, unbinds change events and releases the chest.
/// </summary>
public override void Hide()
{
if (ghostObject != null) ghostObject.SetActive(false);
UnbindChanges();
boundChest = null;
if (windowRoot != null) windowRoot.SetActive(false);
}
/// <summary>
/// Instant close used when the manager initializes panels — just parks the window closed.
/// </summary>
public override void HideInstant()
{
if (ghostObject != null) ghostObject.SetActive(false);
UnbindChanges();
boundChest = null;
if (windowRoot != null) windowRoot.SetActive(false);
}
#endregion
#region Grid Building
/// <summary>
/// Builds the left grid from the local player's full inventory once; reused across opens since
/// the inventory size never changes.
/// </summary>
private void EnsureInventoryGrid()
{
if (inventorySlots != null) return;
PlayerInventory inv = PlayerInventory.Instance;
if (inv == null) return;
int count = inv.InventorySize;
inventorySlots = new ChestSlotUI[count];
for (int i = 0; i < count; i++)
{
ChestSlotUI slot = Instantiate(slotPrefab, inventoryContainer).GetComponent<ChestSlotUI>();
slot.Initialize(this, ChestSlotContainer.Inventory, i);
inventorySlots[i] = slot;
}
}
/// <summary>
/// Builds (or rebuilds when the size differs) the right grid to match the bound chest's slot
/// count, so opening a small chest then a large one lays out the right number of cells.
/// </summary>
private void BuildChestGrid()
{
int count = boundChest != null ? boundChest.SlotCount : 0;
if (chestSlots != null && chestSlots.Length == count) return;
if (chestSlots != null)
foreach (ChestSlotUI s in chestSlots)
if (s != null) Destroy(s.gameObject);
chestSlots = new ChestSlotUI[count];
for (int i = 0; i < count; i++)
{
ChestSlotUI slot = Instantiate(slotPrefab, chestContainer).GetComponent<ChestSlotUI>();
slot.Initialize(this, ChestSlotContainer.Chest, i);
chestSlots[i] = slot;
}
}
#endregion
#region Change Binding
/// <summary>
/// Subscribes to the inventory's and the chest's change notifications so cells refresh live.
/// </summary>
private void BindChanges()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv != null) inv.onSlotChanged.AddListener(HandleInventorySlotChanged);
if (boundChest != null) boundChest.SlotViewChanged += HandleChestSlotChanged;
}
/// <summary>
/// Unsubscribes — mirrors BindChanges exactly.
/// </summary>
private void UnbindChanges()
{
PlayerInventory inv = PlayerInventory.Instance;
if (inv != null) inv.onSlotChanged.RemoveListener(HandleInventorySlotChanged);
if (boundChest != null) boundChest.SlotViewChanged -= HandleChestSlotChanged;
}
/// <summary>
/// Refreshes a single inventory cell when its slot changes.
/// </summary>
private void HandleInventorySlotChanged(int index)
{
if (inventorySlots != null && index >= 0 && index < inventorySlots.Length)
inventorySlots[index].Refresh();
}
/// <summary>
/// Refreshes a single chest cell, or the whole grid when the collection was (re)seeded (-1).
/// </summary>
private void HandleChestSlotChanged(int index)
{
if (chestSlots == null) return;
if (index < 0) { RefreshChest(); return; }
if (index < chestSlots.Length) chestSlots[index].Refresh();
}
/// <summary>
/// Redraws every inventory cell.
/// </summary>
private void RefreshInventory()
{
if (inventorySlots == null) return;
for (int i = 0; i < inventorySlots.Length; i++) inventorySlots[i].Refresh();
}
/// <summary>
/// Redraws every chest cell.
/// </summary>
private void RefreshChest()
{
if (chestSlots == null) return;
for (int i = 0; i < chestSlots.Length; i++) chestSlots[i].Refresh();
}
#endregion
#region Transfer Routing
/// <summary>
/// Routes a drag-drop between two cells into the matching operation: inventory swap, deposit,
/// withdraw, or move within the chest.
/// </summary>
public void HandleTransfer(ChestSlotUI from, ChestSlotUI to)
{
if (from == null || to == null || boundChest == null) return;
bool fromInv = from.Container == ChestSlotContainer.Inventory;
bool toInv = to.Container == ChestSlotContainer.Inventory;
if (fromInv && toInv) InventoryUI.OnSwapRequested(from.Index, to.Index);
else if (fromInv) boundChest.RequestDepositToSlot(from.Index, to.Index);
else if (toInv) boundChest.RequestWithdraw(from.Index);
else boundChest.RequestMoveWithin(from.Index, to.Index);
}
/// <summary>
/// Right-click quick transfer: an inventory cell deposits (auto-placed), a chest cell withdraws.
/// </summary>
public void HandleQuickTransfer(ChestSlotUI slot)
{
if (slot == null || boundChest == null) return;
if (slot.Container == ChestSlotContainer.Inventory)
boundChest.RequestDepositToSlot(slot.Index, -1);
else
boundChest.RequestWithdraw(slot.Index);
}
/// <summary>
/// "Deposit all" button: stores every inventory item in the chest.
/// </summary>
private void HandleDepositAll()
{
if (boundChest != null) boundChest.RequestDepositAll();
}
/// <summary>
/// "Take all" button: withdraws every chest item into the inventory.
/// </summary>
private void HandleWithdrawAll()
{
if (boundChest != null) boundChest.RequestWithdrawAll();
}
#endregion
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: b5e351717263d20488f62645e8ffdff3
+2 -2
View File
@@ -8,8 +8,8 @@ namespace Ashwild.UI
/// One resource line of a build's cost, shown near the crosshair while placing: an icon plus an
/// "owned / required" count (e.g. 2/3). A pure, data-agnostic view — it is handed an icon, the two
/// numbers and an "affordable" flag and renders exactly that (tinting the count to signal a
/// shortfall), never resolving any domain data itself. CrosshairManager instantiates one of these
/// per cost line into its holder.
/// shortfall), never resolving any domain data itself. Authored as pre-placed children of the
/// crosshair's cost holder — CrosshairManager fills and shows/hides them, it does not instantiate.
/// </summary>
[DisallowMultipleComponent]
public class BuildCostEntryUI : MonoBehaviour
+88 -41
View File
@@ -41,17 +41,36 @@ namespace Ashwild.UI
/// </summary>
[SerializeField] private TMP_Text promptLabel;
[Header("Build Cost")]
[Header("Layout")]
/// <summary>
/// Container the per-resource cost entries are parented under while a build is being placed.
/// Its own layout (e.g. a HorizontalLayoutGroup) arranges them.
/// The vertical group stacking the crosshair icon, prompt and cost holder. Its spacing widens in
/// build mode so the build cost readout gets room below the reticle, and tightens back otherwise.
/// </summary>
[SerializeField] private RectTransform costHolder;
[SerializeField] private VerticalLayoutGroup verticalGroup;
/// <summary>
/// Prefab instantiated once per cost line (icon + amount) into the holder. Pooled and reused.
/// Spacing applied to the vertical group outside build mode (the compact reticle).
/// </summary>
[SerializeField] private BuildCostEntryUI costEntryPrefab;
[SerializeField] private float idleSpacing = 0f;
/// <summary>
/// Spacing applied to the vertical group while building, to separate the reticle from the cost.
/// </summary>
[SerializeField] private float buildSpacing = 12f;
[Header("Build Cost")]
/// <summary>
/// The "Ressources Needed" container holding the cost entries. Activated while a build cost is
/// shown and deactivated otherwise, so the whole readout (background, layout) disappears with it.
/// </summary>
[SerializeField] private GameObject costHolder;
/// <summary>
/// The pre-placed cost entries, assigned in the inspector in display order. Authored in the UI
/// (not instantiated at runtime) — this manager only fills and shows/hides them per the active
/// build's cost.
/// </summary>
[SerializeField] private BuildCostEntryUI[] costEntries;
[Header("States")]
/// <summary>
@@ -121,12 +140,6 @@ namespace Ashwild.UI
/// </summary>
private bool isShowingCost;
/// <summary>
/// The instantiated cost entries, pooled and reused across placements — activated up to the
/// current cost's line count and hidden beyond it.
/// </summary>
private readonly List<BuildCostEntryUI> costEntries = new List<BuildCostEntryUI>();
#endregion
#region Unity Lifecycle
@@ -144,6 +157,7 @@ namespace Ashwild.UI
}
Instance = this;
HideCostEntries();
}
/// <summary>
@@ -153,8 +167,10 @@ namespace Ashwild.UI
{
PlayerEvents.InteractableHoverChanged += HandleHoverChanged;
PlayerEvents.BuildCostChanged += HandleBuildCostChanged;
PlayerEvents.BuildModeChanged += HandleBuildModeChanged;
ApplyState(idleState, isHover: false, animated: false);
SetLabel(null, animated: false);
ApplySpacing(PlayerEvents.IsBuilding);
}
/// <summary>
@@ -164,6 +180,7 @@ namespace Ashwild.UI
{
PlayerEvents.InteractableHoverChanged -= HandleHoverChanged;
PlayerEvents.BuildCostChanged -= HandleBuildCostChanged;
PlayerEvents.BuildModeChanged -= HandleBuildModeChanged;
}
/// <summary>
@@ -211,6 +228,12 @@ namespace Ashwild.UI
if (!isShowingCost) SetLabel(currentPrompt, animated: true);
}
/// <summary>
/// Widens the vertical group in build mode (so the cost readout gets room) and tightens it back
/// out of build mode. Pure view reaction to the composite bus state — no build logic here.
/// </summary>
private void HandleBuildModeChanged(bool building) => ApplySpacing(building);
/// <summary>
/// Shows the active build's cost (hiding the prompt label and filling the holder) while placing,
/// or clears it and restores the normal prompt when the list is null/empty (placement ended).
@@ -224,6 +247,7 @@ namespace Ashwild.UI
}
isShowingCost = true;
if (costHolder != null) costHolder.SetActive(true);
SetLabel(null, animated: true);
PopulateCost(cost);
}
@@ -265,60 +289,70 @@ namespace Ashwild.UI
}
/// <summary>
/// Sets the prompt text and fades the label in (non-null) or out (null).
/// Sets the prompt text and shows/hides the label. The label object starts inactive in the
/// prefab, so it is activated when there is a prompt and deactivated when there is none — the
/// alpha fade only runs while it is active.
/// </summary>
private void SetLabel(string prompt, bool animated)
{
if (promptLabel == null) return;
bool show = !string.IsNullOrEmpty(prompt);
if (show)
promptLabel.text = prompt;
promptLabel.gameObject.SetActive(show);
if (!show) return;
promptLabel.text = prompt;
labelTween?.Kill();
float target = show ? 1f : 0f;
if (!animated)
{
promptLabel.alpha = target;
promptLabel.alpha = 1f;
return;
}
promptLabel.alpha = 0f;
labelTween = DOTween.To(() => promptLabel.alpha,
a => promptLabel.alpha = a, target, labelFadeDuration);
a => promptLabel.alpha = a, 1f, labelFadeDuration);
}
/// <summary>
/// Fills the holder with one entry per cost line, reusing pooled entries and only instantiating
/// when the current build needs more lines than any previous one. Entries beyond this build's
/// count are hidden. Logs and no-ops when the holder or prefab reference is missing.
/// Hides the cost holder and every inspector-assigned cost entry at startup, so PopulateCost only
/// ever fills and toggles them. No-op on the parts that are unassigned.
/// </summary>
private void HideCostEntries()
{
if (costHolder != null) costHolder.SetActive(false);
if (costEntries == null) return;
for (int i = 0; i < costEntries.Length; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
}
/// <summary>
/// Fills and shows one assigned entry per cost line, hiding the entries beyond this build's line
/// count. Warns and caps when a build needs more lines than there are assigned entries — add more
/// entries in the inspector rather than relying on runtime instantiation.
/// </summary>
private void PopulateCost(IReadOnlyList<BuildCostView> cost)
{
if (costHolder == null || costEntryPrefab == null)
if (costEntries == null || costEntries.Length == 0)
{
Debug.LogError("[CrosshairManager] Cost holder or entry prefab not assigned — cannot show the build cost.", this);
Debug.LogError("[CrosshairManager] No cost entries assigned — cannot show the build cost.", this);
return;
}
for (int i = 0; i < cost.Count; i++)
{
BuildCostEntryUI entry;
if (i < costEntries.Count)
{
entry = costEntries[i];
}
else
{
entry = Instantiate(costEntryPrefab, costHolder);
costEntries.Add(entry);
}
int shown = Mathf.Min(cost.Count, costEntries.Length);
if (cost.Count > costEntries.Length)
Debug.LogWarning($"[CrosshairManager] Build needs {cost.Count} cost lines but only {costEntries.Length} entries exist — extra lines are hidden.", this);
entry.gameObject.SetActive(true);
entry.Set(cost[i].Icon, cost[i].Owned, cost[i].Amount, cost[i].Affordable);
for (int i = 0; i < shown; i++)
{
if (costEntries[i] == null) continue;
costEntries[i].gameObject.SetActive(true);
costEntries[i].Set(cost[i].Icon, cost[i].Owned, cost[i].Amount, cost[i].Affordable);
}
for (int i = cost.Count; i < costEntries.Count; i++)
for (int i = shown; i < costEntries.Length; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
}
@@ -331,12 +365,25 @@ namespace Ashwild.UI
if (!isShowingCost) return;
isShowingCost = false;
for (int i = 0; i < costEntries.Count; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
if (costEntries != null)
for (int i = 0; i < costEntries.Length; i++)
if (costEntries[i] != null) costEntries[i].gameObject.SetActive(false);
if (costHolder != null) costHolder.SetActive(false);
SetLabel(currentPrompt, animated: true);
}
/// <summary>
/// Sets the vertical group's spacing to the build or idle value. No-op when no group is wired,
/// so the crosshair still works on a rig that has no layout group.
/// </summary>
private void ApplySpacing(bool building)
{
if (verticalGroup == null) return;
verticalGroup.spacing = building ? buildSpacing : idleSpacing;
}
/// <summary>
/// Kills every cached tween so none survive a disable/destroy.
/// </summary>
+37 -36
View File
@@ -1,5 +1,4 @@
using UnityEngine;
using UnityEngine.SceneManagement;
using Ashwild.Network;
using Ashwild.Player;
@@ -25,10 +24,6 @@ namespace Ashwild.UI
[Tooltip("The pause menu panel opened by Escape.")]
[SerializeField] private UIPanel pausePanel;
[Header("Quit")]
[Tooltip("Scene loaded when leaving the session back to the main menu.")]
[SerializeField] private string menuSceneName = "MenuScene";
#endregion
#region State
@@ -45,18 +40,16 @@ namespace Ashwild.UI
private UIPanel buildPanel;
/// <summary>
/// True while a panel that pauses the world is open — i.e. anything but the inventory, the
/// construction menu or the chest, all local windows the world keeps running under.
/// True while a panel that pauses the world is open — i.e. anything but the inventory (which a
/// chest reuses) or the construction menu, both local windows the world keeps running under.
/// </summary>
public bool IsPaused => Current != null
&& Current.Kind != PanelKind.Inventory
&& Current.Kind != PanelKind.Build
&& Current.Kind != PanelKind.Chest;
&& Current.Kind != PanelKind.Build;
// Last bus values pushed, so we only fire on real transitions.
private bool lastPaused;
private bool lastInventoryOpen;
private bool lastChestOpen;
private bool lastBuildOpen;
#endregion
@@ -92,11 +85,6 @@ namespace Ashwild.UI
lastInventoryOpen = false;
PlayerEvents.RaiseInventoryOpenChanged(false);
}
if (lastChestOpen)
{
lastChestOpen = false;
PlayerEvents.RaiseChestOpenChanged(false);
}
if (lastBuildOpen)
{
lastBuildOpen = false;
@@ -119,7 +107,18 @@ namespace Ashwild.UI
#region Escape Policy
/// <summary>
/// Escape: close whatever is open (inventory or sub-panel), otherwise open the pause menu.
/// Escape, from the most local context outward: close whatever panel is open (inventory or
/// sub-panel), else back out of an in-world build placement, else open the pause menu.
///
/// The placement step matters because a ghost being positioned is a mode with no panel behind it:
/// the construction menu closes as soon as a card is picked, so HasOpenPanel is already false and
/// Escape used to fall straight through to the pause menu — leaving the player still holding a
/// ghost. It is routed through BuildCancelRequested rather than reaching for BuildManager, so
/// ending a placement stays the bus contract the hammer's right-click tap already uses.
///
/// Demolition mode is deliberately not handled here: it lasts only while right-click is held, so
/// it ends on release, and cancelling it from the outside would leave the hammer thinking it is
/// still held.
/// </summary>
protected override void OnEscape()
{
@@ -132,6 +131,12 @@ namespace Ashwild.UI
return;
}
if (PlayerEvents.IsPlacingBuild)
{
PlayerEvents.RaiseBuildCancelRequested();
return;
}
OpenPause();
}
@@ -172,19 +177,19 @@ namespace Ashwild.UI
/// <summary>
/// Leaves the session and returns to the main menu scene behind the loading curtain. The
/// transition is owned by the persistent NetworkSessionManager (it survives the scene swap,
/// this manager does not); falls back to a direct, uncovered load only if it is missing.
/// transition — and the menu scene name — is owned by the persistent NetworkSessionManager (it
/// survives the scene swap and is the single source of truth for scene names; this manager does
/// neither). Logs and no-ops if it is somehow missing rather than tearing down uncovered.
/// </summary>
public void QuitToMenu()
{
if (NetworkSessionManager.Instance != null)
if (NetworkSessionManager.Instance == null)
{
NetworkSessionManager.Instance.ReturnToMenu(menuSceneName);
Debug.LogError("[GameUIManager] NetworkSessionManager missing — cannot return to menu.", this);
return;
}
Debug.LogWarning("[GameUIManager] NetworkSessionManager missing — loading menu directly without the loading curtain.", this);
SceneManager.LoadScene(menuSceneName);
NetworkSessionManager.Instance.ReturnToMenu();
}
#endregion
@@ -204,9 +209,9 @@ namespace Ashwild.UI
return;
}
// Don't open the inventory on top of the pause menu, the construction menu or a chest.
// Don't open the inventory on top of the pause menu or the construction menu.
if (IsPaused) return;
if (Current != null && (Current.Kind == PanelKind.Build || Current.Kind == PanelKind.Chest)) return;
if (Current != null && Current.Kind == PanelKind.Build) return;
if (inventoryPanel != null)
OpenPanel(inventoryPanel);
@@ -228,7 +233,7 @@ namespace Ashwild.UI
}
if (IsPaused) return;
if (Current != null && (Current.Kind == PanelKind.Inventory || Current.Kind == PanelKind.Chest)) return;
if (Current != null && Current.Kind == PanelKind.Inventory) return;
if (buildPanel != null)
OpenPanel(buildPanel);
@@ -260,19 +265,20 @@ namespace Ashwild.UI
/// <summary>
/// Drives the side effects of the open panel: cursor for any panel, world freeze + GamePaused
/// for pausing panels, and the InventoryOpenChanged / ChestOpenChanged / BuildMenuOpenChanged
/// bus states for the three local windows. The inventory, the chest and the construction menu
/// are windows the world keeps running under, so only "real" menus pause. All four bus states
/// feed PlayerEvents.InputLocked, so player control is cut whenever a panel is open.
/// for pausing panels, and the InventoryOpenChanged / BuildMenuOpenChanged bus states for the
/// two local windows. The inventory (which a chest reuses) and the construction menu are windows
/// the world keeps running under, so only "real" menus pause. The ChestOpenChanged sub-state is
/// owned by the inventory window itself, since a chest is just the inventory panel in chest mode.
/// All these bus states feed PlayerEvents.InputLocked, so player control is cut whenever a panel
/// is open.
/// </summary>
private void ApplyUIState(bool force = false)
{
UIPanel cur = Current;
bool anyOpen = cur != null;
bool inventory = anyOpen && cur.Kind == PanelKind.Inventory;
bool chest = anyOpen && cur.Kind == PanelKind.Chest;
bool build = anyOpen && cur.Kind == PanelKind.Build;
bool paused = anyOpen && !inventory && !chest && !build;
bool paused = anyOpen && !inventory && !build;
Cursor.lockState = anyOpen ? CursorLockMode.None : CursorLockMode.Locked;
Cursor.visible = anyOpen;
@@ -287,11 +293,6 @@ namespace Ashwild.UI
lastInventoryOpen = inventory;
PlayerEvents.RaiseInventoryOpenChanged(inventory);
}
if (force || chest != lastChestOpen)
{
lastChestOpen = chest;
PlayerEvents.RaiseChestOpenChanged(chest);
}
if (force || build != lastBuildOpen)
{
lastBuildOpen = build;
+10 -1
View File
@@ -85,7 +85,16 @@ namespace Ashwild.UI
PlayerEvents.FuelAdded -= OnItemSpent;
}
private void OnItemAdded(ItemData item, int quantity) => Push(item, quantity);
/// <summary>
/// Announces a gain — but never a transfer: pulling a stack out of a chest only shuffles items
/// between the player's own containers, so popping a "+N" for it would be noise.
/// </summary>
private void OnItemAdded(ItemData item, int quantity, bool isTransfer)
{
if (isTransfer) return;
Push(item, quantity);
}
private void OnItemDropped(ItemData item, int quantity) => Push(item, -quantity);
private void OnItemConsumed(ItemData item) => Push(item, -1);
private void OnRecipeDiscovered(Sprite icon, string recipeName) => PushDiscovery(icon, recipeName);
+4 -5
View File
@@ -6,17 +6,16 @@ namespace Ashwild.UI
/// <summary>
/// Classifies how a panel affects gameplay when open, so a UI manager can apply the right
/// side effects: Default = plain menu panel, Pause = freezes the world / raises GamePaused,
/// Inventory = a local UI window where the world keeps running, Build = the construction
/// menu, an inventory-like window that locks input and shows the cursor but never pauses,
/// Chest = the storage window (inventory + chest), the same kind of local, non-pausing window.
/// Inventory = a local UI window where the world keeps running (a chest reuses this same window
/// in "chest mode"), Build = the construction menu, an inventory-like window that locks input and
/// shows the cursor but never pauses.
/// </summary>
public enum PanelKind
{
Default,
Pause,
Inventory,
Build,
Chest
Build
}
[RequireComponent(typeof(CanvasGroup))]