(Feat) Add Build
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))]
|
||||
|
||||
Reference in New Issue
Block a user