Added RocketAssemblyStation and ReadyRocket

This commit is contained in:
2026-08-18 16:12:00 +03:00
parent 7a0ee6b519
commit 63d2836dc9
85 changed files with 15087 additions and 3562 deletions
@@ -16,13 +16,12 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
[Header("Holding Follow")]
[SerializeField, Min(0.1f)] private float maximumFollowSpeed = 12f;
[SerializeField, Min(0.1f)] private float followResponsiveness = 10f;
[SerializeField, Min(0.1f)] private float maximumFollowAcceleration = 80f;
[SerializeField, Min(0.5f)] private float maximumHoldDistance = 4.5f;
[SerializeField, Min(0.1f)] private float rotationFollowSpeed = 14f;
[Header("Local Presentation")]
[Tooltip("Removes the server round-trip delay for the player currently carrying the item.")]
[SerializeField] private bool correctLocalHolderPresentation = true;
[SerializeField, Min(0.1f)] private float maximumLocalCorrectionDistance = 2f;
[SerializeField, Min(0.1f)] private float rotationResponsiveness = 10f;
[SerializeField, Min(0.1f)] private float maximumAngularAcceleration = 80f;
[Header("Throw")]
[SerializeField, Min(0f)] private float throwForce = 6f;
@@ -41,10 +40,10 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
private readonly HashSet<Collider> ignoredPlayerColliders = new();
private readonly HashSet<Collider> desiredIgnoredColliders = new();
private readonly List<Collider> collidersToRestore = new();
private readonly RaycastHit[] localCorrectionHits = new RaycastHit[24];
private float nextCollisionRefreshTime;
private bool initialUseGravity;
private CollisionDetectionMode initialCollisionDetectionMode;
private NetworkTransform networkTransform;
protected abstract Vector3 HoldingOffset { get; }
protected abstract string DropPrompt { get; }
@@ -80,6 +79,8 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
initialUseGravity = physicsBody.useGravity;
initialCollisionDetectionMode = physicsBody.collisionDetectionMode;
}
networkTransform = GetComponent<NetworkTransform>();
}
public override void OnNetworkSpawn()
@@ -87,6 +88,12 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
SpawnedHeldObjects.Add(this);
holderClientId.OnValueChanged += HandleHolderChanged;
isExternallyControlled.OnValueChanged += HandleExternalControlChanged;
if (networkTransform != null)
{
networkTransform.Interpolate = true;
networkTransform.UseUnreliableDeltas = true;
}
ApplyHoldingState();
}
@@ -129,28 +136,6 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
}
private void LateUpdate()
{
if (!correctLocalHolderPresentation || IsServer || !IsSpawned ||
!IsHeldByLocalPlayer || IsExternallyControlled || physicsBody == null ||
!TryGetPlayerObject(holderClientId.Value, out NetworkObject holder))
{
return;
}
Transform anchor = ResolveHoldingAnchor(holder);
Vector3 targetPosition = anchor.TransformPoint(HoldingOffset);
Vector3 targetDelta = targetPosition - physicsBody.position;
if (targetDelta.sqrMagnitude >
maximumLocalCorrectionDistance * maximumLocalCorrectionDistance ||
HasBlockingColliderForLocalCorrection(targetDelta))
{
return;
}
transform.SetPositionAndRotation(targetPosition, anchor.rotation);
}
public void RequestThrow()
{
if (IsSpawned && IsHeldByLocalPlayer)
@@ -292,10 +277,14 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
return false;
}
Vector3 targetVelocity = targetDelta / Mathf.Max(Time.fixedDeltaTime, 0.001f);
physicsBody.linearVelocity = Vector3.ClampMagnitude(
targetVelocity,
float fixedDeltaTime = Mathf.Max(Time.fixedDeltaTime, 0.001f);
Vector3 targetVelocity = Vector3.ClampMagnitude(
targetDelta * followResponsiveness,
maximumFollowSpeed);
physicsBody.linearVelocity = Vector3.MoveTowards(
physicsBody.linearVelocity,
targetVelocity,
maximumFollowAcceleration * fixedDeltaTime);
DriveRotation(anchor.rotation);
return true;
@@ -306,57 +295,25 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
Quaternion rotationDelta = targetRotation * Quaternion.Inverse(physicsBody.rotation);
rotationDelta.ToAngleAxis(out float angle, out Vector3 axis);
if (axis.sqrMagnitude < 0.0001f || float.IsNaN(axis.x))
{
physicsBody.angularVelocity = Vector3.MoveTowards(
physicsBody.angularVelocity,
Vector3.zero,
maximumAngularAcceleration * Mathf.Max(Time.fixedDeltaTime, 0.001f));
return;
}
if (angle > 180f)
angle -= 360f;
Vector3 targetAngularVelocity = axis.normalized *
(angle * Mathf.Deg2Rad / Mathf.Max(Time.fixedDeltaTime, 0.001f));
physicsBody.angularVelocity = Vector3.ClampMagnitude(
targetAngularVelocity,
float fixedDeltaTime = Mathf.Max(Time.fixedDeltaTime, 0.001f);
Vector3 targetAngularVelocity = Vector3.ClampMagnitude(
axis.normalized * angle * Mathf.Deg2Rad * rotationResponsiveness,
rotationFollowSpeed);
}
private bool HasBlockingColliderForLocalCorrection(Vector3 targetDelta)
{
float distance = targetDelta.magnitude;
if (distance <= 0.0001f)
return false;
int hitCount = Physics.RaycastNonAlloc(
physicsBody.position,
targetDelta / distance,
localCorrectionHits,
distance,
~0,
QueryTriggerInteraction.Ignore);
for (int hitIndex = 0; hitIndex < hitCount; hitIndex++)
{
Collider hitCollider = localCorrectionHits[hitIndex].collider;
if (hitCollider == null ||
ignoredPlayerColliders.Contains(hitCollider) ||
IsObjectCollider(hitCollider))
{
continue;
}
return true;
}
return false;
}
private bool IsObjectCollider(Collider candidate)
{
foreach (Collider objectCollider in objectColliders)
{
if (objectCollider == candidate)
return true;
}
return false;
physicsBody.angularVelocity = Vector3.MoveTowards(
physicsBody.angularVelocity,
targetAngularVelocity,
maximumAngularAcceleration * fixedDeltaTime);
}
private void HandleHolderChanged(ulong _, ulong __) => ApplyHoldingState();
@@ -369,6 +326,9 @@ public abstract class NetworkHeldInteractable : NetworkInteractable, IWorkstatio
{
bool isPhysicallyHeld = IsHeld && !IsExternallyControlled;
bool shouldBeKinematic = IsExternallyControlled || !IsServer;
physicsBody.interpolation = IsServer
? RigidbodyInterpolation.Interpolate
: RigidbodyInterpolation.None;
if (shouldBeKinematic)
{
@@ -23,7 +23,7 @@ public sealed class ItemCatalog : ScriptableObject
return false;
}
return itemsById.TryGetValue(itemId.Trim().ToLowerInvariant(), out definition);
return itemsById.TryGetValue(NormalizeId(itemId), out definition);
}
private void RebuildIndex()
@@ -39,4 +39,7 @@ public sealed class ItemCatalog : ScriptableObject
Debug.LogError($"Duplicate item id '{definition.ItemId}' in {name}.", this);
}
}
private static string NormalizeId(string value) =>
value.Trim().ToLowerInvariant().Replace(' ', '-');
}
@@ -1,33 +1,23 @@
using Unity.Netcode;
using UnityEngine;
public enum ItemCategory
{
Scrap,
SmeltedMaterial,
RocketPart,
Tool,
Consumable
}
[CreateAssetMenu(fileName = "ItemDefinition", menuName = "FORT-BO/Items/Item Definition")]
public sealed class ItemDefinition : ScriptableObject
public class ItemDefinition : ScriptableObject
{
[SerializeField] private string itemId;
[SerializeField] private string displayName;
[SerializeField] private ItemCategory category;
[SerializeField] private bool isAssemblyReady;
[SerializeField] private NetworkObject worldPrefab;
public string ItemId => itemId;
public string DisplayName => string.IsNullOrWhiteSpace(displayName) ? name : displayName;
public ItemCategory Category => category;
public bool IsAssemblyReady => isAssemblyReady;
public NetworkObject WorldPrefab => worldPrefab;
private void OnValidate()
protected virtual void OnValidate()
{
itemId = itemId?.Trim().ToLowerInvariant();
itemId = NormalizeId(itemId);
displayName = displayName?.Trim();
}
protected static string NormalizeId(string value) =>
value?.Trim().ToLowerInvariant().Replace(' ', '-');
}
@@ -10,5 +10,11 @@ public sealed class NetworkItem : NetworkBehaviour
public ItemDefinition Definition => definition;
public string ItemId => definition != null ? definition.ItemId : string.Empty;
public string DisplayName => definition != null ? definition.DisplayName : name;
public ItemCategory Category => definition != null ? definition.Category : default;
public bool TryGetDefinition<TDefinition>(out TDefinition typedDefinition)
where TDefinition : ItemDefinition
{
typedDefinition = definition as TDefinition;
return typedDefinition != null;
}
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f100
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
using UnityEngine;
[CreateAssetMenu(
fileName = "MaterialItemDefinition",
menuName = "FORT-BO/Items/Definitions/Material")]
public sealed class MaterialItemDefinition : ItemDefinition
{
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f101
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,29 @@
using UnityEngine;
public enum PartProcessingState : byte
{
Raw,
Ready
}
[CreateAssetMenu(
fileName = "PartItemDefinition",
menuName = "FORT-BO/Items/Definitions/Part")]
public sealed class PartItemDefinition : ItemDefinition
{
[Tooltip("Data-driven part family id, for example rocket-engine or rocket-cone-standard.")]
[SerializeField] private string partTypeId;
[SerializeField] private PartProcessingState processingState;
public string PartTypeId => string.IsNullOrWhiteSpace(partTypeId)
? ItemId
: partTypeId;
public PartProcessingState ProcessingState => processingState;
public bool IsReadyForAssembly => processingState == PartProcessingState.Ready;
protected override void OnValidate()
{
base.OnValidate();
partTypeId = NormalizeId(partTypeId);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f102
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,22 @@
using UnityEngine;
[CreateAssetMenu(
fileName = "RocketItemDefinition",
menuName = "FORT-BO/Items/Definitions/Rocket")]
public sealed class RocketItemDefinition : ItemDefinition
{
[Tooltip("Stable rocket family id used by launchers and orders.")]
[SerializeField] private string rocketTypeId;
[SerializeField, Min(0.1f)] private float criticalImpactSpeed = 7f;
public string RocketTypeId => string.IsNullOrWhiteSpace(rocketTypeId)
? ItemId
: rocketTypeId;
public float CriticalImpactSpeed => criticalImpactSpeed;
protected override void OnValidate()
{
base.OnValidate();
rocketTypeId = NormalizeId(rocketTypeId);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f103
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
using UnityEngine;
[CreateAssetMenu(
fileName = "UsableItemDefinition",
menuName = "FORT-BO/Items/Definitions/Usable")]
public sealed class UsableItemDefinition : ItemDefinition
{
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f104
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f110
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,168 @@
using System;
using Unity.Netcode;
using UnityEngine;
public enum RocketAssemblyQuality : byte
{
Normal,
Defective
}
public struct RocketCriticalImpactState : INetworkSerializable,
IEquatable<RocketCriticalImpactState>
{
public uint Sequence;
public Vector3 Point;
public float Speed;
public RocketCriticalImpactState(uint sequence, Vector3 point, float speed)
{
Sequence = sequence;
Point = point;
Speed = speed;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref Sequence);
serializer.SerializeValue(ref Point);
serializer.SerializeValue(ref Speed);
}
public bool Equals(RocketCriticalImpactState other) =>
Sequence == other.Sequence && Point == other.Point && Speed.Equals(other.Speed);
}
[DisallowMultipleComponent]
[RequireComponent(typeof(NetworkItem), typeof(Rigidbody))]
public sealed class NetworkRocketItem : NetworkBehaviour
{
[SerializeField] private NetworkItem networkItem;
[SerializeField] private Rigidbody physicsBody;
private readonly NetworkVariable<RocketAssemblyQuality> assemblyQuality = new(
RocketAssemblyQuality.Normal,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<RocketCriticalImpactState> criticalImpactState = new(
default,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
public RocketItemDefinition Definition =>
networkItem != null && networkItem.TryGetDefinition(out RocketItemDefinition definition)
? definition
: null;
public string RocketTypeId => Definition != null ? Definition.RocketTypeId : string.Empty;
public RocketAssemblyQuality AssemblyQuality => assemblyQuality.Value;
public bool IsDefective => AssemblyQuality == RocketAssemblyQuality.Defective;
public bool IsCriticallyDamaged => criticalImpactState.Value.Sequence > 0;
public uint CriticalImpactSequence => criticalImpactState.Value.Sequence;
public Vector3 LastCriticalImpactPoint => criticalImpactState.Value.Point;
public float LastCriticalImpactSpeed => criticalImpactState.Value.Speed;
public event Action Changed;
public event Action CriticalImpact;
private void Reset() => ResolveReferences();
private void Awake() => ResolveReferences();
public override void OnNetworkSpawn()
{
assemblyQuality.OnValueChanged += HandleAssemblyQualityChanged;
criticalImpactState.OnValueChanged += HandleCriticalImpactChanged;
if (Definition == null)
{
Debug.LogError(
$"{name} has NetworkRocketItem but its NetworkItem does not reference " +
"a RocketItemDefinition.",
this);
}
Changed?.Invoke();
}
public override void OnNetworkDespawn()
{
assemblyQuality.OnValueChanged -= HandleAssemblyQualityChanged;
criticalImpactState.OnValueChanged -= HandleCriticalImpactChanged;
}
private void OnCollisionEnter(Collision collision)
{
if (!IsServer || !IsSpawned || IsCriticallyDamaged || Definition == null)
return;
float impactSpeed = collision.relativeVelocity.magnitude;
if (impactSpeed < Definition.CriticalImpactSpeed)
return;
Vector3 impactPoint = collision.contactCount > 0
? collision.GetContact(0).point
: transform.position;
TryTriggerCriticalImpact(impactPoint, impactSpeed);
}
public bool TryTriggerCriticalImpact(Vector3 impactPoint, float impactSpeed)
{
if (!IsServer || !IsSpawned || IsCriticallyDamaged || Definition == null ||
impactSpeed < Definition.CriticalImpactSpeed)
{
return false;
}
criticalImpactState.Value = new RocketCriticalImpactState(
criticalImpactState.Value.Sequence + 1,
impactPoint,
impactSpeed);
return true;
}
public bool TryInitializeAssemblyQuality(bool isDefectiveResult)
{
if (!IsServer || !IsSpawned)
return false;
assemblyQuality.Value = isDefectiveResult
? RocketAssemblyQuality.Defective
: RocketAssemblyQuality.Normal;
return true;
}
[ContextMenu("Debug/Trigger Critical Impact (Server Play Mode)")]
private void TriggerCriticalImpactFromContextMenu()
{
if (Application.isPlaying && IsServer && IsSpawned && Definition != null)
{
TryTriggerCriticalImpact(
transform.position,
Definition.CriticalImpactSpeed);
}
}
private void ResolveReferences()
{
if (networkItem == null)
networkItem = GetComponent<NetworkItem>();
if (physicsBody == null)
physicsBody = GetComponent<Rigidbody>();
}
private void HandleAssemblyQualityChanged(
RocketAssemblyQuality _,
RocketAssemblyQuality __) => Changed?.Invoke();
private void HandleCriticalImpactChanged(
RocketCriticalImpactState previous,
RocketCriticalImpactState current)
{
Changed?.Invoke();
if (current.Sequence != previous.Sequence)
CriticalImpact?.Invoke();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f111
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,102 @@
using UnityEngine;
using UnityEngine.Events;
[DisallowMultipleComponent]
public sealed class RocketItemView : MonoBehaviour
{
[SerializeField] private NetworkRocketItem rocket;
[Header("State")]
[SerializeField] private UnityEvent onNormalQualityApplied;
[SerializeField] private UnityEvent onDefectiveQualityApplied;
[SerializeField] private UnityEvent onDamagedStateApplied;
[Header("Critical Impact")]
[SerializeField] private UnityEvent onCriticalImpact;
[SerializeField] private UnityEvent<Vector3> onCriticalImpactPoint;
[SerializeField] private UnityEvent<float> onCriticalImpactSpeed;
private uint observedImpactSequence;
private RocketAssemblyQuality observedQuality;
private bool hasAppliedQuality;
private bool hasAppliedDamagedState;
private void Reset() => rocket = GetComponentInParent<NetworkRocketItem>();
private void Awake()
{
if (rocket == null)
rocket = GetComponentInParent<NetworkRocketItem>();
}
private void OnEnable()
{
if (rocket == null)
return;
observedImpactSequence = rocket.CriticalImpactSequence;
observedQuality = rocket.AssemblyQuality;
hasAppliedQuality = false;
hasAppliedDamagedState = false;
rocket.Changed += ApplyState;
rocket.CriticalImpact += HandleCriticalImpact;
ApplyState();
}
private void OnDisable()
{
if (rocket == null)
return;
rocket.Changed -= ApplyState;
rocket.CriticalImpact -= HandleCriticalImpact;
}
[ContextMenu("Preview/Critical Impact")]
private void PreviewCriticalImpact()
{
onCriticalImpactPoint?.Invoke(transform.position);
onCriticalImpactSpeed?.Invoke(7f);
onCriticalImpact?.Invoke();
onDamagedStateApplied?.Invoke();
}
[ContextMenu("Preview/Normal Quality")]
private void PreviewNormalQuality() => onNormalQualityApplied?.Invoke();
[ContextMenu("Preview/Defective Quality")]
private void PreviewDefectiveQuality() => onDefectiveQualityApplied?.Invoke();
private void ApplyState()
{
if (rocket == null)
return;
if (!hasAppliedQuality || observedQuality != rocket.AssemblyQuality)
{
observedQuality = rocket.AssemblyQuality;
hasAppliedQuality = true;
if (observedQuality == RocketAssemblyQuality.Defective)
onDefectiveQualityApplied?.Invoke();
else
onNormalQualityApplied?.Invoke();
}
if (rocket.IsCriticallyDamaged && !hasAppliedDamagedState)
{
hasAppliedDamagedState = true;
onDamagedStateApplied?.Invoke();
}
}
private void HandleCriticalImpact()
{
if (rocket == null || rocket.CriticalImpactSequence == observedImpactSequence)
return;
observedImpactSequence = rocket.CriticalImpactSequence;
onCriticalImpactPoint?.Invoke(rocket.LastCriticalImpactPoint);
onCriticalImpactSpeed?.Invoke(rocket.LastCriticalImpactSpeed);
onCriticalImpact?.Invoke();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f112
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f130
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,6 @@
using Unity.Netcode;
public interface IHandItemUseAction
{
bool TryUseOnServer(NetworkObject playerObject);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f131
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using Unity.Netcode;
using UnityEngine;
[DisallowMultipleComponent]
public sealed class NetworkUsableHandItemInteractable : NetworkHandItemInteractable
{
[Tooltip("Component on this prefab implementing IHandItemUseAction.")]
[SerializeField] private MonoBehaviour useActionComponent;
private IHandItemUseAction UseAction => useActionComponent as IHandItemUseAction;
protected override void Awake()
{
base.Awake();
ResolveUseAction();
}
private void OnValidate() => ResolveUseAction();
protected override void UseOnServer(NetworkObject playerObject)
{
if (UseAction == null)
{
Debug.LogWarning(
$"{name} was used but has no IHandItemUseAction component assigned.",
this);
return;
}
UseAction.TryUseOnServer(playerObject);
}
private void ResolveUseAction()
{
if (useActionComponent is IHandItemUseAction)
return;
foreach (MonoBehaviour component in GetComponents<MonoBehaviour>())
{
if (component is IHandItemUseAction)
{
useActionComponent = component;
return;
}
}
useActionComponent = null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d1e6d4d81ab34d2e9ee3a1bfa201f132
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -4,6 +4,7 @@ using UnityEngine;
public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSystem
{
[SerializeField, Range(1f, 89f)] private float verticalLookLimit = 80f;
[SerializeField, Min(0.1f)] private float remotePitchFollowSpeed = 20f;
private readonly NetworkVariable<float> synchronizedPitch = new(
0f,
@@ -14,6 +15,7 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
private AudioListener audioListener;
private MeshRenderer playerRenderer;
private float pitch;
private float targetPitch;
private bool isInitialized;
public Camera PlayerCamera => playerCamera;
@@ -34,7 +36,10 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
playerCamera.gameObject.SetActive(isLocalPlayer);
if (!isLocalPlayer)
ApplyCameraPitch(synchronizedPitch.Value);
{
targetPitch = synchronizedPitch.Value;
ApplyCameraPitch(targetPitch);
}
if (!isLocalPlayer)
return;
@@ -62,7 +67,10 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
synchronizedPitch.OnValueChanged += HandleSynchronizedPitchChanged;
if (isInitialized && !IsOwner)
ApplyCameraPitch(synchronizedPitch.Value);
{
targetPitch = synchronizedPitch.Value;
ApplyCameraPitch(targetPitch);
}
if (isInitialized && IsOwner)
CaptureCursor();
@@ -70,7 +78,17 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
private void LateUpdate()
{
if (!isInitialized || !IsOwner || !Application.isFocused)
if (!isInitialized)
return;
if (!IsOwner)
{
float interpolation = 1f - Mathf.Exp(-remotePitchFollowSpeed * Time.deltaTime);
ApplyCameraPitch(Mathf.LerpAngle(pitch, targetPitch, interpolation));
return;
}
if (!Application.isFocused)
return;
if (Cursor.lockState != CursorLockMode.Locked || Cursor.visible)
@@ -89,6 +107,7 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
return;
pitch = Mathf.Clamp(pitch - pitchDelta, -verticalLookLimit, verticalLookLimit);
targetPitch = pitch;
ApplyCameraPitch(pitch);
if (IsSpawned)
@@ -110,7 +129,7 @@ public sealed class FirstPersonCameraController : NetworkBehaviour, IPlayerSyste
private void HandleSynchronizedPitchChanged(float _, float currentPitch)
{
if (!IsOwner)
ApplyCameraPitch(currentPitch);
targetPitch = currentPitch;
}
private void ApplyCameraPitch(float currentPitch)
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Serialization;
public enum RocketAssemblyWorkstationState : byte
{
@@ -28,7 +29,9 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
[Header("Interaction")]
[SerializeField, Min(0.1f)] private float maximumTakeDistance = 3.5f;
[SerializeField, Min(0.05f)] private float reinsertCooldownAfterTake = 0.5f;
[FormerlySerializedAs("reinsertCooldownAfterTake")]
[SerializeField, Min(0f)] private float minimumReinsertBlockDuration = 0.1f;
[SerializeField, Min(0f)] private float reinsertClearance = 0.1f;
private readonly NetworkVariable<RocketAssemblyWorkstationState> state = new(
RocketAssemblyWorkstationState.Loading,
@@ -63,7 +66,8 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
private readonly NetworkItem[] slottedItems = new NetworkItem[MaximumSlotCount];
private readonly IWorkstationItemControl[] slottedItemControls =
new IWorkstationItemControl[MaximumSlotCount];
private readonly Dictionary<ulong, double> reinsertBlockedUntil = new();
private readonly Dictionary<ulong, ReinsertBlock> reinsertBlocks = new();
private readonly List<ulong> clearedReinsertBlockIds = new();
private NetworkItem currentOutput;
@@ -170,6 +174,7 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
if (!IsServer || !IsSpawned)
return;
UpdateReinsertBlocks();
RemoveMissingSlottedItems();
if (State == RocketAssemblyWorkstationState.Assembling &&
@@ -311,8 +316,11 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
NetworkItem item = slottedItems[slotIndex];
if (item != null)
{
reinsertBlockedUntil[item.NetworkObjectId] =
GetNetworkTime() + reinsertCooldownAfterTake;
reinsertBlocks[item.NetworkObjectId] = new ReinsertBlock(
item,
slotIndex,
item.GetComponentsInChildren<Collider>(true),
GetNetworkTime() + minimumReinsertBlockDuration);
}
ClearSlot(slotIndex);
@@ -333,7 +341,8 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
Array.Clear(slottedItems, 0, slottedItems.Length);
Array.Clear(slottedItemControls, 0, slottedItemControls.Length);
reinsertBlockedUntil.Clear();
reinsertBlocks.Clear();
clearedReinsertBlockIds.Clear();
currentOutput = null;
selectedRecipeIndex.Value = FindFirstValidRecipeIndex();
assemblyStartedAt.Value = 0d;
@@ -380,9 +389,7 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
}
bool defective = assemblyWillBeDefective.Value;
ItemDefinition outputDefinition = defective
? recipe.DefectiveOutputItem
: recipe.OutputItem;
RocketItemDefinition outputDefinition = recipe.OutputItem;
if (outputDefinition == null || outputDefinition.WorldPrefab == null)
{
CancelAssemblyOnServer();
@@ -408,12 +415,14 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
outputObject.Spawn();
currentOutput = outputObject.GetComponent<NetworkItem>();
if (currentOutput == null ||
NetworkRocketItem rocketItem = outputObject.GetComponent<NetworkRocketItem>();
if (currentOutput == null || rocketItem == null ||
!WorkstationItemControlUtility.TryGetControl(currentOutput, out _))
{
Debug.LogError(
$"{name} cannot produce '{outputDefinition.DisplayName}': the output prefab " +
"must contain NetworkItem and a supported Carry or Drag interaction component.",
"must contain NetworkItem, NetworkRocketItem and a supported Carry or Drag " +
"interaction component.",
this);
outputObject.Despawn(true);
currentOutput = null;
@@ -421,6 +430,8 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
return;
}
rocketItem.TryInitializeAssemblyQuality(defective);
assemblyStartedAt.Value = 0d;
assemblyEndsAt.Value = 0d;
state.Value = RocketAssemblyWorkstationState.OutputReady;
@@ -513,17 +524,51 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
private bool IsTemporarilyBlockedFromReinsert(NetworkItem item)
{
if (item == null ||
!reinsertBlockedUntil.TryGetValue(item.NetworkObjectId, out double blockedUntil))
return item != null && reinsertBlocks.ContainsKey(item.NetworkObjectId);
}
private void UpdateReinsertBlocks()
{
if (reinsertBlocks.Count == 0)
return;
double now = GetNetworkTime();
clearedReinsertBlockIds.Clear();
foreach (KeyValuePair<ulong, ReinsertBlock> pair in reinsertBlocks)
{
return false;
ReinsertBlock block = pair.Value;
bool canReleaseBlock = block.Item == null || !block.Item.IsSpawned ||
(now >= block.MinimumBlockedUntil &&
(!IsValidSlotIndex(block.SourceSlotIndex) ||
!slotsBottomToTop[block.SourceSlotIndex].OverlapsAny(
block.ItemColliders,
reinsertClearance)));
if (canReleaseBlock)
clearedReinsertBlockIds.Add(pair.Key);
}
if (GetNetworkTime() < blockedUntil)
return true;
foreach (ulong networkObjectId in clearedReinsertBlockIds)
reinsertBlocks.Remove(networkObjectId);
}
reinsertBlockedUntil.Remove(item.NetworkObjectId);
return false;
private readonly struct ReinsertBlock
{
public ReinsertBlock(
NetworkItem item,
int sourceSlotIndex,
Collider[] itemColliders,
double minimumBlockedUntil)
{
Item = item;
SourceSlotIndex = sourceSlotIndex;
ItemColliders = itemColliders;
MinimumBlockedUntil = minimumBlockedUntil;
}
public NetworkItem Item { get; }
public int SourceSlotIndex { get; }
public Collider[] ItemColliders { get; }
public double MinimumBlockedUntil { get; }
}
private NetworkItem ResolveSynchronizedItem(int slotIndex)
@@ -588,8 +633,9 @@ public sealed class NetworkRocketAssemblyWorkstation : NetworkWorkstationControl
slotIndex >= 0 && slotIndex < SlotCount && slotsBottomToTop[slotIndex] != null;
private static bool IsReadyRocketPart(NetworkItem item) =>
item != null && item.IsSpawned && item.Definition != null &&
item.Category == ItemCategory.RocketPart && item.Definition.IsAssemblyReady;
item != null && item.IsSpawned &&
item.TryGetDefinition(out PartItemDefinition definition) &&
definition.IsReadyForAssembly;
private static bool IsRecipeValid(RocketAssemblyRecipe recipe) =>
recipe != null && recipe.IsConfigured;
@@ -8,6 +8,7 @@ public sealed class RocketAssemblyInputSlot : MonoBehaviour, IInteractable
[SerializeField] private Transform snapPoint;
private NetworkRocketAssemblyWorkstation workstation;
private Collider triggerCollider;
private int slotIndex = -1;
public Transform SnapPoint => snapPoint != null ? snapPoint : transform;
@@ -21,10 +22,17 @@ public sealed class RocketAssemblyInputSlot : MonoBehaviour, IInteractable
private void Reset()
{
snapPoint = transform;
GetComponent<Collider>().isTrigger = true;
ResolveTriggerCollider();
triggerCollider.isTrigger = true;
}
private void OnValidate() => GetComponent<Collider>().isTrigger = true;
private void Awake() => ResolveTriggerCollider();
private void OnValidate()
{
ResolveTriggerCollider();
triggerCollider.isTrigger = true;
}
public void Configure(NetworkRocketAssemblyWorkstation owner, int index)
{
@@ -51,4 +59,30 @@ public sealed class RocketAssemblyInputSlot : MonoBehaviour, IInteractable
if (item != null)
workstation.TryInsertItem(slotIndex, item);
}
public bool OverlapsAny(Collider[] itemColliders, float clearance)
{
ResolveTriggerCollider();
if (triggerCollider == null || !triggerCollider.enabled || itemColliders == null)
return false;
Bounds triggerBounds = triggerCollider.bounds;
triggerBounds.Expand(Mathf.Max(0f, clearance) * 2f);
foreach (Collider itemCollider in itemColliders)
{
if (itemCollider != null && itemCollider.enabled &&
triggerBounds.Intersects(itemCollider.bounds))
{
return true;
}
}
return false;
}
private void ResolveTriggerCollider()
{
if (triggerCollider == null)
triggerCollider = GetComponent<Collider>();
}
}
@@ -12,9 +12,8 @@ public sealed class RocketAssemblyRecipe : ScriptableObject
[SerializeField] private string recipeId;
[SerializeField] private string displayName;
[Tooltip("Exact assembly order. Element 0 is the lowest rocket part.")]
[SerializeField] private List<ItemDefinition> requiredPartsBottomToTop = new();
[SerializeField] private ItemDefinition outputItem;
[SerializeField] private ItemDefinition defectiveOutputItem;
[SerializeField] private List<PartItemDefinition> requiredPartsBottomToTop = new();
[SerializeField] private RocketItemDefinition outputItem;
[SerializeField, Min(0.05f)] private float assemblyDuration = 5f;
[Header("Blueprint")]
@@ -23,10 +22,9 @@ public sealed class RocketAssemblyRecipe : ScriptableObject
public string RecipeId => recipeId;
public string DisplayName => string.IsNullOrWhiteSpace(displayName) ? name : displayName;
public IReadOnlyList<ItemDefinition> RequiredPartsBottomToTop => requiredPartsBottomToTop;
public IReadOnlyList<PartItemDefinition> RequiredPartsBottomToTop => requiredPartsBottomToTop;
public int RequiredPartCount => requiredPartsBottomToTop.Count;
public ItemDefinition OutputItem => outputItem;
public ItemDefinition DefectiveOutputItem => defectiveOutputItem;
public RocketItemDefinition OutputItem => outputItem;
public float AssemblyDuration => assemblyDuration;
public string BlueprintTitle => string.IsNullOrWhiteSpace(blueprintTitle)
? DisplayName
@@ -36,8 +34,7 @@ public sealed class RocketAssemblyRecipe : ScriptableObject
public bool IsConfigured =>
requiredPartsBottomToTop.Count is > 0 and <= MaximumPartCount &&
requiredPartsBottomToTop.TrueForAll(IsReadyRocketPart) &&
IsSpawnable(outputItem) &&
IsSpawnable(defectiveOutputItem);
IsSpawnable(outputItem);
public bool Matches(IReadOnlyList<NetworkItem> slottedItems)
{
@@ -75,14 +72,12 @@ public sealed class RocketAssemblyRecipe : ScriptableObject
requiredPartsBottomToTop.Count - MaximumPartCount);
}
private static bool MatchesDefinition(NetworkItem item, ItemDefinition definition) =>
private static bool MatchesDefinition(NetworkItem item, PartItemDefinition definition) =>
item.Definition == definition ||
(!string.IsNullOrEmpty(definition.ItemId) && item.ItemId == definition.ItemId);
private static bool IsReadyRocketPart(ItemDefinition definition) =>
definition != null &&
definition.Category == ItemCategory.RocketPart &&
definition.IsAssemblyReady;
private static bool IsReadyRocketPart(PartItemDefinition definition) =>
definition != null && definition.IsReadyForAssembly;
private static bool IsSpawnable(ItemDefinition definition) =>
definition != null && definition.WorldPrefab != null;