Map mehanic ready to multiplayer

This commit is contained in:
2026-08-18 17:17:48 +03:00
parent c01ff800f6
commit bb2f4a3b16
20 changed files with 4691 additions and 1648 deletions
@@ -0,0 +1,289 @@
using Unity.Netcode;
using Unity.Netcode.Components;
using UnityEditor;
using UnityEngine;
public static class MapRadarNetworkSetup
{
private const string LeverPrefabPath =
"Assets/CONTENT/Game/Prefabs/Interaction/PhysicalLever.prefab";
[MenuItem("FORT-BO/Map Radar/Configure Network Prototype")]
public static void ConfigureNetworkPrototype()
{
MapController mapController = Object.FindFirstObjectByType<MapController>();
if (mapController == null)
{
Debug.LogError("MapController was not found in the open scene.");
return;
}
MapCoordinateSystem coordinateSystem = mapController.CoordinateSystem;
if (coordinateSystem == null || !coordinateSystem.HasValidCorners())
{
Debug.LogError("MapCoordinateSystem is not configured.", mapController);
return;
}
Undo.RegisterFullObjectHierarchyUndo(mapController.transform.root.gameObject,
"Configure network map prototype");
GetOrAdd<NetworkObject>(mapController.gameObject);
NetworkBaseHealth baseHealth = GetOrAdd<NetworkBaseHealth>(mapController.gameObject);
MapControlBinder binder = GetOrAdd<MapControlBinder>(mapController.gameObject);
Transform controlsRoot = GetOrCreateChild(
mapController.transform.root,
"Map Controls");
MapBasis basis = CreateBasis(coordinateSystem);
NetworkPhysicalWheelInteractable horizontalWheel = CreateWheel(
controlsRoot,
"Map Horizontal Wheel",
basis.ControlCenter - basis.Horizontal * 1.8f,
basis.Rotation,
"Aim horizontal");
NetworkPhysicalWheelInteractable verticalWheel = CreateWheel(
controlsRoot,
"Map Vertical Wheel",
basis.ControlCenter - basis.Horizontal * 0.6f,
basis.Rotation,
"Aim vertical");
NetworkPhysicalLeverInteractable fireLever = CreateFireLever(
controlsRoot,
basis.ControlCenter + basis.Horizontal * 0.8f,
basis.Rotation);
NetworkButtonInteractable scanButton = CreateScanButton(
controlsRoot,
basis.ControlCenter + basis.Horizontal * 2f,
basis.Rotation);
SerializedObject map = new(mapController);
SetObject(map, "roundGameLoop",
Object.FindFirstObjectByType<RoundGameLoopController>());
SetObject(map, "baseHealth", baseHealth);
map.ApplyModifiedPropertiesWithoutUndo();
SerializedObject binding = new(binder);
SetObject(binding, "mapController", mapController);
SetObject(binding, "horizontalWheel", horizontalWheel);
SetObject(binding, "verticalWheel", verticalWheel);
SetObject(binding, "fireLever", fireLever);
SetObject(binding, "scanButton", scanButton);
binding.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(mapController);
EditorUtility.SetDirty(binder);
EditorUtility.SetDirty(baseHealth);
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(
mapController.gameObject.scene);
UnityEditor.SceneManagement.EditorSceneManager.SaveOpenScenes();
Selection.activeObject = controlsRoot.gameObject;
Debug.Log(
"[Map] Network prototype configured: X/Y wheels, fire lever, " +
"scan button and base health are connected.",
mapController);
}
private static NetworkPhysicalWheelInteractable CreateWheel(
Transform parent,
string name,
Vector3 position,
Quaternion rotation,
string interactionLabel)
{
Transform root = GetOrCreateChild(parent, name);
root.SetPositionAndRotation(position, rotation);
root.localScale = Vector3.one;
GetOrAdd<NetworkObject>(root.gameObject);
NetworkTransform networkTransform = GetOrAdd<NetworkTransform>(root.gameObject);
networkTransform.AuthorityMode = NetworkTransform.AuthorityModes.Server;
networkTransform.UseUnreliableDeltas = true;
Transform legacyVisual = root.Find("Wheel Visual");
Transform visual = root.Find("Wheel Pivot");
if (visual == null)
{
if (legacyVisual != null)
Object.DestroyImmediate(legacyVisual.gameObject);
visual = new GameObject("Wheel Pivot").transform;
visual.SetParent(root, false);
}
visual.localPosition = Vector3.zero;
visual.localRotation = Quaternion.identity;
visual.localScale = Vector3.one;
Transform disk = visual.Find("Wheel Disk");
if (disk == null)
{
GameObject cylinder = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
cylinder.name = "Wheel Disk";
disk = cylinder.transform;
disk.SetParent(visual, false);
}
disk.localPosition = Vector3.zero;
disk.localRotation = Quaternion.Euler(90f, 0f, 0f);
disk.localScale = new Vector3(0.48f, 0.08f, 0.48f);
Transform handle = visual.Find("Handle");
if (handle == null)
{
GameObject handleObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
handleObject.name = "Handle";
handle = handleObject.transform;
handle.SetParent(visual, false);
Object.DestroyImmediate(handleObject.GetComponent<Collider>());
}
handle.localPosition = new Vector3(0f, 0.65f, 0f);
handle.localRotation = Quaternion.identity;
handle.localScale = new Vector3(0.14f, 0.4f, 0.14f);
NetworkPhysicalWheelInteractable wheel =
GetOrAdd<NetworkPhysicalWheelInteractable>(root.gameObject);
SerializedObject serializedWheel = new(wheel);
SetObject(serializedWheel, "wheelVisual", visual);
SetVector3(serializedWheel, "neutralLocalEulerAngles", Vector3.zero);
SetString(serializedWheel, "interactionLabel", interactionLabel);
SetVector3(serializedWheel, "localRotationAxis", Vector3.forward);
SetVector3(serializedWheel, "localReferenceDirection", Vector3.up);
SetFloat(serializedWheel, "minimumAngle", -180f);
SetFloat(serializedWheel, "maximumAngle", 180f);
SetFloat(serializedWheel, "initialNormalizedValue", 0.5f);
SetFloat(serializedWheel, "maximumInteractionDistance", 3.5f);
SetFloat(serializedWheel, "maximumControlDistance", 4f);
serializedWheel.ApplyModifiedPropertiesWithoutUndo();
return wheel;
}
private static NetworkPhysicalLeverInteractable CreateFireLever(
Transform parent,
Vector3 position,
Quaternion rotation)
{
Transform existing = parent.Find("Map Fire Lever");
GameObject root;
if (existing != null)
{
root = existing.gameObject;
}
else
{
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(LeverPrefabPath);
if (prefab == null)
throw new MissingReferenceException($"Lever prefab not found: {LeverPrefabPath}");
root = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent);
root.name = "Map Fire Lever";
}
root.transform.SetPositionAndRotation(position, rotation);
root.transform.localScale = Vector3.one * 0.55f;
NetworkPhysicalLeverInteractable lever =
root.GetComponent<NetworkPhysicalLeverInteractable>();
SerializedObject serializedLever = new(lever);
SetString(serializedLever, "interactionLabel", "Fire");
SetBool(serializedLever, "initialPositionIsMaximum", false);
SetBool(serializedLever, "returnToInitialPositionAfterSwitch", true);
SetFloat(serializedLever, "automaticReturnDelay", 0.2f);
SetFloat(serializedLever, "maximumInteractionDistance", 3.5f);
SetFloat(serializedLever, "maximumControlDistance", 4f);
serializedLever.ApplyModifiedPropertiesWithoutUndo();
return lever;
}
private static NetworkButtonInteractable CreateScanButton(
Transform parent,
Vector3 position,
Quaternion rotation)
{
Transform root = GetOrCreateChild(parent, "Map Scan Button");
root.SetPositionAndRotation(position, rotation);
root.localScale = Vector3.one;
GetOrAdd<NetworkObject>(root.gameObject);
NetworkTransform networkTransform = GetOrAdd<NetworkTransform>(root.gameObject);
networkTransform.AuthorityMode = NetworkTransform.AuthorityModes.Server;
Transform visual = root.Find("Button Visual");
if (visual == null)
{
GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.name = "Button Visual";
visual = cube.transform;
visual.SetParent(root, false);
}
visual.localPosition = Vector3.zero;
visual.localRotation = Quaternion.identity;
visual.localScale = new Vector3(0.55f, 0.55f, 0.18f);
NetworkButtonInteractable button = GetOrAdd<NetworkButtonInteractable>(root.gameObject);
SerializedObject serializedButton = new(button);
SetString(serializedButton, "interactionLabel", "Scan map");
SetFloat(serializedButton, "maximumInteractionDistance", 3.5f);
serializedButton.ApplyModifiedPropertiesWithoutUndo();
return button;
}
private static MapBasis CreateBasis(MapCoordinateSystem coordinates)
{
Vector3 bottomLeft = coordinates.BottomLeft.position;
Vector3 horizontalEdge = coordinates.BottomRight.position - bottomLeft;
Vector3 verticalEdge = coordinates.TopLeft.position - bottomLeft;
Vector3 horizontal = horizontalEdge.normalized;
Vector3 vertical = verticalEdge.normalized;
Vector3 normal = Vector3.Cross(horizontal, vertical).normalized;
Vector3 bottomCenter = bottomLeft + horizontalEdge * 0.5f;
Vector3 controlCenter = bottomCenter - vertical * 0.85f + normal * 0.55f;
return new MapBasis(
horizontal,
controlCenter,
Quaternion.LookRotation(normal, vertical));
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
Transform existing = parent.Find(name);
if (existing != null)
return existing;
GameObject child = new(name);
child.transform.SetParent(parent, false);
Undo.RegisterCreatedObjectUndo(child, $"Create {name}");
return child.transform;
}
private static T GetOrAdd<T>(GameObject target) where T : Component
{
T component = target.GetComponent<T>();
return component != null ? component : Undo.AddComponent<T>(target);
}
private static void SetObject(SerializedObject target, string property, Object value) =>
target.FindProperty(property).objectReferenceValue = value;
private static void SetString(SerializedObject target, string property, string value) =>
target.FindProperty(property).stringValue = value;
private static void SetFloat(SerializedObject target, string property, float value) =>
target.FindProperty(property).floatValue = value;
private static void SetBool(SerializedObject target, string property, bool value) =>
target.FindProperty(property).boolValue = value;
private static void SetVector3(SerializedObject target, string property, Vector3 value) =>
target.FindProperty(property).vector3Value = value;
private readonly struct MapBasis
{
public readonly Vector3 Horizontal;
public readonly Vector3 ControlCenter;
public readonly Quaternion Rotation;
public MapBasis(Vector3 horizontal, Vector3 controlCenter, Quaternion rotation)
{
Horizontal = horizontal;
ControlCenter = controlCenter;
Rotation = rotation;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3807965ea93055647ad20aa098cd2032
@@ -0,0 +1,73 @@
using UnityEngine;
[DisallowMultipleComponent]
public sealed class MapControlBinder : MonoBehaviour
{
[SerializeField] private MapController mapController;
[SerializeField] private NetworkPhysicalWheelInteractable horizontalWheel;
[SerializeField] private NetworkPhysicalWheelInteractable verticalWheel;
[SerializeField] private NetworkPhysicalLeverInteractable fireLever;
[SerializeField] private NetworkButtonInteractable scanButton;
private bool initialValuesApplied;
private void Reset() => mapController = GetComponent<MapController>();
private void OnEnable()
{
if (horizontalWheel != null)
horizontalWheel.ValueChanged += HandleHorizontalChanged;
if (verticalWheel != null)
verticalWheel.ValueChanged += HandleVerticalChanged;
if (fireLever != null)
fireLever.StateChanged += HandleFireChanged;
if (scanButton != null)
scanButton.Pressed += HandleScanPressed;
}
private void OnDisable()
{
if (horizontalWheel != null)
horizontalWheel.ValueChanged -= HandleHorizontalChanged;
if (verticalWheel != null)
verticalWheel.ValueChanged -= HandleVerticalChanged;
if (fireLever != null)
fireLever.StateChanged -= HandleFireChanged;
if (scanButton != null)
scanButton.Pressed -= HandleScanPressed;
}
private void Update()
{
if (initialValuesApplied || mapController == null || !mapController.IsSpawned ||
horizontalWheel == null || verticalWheel == null ||
!horizontalWheel.IsSpawned || !verticalWheel.IsSpawned)
{
return;
}
initialValuesApplied = true;
HandleHorizontalChanged(horizontalWheel.NormalizedValue);
HandleVerticalChanged(verticalWheel.NormalizedValue);
}
private void HandleHorizontalChanged(float value) =>
ApplyOnServer(() => mapController.SetHorizontalNormalized(value));
private void HandleVerticalChanged(float value) =>
ApplyOnServer(() => mapController.SetVerticalNormalized(value));
private void HandleFireChanged(bool active)
{
if (active)
ApplyOnServer(mapController.RequestPseudoFire);
}
private void HandleScanPressed() => ApplyOnServer(mapController.RequestRadarScan);
private void ApplyOnServer(System.Action action)
{
if (mapController != null && mapController.IsSpawned && mapController.IsServer)
action?.Invoke();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1a23aadbde3d31042abf625ff22e6fd8
+480 -46
View File
@@ -1,100 +1,534 @@
using System;
using Unity.Netcode;
using UnityEngine; using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization; using UnityEngine.Serialization;
[DisallowMultipleComponent] [DisallowMultipleComponent]
public sealed class MapController : MonoBehaviour [RequireComponent(typeof(NetworkObject))]
public sealed class MapController : NetworkBehaviour
{ {
[Header("Main References")] [Header("Main References")]
[SerializeField] private MapCrosshairController crosshair = null; [SerializeField] private MapCrosshairController crosshair;
[SerializeField] private MapCoordinateSystem coordinateSystem = null; [SerializeField] private MapCoordinateSystem coordinateSystem;
[SerializeField] private MapPlayer mapPlayer = null; [SerializeField] private MapPlayer mapPlayer;
[SerializeField] private RoundGameLoopController roundGameLoop;
[SerializeField] private NetworkBaseHealth baseHealth;
[Header("Target System")] [Header("Target System")]
[SerializeField] private MapTargetController targetController = null; [SerializeField] private MapTargetController targetController;
[SerializeField] private Transform targetParent = null; [SerializeField] private Transform targetParent;
[SerializeField, Min(0.02f)] private float simulationStep = 0.1f;
[Header("Radar System")] [Header("Radar System")]
[SerializeField] private MapRadarController radarController = null; [SerializeField] private MapRadarController radarController;
[FormerlySerializedAs("targetPrefab")] [FormerlySerializedAs("targetPrefab")]
[SerializeField] private MapTarget radarMarkPrefab = null; [SerializeField] private MapTarget radarMarkPrefab;
[Header("Crosshair Objects")] [Header("Crosshair Objects")]
[SerializeField] private Transform relayX = null; [SerializeField] private Transform relayX;
[SerializeField] private Transform relayY = null; [SerializeField] private Transform relayY;
[SerializeField] private Transform crosshairVisual = null; [SerializeField] private Transform crosshairVisual;
[Header("Input")] [Header("Pseudo Shot")]
[SerializeField, Min(0f)] private float movementSpeed = 1f; [SerializeField, Min(0.01f)] private float hitRadius = 0.65f;
[SerializeField] private bool useKeyboardInput = true; [SerializeField, Min(1)] private int shotDamage = 1;
[SerializeField] private MissileType pseudoMissileType = MissileType.Standard;
[SerializeField] private bool ignoreMissileTypeForPrototype = true;
[Header("Network Events")]
[SerializeField] private UnityEvent<int> waveStarted;
[SerializeField] private UnityEvent<bool> shotResolved;
[SerializeField] private UnityEvent allTargetsResolved;
[SerializeField] private UnityEvent allTargetsDestroyed;
private readonly NetworkVariable<Vector2> crosshairCoordinates = new(
new Vector2(5f, 5f), NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<int> currentWave = new(
0, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<double> radarReadyAt = new(
0d, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<MapShotResultState> lastShotResult = new(
default, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<uint> resolvedSequence = new(
0, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<uint> destroyedSequence = new(
0, NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkList<MapTargetNetworkState> activeTargets = new();
private readonly NetworkList<Vector2> radarMarks = new();
private float simulationAccumulator;
private int nextTargetId = 1;
private int initialTargetCount;
private int escapedTargetCount;
private bool waveIsActive;
private bool roundEventsBound;
public MapCoordinateSystem CoordinateSystem => coordinateSystem; public MapCoordinateSystem CoordinateSystem => coordinateSystem;
public Transform CrosshairVisual => crosshairVisual; public Transform CrosshairVisual => crosshairVisual;
public Vector2 CrosshairCoordinates => crosshairCoordinates.Value;
public int CurrentWave => currentWave.Value;
public int ActiveTargetCount => activeTargets.Count;
public bool WaveIsActive => waveIsActive;
public double RadarCooldownRemaining => IsSpawned && NetworkManager != null
? Math.Max(0d, radarReadyAt.Value - NetworkManager.ServerTime.Time)
: 0d;
private void Awake() public event Action<int> WaveStarted;
public event Action<MapShotResultState> ShotResolved;
public event Action AllTargetsResolved;
public event Action AllTargetsDestroyed;
private void Awake() => ConfigurePresentation();
private void OnValidate()
{ {
if (mapPlayer != null) simulationStep = Mathf.Max(0.02f, simulationStep);
mapPlayer.Configure(coordinateSystem); hitRadius = Mathf.Max(0.01f, hitRadius);
shotDamage = Mathf.Max(1, shotDamage);
}
if (targetController != null) public override void OnNetworkSpawn()
targetController.Configure(coordinateSystem, targetParent, mapPlayer); {
activeTargets.OnListChanged += HandleTargetListChanged;
radarMarks.OnListChanged += HandleRadarListChanged;
crosshairCoordinates.OnValueChanged += HandleCrosshairChanged;
currentWave.OnValueChanged += HandleWaveChanged;
radarReadyAt.OnValueChanged += HandleRadarReadyAtChanged;
lastShotResult.OnValueChanged += HandleShotResultChanged;
resolvedSequence.OnValueChanged += HandleResolvedSequenceChanged;
destroyedSequence.OnValueChanged += HandleDestroyedSequenceChanged;
ConfigurePresentation();
BindRoundEvents();
ApplyPresentation();
if (IsServer && roundGameLoop != null &&
roundGameLoop.Phase == RoundPhase.WorkInProgress && currentWave.Value == 0)
StartNextWaveOnServer();
}
public override void OnNetworkDespawn()
{
activeTargets.OnListChanged -= HandleTargetListChanged;
radarMarks.OnListChanged -= HandleRadarListChanged;
crosshairCoordinates.OnValueChanged -= HandleCrosshairChanged;
currentWave.OnValueChanged -= HandleWaveChanged;
radarReadyAt.OnValueChanged -= HandleRadarReadyAtChanged;
lastShotResult.OnValueChanged -= HandleShotResultChanged;
resolvedSequence.OnValueChanged -= HandleResolvedSequenceChanged;
destroyedSequence.OnValueChanged -= HandleDestroyedSequenceChanged;
UnbindRoundEvents();
}
private void OnDestroy()
{
activeTargets.Dispose();
radarMarks.Dispose();
}
private void Update()
{
if (!roundEventsBound)
BindRoundEvents();
if (radarController != null) if (radarController != null)
radarController.Configure(coordinateSystem, targetController, radarMarkPrefab); radarController.SetCooldownRemaining((float)RadarCooldownRemaining);
if (!IsServer || !IsSpawned || !waveIsActive)
if (crosshair == null)
return; return;
crosshair.Configure( simulationAccumulator += Time.deltaTime;
coordinateSystem, while (simulationAccumulator >= simulationStep)
relayX, {
relayY, simulationAccumulator -= simulationStep;
crosshairVisual, SimulateTargets(simulationStep);
movementSpeed, }
useKeyboardInput,
targetController);
} }
public void SetHorizontalAxis(float value) public void SetHorizontalNormalized(float value)
{ {
if (crosshair != null) Vector2 next = crosshairCoordinates.Value;
crosshair.SetHorizontalAxis(value); next.x = Mathf.Clamp01(value) * MapCoordinateSystem.CoordinateMaximum;
RequestCrosshairCoordinates(next);
} }
public void SetVerticalAxis(float value) public void SetVerticalNormalized(float value)
{ {
if (crosshair != null) Vector2 next = crosshairCoordinates.Value;
crosshair.SetVerticalAxis(value); next.y = Mathf.Clamp01(value) * MapCoordinateSystem.CoordinateMaximum;
RequestCrosshairCoordinates(next);
} }
public Vector2 GetCrosshairCoordinates() public void SetHorizontalAxis(float value) =>
SetHorizontalNormalized(Mathf.InverseLerp(-1f, 1f, value));
public void SetVerticalAxis(float value) =>
SetVerticalNormalized(Mathf.InverseLerp(-1f, 1f, value));
public Vector2 GetCrosshairCoordinates() => crosshairCoordinates.Value;
public void RequestPseudoFire()
{ {
return crosshair != null ? crosshair.GetCrosshairCoordinates() : Vector2.zero; if (!IsSpawned)
return;
if (IsServer)
ResolvePseudoShotOnServer();
else
RequestPseudoFireRpc();
} }
public void CreateAndPlaceTargets() public void RequestRadarScan()
{ {
if (targetController != null) if (!IsSpawned)
targetController.CreateAndPlaceTargets(); return;
if (IsServer)
ScanRadarOnServer();
else
RequestRadarScanRpc();
} }
public bool ActivateRadar()
{
if (!IsSpawned || RadarCooldownRemaining > 0d)
return false;
RequestRadarScan();
return true;
}
public void CreateAndPlaceTargets() => StartNextWave();
public bool StartNextWave() public bool StartNextWave()
{ {
return targetController != null && targetController.StartNextWave(); if (!IsSpawned)
return false;
if (IsServer)
return StartNextWaveOnServer();
RequestStartNextWaveRpc();
return true;
} }
public bool TryGetNearestTarget(out MapTarget target, out float distance) public bool TryGetNearestTarget(out MapTarget target, out float distance)
{ {
if (crosshair != null) if (crosshair != null)
return crosshair.TryGetNearestTarget(out target, out distance); return crosshair.TryGetNearestTarget(out target, out distance);
target = null; target = null;
distance = -1f; distance = -1f;
return false; return false;
} }
public bool ActivateRadar() [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void SetCrosshairCoordinatesRpc(Vector2 coordinates) =>
SetCrosshairCoordinatesOnServer(coordinates);
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestPseudoFireRpc() => ResolvePseudoShotOnServer();
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestRadarScanRpc() => ScanRadarOnServer();
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestStartNextWaveRpc() => StartNextWaveOnServer();
private void RequestCrosshairCoordinates(Vector2 coordinates)
{ {
return radarController != null && radarController.ActivateRadar(); if (!IsSpawned)
return;
if (IsServer)
SetCrosshairCoordinatesOnServer(coordinates);
else
SetCrosshairCoordinatesRpc(coordinates);
}
private void SetCrosshairCoordinatesOnServer(Vector2 coordinates)
{
if (!IsServer)
return;
crosshairCoordinates.Value = coordinateSystem != null
? coordinateSystem.ClampCoordinates(coordinates)
: new Vector2(
Mathf.Clamp(coordinates.x, 0f, MapCoordinateSystem.CoordinateMaximum),
Mathf.Clamp(coordinates.y, 0f, MapCoordinateSystem.CoordinateMaximum));
}
private bool StartNextWaveOnServer()
{
if (!IsServer || targetController == null ||
!targetController.HasValidConfiguration())
{
Debug.LogWarning("[Map] Cannot start target wave: map or target configs are incomplete.", this);
return false;
}
activeTargets.Clear();
radarMarks.Clear();
currentWave.Value++;
escapedTargetCount = 0;
initialTargetCount = 0;
waveIsActive = true;
simulationAccumulator = 0f;
for (int index = 0; index < targetController.TargetsPerWave; index++)
{
int configIndex = targetController.GetRandomValidConfigIndex();
TargetConfig config = targetController.GetConfig(configIndex);
if (config == null)
continue;
Vector2 coordinates = new(
UnityEngine.Random.Range(0f, MapCoordinateSystem.CoordinateMaximum),
UnityEngine.Random.Range(0f, MapCoordinateSystem.CoordinateMaximum));
activeTargets.Add(new MapTargetNetworkState(
nextTargetId++, configIndex, coordinates,
config.Health, config.TimeBeforeAttack));
initialTargetCount++;
}
Debug.Log($"[Map] Wave {currentWave.Value} started with {initialTargetCount} targets.", this);
if (initialTargetCount == 0)
CompleteWaveOnServer();
return true;
}
private void SimulateTargets(float deltaTime)
{
for (int index = activeTargets.Count - 1; index >= 0; index--)
{
MapTargetNetworkState state = activeTargets[index];
TargetConfig config = targetController.GetConfig(state.ConfigIndex);
if (config == null)
{
ResolveEscapedTarget(index, state, null);
continue;
}
bool isAirTarget = config.TargetPrefab is AirTarget;
if (isAirTarget && mapPlayer != null && config.MovementSpeed > 0f)
{
Vector2 destination = mapPlayer.MapCoordinates;
state.Coordinates = Vector2.MoveTowards(
state.Coordinates, destination, config.MovementSpeed * deltaTime);
if (Vector2.Distance(state.Coordinates, destination) <= config.StoppingDistance)
{
ResolveEscapedTarget(index, state, config);
continue;
}
}
else
{
state.AttackTimeRemaining = Mathf.Max(
0f, state.AttackTimeRemaining - deltaTime);
if (state.AttackTimeRemaining <= 0f)
{
ResolveEscapedTarget(index, state, config);
continue;
}
}
activeTargets[index] = state;
}
TryCompleteWaveOnServer();
}
private void ResolveEscapedTarget(int index, MapTargetNetworkState target, TargetConfig config)
{
int damage = config != null ? config.BaseDamage : 1;
escapedTargetCount++;
activeTargets.RemoveAt(index);
if (baseHealth != null)
baseHealth.ApplyDamage(damage);
Debug.Log($"[Map] Target {target.TargetId} reached the base. Base damage: {damage}.", this);
}
private void ResolvePseudoShotOnServer()
{
if (!IsServer || !waveIsActive)
{
PublishShotResult(false, -1, -1f);
return;
}
int nearestIndex = -1;
float nearestDistance = float.PositiveInfinity;
for (int index = 0; index < activeTargets.Count; index++)
{
float distance = Vector2.Distance(
crosshairCoordinates.Value, activeTargets[index].Coordinates);
if (distance >= nearestDistance)
continue;
nearestDistance = distance;
nearestIndex = index;
}
if (nearestIndex >= 0 && nearestDistance <= hitRadius)
{
MapTargetNetworkState target = activeTargets[nearestIndex];
TargetConfig config = targetController.GetConfig(target.ConfigIndex);
bool missileMatches = ignoreMissileTypeForPrototype || config == null ||
config.RequiredMissileType == MissileType.None ||
config.RequiredMissileType == pseudoMissileType;
if (missileMatches)
{
target.Health -= shotDamage;
if (target.Health <= 0)
activeTargets.RemoveAt(nearestIndex);
else
activeTargets[nearestIndex] = target;
PublishShotResult(true, target.TargetId, nearestDistance);
Debug.Log($"[Map] HIT target {target.TargetId} at {nearestDistance:0.00} map units.", this);
TryCompleteWaveOnServer();
return;
}
}
int missedTargetId = nearestIndex >= 0 ? activeTargets[nearestIndex].TargetId : -1;
PublishShotResult(false, missedTargetId, nearestDistance);
float printedDistance = float.IsPositiveInfinity(nearestDistance) ? -1f : nearestDistance;
Debug.Log($"[Map] MISS at {crosshairCoordinates.Value}. Nearest distance: {printedDistance:0.00}.", this);
}
private void PublishShotResult(bool hit, int targetId, float distance)
{
lastShotResult.Value = new MapShotResultState(
lastShotResult.Value.Sequence + 1, hit, targetId,
crosshairCoordinates.Value,
float.IsPositiveInfinity(distance) ? -1f : distance);
}
private void ScanRadarOnServer()
{
if (!IsServer || radarController == null || NetworkManager == null ||
NetworkManager.ServerTime.Time < radarReadyAt.Value)
return;
radarMarks.Clear();
foreach (MapTargetNetworkState target in activeTargets)
radarMarks.Add(target.Coordinates);
radarReadyAt.Value = NetworkManager.ServerTime.Time + radarController.Cooldown;
Debug.Log($"[Map] Radar scan found {radarMarks.Count} targets.", this);
}
private void TryCompleteWaveOnServer()
{
if (waveIsActive && activeTargets.Count == 0)
CompleteWaveOnServer();
}
private void CompleteWaveOnServer()
{
waveIsActive = false;
resolvedSequence.Value++;
if (initialTargetCount > 0 && escapedTargetCount == 0)
destroyedSequence.Value++;
Debug.Log(
$"[Map] Wave {currentWave.Value} resolved. " +
$"Destroyed: {Mathf.Max(0, initialTargetCount - escapedTargetCount)}, " +
$"escaped: {escapedTargetCount}.", this);
}
private void ConfigurePresentation()
{
mapPlayer?.Configure(coordinateSystem);
targetController?.Configure(coordinateSystem, targetParent, mapPlayer);
radarController?.Configure(coordinateSystem, radarMarkPrefab);
crosshair?.Configure(
coordinateSystem, relayX, relayY, crosshairVisual, targetController);
}
private void ApplyPresentation()
{
crosshair?.SetCoordinates(crosshairCoordinates.Value);
targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), currentWave.Value);
radarController?.ApplyRadarMarks(CreateRadarSnapshot());
if (radarController != null)
radarController.SetCooldownRemaining((float)RadarCooldownRemaining);
}
private void BindRoundEvents()
{
if (roundEventsBound)
return;
if (roundGameLoop == null)
roundGameLoop = FindFirstObjectByType<RoundGameLoopController>();
if (roundGameLoop == null || !roundGameLoop.IsSpawned)
return;
roundGameLoop.PhaseChanged += HandleRoundPhaseChanged;
roundEventsBound = true;
}
private void UnbindRoundEvents()
{
if (!roundEventsBound || roundGameLoop == null)
return;
roundGameLoop.PhaseChanged -= HandleRoundPhaseChanged;
roundEventsBound = false;
}
private void HandleRoundPhaseChanged(RoundPhase phase)
{
if (IsServer && phase == RoundPhase.WorkInProgress && !waveIsActive)
StartNextWaveOnServer();
}
private void HandleTargetListChanged(NetworkListEvent<MapTargetNetworkState> _) =>
targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), currentWave.Value);
private void HandleRadarListChanged(NetworkListEvent<Vector2> _) =>
radarController?.ApplyRadarMarks(CreateRadarSnapshot());
private void HandleCrosshairChanged(Vector2 _, Vector2 current) =>
crosshair?.SetCoordinates(current);
private void HandleWaveChanged(int _, int current)
{
targetController?.ApplyNetworkTargets(CreateTargetSnapshot(), current);
WaveStarted?.Invoke(current);
waveStarted?.Invoke(current);
}
private void HandleRadarReadyAtChanged(double _, double __)
{
if (radarController != null)
radarController.SetCooldownRemaining((float)RadarCooldownRemaining);
}
private void HandleShotResultChanged(MapShotResultState previous, MapShotResultState current)
{
if (current.Sequence == 0 || current.Sequence == previous.Sequence)
return;
ShotResolved?.Invoke(current);
shotResolved?.Invoke(current.Hit);
}
private void HandleResolvedSequenceChanged(uint previous, uint current)
{
if (current == previous)
return;
AllTargetsResolved?.Invoke();
allTargetsResolved?.Invoke();
}
private void HandleDestroyedSequenceChanged(uint previous, uint current)
{
if (current == previous)
return;
AllTargetsDestroyed?.Invoke();
allTargetsDestroyed?.Invoke();
}
private MapTargetNetworkState[] CreateTargetSnapshot()
{
MapTargetNetworkState[] snapshot = new MapTargetNetworkState[activeTargets.Count];
for (int index = 0; index < snapshot.Length; index++)
snapshot[index] = activeTargets[index];
return snapshot;
}
private Vector2[] CreateRadarSnapshot()
{
Vector2[] snapshot = new Vector2[radarMarks.Count];
for (int index = 0; index < snapshot.Length; index++)
snapshot[index] = radarMarks[index];
return snapshot;
} }
} }
@@ -1,11 +1,10 @@
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem;
[DisallowMultipleComponent] [DisallowMultipleComponent]
public sealed class MapCrosshairController : MonoBehaviour public sealed class MapCrosshairController : MonoBehaviour
{ {
[Header("Nearest Target (Runtime)")] [Header("Nearest Target (Runtime)")]
[SerializeField] private MapTarget nearestTarget = null; [SerializeField] private MapTarget nearestTarget;
[SerializeField] private float distanceToNearestTarget = -1f; [SerializeField] private float distanceToNearestTarget = -1f;
private MapCoordinateSystem coordinateSystem; private MapCoordinateSystem coordinateSystem;
@@ -13,79 +12,42 @@ public sealed class MapCrosshairController : MonoBehaviour
private Transform relayX; private Transform relayX;
private Transform relayY; private Transform relayY;
private Transform crosshairVisual; private Transform crosshairVisual;
private float movementSpeed;
private bool useKeyboardInput;
private float horizontalAxis;
private float verticalAxis;
private Vector2 crosshairCoordinates; private Vector2 crosshairCoordinates;
private Vector3 relayXOffset; private Vector3 relayXOffset;
private Vector3 relayYOffset; private Vector3 relayYOffset;
private bool isConfigured; private bool isConfigured;
public MapCoordinateSystem CoordinateSystem => coordinateSystem; public MapCoordinateSystem CoordinateSystem => coordinateSystem;
public Vector2 Coordinates => crosshairCoordinates;
public void Configure( public void Configure(
MapCoordinateSystem newCoordinateSystem, MapCoordinateSystem newCoordinateSystem,
Transform newRelayX, Transform newRelayX,
Transform newRelayY, Transform newRelayY,
Transform newCrosshairVisual, Transform newCrosshairVisual,
float newMovementSpeed,
bool enableKeyboardInput,
MapTargetController newTargetController) MapTargetController newTargetController)
{ {
coordinateSystem = newCoordinateSystem; coordinateSystem = newCoordinateSystem;
relayX = newRelayX; relayX = newRelayX;
relayY = newRelayY; relayY = newRelayY;
crosshairVisual = newCrosshairVisual; crosshairVisual = newCrosshairVisual;
movementSpeed = Mathf.Max(0f, newMovementSpeed);
useKeyboardInput = enableKeyboardInput;
targetController = newTargetController; targetController = newTargetController;
crosshairCoordinates = Vector2.zero;
isConfigured = CalibrateObjects(); isConfigured = CalibrateObjects();
if (isConfigured)
ApplyCoordinates();
} }
private void Update() public void SetCoordinates(Vector2 coordinates)
{ {
if (!isConfigured) if (!isConfigured)
return; return;
if (useKeyboardInput) crosshairCoordinates = coordinateSystem.ClampCoordinates(coordinates);
ReadKeyboardInput();
Vector2 movement = new(horizontalAxis, verticalAxis);
crosshairCoordinates += movement * (movementSpeed * Time.deltaTime);
crosshairCoordinates = coordinateSystem.ClampCoordinates(crosshairCoordinates);
ApplyCoordinates(); ApplyCoordinates();
UpdateNearestTarget(); UpdateNearestTarget();
} }
public void SetHorizontalAxis(float value) public Vector2 GetCrosshairCoordinates() => crosshairCoordinates;
{ public MapTarget GetNearestTarget() => nearestTarget;
horizontalAxis = Mathf.Clamp(value, -1f, 1f); public float GetDistanceToNearestTarget() => distanceToNearestTarget;
}
public void SetVerticalAxis(float value)
{
verticalAxis = Mathf.Clamp(value, -1f, 1f);
}
public Vector2 GetCrosshairCoordinates()
{
return crosshairCoordinates;
}
public MapTarget GetNearestTarget()
{
return nearestTarget;
}
public float GetDistanceToNearestTarget()
{
return distanceToNearestTarget;
}
public bool TryGetNearestTarget(out MapTarget target, out float distance) public bool TryGetNearestTarget(out MapTarget target, out float distance)
{ {
@@ -94,62 +56,49 @@ public sealed class MapCrosshairController : MonoBehaviour
return target != null; return target != null;
} }
private void ReadKeyboardInput()
{
Keyboard keyboard = Keyboard.current;
if (keyboard == null)
{
SetHorizontalAxis(0f);
SetVerticalAxis(0f);
return;
}
SetHorizontalAxis(
(keyboard.lKey.isPressed ? 1f : 0f) - (keyboard.jKey.isPressed ? 1f : 0f));
SetVerticalAxis(
(keyboard.iKey.isPressed ? 1f : 0f) - (keyboard.kKey.isPressed ? 1f : 0f));
}
private void ApplyCoordinates() private void ApplyCoordinates()
{ {
Vector3 targetWorldPosition = coordinateSystem.CoordinatesToWorldPosition(crosshairCoordinates); crosshairVisual.position =
crosshairVisual.position = targetWorldPosition; coordinateSystem.CoordinatesToWorldPosition(crosshairCoordinates);
if (relayX != null) if (relayX != null)
{
relayX.position = coordinateSystem.CoordinatesToWorldPosition( relayX.position = coordinateSystem.CoordinatesToWorldPosition(
new Vector2(crosshairCoordinates.x, 0f)) + relayXOffset; new Vector2(crosshairCoordinates.x, 0f)) + relayXOffset;
}
if (relayY != null) if (relayY != null)
{
relayY.position = coordinateSystem.CoordinatesToWorldPosition( relayY.position = coordinateSystem.CoordinatesToWorldPosition(
new Vector2(0f, crosshairCoordinates.y)) + relayYOffset; new Vector2(0f, crosshairCoordinates.y)) + relayYOffset;
}
} }
private bool CalibrateObjects() private bool CalibrateObjects()
{ {
if (coordinateSystem == null || crosshairVisual == null || !coordinateSystem.HasValidCorners()) if (coordinateSystem == null || crosshairVisual == null ||
!coordinateSystem.HasValidCorners())
{
return false; return false;
}
Vector3 origin = coordinateSystem.CoordinatesToWorldPosition(Vector2.zero); Vector3 origin = coordinateSystem.CoordinatesToWorldPosition(Vector2.zero);
Vector3 horizontalDirection = Vector3 horizontalDirection = coordinateSystem.CoordinatesToWorldPosition(
coordinateSystem.CoordinatesToWorldPosition(new Vector2(MapCoordinateSystem.CoordinateMaximum, 0f)) - new Vector2(MapCoordinateSystem.CoordinateMaximum, 0f)) - origin;
origin; Vector3 verticalDirection = coordinateSystem.CoordinatesToWorldPosition(
Vector3 verticalDirection = new Vector2(0f, MapCoordinateSystem.CoordinateMaximum)) - origin;
coordinateSystem.CoordinatesToWorldPosition(new Vector2(0f, MapCoordinateSystem.CoordinateMaximum)) -
origin;
if (horizontalDirection.sqrMagnitude <= Mathf.Epsilon || if (horizontalDirection.sqrMagnitude <= Mathf.Epsilon ||
verticalDirection.sqrMagnitude <= Mathf.Epsilon) verticalDirection.sqrMagnitude <= Mathf.Epsilon)
{
return false; return false;
}
horizontalDirection.Normalize(); horizontalDirection.Normalize();
verticalDirection.Normalize(); verticalDirection.Normalize();
if (relayX != null) if (relayX != null)
relayXOffset = Vector3.ProjectOnPlane(relayX.position - origin, horizontalDirection); relayXOffset = Vector3.ProjectOnPlane(relayX.position - origin, horizontalDirection);
if (relayY != null) if (relayY != null)
relayYOffset = Vector3.ProjectOnPlane(relayY.position - origin, verticalDirection); relayYOffset = Vector3.ProjectOnPlane(relayY.position - origin, verticalDirection);
return true; return true;
} }
@@ -157,7 +106,6 @@ public sealed class MapCrosshairController : MonoBehaviour
{ {
nearestTarget = null; nearestTarget = null;
distanceToNearestTarget = -1f; distanceToNearestTarget = -1f;
if (targetController == null) if (targetController == null)
return; return;
@@ -1,90 +1,56 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem;
[DisallowMultipleComponent] [DisallowMultipleComponent]
public sealed class MapRadarController : MonoBehaviour public sealed class MapRadarController : MonoBehaviour
{ {
[Header("Radar Settings")] [Header("Radar Settings")]
[SerializeField, Min(0f)] private float cooldown = 5f; [SerializeField, Min(0f)] private float cooldown = 5f;
[SerializeField] private bool useKeyboardInput = true;
[Header("Radar State (Runtime)")] [Header("Radar State (Runtime)")]
[SerializeField] private float cooldownRemaining; [SerializeField] private float cooldownRemaining;
private readonly List<GameObject> radarMarks = new(); private readonly List<GameObject> radarMarks = new();
private MapCoordinateSystem coordinateSystem; private MapCoordinateSystem coordinateSystem;
private MapTargetController targetController;
private MapTarget radarMarkPrefab; private MapTarget radarMarkPrefab;
private float nextActivationTime;
private bool isConfigured;
public float Cooldown => Mathf.Max(0f, cooldown);
public float CooldownRemaining => cooldownRemaining; public float CooldownRemaining => cooldownRemaining;
public bool IsReady => cooldownRemaining <= 0f; public bool IsReady => cooldownRemaining <= 0f;
private void OnValidate() private void OnValidate() => cooldown = Mathf.Max(0f, cooldown);
{
cooldown = Mathf.Max(0f, cooldown);
}
public void Configure( public void Configure(
MapCoordinateSystem newCoordinateSystem, MapCoordinateSystem newCoordinateSystem,
MapTargetController newTargetController,
MapTarget newRadarMarkPrefab) MapTarget newRadarMarkPrefab)
{ {
coordinateSystem = newCoordinateSystem; coordinateSystem = newCoordinateSystem;
targetController = newTargetController;
radarMarkPrefab = newRadarMarkPrefab; radarMarkPrefab = newRadarMarkPrefab;
isConfigured = coordinateSystem != null &&
coordinateSystem.HasValidCorners() &&
targetController != null &&
radarMarkPrefab != null;
if (targetController != null)
targetController.SetTargetVisualsVisible(false);
} }
private void Update() public void SetCooldownRemaining(float seconds) =>
{ cooldownRemaining = Mathf.Max(0f, seconds);
cooldownRemaining = Mathf.Max(0f, nextActivationTime - Time.time);
if (!useKeyboardInput) public void ApplyRadarMarks(IReadOnlyList<Vector2> coordinates)
{
if (coordinateSystem == null || !coordinateSystem.HasValidCorners() ||
radarMarkPrefab == null)
{
return; return;
}
Keyboard keyboard = Keyboard.current; EnsureRadarMarkCount(coordinates.Count);
if (keyboard != null && keyboard.oKey.wasPressedThisFrame)
ActivateRadar();
}
public bool ActivateRadar()
{
if (!isConfigured || Time.time < nextActivationTime)
return false;
PlaceRadarMarks();
nextActivationTime = Time.time + Mathf.Max(0f, cooldown);
cooldownRemaining = Mathf.Max(0f, nextActivationTime - Time.time);
return true;
}
private void PlaceRadarMarks()
{
int targetCount = targetController.Targets.Count;
EnsureRadarMarkCount(targetCount);
for (int index = 0; index < radarMarks.Count; index++) for (int index = 0; index < radarMarks.Count; index++)
{ {
bool hasTarget = index < targetCount && targetController.Targets[index] != null; bool active = index < coordinates.Count;
GameObject radarMark = radarMarks[index]; GameObject radarMark = radarMarks[index];
radarMark.SetActive(hasTarget); radarMark.SetActive(active);
if (active)
if (!hasTarget) {
continue; radarMark.name = $"Radar Mark {index + 1}";
radarMark.transform.position =
MapTarget target = targetController.Targets[index]; coordinateSystem.CoordinatesToWorldPosition(coordinates[index]);
radarMark.name = $"Radar Mark - {target.name}"; }
radarMark.transform.position =
coordinateSystem.CoordinatesToWorldPosition(target.MapCoordinates);
} }
} }
@@ -100,9 +66,9 @@ public sealed class MapRadarController : MonoBehaviour
private static void PrepareRadarMark(GameObject radarMark) private static void PrepareRadarMark(GameObject radarMark)
{ {
MapTarget targetMovement = radarMark.GetComponent<MapTarget>(); MapTarget target = radarMark.GetComponent<MapTarget>();
if (targetMovement != null) if (target != null)
targetMovement.enabled = false; target.enabled = false;
foreach (Collider targetCollider in radarMark.GetComponentsInChildren<Collider>(true)) foreach (Collider targetCollider in radarMark.GetComponentsInChildren<Collider>(true))
targetCollider.enabled = false; targetCollider.enabled = false;
+22 -37
View File
@@ -1,4 +1,3 @@
using System;
using UnityEngine; using UnityEngine;
[DisallowMultipleComponent] [DisallowMultipleComponent]
@@ -6,17 +5,18 @@ public class MapTarget : MonoBehaviour
{ {
[Header("Target State (Runtime)")] [Header("Target State (Runtime)")]
[SerializeField] private TargetConfig config; [SerializeField] private TargetConfig config;
[SerializeField] private int targetId = -1;
[SerializeField] private float timeUntilAttack; [SerializeField] private float timeUntilAttack;
[SerializeField] private Vector2 mapCoordinates; [SerializeField] private Vector2 mapCoordinates;
[SerializeField] private int currentHealth; [SerializeField] private int currentHealth;
[SerializeField] private MissileType requiredMissileType; [SerializeField] private MissileType requiredMissileType;
[SerializeField] private bool isReadyToAttack;
private MapCoordinateSystem coordinateSystem; private MapCoordinateSystem coordinateSystem;
private MapPlayer player; private MapPlayer player;
private bool isConfigured;
private Renderer[] targetRenderers; private Renderer[] targetRenderers;
private Collider[] targetColliders;
public int TargetId => targetId;
public Vector2 MapCoordinates => mapCoordinates; public Vector2 MapCoordinates => mapCoordinates;
public MapCoordinateSystem CoordinateSystem => coordinateSystem; public MapCoordinateSystem CoordinateSystem => coordinateSystem;
public TargetConfig Config => config; public TargetConfig Config => config;
@@ -24,34 +24,26 @@ public class MapTarget : MonoBehaviour
public float TimeUntilAttack => timeUntilAttack; public float TimeUntilAttack => timeUntilAttack;
public int CurrentHealth => currentHealth; public int CurrentHealth => currentHealth;
public MissileType RequiredMissileType => requiredMissileType; public MissileType RequiredMissileType => requiredMissileType;
public bool IsReadyToAttack => isReadyToAttack;
public event Action<MapTarget> AttackReady;
public void Configure( public void Configure(
TargetConfig newConfig, TargetConfig newConfig,
MapCoordinateSystem newCoordinateSystem, MapCoordinateSystem newCoordinateSystem,
Vector2 startCoordinates,
MapPlayer newPlayer) MapPlayer newPlayer)
{ {
config = newConfig; config = newConfig;
coordinateSystem = newCoordinateSystem; coordinateSystem = newCoordinateSystem;
player = newPlayer; player = newPlayer;
timeUntilAttack = config != null ? config.TimeBeforeAttack : 0f;
currentHealth = config != null ? config.Health : 1;
requiredMissileType = config != null ? config.RequiredMissileType : MissileType.None;
isReadyToAttack = false;
isConfigured = coordinateSystem != null && coordinateSystem.HasValidCorners();
SetMapCoordinates(startCoordinates);
} }
protected virtual void Update() public void ApplyNetworkState(MapTargetNetworkState state)
{ {
if (!isConfigured) targetId = state.TargetId;
return; timeUntilAttack = state.AttackTimeRemaining;
currentHealth = state.Health;
TickAttackTimer(Time.deltaTime); requiredMissileType = config != null
TickMovement(Time.deltaTime); ? config.RequiredMissileType
: MissileType.None;
SetMapCoordinates(state.Coordinates);
} }
public void SetMapCoordinates(Vector2 coordinates) public void SetMapCoordinates(Vector2 coordinates)
@@ -62,41 +54,34 @@ public class MapTarget : MonoBehaviour
Mathf.Clamp(coordinates.x, 0f, MapCoordinateSystem.CoordinateMaximum), Mathf.Clamp(coordinates.x, 0f, MapCoordinateSystem.CoordinateMaximum),
Mathf.Clamp(coordinates.y, 0f, MapCoordinateSystem.CoordinateMaximum)); Mathf.Clamp(coordinates.y, 0f, MapCoordinateSystem.CoordinateMaximum));
if (isConfigured) if (coordinateSystem != null && coordinateSystem.HasValidCorners())
transform.position = coordinateSystem.CoordinatesToWorldPosition(mapCoordinates); transform.position = coordinateSystem.CoordinatesToWorldPosition(mapCoordinates);
} }
public Vector2 GetMapCoordinates() public Vector2 GetMapCoordinates() => mapCoordinates;
{
return mapCoordinates;
}
public void SetVisualsVisible(bool isVisible) public void SetVisualsVisible(bool isVisible)
{ {
if (targetRenderers == null) if (targetRenderers == null)
targetRenderers = GetComponentsInChildren<Renderer>(true); targetRenderers = GetComponentsInChildren<Renderer>(true);
if (targetColliders == null)
targetColliders = GetComponentsInChildren<Collider>(true);
foreach (Renderer targetRenderer in targetRenderers) foreach (Renderer targetRenderer in targetRenderers)
{ {
if (targetRenderer != null) if (targetRenderer != null)
targetRenderer.enabled = isVisible; targetRenderer.enabled = isVisible;
} }
// Map targets are presentation objects, never physical world objects.
foreach (Collider targetCollider in targetColliders)
{
if (targetCollider != null)
targetCollider.enabled = false;
}
} }
protected virtual void TickMovement(float deltaTime) protected virtual void TickMovement(float deltaTime)
{ {
} }
private void TickAttackTimer(float deltaTime)
{
if (isReadyToAttack)
return;
timeUntilAttack = Mathf.Max(0f, timeUntilAttack - deltaTime);
if (timeUntilAttack > 0f)
return;
isReadyToAttack = true;
AttackReady?.Invoke(this);
}
} }
@@ -1,33 +1,31 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; using UnityEngine;
[DisallowMultipleComponent] [DisallowMultipleComponent]
public sealed class MapTargetController : MonoBehaviour public sealed class MapTargetController : MonoBehaviour
{ {
[Header("Random Wave Settings")] [Header("Wave Settings")]
[SerializeField, Min(0)] private int targetsPerWave = 5; [SerializeField, Min(0)] private int targetsPerWave = 5;
[SerializeField] private TargetConfig[] randomTargetConfigs = System.Array.Empty<TargetConfig>(); [SerializeField] private TargetConfig[] randomTargetConfigs = Array.Empty<TargetConfig>();
[SerializeField] private bool showLiveTargetVisuals;
[Header("Current Wave (Runtime)")] [Header("Current Wave (Runtime)")]
[SerializeField, Min(0)] private int currentWave; [SerializeField, Min(0)] private int currentWave;
[SerializeField] private List<MapTarget> currentWaveTargets = new(); [SerializeField] private List<MapTarget> currentWaveTargets = new();
private readonly List<MapTarget> pooledTargets = new(); private readonly List<MapTarget> pooledTargets = new();
private readonly Dictionary<int, MapTarget> targetsById = new();
private readonly HashSet<int> synchronizedTargetIds = new();
private MapCoordinateSystem coordinateSystem; private MapCoordinateSystem coordinateSystem;
private Transform targetParent; private Transform targetParent;
private MapPlayer player; private MapPlayer player;
private bool isConfigured;
public IReadOnlyList<MapTarget> Targets => currentWaveTargets; public IReadOnlyList<MapTarget> Targets => currentWaveTargets;
public IReadOnlyList<TargetConfig> TargetConfigs => randomTargetConfigs;
public int CurrentWave => currentWave; public int CurrentWave => currentWave;
public int TargetsPerWave => targetsPerWave; public int TargetsPerWave => targetsPerWave;
private void Start()
{
if (currentWave == 0)
StartNextWave();
}
private void OnValidate() private void OnValidate()
{ {
targetsPerWave = Mathf.Max(0, targetsPerWave); targetsPerWave = Mathf.Max(0, targetsPerWave);
@@ -42,9 +40,10 @@ public sealed class MapTargetController : MonoBehaviour
coordinateSystem = newCoordinateSystem; coordinateSystem = newCoordinateSystem;
targetParent = newTargetParent != null ? newTargetParent : transform; targetParent = newTargetParent != null ? newTargetParent : transform;
player = newPlayer; player = newPlayer;
isConfigured = coordinateSystem != null && coordinateSystem.HasValidCorners();
pooledTargets.Clear(); pooledTargets.Clear();
targetsById.Clear();
currentWaveTargets.Clear();
foreach (MapTarget existingTarget in targetParent.GetComponentsInChildren<MapTarget>(true)) foreach (MapTarget existingTarget in targetParent.GetComponentsInChildren<MapTarget>(true))
{ {
existingTarget.SetVisualsVisible(false); existingTarget.SetVisualsVisible(false);
@@ -53,44 +52,78 @@ public sealed class MapTargetController : MonoBehaviour
} }
} }
public bool StartNextWave() public void ApplyNetworkTargets(
IReadOnlyList<MapTargetNetworkState> states,
int wave)
{ {
if (!isConfigured || !HasValidTargetConfig()) currentWave = Mathf.Max(0, wave);
synchronizedTargetIds.Clear();
for (int index = 0; index < states.Count; index++)
{ {
Debug.LogWarning("Target controller has no coordinate system or valid TargetConfig.", this); MapTargetNetworkState state = states[index];
return false; TargetConfig config = GetConfig(state.ConfigIndex);
} if (config == null || config.TargetPrefab == null)
continue;
RetireCurrentWave(); synchronizedTargetIds.Add(state.TargetId);
currentWave++; if (!targetsById.TryGetValue(state.TargetId, out MapTarget target) || target == null)
{
target = GetPooledTarget(config);
if (target == null)
target = Instantiate(config.TargetPrefab, targetParent);
for (int index = 0; index < targetsPerWave; index++) targetsById[state.TargetId] = target;
{ currentWaveTargets.Add(target);
TargetConfig config = GetRandomValidConfig(); }
if (config == null)
break;
MapTarget target = GetPooledTarget(config); target.name = $"Wave {currentWave} - {config.name} - {state.TargetId}";
if (target == null) target.Configure(config, coordinateSystem, player);
target = Instantiate(config.TargetPrefab, targetParent); target.ApplyNetworkState(state);
target.SetVisualsVisible(showLiveTargetVisuals);
target.name = $"Wave {currentWave} - {config.name} - {index + 1}";
target.Configure(config, coordinateSystem, GetRandomCoordinates(), player);
target.SetVisualsVisible(false);
target.gameObject.SetActive(true); target.gameObject.SetActive(true);
currentWaveTargets.Add(target);
} }
return currentWaveTargets.Count > 0 || targetsPerWave == 0; for (int index = currentWaveTargets.Count - 1; index >= 0; index--)
{
MapTarget target = currentWaveTargets[index];
if (target != null && synchronizedTargetIds.Contains(target.TargetId))
continue;
RetireTarget(target);
currentWaveTargets.RemoveAt(index);
}
} }
public void CreateAndPlaceTargets() public TargetConfig GetConfig(int index) =>
index >= 0 && index < randomTargetConfigs.Length
? randomTargetConfigs[index]
: null;
public int GetRandomValidConfigIndex()
{ {
StartNextWave(); if (randomTargetConfigs == null || randomTargetConfigs.Length == 0)
return -1;
int startIndex = UnityEngine.Random.Range(0, randomTargetConfigs.Length);
for (int offset = 0; offset < randomTargetConfigs.Length; offset++)
{
int index = (startIndex + offset) % randomTargetConfigs.Length;
TargetConfig config = randomTargetConfigs[index];
if (config != null && config.TargetPrefab != null)
return index;
}
return -1;
} }
public bool HasValidConfiguration() =>
coordinateSystem != null && coordinateSystem.HasValidCorners() &&
GetRandomValidConfigIndex() >= 0;
public void SetTargetVisualsVisible(bool isVisible) public void SetTargetVisualsVisible(bool isVisible)
{ {
showLiveTargetVisuals = isVisible;
foreach (MapTarget target in currentWaveTargets) foreach (MapTarget target in currentWaveTargets)
{ {
if (target != null) if (target != null)
@@ -98,28 +131,15 @@ public sealed class MapTargetController : MonoBehaviour
} }
} }
public MapTarget GetTarget(int index) public MapTarget GetTarget(int index) =>
{ index >= 0 && index < currentWaveTargets.Count
return index >= 0 && index < currentWaveTargets.Count
? currentWaveTargets[index] ? currentWaveTargets[index]
: null; : null;
}
private void RetireCurrentWave() public bool StartNextWave()
{ {
foreach (MapTarget target in currentWaveTargets) MapController mapController = FindFirstObjectByType<MapController>();
{ return mapController != null && mapController.StartNextWave();
if (target == null)
continue;
target.SetVisualsVisible(false);
target.gameObject.SetActive(false);
if (!pooledTargets.Contains(target))
pooledTargets.Add(target);
}
currentWaveTargets.Clear();
} }
private MapTarget GetPooledTarget(TargetConfig config) private MapTarget GetPooledTarget(TargetConfig config)
@@ -143,30 +163,15 @@ public sealed class MapTargetController : MonoBehaviour
return null; return null;
} }
private bool HasValidTargetConfig() private void RetireTarget(MapTarget target)
{ {
return GetRandomValidConfig() != null; if (target == null)
} return;
private TargetConfig GetRandomValidConfig() targetsById.Remove(target.TargetId);
{ target.SetVisualsVisible(false);
if (randomTargetConfigs == null || randomTargetConfigs.Length == 0) target.gameObject.SetActive(false);
return null; if (!pooledTargets.Contains(target))
pooledTargets.Add(target);
int startIndex = Random.Range(0, randomTargetConfigs.Length);
for (int offset = 0; offset < randomTargetConfigs.Length; offset++)
{
TargetConfig config = randomTargetConfigs[(startIndex + offset) % randomTargetConfigs.Length];
if (config != null && config.TargetPrefab != null)
return config;
}
return null;
}
private static Vector2 GetRandomCoordinates()
{
float maximum = MapCoordinateSystem.CoordinateMaximum;
return new Vector2(Random.Range(0f, maximum), Random.Range(0f, maximum));
} }
} }
@@ -0,0 +1,83 @@
using System;
using Unity.Netcode;
using UnityEngine;
public struct MapTargetNetworkState : INetworkSerializable,
IEquatable<MapTargetNetworkState>
{
public int TargetId;
public int ConfigIndex;
public Vector2 Coordinates;
public int Health;
public float AttackTimeRemaining;
public MapTargetNetworkState(
int targetId,
int configIndex,
Vector2 coordinates,
int health,
float attackTimeRemaining)
{
TargetId = targetId;
ConfigIndex = configIndex;
Coordinates = coordinates;
Health = health;
AttackTimeRemaining = attackTimeRemaining;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref TargetId);
serializer.SerializeValue(ref ConfigIndex);
serializer.SerializeValue(ref Coordinates);
serializer.SerializeValue(ref Health);
serializer.SerializeValue(ref AttackTimeRemaining);
}
public bool Equals(MapTargetNetworkState other) =>
TargetId == other.TargetId &&
ConfigIndex == other.ConfigIndex &&
Coordinates == other.Coordinates &&
Health == other.Health &&
AttackTimeRemaining.Equals(other.AttackTimeRemaining);
}
public struct MapShotResultState : INetworkSerializable,
IEquatable<MapShotResultState>
{
public uint Sequence;
public bool Hit;
public int TargetId;
public Vector2 Coordinates;
public float Distance;
public MapShotResultState(
uint sequence,
bool hit,
int targetId,
Vector2 coordinates,
float distance)
{
Sequence = sequence;
Hit = hit;
TargetId = targetId;
Coordinates = coordinates;
Distance = distance;
}
public void NetworkSerialize<T>(BufferSerializer<T> serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref Sequence);
serializer.SerializeValue(ref Hit);
serializer.SerializeValue(ref TargetId);
serializer.SerializeValue(ref Coordinates);
serializer.SerializeValue(ref Distance);
}
public bool Equals(MapShotResultState other) =>
Sequence == other.Sequence && Hit == other.Hit &&
TargetId == other.TargetId && Coordinates == other.Coordinates &&
Distance.Equals(other.Distance);
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 374f32cae69b9184fb3fffe2969c6cce
@@ -0,0 +1,79 @@
using System;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Events;
[DisallowMultipleComponent]
[RequireComponent(typeof(NetworkObject))]
public sealed class NetworkBaseHealth : NetworkBehaviour
{
[SerializeField, Min(1)] private int maximumHealth = 10;
[SerializeField] private UnityEvent<int> healthChanged;
[SerializeField] private UnityEvent depleted;
private readonly NetworkVariable<int> currentHealth = new(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
public int MaximumHealth => Mathf.Max(1, maximumHealth);
public int CurrentHealth => currentHealth.Value;
public bool IsDepleted => CurrentHealth <= 0;
public event Action<int> Changed;
public event Action Depleted;
private void OnValidate() => maximumHealth = Mathf.Max(1, maximumHealth);
public override void OnNetworkSpawn()
{
currentHealth.OnValueChanged += HandleHealthChanged;
if (IsServer)
currentHealth.Value = MaximumHealth;
ApplyState(currentHealth.Value, false);
}
public override void OnNetworkDespawn()
{
currentHealth.OnValueChanged -= HandleHealthChanged;
}
public bool ApplyDamage(int amount)
{
if (!IsServer || !IsSpawned || amount <= 0 || IsDepleted)
return false;
currentHealth.Value = Mathf.Max(0, CurrentHealth - amount);
return true;
}
public void ResetHealth()
{
if (IsServer && IsSpawned)
currentHealth.Value = MaximumHealth;
}
[ContextMenu("Debug/Apply One Damage (Server Play Mode)")]
private void ApplyDebugDamage()
{
if (Application.isPlaying)
ApplyDamage(1);
}
private void HandleHealthChanged(int previous, int current) =>
ApplyState(current, previous > 0 && current <= 0);
private void ApplyState(int health, bool becameDepleted)
{
Changed?.Invoke(health);
healthChanged?.Invoke(health);
if (!becameDepleted)
return;
Depleted?.Invoke();
depleted?.Invoke();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f171a6cd9e9609e4e8a4822dbefe982f
@@ -9,6 +9,7 @@ public sealed class TargetConfig : ScriptableObject
[Header("Target Stats")] [Header("Target Stats")]
[SerializeField, Min(0f)] private float timeBeforeAttack = 5f; [SerializeField, Min(0f)] private float timeBeforeAttack = 5f;
[SerializeField, Min(1)] private int health = 100; [SerializeField, Min(1)] private int health = 100;
[SerializeField, Min(1)] private int baseDamage = 1;
[SerializeField] private MissileType requiredMissileType = MissileType.Standard; [SerializeField] private MissileType requiredMissileType = MissileType.Standard;
[Header("Air Target Movement")] [Header("Air Target Movement")]
@@ -18,6 +19,7 @@ public sealed class TargetConfig : ScriptableObject
public MapTarget TargetPrefab => targetPrefab; public MapTarget TargetPrefab => targetPrefab;
public float TimeBeforeAttack => timeBeforeAttack; public float TimeBeforeAttack => timeBeforeAttack;
public int Health => health; public int Health => health;
public int BaseDamage => baseDamage;
public MissileType RequiredMissileType => requiredMissileType; public MissileType RequiredMissileType => requiredMissileType;
public float MovementSpeed => movementSpeed; public float MovementSpeed => movementSpeed;
public float StoppingDistance => stoppingDistance; public float StoppingDistance => stoppingDistance;
@@ -26,6 +28,7 @@ public sealed class TargetConfig : ScriptableObject
{ {
timeBeforeAttack = Mathf.Max(0f, timeBeforeAttack); timeBeforeAttack = Mathf.Max(0f, timeBeforeAttack);
health = Mathf.Max(1, health); health = Mathf.Max(1, health);
baseDamage = Mathf.Max(1, baseDamage);
movementSpeed = Mathf.Max(0f, movementSpeed); movementSpeed = Mathf.Max(0f, movementSpeed);
stoppingDistance = Mathf.Max(0f, stoppingDistance); stoppingDistance = Mathf.Max(0f, stoppingDistance);
} }
@@ -1,3 +1,4 @@
using System;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
using UnityEngine.Events; using UnityEngine.Events;
@@ -13,6 +14,9 @@ public sealed class NetworkButtonInteractable : NetworkInteractable
public bool IsPressed => isPressed.Value; public bool IsPressed => isPressed.Value;
public event Action Pressed;
public event Action<bool> StateChanged;
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
isPressed.OnValueChanged += HandleStateChanged; isPressed.OnValueChanged += HandleStateChanged;
@@ -28,11 +32,15 @@ public sealed class NetworkButtonInteractable : NetworkInteractable
protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject) protected override void InteractOnServer(ulong senderClientId, NetworkObject playerObject)
{ {
Pressed?.Invoke();
isPressed.Value = !isPressed.Value; isPressed.Value = !isPressed.Value;
ApplyState(isPressed.Value);
} }
private void HandleStateChanged(bool _, bool currentValue) => ApplyState(currentValue); private void HandleStateChanged(bool _, bool currentValue) => ApplyState(currentValue);
private void ApplyState(bool currentValue) => buttonStateChanged?.Invoke(currentValue); private void ApplyState(bool currentValue)
{
StateChanged?.Invoke(currentValue);
buttonStateChanged?.Invoke(currentValue);
}
} }
@@ -1,3 +1,4 @@
using System;
using Unity.Netcode; using Unity.Netcode;
using UnityEngine; using UnityEngine;
using UnityEngine.Events; using UnityEngine.Events;
@@ -52,7 +53,10 @@ public sealed class NetworkPhysicalLeverInteractable :
public bool IsControlled => controllingClientId.Value != NoController; public bool IsControlled => controllingClientId.Value != NoController;
public bool IsControlledByLocalPlayer => public bool IsControlledByLocalPlayer =>
NetworkManager != null && IsControlledBy(NetworkManager.LocalClientId); NetworkManager != null && IsControlledBy(NetworkManager.LocalClientId);
public string ReleasePrompt => "[Hold E] Pulling lever"; public string ReleasePrompt => "[Hold LMB] Pulling lever";
public event Action<float> ValueChanged;
public event Action<bool> StateChanged;
public override string InteractionPrompt => IsControlledByLocalPlayer public override string InteractionPrompt => IsControlledByLocalPlayer
? ReleasePrompt ? ReleasePrompt
@@ -304,11 +308,15 @@ public sealed class NetworkPhysicalLeverInteractable :
private void HandleValueChanged(float _, float currentValue) private void HandleValueChanged(float _, float currentValue)
{ {
ApplyVisual(currentValue); ApplyVisual(currentValue);
ValueChanged?.Invoke(currentValue);
leverValueChanged?.Invoke(currentValue); leverValueChanged?.Invoke(currentValue);
} }
private void HandleActivationChanged(bool _, bool currentState) => private void HandleActivationChanged(bool _, bool currentState)
{
StateChanged?.Invoke(currentState);
leverStateChanged?.Invoke(currentState); leverStateChanged?.Invoke(currentState);
}
private void ApplyVisual(float value) private void ApplyVisual(float value)
{ {
@@ -0,0 +1,270 @@
using System;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.Events;
[DisallowMultipleComponent]
public sealed class NetworkPhysicalWheelInteractable :
NetworkInteractable,
IPlayerInteractionSession
{
private const ulong NoController = ulong.MaxValue;
[Header("Wheel visual")]
[SerializeField] private Transform wheelVisual;
[SerializeField] private Vector3 neutralLocalEulerAngles;
[SerializeField] private Vector3 localRotationAxis = Vector3.right;
[SerializeField] private Vector3 localReferenceDirection = Vector3.up;
[SerializeField, Range(-720f, 0f)] private float minimumAngle = -180f;
[SerializeField, Range(0f, 720f)] private float maximumAngle = 180f;
[SerializeField, Range(0f, 1f)] private float initialNormalizedValue = 0.5f;
[SerializeField, Min(1f)] private float maximumRotationSpeed = 360f;
[Header("Control")]
[SerializeField, Min(0.5f)] private float maximumControlDistance = 4f;
[SerializeField] private UnityEvent<float> normalizedValueChanged;
private readonly NetworkVariable<ulong> controllingClientId = new(
NoController,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private readonly NetworkVariable<float> synchronizedAngle = new(
0f,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server);
private float lastPointerAngle;
private bool hasPointerAngle;
public float Angle => synchronizedAngle.Value;
public float NormalizedValue => Mathf.InverseLerp(
minimumAngle,
maximumAngle,
synchronizedAngle.Value);
public bool IsControlled => controllingClientId.Value != NoController;
public bool IsControlledByLocalPlayer =>
NetworkManager != null && IsControlledBy(NetworkManager.LocalClientId);
public string ReleasePrompt => "[Hold LMB] Turning wheel";
public override string InteractionPrompt => IsControlledByLocalPlayer
? ReleasePrompt
: IsControlled
? "Wheel in use"
: InteractionLabel;
public override bool IsInteractionAvailable =>
base.IsInteractionAvailable && (!IsControlled || IsControlledByLocalPlayer);
public event Action<float> ValueChanged;
private Quaternion NeutralLocalRotation => Quaternion.Euler(neutralLocalEulerAngles);
private Vector3 RotationAxis => localRotationAxis.sqrMagnitude > 0.0001f
? localRotationAxis.normalized
: Vector3.right;
private Vector3 ReferenceDirection => localReferenceDirection.sqrMagnitude > 0.0001f
? localReferenceDirection.normalized
: Vector3.up;
private void Reset()
{
wheelVisual = transform;
neutralLocalEulerAngles = wheelVisual.localEulerAngles;
}
private void OnValidate()
{
if (maximumAngle <= minimumAngle)
maximumAngle = minimumAngle + 1f;
}
public override void OnNetworkSpawn()
{
PlayerInteractionSessionRegistry.Register(this);
if (IsServer)
{
synchronizedAngle.Value = Mathf.Lerp(
minimumAngle,
maximumAngle,
initialNormalizedValue);
}
synchronizedAngle.OnValueChanged += HandleAngleChanged;
ApplyVisual(synchronizedAngle.Value);
InvokeValueChanged();
}
public override void OnNetworkDespawn()
{
PlayerInteractionSessionRegistry.Unregister(this);
synchronizedAngle.OnValueChanged -= HandleAngleChanged;
}
private void FixedUpdate()
{
if (!IsServer || !IsSpawned || !IsControlled || wheelVisual == null)
return;
if (!TryGetPlayerObject(controllingClientId.Value, out NetworkObject playerObject) ||
!IsPlayerWithinControlDistance(playerObject))
{
EndControl();
return;
}
if (!TryCalculatePointerAngle(playerObject, out float pointerAngle))
{
hasPointerAngle = false;
return;
}
if (!hasPointerAngle)
{
lastPointerAngle = pointerAngle;
hasPointerAngle = true;
return;
}
float pointerDelta = Mathf.DeltaAngle(lastPointerAngle, pointerAngle);
lastPointerAngle = pointerAngle;
float maximumStep = maximumRotationSpeed * Time.fixedDeltaTime;
float nextAngle = Mathf.Clamp(
synchronizedAngle.Value + Mathf.Clamp(pointerDelta, -maximumStep, maximumStep),
minimumAngle,
maximumAngle);
if (!Mathf.Approximately(nextAngle, synchronizedAngle.Value))
synchronizedAngle.Value = nextAngle;
}
public bool IsControlledBy(ulong clientId) =>
controllingClientId.Value == clientId;
public void RequestRelease()
{
if (IsSpawned && IsControlledByLocalPlayer)
RequestReleaseServerRpc();
}
protected override bool CanInteractOnServer(ulong senderClientId) =>
!IsControlled || IsControlledBy(senderClientId);
protected override void InteractOnServer(
ulong senderClientId,
NetworkObject playerObject)
{
if (IsControlledBy(senderClientId))
EndControl();
else
BeginControl(senderClientId, playerObject);
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void RequestReleaseServerRpc(RpcParams rpcParams = default)
{
if (IsControlledBy(rpcParams.Receive.SenderClientId))
EndControl();
}
private void BeginControl(ulong clientId, NetworkObject playerObject)
{
controllingClientId.Value = clientId;
hasPointerAngle = TryCalculatePointerAngle(playerObject, out lastPointerAngle);
}
private void EndControl()
{
controllingClientId.Value = NoController;
hasPointerAngle = false;
}
private bool IsPlayerWithinControlDistance(NetworkObject playerObject)
{
float maximumDistanceSquared = maximumControlDistance * maximumControlDistance;
return (playerObject.transform.position - wheelVisual.position).sqrMagnitude <=
maximumDistanceSquared;
}
private bool TryCalculatePointerAngle(
NetworkObject playerObject,
out float pointerAngle)
{
pointerAngle = 0f;
FirstPersonCameraController cameraController =
playerObject.GetComponent<FirstPersonCameraController>();
Transform aimTransform = cameraController != null
? cameraController.AimTransform
: playerObject.transform;
Transform wheelParent = wheelVisual.parent;
Vector3 axisWorld = wheelParent != null
? wheelParent.TransformDirection(NeutralLocalRotation * RotationAxis)
: NeutralLocalRotation * RotationAxis;
Vector3 referenceWorld = wheelParent != null
? wheelParent.TransformDirection(NeutralLocalRotation * ReferenceDirection)
: NeutralLocalRotation * ReferenceDirection;
Ray aimRay = new(aimTransform.position, aimTransform.forward);
Plane wheelPlane = new(axisWorld, wheelVisual.position);
Vector3 targetPoint;
if (wheelPlane.Raycast(aimRay, out float enter) && enter >= 0f)
{
targetPoint = aimRay.GetPoint(enter);
}
else
{
float distanceAlongRay = Mathf.Max(
0.25f,
Vector3.Dot(wheelVisual.position - aimRay.origin, aimRay.direction));
targetPoint = aimRay.GetPoint(distanceAlongRay);
}
Vector3 pointerDirection = Vector3.ProjectOnPlane(
targetPoint - wheelVisual.position,
axisWorld);
if (pointerDirection.sqrMagnitude < 0.0001f)
return false;
pointerAngle = Vector3.SignedAngle(
referenceWorld,
pointerDirection.normalized,
axisWorld);
return true;
}
private void HandleAngleChanged(float _, float current) =>
ApplyNetworkValue(current);
private void ApplyNetworkValue(float angle)
{
ApplyVisual(angle);
InvokeValueChanged();
}
private void InvokeValueChanged()
{
float value = NormalizedValue;
ValueChanged?.Invoke(value);
normalizedValueChanged?.Invoke(value);
}
private void ApplyVisual(float angle)
{
if (wheelVisual != null)
{
wheelVisual.localRotation = NeutralLocalRotation *
Quaternion.AngleAxis(angle, RotationAxis);
}
}
[ContextMenu("Preview/Minimum")]
private void PreviewMinimum() => ApplyVisual(minimumAngle);
[ContextMenu("Preview/Center")]
private void PreviewCenter() => ApplyVisual(Mathf.Lerp(
minimumAngle,
maximumAngle,
0.5f));
[ContextMenu("Preview/Maximum")]
private void PreviewMaximum() => ApplyVisual(maximumAngle);
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c645663bb9dcbaf43991bd38665923da
@@ -22,7 +22,7 @@ public sealed class PlayerInteractionController : NetworkBehaviour, IPlayerSyste
private void Update() private void Update()
{ {
if (!isInitialized || !IsOwner || Keyboard.current == null) if (!isInitialized || !IsOwner)
return; return;
if (PlayerInteractionSessionRegistry.TryGetControlledSession( if (PlayerInteractionSessionRegistry.TryGetControlledSession(
@@ -31,7 +31,9 @@ public sealed class PlayerInteractionController : NetworkBehaviour, IPlayerSyste
{ {
focusedInteractable = null; focusedInteractable = null;
if (!Keyboard.current.eKey.isPressed && !activeSessionReleaseRequested) bool isHoldingControl = Mouse.current != null &&
Mouse.current.leftButton.isPressed;
if (!isHoldingControl && !activeSessionReleaseRequested)
{ {
activeSessionReleaseRequested = true; activeSessionReleaseRequested = true;
activeSession.RequestRelease(); activeSession.RequestRelease();
@@ -44,9 +46,14 @@ public sealed class PlayerInteractionController : NetworkBehaviour, IPlayerSyste
activeSessionReleaseRequested = false; activeSessionReleaseRequested = false;
focusedInteractable = FindFocusedInteractable(); focusedInteractable = FindFocusedInteractable();
if (Keyboard.current.eKey.wasPressedThisFrame && if (focusedInteractable == null || !focusedInteractable.IsInteractionAvailable)
focusedInteractable != null && return;
focusedInteractable.IsInteractionAvailable)
bool startsHoldSession = focusedInteractable is IPlayerInteractionSession &&
Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame;
bool performsRegularInteraction = focusedInteractable is not IPlayerInteractionSession &&
Keyboard.current != null && Keyboard.current.eKey.wasPressedThisFrame;
if (startsHoldSession || performsRegularInteraction)
{ {
focusedInteractable.RequestInteraction(); focusedInteractable.RequestInteraction();
} }
@@ -128,8 +135,11 @@ public sealed class PlayerInteractionController : NetworkBehaviour, IPlayerSyste
if (focusedInteractable == null) if (focusedInteractable == null)
return; return;
string inputPrompt = focusedInteractable is IPlayerInteractionSession
? "[Hold LMB] "
: "[E] ";
string prompt = focusedInteractable.IsInteractionAvailable string prompt = focusedInteractable.IsInteractionAvailable
? "[E] " + focusedInteractable.InteractionPrompt ? inputPrompt + focusedInteractable.InteractionPrompt
: "In use"; : "In use";
DrawPrompt(prompt); DrawPrompt(prompt);
} }
File diff suppressed because it is too large Load Diff