diff --git a/Assets/Prefabs/Player.prefab b/Assets/Prefabs/Player.prefab index bd812a5..f3684ca 100644 --- a/Assets/Prefabs/Player.prefab +++ b/Assets/Prefabs/Player.prefab @@ -15,6 +15,7 @@ GameObject: - component: {fileID: 6222683508516611630} - component: {fileID: 2738588686490111713} - component: {fileID: 1819053832086648781} + - component: {fileID: 8888888888888888803} m_Layer: 0 m_Name: Player m_TagString: Untagged @@ -215,7 +216,8 @@ Transform: m_LocalPosition: {x: 0.07, y: 0.86, z: 0.14} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: [] + m_Children: + - {fileID: 8888888888888888802} m_Father: {fileID: 5481062863680931684} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!20 &6771695443112085325 @@ -321,6 +323,53 @@ MonoBehaviour: m_VarianceClampScale: 0.9 m_ContrastAdaptiveSharpening: 0 m_Version: 2 +--- !u!1 &8888888888888888801 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8888888888888888802} + m_Layer: 0 + m_Name: HandSocket + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &8888888888888888802 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8888888888888888801} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0.3, y: -0.2, z: 0.6} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 7530582357117073923} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &8888888888888888803 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2721740664708778080} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6ed618ff659f6684f8c2ce58ba032c35, type: 3} + m_Name: + m_EditorClassIdentifier: AfterHours.Runtime::AfterHours.Networking.PlayerCarry + ShowTopMostFoldoutHeaderGroup: 1 + _handSocket: {fileID: 8888888888888888802} + _pourInterval: 0.4 + _maxPourReach: 2.5 --- !u!1 &7157595194792186803 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Prefabs/ShelfSlot.prefab b/Assets/Prefabs/ShelfSlot.prefab index 8328e9d..a8f78ce 100644 --- a/Assets/Prefabs/ShelfSlot.prefab +++ b/Assets/Prefabs/ShelfSlot.prefab @@ -98,7 +98,7 @@ MonoBehaviour: _catalog: {fileID: 11400000, guid: e173fe210071dba4790dd003a88e4fcd, type: 2} _capacityUnits: 12 _exposedToStreet: 0 - _displayRoot: {fileID: 0} + _displayRoot: {fileID: 5742035034345165094} --- !u!1 &6000252374135812501 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Scenes/Shop.unity b/Assets/Scenes/Shop.unity index 879baab..b833a84 100644 --- a/Assets/Scenes/Shop.unity +++ b/Assets/Scenes/Shop.unity @@ -450,6 +450,37 @@ MonoBehaviour: m_SelectedFaces: m_SelectedEdges: [] m_SelectedVertices: +--- !u!1 &53478574 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 53478575} + m_Layer: 0 + m_Name: ContentsRoot + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &53478575 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 53478574} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: -0.243, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 959439192} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &64083402 GameObject: m_ObjectHideFlags: 0 @@ -3863,7 +3894,10 @@ PrefabInstance: objectReference: {fileID: 0} m_RemovedComponents: [] m_RemovedGameObjects: [] - m_AddedGameObjects: [] + m_AddedGameObjects: + - targetCorrespondingSourceObject: {fileID: -8679921383154817045, guid: 280091748b47569469096b4ec9745729, type: 3} + insertIndex: -1 + addedObject: {fileID: 53478575} m_AddedComponents: - targetCorrespondingSourceObject: {fileID: 919132149155446097, guid: 280091748b47569469096b4ec9745729, type: 3} insertIndex: -1 @@ -3883,6 +3917,9 @@ PrefabInstance: - targetCorrespondingSourceObject: {fileID: 919132149155446097, guid: 280091748b47569469096b4ec9745729, type: 3} insertIndex: -1 addedObject: {fileID: 959439197} + - targetCorrespondingSourceObject: {fileID: 919132149155446097, guid: 280091748b47569469096b4ec9745729, type: 3} + insertIndex: -1 + addedObject: {fileID: 959439200} m_SourcePrefab: {fileID: 100100000, guid: 280091748b47569469096b4ec9745729, type: 3} --- !u!1 &959439188 stripped GameObject: @@ -3925,6 +3962,11 @@ Transform: m_CorrespondingSourceObject: {fileID: -7629218545453614500, guid: 280091748b47569469096b4ec9745729, type: 3} m_PrefabInstance: {fileID: 959439187} m_PrefabAsset: {fileID: 0} +--- !u!4 &959439192 stripped +Transform: + m_CorrespondingSourceObject: {fileID: -8679921383154817045, guid: 280091748b47569469096b4ec9745729, type: 3} + m_PrefabInstance: {fileID: 959439187} + m_PrefabAsset: {fileID: 0} --- !u!114 &959439195 MonoBehaviour: m_ObjectHideFlags: 0 @@ -3942,6 +3984,11 @@ MonoBehaviour: deckelRechts: {fileID: 959439190} oeffnungsWinkel: 170 geschwindigkeit: 5 + _catalog: {fileID: 11400000, guid: e173fe210071dba4790dd003a88e4fcd, type: 2} + _productId: 1 + _startingQuantity: 12 + _contentsRoot: {fileID: 53478575} + _maxVisiblePackages: 12 --- !u!65 &959439196 BoxCollider: m_ObjectHideFlags: 0 @@ -4053,6 +4100,22 @@ Rigidbody: m_Interpolate: 0 m_Constraints: 0 m_CollisionDetection: 0 +--- !u!114 &959439200 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 959439188} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b73b32288703e1c4995e220e80249f35, type: 3} + m_Name: + m_EditorClassIdentifier: AfterHours.Runtime::AfterHours.Interaction.Carryable + ShowTopMostFoldoutHeaderGroup: 1 + _collidersToDisableWhileCarried: [] + _networkTransform: {fileID: 0} + _rigidbody: {fileID: 0} --- !u!1 &975800552 GameObject: m_ObjectHideFlags: 0 @@ -5468,6 +5531,157 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1001 &1637614133 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 0} + m_Modifications: + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalPosition.x + value: -4.018 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalPosition.y + value: 1.047 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalPosition.z + value: 0.603 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalRotation.w + value: 0.7071067 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalRotation.x + value: -0.7071068 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalRotation.z + value: -0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -8679921383154817045, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: -7511558181221131132, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: 'm_Materials.Array.data[0]' + value: + objectReference: {fileID: 2100000, guid: 9ff3d1c8bfaace6428d25719c6afa121, type: 2} + - target: {fileID: 919132149155446097, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} + propertyPath: m_Name + value: Meshy_AI_wooden_shelf_3_compar_0814175655_image-to-3d-texture + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} +--- !u!1 &1658583203 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1658583206} + - component: {fileID: 1658583205} + - component: {fileID: 1658583204} + m_Layer: 0 + m_Name: mesh_node + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!23 &1658583204 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1658583203} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!33 &1658583205 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1658583203} + m_Mesh: {fileID: 7158796146257112550, guid: 0d912f468f12f1a48b2df1314b991b38, type: 3} +--- !u!4 &1658583206 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1658583203} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: -4.069018, y: 0.00000036529013, z: 0.19831897} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!43 &1899577878 Mesh: m_ObjectHideFlags: 0 @@ -6020,3 +6234,5 @@ SceneRoots: - {fileID: 959439187} - {fileID: 1091476159} - {fileID: 1417349271} + - {fileID: 1637614133} + - {fileID: 1658583206} diff --git a/Assets/Scripts/Interaction/Carryable.cs b/Assets/Scripts/Interaction/Carryable.cs new file mode 100644 index 0000000..777d227 --- /dev/null +++ b/Assets/Scripts/Interaction/Carryable.cs @@ -0,0 +1,86 @@ +using AfterHours.Networking; +using Unity.Netcode; +using Unity.Netcode.Components; +using UnityEngine; + +namespace AfterHours.Interaction +{ + /// + /// Macht ein Objekt aufheb- und tragbar. Reines Trage-Handwerkszeug ohne + /// Fachwissen über Inhalt/Produkte – das bleibt beim jeweiligen Objekt + /// (z. B. Lieferkarton). Positionierung beim Tragen ist rein kosmetisch + /// und läuft auf jedem Peer selbst (siehe ShelfSlot.RefreshVisuals-Muster), + /// kein NGO-Reparenting – siehe Plan-Begründung in Lieferkarton.cs. + /// + [RequireComponent(typeof(NetworkObject))] + public sealed class Carryable : NetworkBehaviour + { + [SerializeField] private Collider[] _collidersToDisableWhileCarried; + [SerializeField] private NetworkTransform _networkTransform; + [SerializeField] private Rigidbody _rigidbody; + + private readonly NetworkVariable _isCarried = new( + false, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server + ); + + private readonly NetworkVariable _carriedBy = new( + default, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server + ); + + public bool IsCarried => _isCarried.Value; + + public override void OnNetworkSpawn() + { + _isCarried.OnValueChanged += (_, current) => ApplyCarriedState(current); + ApplyCarriedState(_isCarried.Value); + } + + /// Server: Träger übernimmt das Objekt. False, wenn schon getragen. + public bool TryPickUp(NetworkObject carrier) + { + if (!IsServer || _isCarried.Value) return false; + + _carriedBy.Value = carrier; + _isCarried.Value = true; + return true; + } + + /// Server: Objekt an fester Position/Rotation ablegen. + public void Drop(Vector3 position, Quaternion rotation) + { + if (!IsServer || !_isCarried.Value) return; + + transform.SetPositionAndRotation(position, rotation); + _isCarried.Value = false; + _carriedBy.Value = default; + } + + private void ApplyCarriedState(bool carried) + { + foreach (var collider in _collidersToDisableWhileCarried) + if (collider != null) collider.enabled = !carried; + + if (_networkTransform != null) _networkTransform.enabled = !carried; + + // Sonst kämpft die Physik gegen das kosmetische Tragen (LateUpdate unten). + if (_rigidbody != null) + { + _rigidbody.isKinematic = carried; + if (!carried) _rigidbody.linearVelocity = Vector3.zero; + } + } + + private void LateUpdate() + { + if (!_isCarried.Value) return; + if (!_carriedBy.Value.TryGet(out var carrier)) return; + if (!carrier.TryGetComponent(out var carry) || carry.HandSocket == null) return; + + transform.SetPositionAndRotation(carry.HandSocket.position, carry.HandSocket.rotation); + } + } +} diff --git a/Assets/Scripts/Interaction/Carryable.cs.meta b/Assets/Scripts/Interaction/Carryable.cs.meta new file mode 100644 index 0000000..1dac910 --- /dev/null +++ b/Assets/Scripts/Interaction/Carryable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b73b32288703e1c4995e220e80249f35 \ No newline at end of file diff --git a/Assets/Scripts/Interaction/IPourSource.cs b/Assets/Scripts/Interaction/IPourSource.cs new file mode 100644 index 0000000..afc8f70 --- /dev/null +++ b/Assets/Scripts/Interaction/IPourSource.cs @@ -0,0 +1,15 @@ +namespace AfterHours.Interaction +{ + /// + /// Alles, was per Klick-halten Stück für Stück ausgeschüttet werden kann + /// (z. B. Lieferkarton, später Lagerregal-Karton). TryTakeOne() läuft + /// serverseitig und muss selbst validieren, genau wie IPourTarget.TryAddOne(). + /// + public interface IPourSource + { + bool IsOpen { get; } + int ProductId { get; } + bool IsEmpty { get; } + bool TryTakeOne(); + } +} diff --git a/Assets/Scripts/Interaction/IPourSource.cs.meta b/Assets/Scripts/Interaction/IPourSource.cs.meta new file mode 100644 index 0000000..e6c17d8 --- /dev/null +++ b/Assets/Scripts/Interaction/IPourSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7117f9f519059394889cc6edda07b9e1 \ No newline at end of file diff --git a/Assets/Scripts/Interaction/IPourTarget.cs b/Assets/Scripts/Interaction/IPourTarget.cs new file mode 100644 index 0000000..9353848 --- /dev/null +++ b/Assets/Scripts/Interaction/IPourTarget.cs @@ -0,0 +1,19 @@ +using UnityEngine; + +namespace AfterHours.Interaction +{ + /// + /// Alles, was per Klick-halten Stück für Stück befüllt werden kann + /// (z. B. ShelfSlot, später Lagerregal). TryAddOne() läuft serverseitig + /// und muss selbst validieren, genau wie IInteractable.Interact(). + /// + public interface IPourTarget + { + Transform Transform { get; } + int ProductId { get; } + int Quantity { get; } + bool IsEmpty { get; } + bool IsFull { get; } + bool TryAddOne(int productId); + } +} diff --git a/Assets/Scripts/Interaction/IPourTarget.cs.meta b/Assets/Scripts/Interaction/IPourTarget.cs.meta new file mode 100644 index 0000000..3d43d05 --- /dev/null +++ b/Assets/Scripts/Interaction/IPourTarget.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 024acb3d9dadf024b9739a835debdbaa \ No newline at end of file diff --git a/Assets/Scripts/Interaction/PlayerInteractor.cs b/Assets/Scripts/Interaction/PlayerInteractor.cs index 8522541..36848ee 100644 --- a/Assets/Scripts/Interaction/PlayerInteractor.cs +++ b/Assets/Scripts/Interaction/PlayerInteractor.cs @@ -1,3 +1,4 @@ +using AfterHours.Networking; using Unity.Netcode; using UnityEngine; @@ -19,6 +20,8 @@ namespace AfterHours.Interaction private const float ServerReachTolerance = 1.5f; private IInteractable _current; + private IPourTarget _currentPourTarget; + private IPourTarget _pouringTarget; public string CurrentPrompt { get; private set; } private void Update() @@ -27,24 +30,38 @@ namespace AfterHours.Interaction RefreshTarget(); - if (_current != null && Input.GetKeyDown(KeyCode.E)) + if (Input.GetKeyDown(KeyCode.E)) { - var target = ((MonoBehaviour)_current).GetComponent(); - if (target != null) RequestInteractRpc(target); + if (_current != null) + { + var target = ((MonoBehaviour)_current).GetComponent(); + if (target != null) RequestInteractRpc(target); + } + else if (TryGetComponent(out var carry) && carry.IsCarrying) + { + carry.RequestDropRpc(); + } } + + HandlePourInput(); } private void RefreshTarget() { var previous = _current; _current = null; + _currentPourTarget = null; CurrentPrompt = null; - if (Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask) - && hit.collider.TryGetComponent(out var interactable)) + if (Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask)) { - _current = interactable; - CurrentPrompt = interactable.GetPrompt(OwnerClientId); + if (hit.collider.TryGetComponent(out var interactable)) + { + _current = interactable; + CurrentPrompt = interactable.GetPrompt(OwnerClientId); + } + + hit.collider.TryGetComponent(out _currentPourTarget); } // TODO: entfernen, sobald ein echter Prompt im UI angezeigt wird. @@ -56,21 +73,62 @@ namespace AfterHours.Interaction } } + /// Maustaste halten, um ein IPourTarget im Blick Stück für Stück zu befüllen. + private void HandlePourInput() + { + if (Input.GetMouseButtonDown(0) && _currentPourTarget != null) + { + var targetNo = ((MonoBehaviour)_currentPourTarget).GetComponent(); + if (targetNo != null) + { + _pouringTarget = _currentPourTarget; + RequestPourStartRpc(targetNo); + } + } + + if (_pouringTarget != null && (Input.GetMouseButtonUp(0) || _currentPourTarget != _pouringTarget)) + { + _pouringTarget = null; + RequestPourStopRpc(); + } + } + [Rpc(SendTo.Server)] private void RequestInteractRpc(NetworkObjectReference targetRef) { if (!targetRef.TryGet(out var target)) return; // Server-Validierung: ist der Spieler überhaupt in Reichweite? - float distance = Vector3.Distance(transform.position, target.transform.position); - if (distance > _reach + ServerReachTolerance) + if (!IsInReach(target.transform.position)) { - Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt ({distance:F1} m)."); + Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt."); return; } if (target.TryGetComponent(out var interactable)) interactable.Interact(NetworkObject); } + + [Rpc(SendTo.Server)] + private void RequestPourStartRpc(NetworkObjectReference targetRef) + { + if (!targetRef.TryGet(out var target) || !target.TryGetComponent(out var pourTarget)) return; + if (!IsInReach(target.transform.position)) return; + + if (TryGetComponent(out var carry)) carry.BeginPour(pourTarget); + } + + [Rpc(SendTo.Server)] + private void RequestPourStopRpc() + { + if (TryGetComponent(out var carry)) carry.StopPour(); + } + + /// Server-Validierung: ist der Spieler überhaupt in Reichweite? + private bool IsInReach(Vector3 point) + { + float distance = Vector3.Distance(transform.position, point); + return distance <= _reach + ServerReachTolerance; + } } } diff --git a/Assets/Scripts/Networking/PlayerCarry.cs b/Assets/Scripts/Networking/PlayerCarry.cs new file mode 100644 index 0000000..8d7b801 --- /dev/null +++ b/Assets/Scripts/Networking/PlayerCarry.cs @@ -0,0 +1,134 @@ +using AfterHours.Interaction; +using AfterHours.Shop; +using Unity.Netcode; +using UnityEngine; + +namespace AfterHours.Networking +{ + /// + /// Was der Spieler gerade als physisches Objekt trägt (z. B. ein + /// Lieferkarton) sowie das serverseitig getaktete Ausschütten daraus in + /// ein IPourTarget. Getrennt von PlayerHands (abstrakte Stückzahl) – + /// beide sind gegenseitig exklusiv, siehe TryPickUp/StorageCrate.Interact. + /// + public sealed class PlayerCarry : NetworkBehaviour + { + [SerializeField] private Transform _handSocket; + [SerializeField] private float _pourInterval = 0.4f; + [SerializeField] private float _maxPourReach = 2.5f; + [SerializeField] private float _flightDuration = 0.35f; + + private readonly NetworkVariable _isCarrying = new( + false, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server + ); + + private readonly NetworkVariable _carriedObject = new( + default, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server + ); + + public Transform HandSocket => _handSocket; + public bool IsCarrying => _isCarrying.Value; + + private IPourTarget _pourTarget; + private IPourSource _pourSource; + private NetworkObjectReference _pourTargetRef; + private float _pourTimer; + + /// Server: versucht, aufzuheben. + public bool TryPickUp(Carryable target) + { + if (!IsServer || _isCarrying.Value || target == null) return false; + if (TryGetComponent(out var hands) && !hands.IsEmpty) return false; + if (!target.TryPickUp(NetworkObject)) return false; + + _carriedObject.Value = target.NetworkObject; + _isCarrying.Value = true; + return true; + } + + /// Server: legt das getragene Objekt vor dem Spieler ab. + [Rpc(SendTo.Server)] + public void RequestDropRpc() + { + if (!_isCarrying.Value) return; + + StopPour(); + + if (_carriedObject.Value.TryGet(out var carried) && carried.TryGetComponent(out var carryable)) + { + Vector3 dropPosition = transform.position + transform.forward * 1f; + carryable.Drop(dropPosition, transform.rotation); + } + + _isCarrying.Value = false; + _carriedObject.Value = default; + } + + /// Server: startet das Ausschütten in , falls möglich. + public bool BeginPour(IPourTarget target) + { + if (!IsServer || target == null) return false; + if (!_isCarrying.Value || !_carriedObject.Value.TryGet(out var carried)) return false; + if (!carried.TryGetComponent(out var source)) return false; + if (!source.IsOpen || source.IsEmpty || target.IsFull) return false; + if (!target.IsEmpty && target.ProductId != source.ProductId) return false; + if (!((Component)target).TryGetComponent(out var targetNo)) return false; + + _pourSource = source; + _pourTarget = target; + _pourTargetRef = targetNo; + _pourTimer = 0f; // erste Einheit sofort, danach im Takt von _pourInterval + return true; + } + + public void StopPour() + { + _pourSource = null; + _pourTarget = null; + } + + private void Update() + { + if (!IsServer || _pourSource == null || _pourTarget == null) return; + + if (!_isCarrying.Value || _pourSource.IsEmpty || _pourTarget.IsFull) + { + StopPour(); + return; + } + + if (Vector3.Distance(transform.position, _pourTarget.Transform.position) > _maxPourReach) + { + StopPour(); + return; + } + + _pourTimer -= Time.deltaTime; + if (_pourTimer > 0f) return; + _pourTimer = _pourInterval; + + int landingIndex = _pourTarget.Quantity; + int productId = _pourSource.ProductId; + if (_pourSource.TryTakeOne() && _pourTarget.TryAddOne(productId)) + PlayFlightRpc(_carriedObject.Value, _pourTargetRef, landingIndex); + } + + /// Läuft auf jedem Peer: rein kosmetisches Flug-Visual für die soeben transferierte Einheit. + [Rpc(SendTo.Everyone)] + private void PlayFlightRpc(NetworkObjectReference sourceRef, NetworkObjectReference targetRef, int landingIndex) + { + if (!sourceRef.TryGet(out var sourceObj) || !sourceObj.TryGetComponent(out var box)) return; + if (!targetRef.TryGet(out var targetObj) || !targetObj.TryGetComponent(out var shelf)) return; + + GameObject prefab = box.GetPackagePrefab(); + Vector3 start = box.transform.position + Vector3.up * 0.3f; + Vector3 end = shelf.GetSlotWorldPosition(landingIndex); + + FlyingPackage.Spawn(prefab, start, end, shelf.transform.rotation, _flightDuration); + } + } +} diff --git a/Assets/Scripts/Networking/PlayerCarry.cs.meta b/Assets/Scripts/Networking/PlayerCarry.cs.meta new file mode 100644 index 0000000..0794470 --- /dev/null +++ b/Assets/Scripts/Networking/PlayerCarry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ed618ff659f6684f8c2ce58ba032c35 \ No newline at end of file diff --git a/Assets/Scripts/Shop/FlyingPackage.cs b/Assets/Scripts/Shop/FlyingPackage.cs new file mode 100644 index 0000000..448516c --- /dev/null +++ b/Assets/Scripts/Shop/FlyingPackage.cs @@ -0,0 +1,54 @@ +using UnityEngine; + +namespace AfterHours.Shop +{ + /// + /// Rein kosmetisches, nicht genetzwertes Flug-Visual für ein Stück Ware, + /// das vom Karton ins Regal wandert. Jeder Peer erzeugt seine eigene + /// Instanz (ausgelöst über PlayerCarry.PlayFlightRpc) – keine + /// Server-Autorität nötig, der eigentliche Bestand ist längst per + /// NetworkVariable synchron. + /// + public sealed class FlyingPackage : MonoBehaviour + { + private const float ArcHeight = 0.35f; + + private Vector3 _start; + private Vector3 _end; + private Quaternion _startRotation; + private Quaternion _endRotation; + private float _duration; + private float _elapsed; + + public static void Spawn(GameObject prefab, Vector3 start, Vector3 end, Quaternion endRotation, float duration) + { + if (prefab == null) return; + + var instance = Instantiate(prefab, start, Quaternion.identity); + var flight = instance.AddComponent(); + flight.Init(start, end, endRotation, duration); + } + + private void Init(Vector3 start, Vector3 end, Quaternion endRotation, float duration) + { + _start = start; + _end = end; + _startRotation = transform.rotation; + _endRotation = endRotation; + _duration = Mathf.Max(0.05f, duration); + } + + private void Update() + { + _elapsed += Time.deltaTime; + float t = Mathf.Clamp01(_elapsed / _duration); + + Vector3 flat = Vector3.Lerp(_start, _end, t); + float arc = ArcHeight * Mathf.Sin(t * Mathf.PI); + transform.position = flat + Vector3.up * arc; + transform.rotation = Quaternion.Slerp(_startRotation, _endRotation, t); + + if (t >= 1f) Destroy(gameObject); + } + } +} diff --git a/Assets/Scripts/Shop/FlyingPackage.cs.meta b/Assets/Scripts/Shop/FlyingPackage.cs.meta new file mode 100644 index 0000000..caae1b2 --- /dev/null +++ b/Assets/Scripts/Shop/FlyingPackage.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3084ee344a724f9458cc369f42643325 \ No newline at end of file diff --git a/Assets/Scripts/Shop/Lieferkarton.cs b/Assets/Scripts/Shop/Lieferkarton.cs index c753b3e..e52b643 100644 --- a/Assets/Scripts/Shop/Lieferkarton.cs +++ b/Assets/Scripts/Shop/Lieferkarton.cs @@ -1,11 +1,14 @@ +using AfterHours.Data; using AfterHours.Interaction; +using AfterHours.Networking; using Unity.Netcode; using UnityEngine; namespace AfterHours.Shop { [RequireComponent(typeof(NetworkObject))] - public class Lieferkarton : NetworkBehaviour, IInteractable + [RequireComponent(typeof(Carryable))] + public class Lieferkarton : NetworkBehaviour, IInteractable, IPourSource { [Header("Deckel Objekte")] [SerializeField] private Transform deckelLinks; @@ -15,6 +18,18 @@ namespace AfterHours.Shop [SerializeField] private float oeffnungsWinkel = 110f; // Winkel in Grad nach außen [SerializeField] private float geschwindigkeit = 5f; // Geschwindigkeit des Aufklappens + [Header("Inhalt")] + [SerializeField] private ProductCatalog _catalog; + [SerializeField] private int _productId = -1; + [SerializeField] private int _startingQuantity = 20; + [SerializeField] private Transform _contentsRoot; + [SerializeField] private int _maxVisiblePackages = 12; + + [Header("Anzeige-Raster (sauber gestapelt, so wie Platz ist)")] + [SerializeField] private int _displayColumns = 3; + [SerializeField] private int _displayRows = 3; + [SerializeField] private Vector3 _displaySpacing = new(0.12f, 0.1f, 0.12f); + // Eine synchronisierte Variable, die den Zustand des Kartons speichert private NetworkVariable istOffen = new NetworkVariable( false, @@ -22,14 +37,31 @@ namespace AfterHours.Shop NetworkVariableWritePermission.Server // Nur der Server darf den Zustand ändern ); + private readonly NetworkVariable _quantity = new( + 0, + NetworkVariableReadPermission.Everyone, + NetworkVariableWritePermission.Server + ); + + public bool IsOpen => istOffen.Value; + public int ProductId => _productId; + public int Quantity => _quantity.Value; + public bool IsEmpty => _quantity.Value <= 0; + // Interne Variablen zum Speichern der Ziel-Rotationen private Quaternion linksGeschlossen; private Quaternion linksOffen; private Quaternion rechtsGeschlossen; private Quaternion rechtsOffen; + private Carryable _carryable; + private PackageDisplay _packageDisplay; + private void Awake() { + _carryable = GetComponent(); + _packageDisplay = new PackageDisplay(_contentsRoot, _displayColumns, _displayRows, _displaySpacing); + // Start-Rotationen (Geschlossen) direkt beim Laden sichern if (deckelLinks != null) linksGeschlossen = deckelLinks.localRotation; if (deckelRechts != null) rechtsGeschlossen = deckelRechts.localRotation; @@ -39,6 +71,15 @@ namespace AfterHours.Shop rechtsOffen = rechtsGeschlossen * Quaternion.Euler(oeffnungsWinkel, 0, 0); } + public override void OnNetworkSpawn() + { + if (IsServer) _quantity.Value = _startingQuantity; + + _quantity.OnValueChanged += (_, _) => RefreshVisuals(); + istOffen.OnValueChanged += (_, _) => RefreshVisuals(); + RefreshVisuals(); + } + private void Update() { // Beide Deckel weich auf jedem Client rotieren lassen basierend auf der NetworkVariable @@ -55,23 +96,53 @@ namespace AfterHours.Shop } } - // Wird von deinem Interaktionssystem aufgerufen (z. B. wenn "E" gedrückt wird) + /// + /// Wird von deinem Interaktionssystem aufgerufen (z. B. wenn "E" gedrückt wird). + /// Geschlossen -> öffnen. Offen und nicht getragen -> aufheben. + /// public void Interact(NetworkObject actor) { - ToggleKartonServerRpc(); + if (!IsServer) return; + + if (!istOffen.Value) + { + istOffen.Value = true; + return; + } + + if (!_carryable.IsCarried && actor.TryGetComponent(out var carry)) + carry.TryPickUp(_carryable); } - // Der Server verarbeitet den Aufruf und synchronisiert den Zustand im Netzwerk - [Rpc(SendTo.Server)] - private void ToggleKartonServerRpc() + /// Server: nimmt ein Stück Ware aus dem Karton. Für das Ausschütten (siehe PlayerCarry). + public bool TryTakeOne() { - istOffen.Value = !istOffen.Value; + if (!IsServer || IsEmpty) return false; + _quantity.Value--; + return true; } // Gibt den dynamischen Text für das UI-Prompt zurück public string GetPrompt(ulong clientId) { - return istOffen.Value ? "Karton schließen" : "Karton öffnen"; + if (!istOffen.Value) return "Karton öffnen"; + + var product = _catalog != null ? _catalog.Get(_productId) : null; + string name = product != null ? product.DisplayName : "?"; + return IsEmpty ? $"{name} — leer" : $"{name} aufheben ({_quantity.Value} Stk.)"; + } + + /// Verpackungs-Prefab des aktuellen Produkts (auch für das Flug-Visual beim Ausschütten, siehe PlayerCarry). + public GameObject GetPackagePrefab() + { + var product = _catalog != null ? _catalog.Get(_productId) : null; + return product != null ? product.PackagePrefab : null; + } + + private void RefreshVisuals() + { + int visibleCount = istOffen.Value ? _quantity.Value : 0; + _packageDisplay.SetTarget(GetPackagePrefab(), visibleCount, _maxVisiblePackages); } } } diff --git a/Assets/Scripts/Shop/PackageDisplay.cs b/Assets/Scripts/Shop/PackageDisplay.cs new file mode 100644 index 0000000..ddcbeb6 --- /dev/null +++ b/Assets/Scripts/Shop/PackageDisplay.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace AfterHours.Shop +{ + /// + /// Zeigt eine Stückzahl als einzelne Verpackungs-Prefabs unter einem + /// Wurzel-Transform an (Regalfach, Lieferkarton) – ordentlich in einem + /// Raster (Spalten x Reihen) gestapelt, weitere Lagen stapeln sich nach + /// oben. Rein kosmetisch, läuft auf jedem Peer. Baut bei Mengenänderung + /// nur das Delta neu, nicht die komplette Anzeige – wichtig beim + /// Stück-für-Stück-Ausschütten. + /// + public sealed class PackageDisplay + { + private readonly Transform _root; + private readonly int _columns; + private readonly int _rows; + private readonly Vector3 _spacing; + private readonly List _instances = new(); + private GameObject _prefab; + + public PackageDisplay(Transform root, int columns, int rows, Vector3 spacing) + { + _root = root; + _columns = Mathf.Max(1, columns); + _rows = Mathf.Max(1, rows); + _spacing = spacing; + } + + /// Baut die Anzeige auf Instanzen an (gedeckelt bei maxVisible). + public void SetTarget(GameObject prefab, int count, int maxVisible) + { + if (_root == null) return; + + if (prefab != _prefab) + { + Clear(); + _prefab = prefab; + } + + if (_prefab == null) return; + + int target = Mathf.Clamp(count, 0, Mathf.Max(0, maxVisible)); + + while (_instances.Count < target) + { + var instance = Object.Instantiate(_prefab, _root); + instance.transform.localPosition = SlotLocalPosition(_instances.Count); + instance.transform.localRotation = Quaternion.identity; + _instances.Add(instance); + } + + while (_instances.Count > target) + { + int last = _instances.Count - 1; + if (_instances[last] != null) Object.Destroy(_instances[last]); + _instances.RemoveAt(last); + } + } + + /// Lokale Rasterposition für einen Index – auch als Flug-Ziel genutzt (siehe PlayerCarry). + public Vector3 SlotLocalPosition(int index) + { + int perLayer = _columns * _rows; + int layer = index / perLayer; + int rem = index % perLayer; + int row = rem / _columns; + int col = rem % _columns; + return new Vector3(col * _spacing.x, layer * _spacing.y, row * _spacing.z); + } + + public void Clear() + { + foreach (var instance in _instances) + if (instance != null) Object.Destroy(instance); + + _instances.Clear(); + _prefab = null; + } + } +} diff --git a/Assets/Scripts/Shop/PackageDisplay.cs.meta b/Assets/Scripts/Shop/PackageDisplay.cs.meta new file mode 100644 index 0000000..47f9daf --- /dev/null +++ b/Assets/Scripts/Shop/PackageDisplay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0025e09fe99fd443aff717e718ffc70 \ No newline at end of file diff --git a/Assets/Scripts/Shop/ShelfSlot.cs b/Assets/Scripts/Shop/ShelfSlot.cs index 7a7cde3..ca268f4 100644 --- a/Assets/Scripts/Shop/ShelfSlot.cs +++ b/Assets/Scripts/Shop/ShelfSlot.cs @@ -10,7 +10,7 @@ namespace AfterHours.Shop /// Ein Regalfach: hält genau eine Produktsorte. Auffüllen geht über /// Interact(), Entnahme durch Kunden serverseitig über TakeOne(). /// - public sealed class ShelfSlot : NetworkBehaviour, IInteractable + public sealed class ShelfSlot : NetworkBehaviour, IInteractable, IPourTarget { [SerializeField] private ProductCatalog _catalog; [SerializeField] private int _capacityUnits = 12; @@ -20,6 +20,11 @@ namespace AfterHours.Shop [SerializeField] private Transform _displayRoot; + [Header("Anzeige-Raster (sauber in Reihen eingeräumt)")] + [SerializeField] private int _displayColumns = 4; + [SerializeField] private int _displayRows = 3; + [SerializeField] private Vector3 _displaySpacing = new(0.1f, 0.1f, 0.1f); + private readonly NetworkVariable _productId = new(-1); private readonly NetworkVariable _quantity = new(0); private readonly NetworkVariable _price = new(0f); @@ -29,6 +34,20 @@ namespace AfterHours.Shop public float Price => _price.Value; public bool ExposedToStreet => _exposedToStreet; public bool IsEmpty => _quantity.Value <= 0; + public bool IsFull => _quantity.Value >= Capacity(); + + // Explizite Implementierung, weil "transform" (Component) den Namen schon belegt. + Transform IPourTarget.Transform => transform; + + private PackageDisplay _packageDisplay; + + private void Awake() => _packageDisplay = new PackageDisplay(_displayRoot, _displayColumns, _displayRows, _displaySpacing); + + /// Weltposition des Rasterplatzes mit gegebenem Index – Ziel fürs Flug-Visual (siehe PlayerCarry). + public Vector3 GetSlotWorldPosition(int index) + { + return _displayRoot != null ? _displayRoot.TransformPoint(_packageDisplay.SlotLocalPosition(index)) : transform.position; + } public override void OnNetworkSpawn() { @@ -99,6 +118,24 @@ namespace AfterHours.Shop Debug.Log($"[ShelfSlot] Eingeräumt: {moved} Stück, jetzt {_quantity.Value}/{Capacity()}."); } + /// Server: ein Stück aus einem IPourSource einräumen (siehe PlayerCarry). + public bool TryAddOne(int productId) + { + if (!IsServer) return false; + if (!IsEmpty && _productId.Value != productId) return false; + if (_quantity.Value >= Capacity()) return false; + + if (IsEmpty) + { + _productId.Value = productId; + var def = _catalog.Get(productId); + if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice; + } + + _quantity.Value++; + return true; + } + /// Server: Kunde entnimmt ein Stück. public bool TakeOne() { @@ -126,8 +163,9 @@ namespace AfterHours.Shop private void RefreshVisuals() { - // TODO: Verpackungs-Prefabs nach Menge ein-/ausblenden (Object Pool). - // Läuft auf allen Peers, rein kosmetisch. + var product = _catalog != null ? _catalog.Get(_productId.Value) : null; + GameObject prefab = product != null ? product.PackagePrefab : null; + _packageDisplay.SetTarget(prefab, _quantity.Value, Capacity()); } } } diff --git a/Assets/Scripts/Shop/StorageCrate.cs b/Assets/Scripts/Shop/StorageCrate.cs index e730dc7..d15173e 100644 --- a/Assets/Scripts/Shop/StorageCrate.cs +++ b/Assets/Scripts/Shop/StorageCrate.cs @@ -48,6 +48,7 @@ namespace AfterHours.Shop { if (!IsServer || IsEmpty) return; if (!actor.TryGetComponent(out var hands)) return; + if (actor.TryGetComponent(out var carry) && carry.IsCarrying) return; int want = Mathf.Min(_quantity.Value, PlayerHands.MaxCarry); int taken = hands.TryTake(_productId, want);