- Carryable: generic pickup/carry component, cosmetic per-peer follow of the carrier's HandSocket instead of NGO reparenting (camera pitch isn't networked, so root-level reparenting wouldn't tilt with the view) - PlayerCarry: mutual exclusion with PlayerHands, server-timed hold-to-pour tick (client only signals start/stop, never per-unit requests) - IPourSource/IPourTarget: decouple PlayerInteractor from concrete Shop types, same pattern as IInteractable - Lieferkarton: gains product data (productId/quantity), two-stage Interact() (open, then pick up), IPourSource - ShelfSlot: implements IPourTarget, fills the previous RefreshVisuals TODO - PackageDisplay: renders quantity as a proper column x row grid that stacks into further layers as space runs out, reused by both containers - FlyingPackage: small non-networked cosmetic visual that arcs a package from the carried box to its exact landing slot in the shelf grid on every successful pour tick, broadcast via Rpc(SendTo.Everyone) - StorageCrate: rejects pickup while the player is carrying a box Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
4.7 KiB
C#
135 lines
4.7 KiB
C#
using AfterHours.Networking;
|
||
using Unity.Netcode;
|
||
using UnityEngine;
|
||
|
||
namespace AfterHours.Interaction
|
||
{
|
||
/// <summary>
|
||
/// Raycast auf dem Owner-Client für den Prompt, Ausführung auf dem Server.
|
||
/// Der Server vertraut dem Client die Zielauswahl NICHT blind – er prüft
|
||
/// Distanz und Sichtbarkeit erneut (siehe CLAUDE.md §3).
|
||
/// </summary>
|
||
[RequireComponent(typeof(NetworkObject))]
|
||
public sealed class PlayerInteractor : NetworkBehaviour
|
||
{
|
||
[SerializeField] private Transform _eyes;
|
||
[SerializeField] private float _reach = 2.5f;
|
||
[SerializeField] private LayerMask _interactableMask = ~0;
|
||
|
||
/// <summary>Toleranz, weil Client und Server nie exakt denselben Frame sehen.</summary>
|
||
private const float ServerReachTolerance = 1.5f;
|
||
|
||
private IInteractable _current;
|
||
private IPourTarget _currentPourTarget;
|
||
private IPourTarget _pouringTarget;
|
||
public string CurrentPrompt { get; private set; }
|
||
|
||
private void Update()
|
||
{
|
||
if (!IsOwner) return;
|
||
|
||
RefreshTarget();
|
||
|
||
if (Input.GetKeyDown(KeyCode.E))
|
||
{
|
||
if (_current != null)
|
||
{
|
||
var target = ((MonoBehaviour)_current).GetComponent<NetworkObject>();
|
||
if (target != null) RequestInteractRpc(target);
|
||
}
|
||
else if (TryGetComponent<PlayerCarry>(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))
|
||
{
|
||
if (hit.collider.TryGetComponent<IInteractable>(out var interactable))
|
||
{
|
||
_current = interactable;
|
||
CurrentPrompt = interactable.GetPrompt(OwnerClientId);
|
||
}
|
||
|
||
hit.collider.TryGetComponent<IPourTarget>(out _currentPourTarget);
|
||
}
|
||
|
||
// TODO: entfernen, sobald ein echter Prompt im UI angezeigt wird.
|
||
if (_current != previous)
|
||
{
|
||
Debug.Log(_current != null
|
||
? $"[PlayerInteractor] Ziel im Blick: {CurrentPrompt}"
|
||
: "[PlayerInteractor] Kein Ziel mehr im Blick.");
|
||
}
|
||
}
|
||
|
||
/// <summary>Maustaste halten, um ein IPourTarget im Blick Stück für Stück zu befüllen.</summary>
|
||
private void HandlePourInput()
|
||
{
|
||
if (Input.GetMouseButtonDown(0) && _currentPourTarget != null)
|
||
{
|
||
var targetNo = ((MonoBehaviour)_currentPourTarget).GetComponent<NetworkObject>();
|
||
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?
|
||
if (!IsInReach(target.transform.position))
|
||
{
|
||
Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt.");
|
||
return;
|
||
}
|
||
|
||
if (target.TryGetComponent<IInteractable>(out var interactable))
|
||
interactable.Interact(NetworkObject);
|
||
}
|
||
|
||
[Rpc(SendTo.Server)]
|
||
private void RequestPourStartRpc(NetworkObjectReference targetRef)
|
||
{
|
||
if (!targetRef.TryGet(out var target) || !target.TryGetComponent<IPourTarget>(out var pourTarget)) return;
|
||
if (!IsInReach(target.transform.position)) return;
|
||
|
||
if (TryGetComponent<PlayerCarry>(out var carry)) carry.BeginPour(pourTarget);
|
||
}
|
||
|
||
[Rpc(SendTo.Server)]
|
||
private void RequestPourStopRpc()
|
||
{
|
||
if (TryGetComponent<PlayerCarry>(out var carry)) carry.StopPour();
|
||
}
|
||
|
||
/// <summary>Server-Validierung: ist der Spieler überhaupt in Reichweite?</summary>
|
||
private bool IsInReach(Vector3 point)
|
||
{
|
||
float distance = Vector3.Distance(transform.position, point);
|
||
return distance <= _reach + ServerReachTolerance;
|
||
}
|
||
}
|
||
}
|