enemy spawner
This commit is contained in:
@@ -65,6 +65,19 @@ namespace MegaKoop.Game.Combat
|
||||
NormalizedHealthChanged?.Invoke(normalized);
|
||||
}
|
||||
|
||||
public void Revive(float overrideMaxHealth = -1f)
|
||||
{
|
||||
if (overrideMaxHealth > 0f)
|
||||
{
|
||||
maxHealth = Mathf.Max(1f, overrideMaxHealth);
|
||||
}
|
||||
|
||||
CurrentHealth = maxHealth;
|
||||
float normalized = CurrentHealth / MaxHealth;
|
||||
onHealthChanged?.Invoke(normalized);
|
||||
NormalizedHealthChanged?.Invoke(normalized);
|
||||
}
|
||||
|
||||
private void HandleDeath()
|
||||
{
|
||||
onDeath?.Invoke();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using Game.Scripts.Runtime.Pooling;
|
||||
using Game.Scripts.Runtime.Navigation;
|
||||
using MegaKoop.Game.Combat;
|
||||
using MegaKoop.Game.Networking;
|
||||
using MegaKoop.Game.WeaponSystem;
|
||||
@@ -27,6 +30,7 @@ namespace MegaKoop.Game.Enemy
|
||||
[SerializeField] private float moveSpeed = 3.5f;
|
||||
[SerializeField] private float turnSpeed = 6f;
|
||||
[SerializeField] private float stoppingDistance = 4f;
|
||||
[SerializeField] private float attackStoppingDistance = 1.25f;
|
||||
[SerializeField] private float detectionRadius = 30f;
|
||||
[SerializeField] private float leashDistance = 60f;
|
||||
[SerializeField] private float retargetInterval = 0.4f;
|
||||
@@ -40,6 +44,14 @@ namespace MegaKoop.Game.Enemy
|
||||
[SerializeField] private NetworkIdentity identity;
|
||||
[SerializeField] private SteamNetworkTransform networkTransform;
|
||||
|
||||
[Header("Spawner Integration")]
|
||||
[SerializeField] private EnemyDefinition fallbackDefinition;
|
||||
[SerializeField] private bool applyDefinitionStats = true;
|
||||
[SerializeField] private float healthMultiplier = 1f;
|
||||
[SerializeField] private float moveSpeedMultiplier = 1f;
|
||||
[SerializeField] private float damageMultiplier = 1f;
|
||||
[SerializeField] private float deathDespawnDelay = 0f;
|
||||
|
||||
private SteamCoopNetworkManager networkManager;
|
||||
private Transform currentTargetTransform;
|
||||
private NetworkIdentity currentTargetIdentity;
|
||||
@@ -49,11 +61,17 @@ namespace MegaKoop.Game.Enemy
|
||||
private float retargetTimer;
|
||||
private Vector3 spawnPosition;
|
||||
private bool cachedNavAgentState;
|
||||
private PooledInstance pooledInstance;
|
||||
private EnemyDefinition activeDefinition;
|
||||
private float baseMoveSpeed;
|
||||
private bool pendingDespawn;
|
||||
private float despawnTimer;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureIdentity();
|
||||
spawnPosition = transform.position;
|
||||
baseMoveSpeed = moveSpeed;
|
||||
|
||||
if (navMeshAgent == null)
|
||||
{
|
||||
@@ -75,11 +93,21 @@ namespace MegaKoop.Game.Enemy
|
||||
networkTransform = GetComponent<SteamNetworkTransform>();
|
||||
}
|
||||
|
||||
if (pooledInstance == null)
|
||||
{
|
||||
pooledInstance = GetComponent<PooledInstance>();
|
||||
}
|
||||
|
||||
var navMeshAvailable = NavMeshRuntimeUtility.HasNavMeshData();
|
||||
if (navMeshAgent != null)
|
||||
{
|
||||
navMeshAgent.updateRotation = false;
|
||||
navMeshAgent.stoppingDistance = stoppingDistance;
|
||||
navMeshAgent.speed = moveSpeed;
|
||||
if (!navMeshAvailable && navMeshAgent.enabled)
|
||||
{
|
||||
navMeshAgent.enabled = false;
|
||||
}
|
||||
cachedNavAgentState = navMeshAgent.enabled;
|
||||
}
|
||||
|
||||
@@ -115,7 +143,33 @@ namespace MegaKoop.Game.Enemy
|
||||
private void OnEnable()
|
||||
{
|
||||
networkManager = SteamCoopNetworkManager.Instance;
|
||||
pooledInstance ??= GetComponent<PooledInstance>();
|
||||
pendingDespawn = false;
|
||||
despawnTimer = 0f;
|
||||
spawnPosition = transform.position;
|
||||
lastKnownTargetPosition = spawnPosition;
|
||||
lastTargetPosition = Vector3.positiveInfinity;
|
||||
ClearTarget();
|
||||
retargetTimer = Random.Range(0f, Mathf.Max(0.05f, retargetInterval));
|
||||
SyncNavMeshAgentState(ShouldSimulate());
|
||||
|
||||
if (health != null)
|
||||
{
|
||||
health.NormalizedHealthChanged += HandleHealthChanged;
|
||||
}
|
||||
|
||||
ApplyDefinitionStats();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (health != null)
|
||||
{
|
||||
health.NormalizedHealthChanged -= HandleHealthChanged;
|
||||
}
|
||||
|
||||
pendingDespawn = false;
|
||||
weaponController?.ClearDamageOverride();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
@@ -130,6 +184,12 @@ namespace MegaKoop.Game.Enemy
|
||||
weaponController.enabled = simulate;
|
||||
}
|
||||
|
||||
if (pendingDespawn)
|
||||
{
|
||||
TickDespawn(Time.deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!simulate)
|
||||
{
|
||||
return;
|
||||
@@ -137,7 +197,7 @@ namespace MegaKoop.Game.Enemy
|
||||
|
||||
if (health != null && !health.IsAlive)
|
||||
{
|
||||
StopAgentMovement();
|
||||
BeginDespawnCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,6 +239,16 @@ namespace MegaKoop.Game.Enemy
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NavMeshRuntimeUtility.HasNavMeshData())
|
||||
{
|
||||
if (navMeshAgent.enabled)
|
||||
{
|
||||
navMeshAgent.enabled = false;
|
||||
}
|
||||
cachedNavAgentState = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedNavAgentState == simulate)
|
||||
{
|
||||
return;
|
||||
@@ -289,10 +359,11 @@ namespace MegaKoop.Game.Enemy
|
||||
return;
|
||||
}
|
||||
|
||||
float desiredStopDistance = GetDesiredStoppingDistance();
|
||||
if (navMeshAgent != null && navMeshAgent.enabled && navMeshAgent.isOnNavMesh)
|
||||
{
|
||||
navMeshAgent.speed = moveSpeed;
|
||||
navMeshAgent.stoppingDistance = stoppingDistance;
|
||||
navMeshAgent.stoppingDistance = desiredStopDistance;
|
||||
|
||||
if (!navMeshAgent.hasPath || (targetPosition - lastTargetPosition).sqrMagnitude >= repathThreshold * repathThreshold)
|
||||
{
|
||||
@@ -302,26 +373,27 @@ namespace MegaKoop.Game.Enemy
|
||||
}
|
||||
else
|
||||
{
|
||||
ManualMove(targetPosition, deltaTime);
|
||||
ManualMove(targetPosition, deltaTime, desiredStopDistance);
|
||||
lastTargetPosition = targetPosition;
|
||||
}
|
||||
}
|
||||
|
||||
private void ManualMove(Vector3 targetPosition, float deltaTime)
|
||||
private void ManualMove(Vector3 targetPosition, float deltaTime, float stopDistance)
|
||||
{
|
||||
Vector3 currentPosition = transform.position;
|
||||
Vector3 direction = targetPosition - currentPosition;
|
||||
direction.y = 0f;
|
||||
float distance = direction.magnitude;
|
||||
|
||||
if (distance <= Mathf.Max(0.1f, stoppingDistance))
|
||||
float resolvedStop = Mathf.Max(0.1f, stopDistance);
|
||||
if (distance <= resolvedStop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 normalized = direction / distance;
|
||||
float step = moveSpeed * deltaTime;
|
||||
Vector3 newPosition = currentPosition + normalized * Mathf.Min(step, distance - stoppingDistance);
|
||||
Vector3 newPosition = currentPosition + normalized * Mathf.Min(step, distance - resolvedStop);
|
||||
transform.position = newPosition;
|
||||
}
|
||||
|
||||
@@ -357,13 +429,14 @@ namespace MegaKoop.Game.Enemy
|
||||
return;
|
||||
}
|
||||
|
||||
float stopDistance = Mathf.Max(0.1f, stoppingDistance);
|
||||
if (navMeshAgent != null && navMeshAgent.enabled && navMeshAgent.isOnNavMesh)
|
||||
{
|
||||
navMeshAgent.SetDestination(spawnPosition);
|
||||
}
|
||||
else
|
||||
{
|
||||
ManualMove(spawnPosition, Time.deltaTime);
|
||||
ManualMove(spawnPosition, Time.deltaTime, stopDistance);
|
||||
FacePoint(spawnPosition, Time.deltaTime);
|
||||
}
|
||||
}
|
||||
@@ -430,6 +503,113 @@ namespace MegaKoop.Game.Enemy
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleHealthChanged(float normalized)
|
||||
{
|
||||
if (normalized > 0f || pendingDespawn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BeginDespawnCountdown();
|
||||
}
|
||||
|
||||
private void BeginDespawnCountdown()
|
||||
{
|
||||
if (pendingDespawn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDespawn = true;
|
||||
despawnTimer = Mathf.Max(0f, deathDespawnDelay);
|
||||
StopAgentMovement();
|
||||
}
|
||||
|
||||
private void TickDespawn(float deltaTime)
|
||||
{
|
||||
if (!pendingDespawn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
despawnTimer -= deltaTime;
|
||||
if (despawnTimer <= 0f)
|
||||
{
|
||||
CompleteDespawn();
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteDespawn()
|
||||
{
|
||||
pendingDespawn = false;
|
||||
if (pooledInstance != null)
|
||||
{
|
||||
pooledInstance.ReturnToPool();
|
||||
}
|
||||
else
|
||||
{
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyDefinitionStats()
|
||||
{
|
||||
if (health == null && weaponController == null && navMeshAgent == null && !applyDefinitionStats)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activeDefinition = ResolveDefinition();
|
||||
|
||||
if (health != null)
|
||||
{
|
||||
float resolvedHp = health.MaxHealth;
|
||||
if (applyDefinitionStats && activeDefinition != null && activeDefinition.BaseHP > 0f)
|
||||
{
|
||||
resolvedHp = activeDefinition.BaseHP;
|
||||
}
|
||||
|
||||
resolvedHp *= Mathf.Max(0.01f, healthMultiplier);
|
||||
health.Revive(resolvedHp);
|
||||
}
|
||||
|
||||
float resolvedMoveSpeed = baseMoveSpeed;
|
||||
if (applyDefinitionStats && activeDefinition != null && activeDefinition.MoveSpeed > 0f)
|
||||
{
|
||||
resolvedMoveSpeed = activeDefinition.MoveSpeed;
|
||||
}
|
||||
|
||||
resolvedMoveSpeed *= Mathf.Max(0.01f, moveSpeedMultiplier);
|
||||
moveSpeed = resolvedMoveSpeed;
|
||||
if (navMeshAgent != null)
|
||||
{
|
||||
navMeshAgent.speed = moveSpeed;
|
||||
}
|
||||
|
||||
if (weaponController != null)
|
||||
{
|
||||
if (applyDefinitionStats && activeDefinition != null && activeDefinition.Damage > 0f)
|
||||
{
|
||||
float resolvedDamage = activeDefinition.Damage * Mathf.Max(0.01f, damageMultiplier);
|
||||
weaponController.SetDamageOverride(resolvedDamage);
|
||||
}
|
||||
else
|
||||
{
|
||||
weaponController.ClearDamageOverride();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private EnemyDefinition ResolveDefinition()
|
||||
{
|
||||
if (pooledInstance != null && pooledInstance.Definition != null)
|
||||
{
|
||||
return pooledInstance.Definition;
|
||||
}
|
||||
|
||||
return fallbackDefinition;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
Gizmos.color = new Color(1f, 0.25f, 0f, 0.2f);
|
||||
@@ -448,10 +628,26 @@ namespace MegaKoop.Game.Enemy
|
||||
moveSpeed = Mathf.Max(0f, moveSpeed);
|
||||
turnSpeed = Mathf.Max(0f, turnSpeed);
|
||||
stoppingDistance = Mathf.Max(0f, stoppingDistance);
|
||||
attackStoppingDistance = Mathf.Max(0f, attackStoppingDistance);
|
||||
detectionRadius = Mathf.Max(0f, detectionRadius);
|
||||
leashDistance = Mathf.Max(0f, leashDistance);
|
||||
retargetInterval = Mathf.Max(0.05f, retargetInterval);
|
||||
repathThreshold = Mathf.Max(0.1f, repathThreshold);
|
||||
}
|
||||
|
||||
private float GetDesiredStoppingDistance()
|
||||
{
|
||||
if (HasAggroTarget())
|
||||
{
|
||||
return Mathf.Max(0.05f, attackStoppingDistance);
|
||||
}
|
||||
|
||||
return Mathf.Max(0.05f, stoppingDistance);
|
||||
}
|
||||
|
||||
private bool HasAggroTarget()
|
||||
{
|
||||
return currentTargetTransform != null || currentTargetIdentity != null || currentTargetHealth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,14 @@ namespace MegaKoop.Game.Networking
|
||||
CharacterTransform = 3,
|
||||
WeaponFire = 4,
|
||||
HealthSync = 5,
|
||||
ProjectileSpawn = 6
|
||||
ProjectileSpawn = 6,
|
||||
ProjectileImpact = 7
|
||||
}
|
||||
|
||||
public enum ProjectileImpactKind : byte
|
||||
{
|
||||
Hero = 0,
|
||||
Enemy = 1
|
||||
}
|
||||
|
||||
public readonly struct NetworkMessage
|
||||
@@ -213,4 +220,36 @@ namespace MegaKoop.Game.Networking
|
||||
return new ProjectileSpawnMessage(networkId, index, position, direction, speed, life, damage);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ProjectileImpactMessage
|
||||
{
|
||||
public readonly ProjectileImpactKind Kind;
|
||||
public readonly Vector3 Position;
|
||||
public readonly Vector3 Normal;
|
||||
|
||||
public ProjectileImpactMessage(ProjectileImpactKind kind, Vector3 position, Vector3 normal)
|
||||
{
|
||||
Kind = kind;
|
||||
Position = position;
|
||||
Normal = normal;
|
||||
}
|
||||
|
||||
public static byte[] Serialize(ProjectileImpactMessage message)
|
||||
{
|
||||
using var writer = new NetworkWriter();
|
||||
writer.Write((byte)message.Kind);
|
||||
writer.Write(message.Position);
|
||||
writer.Write(message.Normal);
|
||||
return writer.ToArray();
|
||||
}
|
||||
|
||||
public static ProjectileImpactMessage Deserialize(byte[] buffer)
|
||||
{
|
||||
using var reader = new NetworkReader(buffer);
|
||||
ProjectileImpactKind kind = (ProjectileImpactKind)reader.ReadByte();
|
||||
Vector3 position = reader.ReadVector3();
|
||||
Vector3 normal = reader.ReadVector3();
|
||||
return new ProjectileImpactMessage(kind, position, normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,7 @@ namespace MegaKoop.Game.Networking
|
||||
|
||||
weaponController.ProjectileSpawned += OnProjectileSpawned;
|
||||
|
||||
if (!IsAuthoritative() && disableLocalFiringWhenClient)
|
||||
{
|
||||
weaponController.enabled = false;
|
||||
}
|
||||
ApplyLocalAuthoritySetting();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -62,9 +59,9 @@ namespace MegaKoop.Game.Networking
|
||||
{
|
||||
weaponController.ProjectileSpawned -= OnProjectileSpawned;
|
||||
|
||||
if (!IsAuthoritative() && disableLocalFiringWhenClient)
|
||||
if (disableLocalFiringWhenClient)
|
||||
{
|
||||
weaponController.enabled = true;
|
||||
weaponController.SetLocalAuthority(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,5 +121,16 @@ namespace MegaKoop.Game.Networking
|
||||
|
||||
weaponController.SpawnNetworkProjectile(spawnMessage.WeaponIndex, spawnMessage.Position, spawnMessage.Direction, spawnMessage.Speed, spawnMessage.Life, spawnMessage.Damage);
|
||||
}
|
||||
|
||||
private void ApplyLocalAuthoritySetting()
|
||||
{
|
||||
if (weaponController == null || !disableLocalFiringWhenClient)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool allowLocalControl = IsAuthoritative();
|
||||
weaponController.SetLocalAuthority(allowLocalControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
Game/Scripts/Runtime.meta
Normal file
8
Game/Scripts/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24b6efd6c2956a9708880b21174d0fab
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Game/Scripts/Runtime/Abstractions.meta
Normal file
8
Game/Scripts/Runtime/Abstractions.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 61c1ac42a9e4eef6faf3c89f37f23e5c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Game/Scripts/Runtime/Abstractions/IClock.cs
Normal file
8
Game/Scripts/Runtime/Abstractions/IClock.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Game.Scripts.Runtime.Abstractions
|
||||
{
|
||||
public interface IClock
|
||||
{
|
||||
float Elapsed { get; }
|
||||
bool IsRunning { get; }
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Abstractions/IClock.cs.meta
Normal file
2
Game/Scripts/Runtime/Abstractions/IClock.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 571bdb1b643aa3ca9a685d437bfa198e
|
||||
11
Game/Scripts/Runtime/Abstractions/IEnemyFactory.cs
Normal file
11
Game/Scripts/Runtime/Abstractions/IEnemyFactory.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Abstractions
|
||||
{
|
||||
public interface IEnemyFactory
|
||||
{
|
||||
GameObject Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation);
|
||||
void Despawn(GameObject instance);
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Abstractions/IEnemyFactory.cs.meta
Normal file
2
Game/Scripts/Runtime/Abstractions/IEnemyFactory.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9cd9bf4b38832adc09d08d55b0cf1503
|
||||
9
Game/Scripts/Runtime/Abstractions/IPool.cs
Normal file
9
Game/Scripts/Runtime/Abstractions/IPool.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Game.Scripts.Runtime.Abstractions
|
||||
{
|
||||
public interface IPool<TKey, TValue>
|
||||
{
|
||||
void Prewarm(TKey key, int count);
|
||||
TValue Get(TKey key);
|
||||
void Release(TKey key, TValue value);
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Abstractions/IPool.cs.meta
Normal file
2
Game/Scripts/Runtime/Abstractions/IPool.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1a3c285bb7769484ba53b11d2d4dbea
|
||||
10
Game/Scripts/Runtime/Abstractions/ISpawnRule.cs
Normal file
10
Game/Scripts/Runtime/Abstractions/ISpawnRule.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Abstractions
|
||||
{
|
||||
public interface ISpawnRule
|
||||
{
|
||||
bool CanSpawn(EnemyDefinition definition, Vector3 position);
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Abstractions/ISpawnRule.cs.meta
Normal file
2
Game/Scripts/Runtime/Abstractions/ISpawnRule.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53678779ec0e460ac88698b25fc991f6
|
||||
8
Game/Scripts/Runtime/Data.meta
Normal file
8
Game/Scripts/Runtime/Data.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df6848b33d3716cbb91acccd380719f7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
32
Game/Scripts/Runtime/Data/BossSchedule.cs
Normal file
32
Game/Scripts/Runtime/Data/BossSchedule.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Data
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Game/Spawning/Boss Schedule", fileName = "BossSchedule")]
|
||||
public class BossSchedule : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class BossEvent
|
||||
{
|
||||
[Min(0f)] public float TimeSinceStart;
|
||||
public EnemyDefinition Boss;
|
||||
public int Count = 1;
|
||||
[SerializeField] private bool useSpawnRadiusOverride;
|
||||
[SerializeField] private float spawnRadiusOverride = 10f;
|
||||
|
||||
public bool HasSpawnRadiusOverride => useSpawnRadiusOverride;
|
||||
public float SpawnRadiusOverride => spawnRadiusOverride;
|
||||
}
|
||||
|
||||
[SerializeField] private List<BossEvent> events = new();
|
||||
|
||||
public IReadOnlyList<BossEvent> Events => events;
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
events.Sort((a, b) => a.TimeSinceStart.CompareTo(b.TimeSinceStart));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Data/BossSchedule.cs.meta
Normal file
2
Game/Scripts/Runtime/Data/BossSchedule.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e2ad306ddab577d19b1e0e6bbb6fea9
|
||||
23
Game/Scripts/Runtime/Data/EnemyDefinition.cs
Normal file
23
Game/Scripts/Runtime/Data/EnemyDefinition.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Data
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Game/Spawning/Enemy Definition", fileName = "EnemyDefinition")]
|
||||
public class EnemyDefinition : ScriptableObject
|
||||
{
|
||||
[Tooltip("Unique identifier leveraged by pooling and save systems. Defaults to asset name when left blank.")]
|
||||
[SerializeField] private string id;
|
||||
|
||||
[Tooltip("Prefab that represents this enemy. Should include required behaviours and visuals.")]
|
||||
public GameObject Prefab;
|
||||
|
||||
public bool IsBoss;
|
||||
public float BaseHP = 10f;
|
||||
public float MoveSpeed = 3.5f;
|
||||
public float Damage = 1f;
|
||||
public Vector3 PrefabPivotOffset = Vector3.zero;
|
||||
public string[] Tags = System.Array.Empty<string>();
|
||||
|
||||
public string Id => string.IsNullOrEmpty(id) ? name : id;
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Data/EnemyDefinition.cs.meta
Normal file
2
Game/Scripts/Runtime/Data/EnemyDefinition.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b0e84710d3119d60af5d5373005e029
|
||||
107
Game/Scripts/Runtime/Data/SpawnTable.cs
Normal file
107
Game/Scripts/Runtime/Data/SpawnTable.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Data
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Game/Spawning/Spawn Table", fileName = "SpawnTable")]
|
||||
public class SpawnTable : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public class Entry
|
||||
{
|
||||
public EnemyDefinition Def;
|
||||
[Min(0)] public int Weight = 1;
|
||||
public int MinWave = 0;
|
||||
public int MaxWave = int.MaxValue;
|
||||
[Tooltip("Optional cap per wave. Values <= 0 mean unlimited.")]
|
||||
public int Cap = -1;
|
||||
|
||||
public bool IsWaveValid(int wave) => wave >= MinWave && wave <= MaxWave;
|
||||
|
||||
public bool IsUnderCap(int currentCount)
|
||||
{
|
||||
if (Cap <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentCount < Cap;
|
||||
}
|
||||
}
|
||||
|
||||
[SerializeField] private List<Entry> entries = new();
|
||||
|
||||
private readonly List<Entry> _eligibleBuffer = new();
|
||||
|
||||
public IReadOnlyList<Entry> Entries => entries;
|
||||
|
||||
public EnemyDefinition Pick(int wave, System.Random rng)
|
||||
{
|
||||
if (entries == null || entries.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_eligibleBuffer.Clear();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry?.Def == null || entry.Weight <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.IsWaveValid(wave))
|
||||
{
|
||||
_eligibleBuffer.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (_eligibleBuffer.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var totalWeight = 0;
|
||||
foreach (var entry in _eligibleBuffer)
|
||||
{
|
||||
totalWeight += Mathf.Max(1, entry.Weight);
|
||||
}
|
||||
|
||||
var roll = rng.Next(totalWeight);
|
||||
foreach (var entry in _eligibleBuffer)
|
||||
{
|
||||
var weight = Mathf.Max(1, entry.Weight);
|
||||
if (roll < weight)
|
||||
{
|
||||
return entry.Def;
|
||||
}
|
||||
|
||||
roll -= weight;
|
||||
}
|
||||
|
||||
return _eligibleBuffer[^1].Def;
|
||||
}
|
||||
|
||||
public IEnumerable<Entry> EnumerateEligibleEntries(int wave)
|
||||
{
|
||||
if (entries == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry?.Def == null || entry.Weight <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.IsWaveValid(wave))
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Data/SpawnTable.cs.meta
Normal file
2
Game/Scripts/Runtime/Data/SpawnTable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fd6e160d6ab3dfecbc59a0685007a97
|
||||
44
Game/Scripts/Runtime/Data/SpawnerConfig.cs
Normal file
44
Game/Scripts/Runtime/Data/SpawnerConfig.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Data
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Game/Spawning/Spawner Config", fileName = "SpawnerConfig")]
|
||||
public class SpawnerConfig : ScriptableObject
|
||||
{
|
||||
[Header("Radii")]
|
||||
public float MinSpawnRadius = 8f;
|
||||
public float MaxSpawnRadius = 24f;
|
||||
|
||||
[Header("Timing")]
|
||||
public float SpawnInterval = 2f;
|
||||
public AnimationCurve SpawnRateOverTime = AnimationCurve.Linear(0f, 1f, 1f, 1f);
|
||||
|
||||
[Header("Limits")]
|
||||
public int MaxConcurrent = 30;
|
||||
public int MaxPlacementAttempts = 6;
|
||||
public int PrewarmPerType = 4;
|
||||
public int PoolHardCapPerType = 60;
|
||||
|
||||
[Header("Placement")]
|
||||
public float GroundRaycastHeight = 5f;
|
||||
public float MaxHeightDelta = 4f;
|
||||
public bool UseLineOfSight = false;
|
||||
|
||||
[Header("Layers")]
|
||||
public LayerMask GroundMask = Physics.DefaultRaycastLayers;
|
||||
public LayerMask ObstacleMask = Physics.DefaultRaycastLayers;
|
||||
|
||||
[Header("NavMesh")]
|
||||
public int NavMeshAreaMask = -1;
|
||||
|
||||
[Header("RNG")]
|
||||
[SerializeField] private bool useSeed;
|
||||
[SerializeField] private int seedValue = 0;
|
||||
|
||||
[Header("Debug")]
|
||||
public bool DrawGizmos = false;
|
||||
public int GizmoSampleCount = 16;
|
||||
|
||||
public int? Seed => useSeed ? seedValue : (int?)null;
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Data/SpawnerConfig.cs.meta
Normal file
2
Game/Scripts/Runtime/Data/SpawnerConfig.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4af5312c5abd9f725a626ceb6f7f8ae5
|
||||
8
Game/Scripts/Runtime/Game.meta
Normal file
8
Game/Scripts/Runtime/Game.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 933d32c8200115ef88df534d69281d59
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
272
Game/Scripts/Runtime/Game/GameController.cs
Normal file
272
Game/Scripts/Runtime/Game/GameController.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Game.Scripts.Runtime.Abstractions;
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using Game.Scripts.Runtime.Spawning;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Game
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class GameController : MonoBehaviour, IClock
|
||||
{
|
||||
[SerializeField] private EnemySpawner spawner;
|
||||
[SerializeField] private BossSchedule bossSchedule;
|
||||
[SerializeField] private SpawnerConfig defaultSpawnerConfig;
|
||||
[SerializeField, Min(1f)] private float waveDurationSeconds = 30f;
|
||||
[SerializeField] private bool autoStartOnEnable;
|
||||
[SerializeField] private bool logStateChanges;
|
||||
|
||||
public float Elapsed => _elapsed;
|
||||
public bool IsRunning => _isRunning && !_isPaused;
|
||||
public int CurrentWaveIndex => _currentWaveIndex;
|
||||
|
||||
public event Action OnGameStarted;
|
||||
public event Action OnGamePaused;
|
||||
public event Action OnGameResumed;
|
||||
public event Action OnGameStopped;
|
||||
public event Action<int> OnWaveStarted;
|
||||
public event Action<int> OnWaveCompleted;
|
||||
public event Action<EnemyDefinition, int> OnBossSpawned;
|
||||
|
||||
private Coroutine _loop;
|
||||
private float _elapsed;
|
||||
private bool _isRunning;
|
||||
private bool _isPaused;
|
||||
private int _currentWaveIndex = -1;
|
||||
private int _bossCursor;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (autoStartOnEnable)
|
||||
{
|
||||
StartGame();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
StopGame();
|
||||
}
|
||||
|
||||
public void StartGame()
|
||||
{
|
||||
if (_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_elapsed = 0f;
|
||||
_isRunning = true;
|
||||
_isPaused = false;
|
||||
_currentWaveIndex = -1;
|
||||
_bossCursor = 0;
|
||||
|
||||
ConfigureSpawner();
|
||||
spawner?.SetClock(this);
|
||||
spawner?.Begin();
|
||||
|
||||
UpdateWaveState();
|
||||
|
||||
_loop = StartCoroutine(GameLoop());
|
||||
OnGameStarted?.Invoke();
|
||||
Log($"Game started – waveDuration={waveDurationSeconds}s");
|
||||
}
|
||||
|
||||
public void PauseGame()
|
||||
{
|
||||
if (!_isRunning || _isPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPaused = true;
|
||||
OnGamePaused?.Invoke();
|
||||
Log("Game paused");
|
||||
}
|
||||
|
||||
public void ResumeGame()
|
||||
{
|
||||
if (!_isRunning || !_isPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPaused = false;
|
||||
OnGameResumed?.Invoke();
|
||||
Log("Game resumed");
|
||||
}
|
||||
|
||||
public void StopGame()
|
||||
{
|
||||
if (!_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = false;
|
||||
_isPaused = false;
|
||||
|
||||
if (_loop != null)
|
||||
{
|
||||
StopCoroutine(_loop);
|
||||
_loop = null;
|
||||
}
|
||||
|
||||
spawner?.End();
|
||||
|
||||
if (_currentWaveIndex >= 0)
|
||||
{
|
||||
OnWaveCompleted?.Invoke(_currentWaveIndex);
|
||||
}
|
||||
|
||||
OnGameStopped?.Invoke();
|
||||
_currentWaveIndex = -1;
|
||||
_elapsed = 0f;
|
||||
_bossCursor = 0;
|
||||
Log("Game stopped and state reset");
|
||||
}
|
||||
|
||||
private IEnumerator GameLoop()
|
||||
{
|
||||
while (_isRunning)
|
||||
{
|
||||
if (!_isPaused)
|
||||
{
|
||||
_elapsed += Time.deltaTime;
|
||||
UpdateWaveState();
|
||||
TickBossSchedule();
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureSpawner()
|
||||
{
|
||||
if (spawner == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultSpawnerConfig != null)
|
||||
{
|
||||
spawner.Configure(null, defaultSpawnerConfig);
|
||||
}
|
||||
|
||||
PrewarmBosses();
|
||||
}
|
||||
|
||||
private void PrewarmBosses()
|
||||
{
|
||||
if (spawner?.Pool == null || bossSchedule == null || bossSchedule.Events == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cfg = spawner.ActiveConfig ?? defaultSpawnerConfig;
|
||||
foreach (var evt in bossSchedule.Events)
|
||||
{
|
||||
if (evt?.Boss == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cap = cfg?.PoolHardCapPerType ?? 64;
|
||||
var prewarm = cfg?.PrewarmPerType ?? 1;
|
||||
spawner.Pool.SetHardCap(evt.Boss, cap);
|
||||
spawner.Pool.Prewarm(evt.Boss, prewarm);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWaveState()
|
||||
{
|
||||
var safeDuration = Mathf.Max(1f, waveDurationSeconds);
|
||||
var computedWave = Mathf.Max(0, Mathf.FloorToInt(_elapsed / safeDuration));
|
||||
|
||||
if (_currentWaveIndex < 0)
|
||||
{
|
||||
_currentWaveIndex = computedWave;
|
||||
spawner?.SetWaveIndex(_currentWaveIndex);
|
||||
OnWaveStarted?.Invoke(_currentWaveIndex);
|
||||
Log($"Wave {_currentWaveIndex} started (initial)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (computedWave != _currentWaveIndex)
|
||||
{
|
||||
var previous = _currentWaveIndex;
|
||||
_currentWaveIndex = computedWave;
|
||||
OnWaveCompleted?.Invoke(previous);
|
||||
spawner?.SetWaveIndex(_currentWaveIndex);
|
||||
OnWaveStarted?.Invoke(_currentWaveIndex);
|
||||
Log($"Wave advanced: {previous} -> {_currentWaveIndex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void TickBossSchedule()
|
||||
{
|
||||
if (bossSchedule == null || bossSchedule.Events == null || bossSchedule.Events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (_bossCursor < bossSchedule.Events.Count)
|
||||
{
|
||||
var evt = bossSchedule.Events[_bossCursor];
|
||||
if (evt == null || evt.Boss == null)
|
||||
{
|
||||
_bossCursor++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_elapsed < evt.TimeSinceStart)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var spawned = SpawnBossWave(evt);
|
||||
if (spawned > 0)
|
||||
{
|
||||
OnBossSpawned?.Invoke(evt.Boss, spawned);
|
||||
Log($"Boss spawn triggered for {evt.Boss.name} x{spawned}");
|
||||
}
|
||||
|
||||
_bossCursor++;
|
||||
}
|
||||
}
|
||||
|
||||
private int SpawnBossWave(BossSchedule.BossEvent evt)
|
||||
{
|
||||
if (spawner == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = Mathf.Max(1, evt.Count);
|
||||
var success = 0;
|
||||
for (var i = 0; i < total; i++)
|
||||
{
|
||||
var radius = evt.HasSpawnRadiusOverride ? evt.SpawnRadiusOverride : (float?)null;
|
||||
var spawned = radius.HasValue
|
||||
? spawner.TrySpawnWithRadius(evt.Boss, radius.Value)
|
||||
: spawner.TrySpawn(evt.Boss);
|
||||
|
||||
if (spawned)
|
||||
{
|
||||
success++;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
if (logStateChanges)
|
||||
{
|
||||
Debug.Log($"[{name}] {message}", this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Game/GameController.cs.meta
Normal file
2
Game/Scripts/Runtime/Game/GameController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0139f703f0ee517daa8cfa061c70231
|
||||
8
Game/Scripts/Runtime/Navigation.meta
Normal file
8
Game/Scripts/Runtime/Navigation.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46d196a10a132337990b704f03710c79
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
47
Game/Scripts/Runtime/Navigation/NavMeshRuntimeUtility.cs
Normal file
47
Game/Scripts/Runtime/Navigation/NavMeshRuntimeUtility.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using UnityEngine.AI;
|
||||
|
||||
namespace Game.Scripts.Runtime.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight cache around NavMesh availability checks so gameplay code
|
||||
/// can gracefully degrade when a bake is not present.
|
||||
/// </summary>
|
||||
public static class NavMeshRuntimeUtility
|
||||
{
|
||||
private static bool? _cachedHasNavMesh;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if there is any baked NavMesh data loaded at runtime.
|
||||
/// Result is cached until <see cref="InvalidateCache"/> is called.
|
||||
/// </summary>
|
||||
public static bool HasNavMeshData()
|
||||
{
|
||||
if (_cachedHasNavMesh.HasValue)
|
||||
{
|
||||
return _cachedHasNavMesh.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var triangulation = NavMesh.CalculateTriangulation();
|
||||
var hasData = triangulation.vertices != null && triangulation.vertices.Length > 0;
|
||||
_cachedHasNavMesh = hasData;
|
||||
return hasData;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cachedHasNavMesh = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached NavMesh availability state. Call this after baking at runtime.
|
||||
/// </summary>
|
||||
public static void InvalidateCache()
|
||||
{
|
||||
_cachedHasNavMesh = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 19ab015c2ac2d472aa3d70eedc029e97
|
||||
8
Game/Scripts/Runtime/Pooling.meta
Normal file
8
Game/Scripts/Runtime/Pooling.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3e4f9dd504704b52a63505b81c25d5a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
264
Game/Scripts/Runtime/Pooling/ObjectPooler.cs
Normal file
264
Game/Scripts/Runtime/Pooling/ObjectPooler.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Game.Scripts.Runtime.Abstractions;
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Pooling
|
||||
{
|
||||
[DefaultExecutionOrder(-300)]
|
||||
[DisallowMultipleComponent]
|
||||
public class ObjectPooler : MonoBehaviour, IPool<EnemyDefinition, GameObject>, IEnemyFactory
|
||||
{
|
||||
[SerializeField] private Transform poolRoot;
|
||||
[SerializeField] private bool markAsDontDestroyOnLoad = true;
|
||||
[SerializeField] private int defaultHardCap = 64;
|
||||
|
||||
private readonly Dictionary<string, PoolBucket> _pools = new();
|
||||
private readonly Dictionary<GameObject, PoolItem> _lookup = new();
|
||||
|
||||
public static ObjectPooler SharedInstance { get; private set; }
|
||||
|
||||
public event Action<GameObject, EnemyDefinition> InstanceSpawned;
|
||||
public event Action<GameObject, EnemyDefinition> InstanceDespawned;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
EnsureRoot();
|
||||
|
||||
if (markAsDontDestroyOnLoad && Application.isPlaying)
|
||||
{
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
if (SharedInstance == null)
|
||||
{
|
||||
SharedInstance = this;
|
||||
}
|
||||
}
|
||||
|
||||
public void Prewarm(EnemyDefinition definition, int count)
|
||||
{
|
||||
if (definition == null || definition.Prefab == null || count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = GetKey(definition);
|
||||
var bucket = GetOrCreateBucket(key);
|
||||
bucket.HardCap = bucket.HardCap <= 0 ? defaultHardCap : bucket.HardCap;
|
||||
|
||||
var remaining = Mathf.Max(0, Math.Min(count, bucket.HardCap) - bucket.TotalCount);
|
||||
for (var i = 0; i < remaining; i++)
|
||||
{
|
||||
var instance = CreateInstance(definition, key);
|
||||
if (instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.Available.Enqueue(instance);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetHardCap(EnemyDefinition definition, int hardCap)
|
||||
{
|
||||
if (definition == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = GetKey(definition);
|
||||
var bucket = GetOrCreateBucket(key);
|
||||
bucket.HardCap = Mathf.Max(1, hardCap);
|
||||
}
|
||||
|
||||
public GameObject Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation)
|
||||
{
|
||||
if (definition == null || definition.Prefab == null)
|
||||
{
|
||||
Debug.LogWarning("Cannot spawn without a valid definition or prefab.", this);
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = GetKey(definition);
|
||||
var bucket = GetOrCreateBucket(key);
|
||||
bucket.HardCap = bucket.HardCap <= 0 ? defaultHardCap : bucket.HardCap;
|
||||
|
||||
if (bucket.Available.Count == 0 && bucket.TotalCount >= bucket.HardCap)
|
||||
{
|
||||
Debug.LogWarning($"Pool for {key} reached hard cap of {bucket.HardCap}", this);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bucket.Available.Count == 0)
|
||||
{
|
||||
var instance = CreateInstance(definition, key);
|
||||
if (instance != null)
|
||||
{
|
||||
bucket.Available.Enqueue(instance);
|
||||
}
|
||||
}
|
||||
|
||||
if (bucket.Available.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var go = bucket.Available.Dequeue();
|
||||
if (!_lookup.TryGetValue(go, out var item))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bucket.Active.Add(go);
|
||||
go.transform.SetParent(null, true);
|
||||
go.transform.SetPositionAndRotation(position + definition.PrefabPivotOffset, rotation);
|
||||
go.SetActive(true);
|
||||
|
||||
InstanceSpawned?.Invoke(go, definition);
|
||||
return go;
|
||||
}
|
||||
|
||||
public void Despawn(GameObject instance)
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_lookup.TryGetValue(instance, out var item))
|
||||
{
|
||||
Destroy(instance);
|
||||
return;
|
||||
}
|
||||
|
||||
var bucket = GetOrCreateBucket(item.Key);
|
||||
bucket.Active.Remove(instance);
|
||||
|
||||
instance.SetActive(false);
|
||||
instance.transform.SetParent(poolRoot, false);
|
||||
bucket.Available.Enqueue(instance);
|
||||
item.Handle?.NotifyDespawned();
|
||||
InstanceDespawned?.Invoke(instance, item.Definition);
|
||||
}
|
||||
|
||||
void IPool<EnemyDefinition, GameObject>.Prewarm(EnemyDefinition key, int count) => Prewarm(key, count);
|
||||
|
||||
GameObject IPool<EnemyDefinition, GameObject>.Get(EnemyDefinition key) => Spawn(key, Vector3.zero, Quaternion.identity);
|
||||
|
||||
void IPool<EnemyDefinition, GameObject>.Release(EnemyDefinition key, GameObject value) => Despawn(value);
|
||||
|
||||
GameObject IEnemyFactory.Spawn(EnemyDefinition definition, Vector3 position, Quaternion rotation) => Spawn(definition, position, rotation);
|
||||
|
||||
void IEnemyFactory.Despawn(GameObject instance) => Despawn(instance);
|
||||
|
||||
private PoolBucket GetOrCreateBucket(string key)
|
||||
{
|
||||
if (!_pools.TryGetValue(key, out var bucket))
|
||||
{
|
||||
bucket = new PoolBucket { HardCap = defaultHardCap };
|
||||
_pools.Add(key, bucket);
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private GameObject CreateInstance(EnemyDefinition definition, string key)
|
||||
{
|
||||
if (definition == null || definition.Prefab == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureRoot();
|
||||
var go = Instantiate(definition.Prefab, poolRoot);
|
||||
go.name = $"{definition.Prefab.name}_Pooled";
|
||||
go.SetActive(false);
|
||||
|
||||
var handle = go.GetComponent<PooledInstance>();
|
||||
if (handle == null)
|
||||
{
|
||||
handle = go.AddComponent<PooledInstance>();
|
||||
}
|
||||
|
||||
handle.Initialize(this, key, definition);
|
||||
_lookup[go] = new PoolItem(definition, key, handle);
|
||||
return go;
|
||||
}
|
||||
|
||||
private static string GetKey(EnemyDefinition definition)
|
||||
{
|
||||
if (definition == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(definition.Id) ? definition.name : definition.Id;
|
||||
}
|
||||
|
||||
private sealed class PoolBucket
|
||||
{
|
||||
public readonly Queue<GameObject> Available = new();
|
||||
public readonly HashSet<GameObject> Active = new();
|
||||
public int HardCap;
|
||||
public int TotalCount => Available.Count + Active.Count;
|
||||
}
|
||||
|
||||
private sealed class PoolItem
|
||||
{
|
||||
public readonly EnemyDefinition Definition;
|
||||
public readonly string Key;
|
||||
public readonly PooledInstance Handle;
|
||||
|
||||
public PoolItem(EnemyDefinition definition, string key, PooledInstance handle)
|
||||
{
|
||||
Definition = definition;
|
||||
Key = key;
|
||||
Handle = handle;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRoot()
|
||||
{
|
||||
if (poolRoot != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = new GameObject("[Pool]");
|
||||
root.transform.SetParent(transform, false);
|
||||
poolRoot = root.transform;
|
||||
}
|
||||
}
|
||||
|
||||
[DisallowMultipleComponent]
|
||||
public sealed class PooledInstance : MonoBehaviour
|
||||
{
|
||||
private ObjectPooler _pool;
|
||||
private string _key;
|
||||
private EnemyDefinition _definition;
|
||||
|
||||
public event Action<PooledInstance> Despawned;
|
||||
|
||||
internal void Initialize(ObjectPooler pool, string key, EnemyDefinition definition)
|
||||
{
|
||||
_pool = pool;
|
||||
_key = key;
|
||||
_definition = definition;
|
||||
}
|
||||
|
||||
public void ReturnToPool()
|
||||
{
|
||||
_pool?.Despawn(gameObject);
|
||||
}
|
||||
|
||||
internal void NotifyDespawned()
|
||||
{
|
||||
Despawned?.Invoke(this);
|
||||
}
|
||||
|
||||
public EnemyDefinition Definition => _definition;
|
||||
public string Key => _key;
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Pooling/ObjectPooler.cs.meta
Normal file
2
Game/Scripts/Runtime/Pooling/ObjectPooler.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9307c12832cd4aad3b75e864616073a4
|
||||
8
Game/Scripts/Runtime/Spawning.meta
Normal file
8
Game/Scripts/Runtime/Spawning.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1da2bcb2f4ea277dd9c4173e391e4811
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Game/Scripts/Runtime/Spawning/Editor.meta
Normal file
8
Game/Scripts/Runtime/Spawning/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0dcdb636426092128b5b358481d4dee7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
66
Game/Scripts/Runtime/Spawning/Editor/EnemySpawnerGizmos.cs
Normal file
66
Game/Scripts/Runtime/Spawning/Editor/EnemySpawnerGizmos.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Game.Scripts.Runtime.Spawning.Editor
|
||||
{
|
||||
[CustomEditor(typeof(EnemySpawner))]
|
||||
public class EnemySpawnerGizmos : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
base.OnInspectorGUI();
|
||||
|
||||
using (new EditorGUI.DisabledScope(!Application.isPlaying))
|
||||
{
|
||||
if (GUILayout.Button("Force Spawn Test Enemy"))
|
||||
{
|
||||
var spawner = (EnemySpawner)target;
|
||||
var table = spawner.ActiveTable;
|
||||
if (table != null)
|
||||
{
|
||||
var def = table.Pick(spawner.CurrentWaveIndex, new System.Random());
|
||||
if (def != null)
|
||||
{
|
||||
spawner.TrySpawn(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSceneGUI()
|
||||
{
|
||||
var spawner = (EnemySpawner)target;
|
||||
var config = spawner.ActiveConfig;
|
||||
var player = spawner.PlayerTransform;
|
||||
if (config == null || player == null || !config.DrawGizmos)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Handles.color = new Color(1f, 0.8f, 0f, 0.7f);
|
||||
Handles.DrawWireDisc(player.position, Vector3.up, config.MinSpawnRadius);
|
||||
Handles.color = new Color(1f, 0f, 0f, 0.7f);
|
||||
Handles.DrawWireDisc(player.position, Vector3.up, config.MaxSpawnRadius);
|
||||
|
||||
var samples = spawner.RecentSamples;
|
||||
if (samples != null)
|
||||
{
|
||||
foreach (var sample in samples)
|
||||
{
|
||||
Handles.color = sample.Accepted ? Color.green : Color.magenta;
|
||||
Handles.SphereHandleCap(0, sample.Position, Quaternion.identity, 0.35f, EventType.Repaint);
|
||||
}
|
||||
}
|
||||
|
||||
Handles.BeginGUI();
|
||||
GUILayout.BeginArea(new Rect(12f, 12f, 200f, 70f), GUI.skin.box);
|
||||
GUILayout.Label("Spawner Debug", EditorStyles.boldLabel);
|
||||
GUILayout.Label($"Active: {spawner.ActiveEnemyCount}/{config.MaxConcurrent}");
|
||||
GUILayout.EndArea();
|
||||
Handles.EndGUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9439d534025ac2a28a9250ffd9e349be
|
||||
710
Game/Scripts/Runtime/Spawning/EnemySpawner.cs
Normal file
710
Game/Scripts/Runtime/Spawning/EnemySpawner.cs
Normal file
@@ -0,0 +1,710 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Game.Scripts.Runtime.Abstractions;
|
||||
using Game.Scripts.Runtime.Data;
|
||||
using Game.Scripts.Runtime.Pooling;
|
||||
using Game.Scripts.Runtime.Navigation;
|
||||
using MegaKoop.Game;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
|
||||
namespace Game.Scripts.Runtime.Spawning
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class EnemySpawner : MonoBehaviour
|
||||
{
|
||||
[Header("Scene References")]
|
||||
[SerializeField] private Transform player;
|
||||
[SerializeField] private ObjectPooler pooler;
|
||||
|
||||
[Header("Player Resolution")]
|
||||
[SerializeField] private bool autoAssignPlayer = true;
|
||||
[SerializeField] private string fallbackPlayerTag = "Player";
|
||||
[SerializeField] private bool fallbackToMainCamera = true;
|
||||
|
||||
[Header("Data"), Tooltip("Fallback config when Configure() is not called at runtime.")]
|
||||
[SerializeField] private SpawnerConfig config;
|
||||
[SerializeField] private SpawnTable defaultSpawnTable;
|
||||
|
||||
[Header("Overrides")]
|
||||
[SerializeField] private LayerMask groundMask = Physics.DefaultRaycastLayers;
|
||||
[SerializeField] private LayerMask obstacleMask = Physics.DefaultRaycastLayers;
|
||||
[SerializeField] private bool useLineOfSight = true;
|
||||
[SerializeField] private float maxHeightDelta = 4f;
|
||||
[SerializeField] private int navMeshAreaMask = -1;
|
||||
[SerializeField] private List<MonoBehaviour> spawnRuleBehaviours = new();
|
||||
[Header("Debug Logging")]
|
||||
[SerializeField] private bool logLifecycle;
|
||||
[SerializeField] private bool logSpawnAttempts;
|
||||
[SerializeField] private bool logSpawnFailures;
|
||||
|
||||
public event Action<GameObject, EnemyDefinition> OnEnemySpawned;
|
||||
public event Action<GameObject, EnemyDefinition> OnBossSpawned;
|
||||
|
||||
public bool IsRunning => _spawnLoop != null;
|
||||
public int CurrentWaveIndex { get; private set; }
|
||||
|
||||
public IReadOnlyList<SpawnSampleRecord> RecentSamples => _recentSamples;
|
||||
public SpawnerConfig ActiveConfig => _runtimeConfig ?? config;
|
||||
public SpawnTable ActiveTable => _runtimeTable ?? defaultSpawnTable;
|
||||
public Transform PlayerTransform => player;
|
||||
public int ActiveEnemyCount => _activeInstances.Count;
|
||||
public ObjectPooler Pool => pooler;
|
||||
|
||||
private readonly HashSet<GameObject> _activeInstances = new();
|
||||
private readonly Dictionary<GameObject, EnemyDefinition> _instanceToDefinition = new();
|
||||
private readonly Dictionary<EnemyDefinition, int> _spawnedThisWave = new();
|
||||
private readonly List<SpawnSampleRecord> _recentSamples = new();
|
||||
private readonly List<ISpawnRule> _rules = new();
|
||||
|
||||
private SpawnTable _runtimeTable;
|
||||
private SpawnerConfig _runtimeConfig;
|
||||
private Coroutine _spawnLoop;
|
||||
private System.Random _rng;
|
||||
private IClock _clock;
|
||||
private float _localElapsed;
|
||||
private float _nextSpawnTimestamp;
|
||||
private IEnemyFactory _factory;
|
||||
private bool _warnedMissingPlayer;
|
||||
private bool _warnedMissingNavMesh;
|
||||
|
||||
private void OnValidate()
|
||||
{
|
||||
CacheRules();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
CacheRules();
|
||||
if (pooler == null)
|
||||
{
|
||||
pooler = GetComponent<ObjectPooler>();
|
||||
}
|
||||
|
||||
_factory = pooler;
|
||||
EnsurePlayerReference();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EnsurePlayerReference();
|
||||
|
||||
if (pooler != null)
|
||||
{
|
||||
pooler.InstanceDespawned += HandleInstanceDespawned;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (pooler != null)
|
||||
{
|
||||
pooler.InstanceDespawned -= HandleInstanceDespawned;
|
||||
}
|
||||
|
||||
if (_spawnLoop != null)
|
||||
{
|
||||
StopCoroutine(_spawnLoop);
|
||||
_spawnLoop = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Configure(SpawnTable table, SpawnerConfig overrideConfig)
|
||||
{
|
||||
if (table != null)
|
||||
{
|
||||
_runtimeTable = table;
|
||||
}
|
||||
|
||||
if (overrideConfig != null)
|
||||
{
|
||||
_runtimeConfig = overrideConfig;
|
||||
}
|
||||
|
||||
ResetRng();
|
||||
}
|
||||
|
||||
public void SetClock(IClock clock)
|
||||
{
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public void SetPlayer(Transform playerTransform)
|
||||
{
|
||||
player = playerTransform;
|
||||
if (player != null)
|
||||
{
|
||||
_warnedMissingPlayer = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Begin()
|
||||
{
|
||||
if (_spawnLoop != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_runtimeConfig ??= config;
|
||||
_runtimeTable ??= defaultSpawnTable;
|
||||
_factory ??= pooler;
|
||||
|
||||
if (_runtimeTable == null || _runtimeConfig == null || _factory == null)
|
||||
{
|
||||
LogLifecycle($"Cannot begin – missing refs (table:{_runtimeTable != null}, config:{_runtimeConfig != null}, player:{player != null}, factory:{_factory != null})");
|
||||
Debug.LogWarning("EnemySpawner is missing configuration or references and cannot begin.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RequirePlayerReference())
|
||||
{
|
||||
LogLifecycle("Cannot begin – missing player reference");
|
||||
return;
|
||||
}
|
||||
|
||||
ResetRng();
|
||||
PrewarmPools();
|
||||
_activeInstances.Clear();
|
||||
_instanceToDefinition.Clear();
|
||||
_spawnedThisWave.Clear();
|
||||
_localElapsed = 0f;
|
||||
_nextSpawnTimestamp = 0f;
|
||||
_spawnLoop = StartCoroutine(SpawnLoop());
|
||||
LogLifecycle($"Begin – wave {CurrentWaveIndex}, pool prewarm complete, maxConcurrent={_runtimeConfig.MaxConcurrent}");
|
||||
}
|
||||
|
||||
public void End()
|
||||
{
|
||||
if (_spawnLoop == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopCoroutine(_spawnLoop);
|
||||
_spawnLoop = null;
|
||||
LogLifecycle($"End – cleaned up coroutine, active={_activeInstances.Count}");
|
||||
}
|
||||
|
||||
public void SetWaveIndex(int wave)
|
||||
{
|
||||
if (CurrentWaveIndex == wave)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentWaveIndex = Mathf.Max(0, wave);
|
||||
_spawnedThisWave.Clear();
|
||||
LogLifecycle($"Wave index set to {CurrentWaveIndex}");
|
||||
}
|
||||
|
||||
public bool TrySpawn(EnemyDefinition definition, Vector3? positionOverride = null)
|
||||
{
|
||||
return TrySpawnInternal(definition, positionOverride, null);
|
||||
}
|
||||
|
||||
internal bool TrySpawnWithRadius(EnemyDefinition definition, float radiusOverride)
|
||||
{
|
||||
return TrySpawnInternal(definition, null, radiusOverride);
|
||||
}
|
||||
|
||||
public bool TrySampleSpawnPosition(out Vector3 position, float? radiusOverride = null)
|
||||
{
|
||||
return TrySampleSpawnPoint(null, out position, radiusOverride);
|
||||
}
|
||||
|
||||
private IEnumerator SpawnLoop()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_clock == null)
|
||||
{
|
||||
_localElapsed += Time.deltaTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
_localElapsed = _clock.Elapsed;
|
||||
}
|
||||
|
||||
if (_localElapsed >= _nextSpawnTimestamp)
|
||||
{
|
||||
ExecuteSpawnTick();
|
||||
var interval = GetDynamicInterval();
|
||||
_nextSpawnTimestamp = _localElapsed + interval;
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteSpawnTick()
|
||||
{
|
||||
if (_runtimeConfig == null || _runtimeTable == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var attempts = Mathf.Clamp(_runtimeConfig.MaxConcurrent - _activeInstances.Count, 1, 4);
|
||||
for (var i = 0; i < attempts; i++)
|
||||
{
|
||||
var def = PickDefinition();
|
||||
if (def == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!TrySpawnInternal(def, null, null))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private EnemyDefinition PickDefinition()
|
||||
{
|
||||
_runtimeTable ??= defaultSpawnTable;
|
||||
if (_runtimeTable == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureRng();
|
||||
|
||||
var candidates = new List<SpawnTable.Entry>();
|
||||
foreach (var entry in _runtimeTable.EnumerateEligibleEntries(CurrentWaveIndex))
|
||||
{
|
||||
var currentCount = _spawnedThisWave.TryGetValue(entry.Def, out var count) ? count : 0;
|
||||
if (!entry.IsUnderCap(currentCount))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(entry);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var totalWeight = 0;
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
totalWeight += Mathf.Max(1, entry.Weight);
|
||||
}
|
||||
|
||||
var roll = _rng.Next(totalWeight);
|
||||
foreach (var entry in candidates)
|
||||
{
|
||||
var weight = Mathf.Max(1, entry.Weight);
|
||||
if (roll < weight)
|
||||
{
|
||||
return entry.Def;
|
||||
}
|
||||
|
||||
roll -= weight;
|
||||
}
|
||||
|
||||
return candidates[^1].Def;
|
||||
}
|
||||
|
||||
private bool TrySpawnInternal(EnemyDefinition definition, Vector3? positionOverride, float? radiusOverride)
|
||||
{
|
||||
_runtimeConfig ??= config;
|
||||
|
||||
if (definition == null || _factory == null || _runtimeConfig == null)
|
||||
{
|
||||
LogSpawnFailure($"TrySpawnInternal aborted – definition:{definition != null}, factory:{_factory != null}, config:{_runtimeConfig != null}");
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureRng();
|
||||
|
||||
if (_activeInstances.Count >= _runtimeConfig.MaxConcurrent)
|
||||
{
|
||||
LogSpawnFailure($"Spawn blocked – MaxConcurrent reached ({_activeInstances.Count}/{_runtimeConfig.MaxConcurrent})");
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasPosition = positionOverride.HasValue;
|
||||
var candidatePosition = positionOverride.GetValueOrDefault();
|
||||
var definitionName = definition != null ? definition.name : "<null>";
|
||||
if (!hasPosition)
|
||||
{
|
||||
if (!TrySampleSpawnPoint(definition, out candidatePosition, radiusOverride))
|
||||
{
|
||||
LogSpawnFailure($"Failed to sample spawn point for {definitionName} (wave {CurrentWaveIndex})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var spawned = _factory.Spawn(definition, candidatePosition, Quaternion.identity);
|
||||
if (spawned == null)
|
||||
{
|
||||
LogSpawnFailure($"Pool returned null for {definitionName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_activeInstances.Add(spawned);
|
||||
_instanceToDefinition[spawned] = definition;
|
||||
_spawnedThisWave.TryGetValue(definition, out var count);
|
||||
_spawnedThisWave[definition] = count + 1;
|
||||
TrackInstance(spawned);
|
||||
OnEnemySpawned?.Invoke(spawned, definition);
|
||||
LogSpawn($"Spawned {definition.name} at {candidatePosition} | active={_activeInstances.Count}/{_runtimeConfig.MaxConcurrent}");
|
||||
if (definition.IsBoss)
|
||||
{
|
||||
OnBossSpawned?.Invoke(spawned, definition);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void TrackInstance(GameObject instance)
|
||||
{
|
||||
var handle = instance.GetComponent<PooledInstance>();
|
||||
if (handle == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
handle.Despawned -= HandlePooledDespawn;
|
||||
handle.Despawned += HandlePooledDespawn;
|
||||
}
|
||||
|
||||
private bool TrySampleSpawnPoint(EnemyDefinition definition, out Vector3 position, float? radiusOverride)
|
||||
{
|
||||
position = Vector3.zero;
|
||||
if (!RequirePlayerReference())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cfg = _runtimeConfig ?? config;
|
||||
if (cfg == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureRng();
|
||||
|
||||
float minRadius;
|
||||
float maxRadius;
|
||||
if (radiusOverride.HasValue)
|
||||
{
|
||||
minRadius = Mathf.Max(0.1f, radiusOverride.Value);
|
||||
maxRadius = minRadius;
|
||||
}
|
||||
else
|
||||
{
|
||||
minRadius = Mathf.Max(0.1f, cfg.MinSpawnRadius);
|
||||
maxRadius = Mathf.Max(minRadius + 0.1f, cfg.MaxSpawnRadius);
|
||||
}
|
||||
var attempts = Mathf.Max(1, cfg.MaxPlacementAttempts);
|
||||
var navMask = navMeshAreaMask != 0 ? navMeshAreaMask : cfg.NavMeshAreaMask;
|
||||
if (navMask != 0 && !NavMeshRuntimeUtility.HasNavMeshData())
|
||||
{
|
||||
navMask = 0;
|
||||
if (!_warnedMissingNavMesh)
|
||||
{
|
||||
Debug.LogWarning($"[{name}] No baked NavMesh detected. Falling back to ground-only placement.", this);
|
||||
_warnedMissingNavMesh = true;
|
||||
}
|
||||
}
|
||||
|
||||
var los = cfg.UseLineOfSight;
|
||||
var heightTolerance = cfg.MaxHeightDelta > 0f ? cfg.MaxHeightDelta : maxHeightDelta;
|
||||
var groundMaskValue = cfg.GroundMask.value == 0 ? (groundMask.value == 0 ? Physics.DefaultRaycastLayers : groundMask.value) : cfg.GroundMask.value;
|
||||
var obstacleMaskValue = cfg.ObstacleMask.value == 0 ? (obstacleMask.value == 0 ? Physics.DefaultRaycastLayers : obstacleMask.value) : cfg.ObstacleMask.value;
|
||||
|
||||
for (var i = 0; i < attempts; i++)
|
||||
{
|
||||
var angle = (float)(_rng.NextDouble() * Mathf.PI * 2.0);
|
||||
var t = Mathf.Sqrt((float)_rng.NextDouble());
|
||||
var radius = Mathf.Approximately(minRadius, maxRadius) ? minRadius : Mathf.Lerp(minRadius, maxRadius, t);
|
||||
var offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * radius;
|
||||
var sampleOrigin = player.position + offset + Vector3.up * cfg.GroundRaycastHeight;
|
||||
|
||||
if (!Physics.Raycast(sampleOrigin, Vector3.down, out var hit, cfg.GroundRaycastHeight * 2f, groundMaskValue, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
RecordSample(sampleOrigin, false, "NoGround");
|
||||
continue;
|
||||
}
|
||||
|
||||
var point = hit.point;
|
||||
if (Mathf.Abs(point.y - player.position.y) > heightTolerance)
|
||||
{
|
||||
RecordSample(point, false, "Height");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (los || useLineOfSight)
|
||||
{
|
||||
var origin = player.position + Vector3.up * 1.6f;
|
||||
var target = point + Vector3.up * 0.5f;
|
||||
if (Physics.Linecast(origin, target, obstacleMaskValue, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
RecordSample(point, false, "LOS");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (navMask != 0)
|
||||
{
|
||||
if (!NavMesh.SamplePosition(point, out var navHit, 1.5f, navMask))
|
||||
{
|
||||
RecordSample(point, false, "NavMesh");
|
||||
continue;
|
||||
}
|
||||
|
||||
point = navHit.position;
|
||||
}
|
||||
|
||||
if (!EvaluateRules(definition, point))
|
||||
{
|
||||
RecordSample(point, false, "Rule");
|
||||
continue;
|
||||
}
|
||||
|
||||
position = point;
|
||||
RecordSample(point, true, "OK");
|
||||
return true;
|
||||
}
|
||||
|
||||
LogSpawnFailure($"SamplePoint failed after {attempts} attempts. Radius {minRadius}-{maxRadius}, navMask={navMask}, player={player?.name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EvaluateRules(EnemyDefinition definition, Vector3 candidate)
|
||||
{
|
||||
if (_rules.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
if (rule == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rule.CanSpawn(definition, candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private float GetDynamicInterval()
|
||||
{
|
||||
var interval = _runtimeConfig?.SpawnInterval ?? 1f;
|
||||
var curve = _runtimeConfig?.SpawnRateOverTime;
|
||||
if (curve == null)
|
||||
{
|
||||
return Mathf.Max(0.05f, interval);
|
||||
}
|
||||
|
||||
var multiplier = Mathf.Max(0.01f, curve.Evaluate(_localElapsed));
|
||||
return Mathf.Max(0.05f, interval / multiplier);
|
||||
}
|
||||
|
||||
private void PrewarmPools()
|
||||
{
|
||||
if (_runtimeTable == null || _runtimeConfig == null || pooler == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in _runtimeTable.Entries)
|
||||
{
|
||||
if (entry?.Def == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pooler.SetHardCap(entry.Def, _runtimeConfig.PoolHardCapPerType);
|
||||
pooler.Prewarm(entry.Def, _runtimeConfig.PrewarmPerType);
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetRng()
|
||||
{
|
||||
if (_runtimeConfig != null && _runtimeConfig.Seed.HasValue)
|
||||
{
|
||||
_rng = new System.Random(_runtimeConfig.Seed.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
_rng = new System.Random(Environment.TickCount);
|
||||
}
|
||||
|
||||
private void EnsureRng()
|
||||
{
|
||||
if (_rng == null)
|
||||
{
|
||||
_rng = new System.Random(Environment.TickCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleInstanceDespawned(GameObject instance, EnemyDefinition definition)
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_activeInstances.Remove(instance);
|
||||
_instanceToDefinition.Remove(instance);
|
||||
}
|
||||
|
||||
private void HandlePooledDespawn(PooledInstance instance)
|
||||
{
|
||||
HandleInstanceDespawned(instance?.gameObject, instance?.Definition);
|
||||
}
|
||||
|
||||
private void CacheRules()
|
||||
{
|
||||
_rules.Clear();
|
||||
foreach (var behaviour in spawnRuleBehaviours)
|
||||
{
|
||||
if (behaviour is ISpawnRule rule)
|
||||
{
|
||||
_rules.Add(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSample(Vector3 position, bool accepted, string reason)
|
||||
{
|
||||
var limit = Mathf.Max(0, _runtimeConfig?.GizmoSampleCount ?? config?.GizmoSampleCount ?? 0);
|
||||
if (limit <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_recentSamples.Add(new SpawnSampleRecord(position, accepted, reason));
|
||||
while (_recentSamples.Count > limit)
|
||||
{
|
||||
_recentSamples.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct SpawnSampleRecord
|
||||
{
|
||||
public readonly Vector3 Position;
|
||||
public readonly bool Accepted;
|
||||
public readonly string Reason;
|
||||
|
||||
public SpawnSampleRecord(Vector3 position, bool accepted, string reason)
|
||||
{
|
||||
Position = position;
|
||||
Accepted = accepted;
|
||||
Reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogLifecycle(string message)
|
||||
{
|
||||
if (logLifecycle)
|
||||
{
|
||||
Debug.Log($"[{name}] {message}", this);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogSpawn(string message)
|
||||
{
|
||||
if (logSpawnAttempts)
|
||||
{
|
||||
Debug.Log($"[{name}] {message}", this);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogSpawnFailure(string message)
|
||||
{
|
||||
if (logSpawnFailures)
|
||||
{
|
||||
Debug.LogWarning($"[{name}] {message}", this);
|
||||
}
|
||||
}
|
||||
|
||||
private bool RequirePlayerReference()
|
||||
{
|
||||
if (EnsurePlayerReference())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_warnedMissingPlayer)
|
||||
{
|
||||
Debug.LogWarning($"[{name}] EnemySpawner could not locate a player Transform. Assign one manually or enable auto assignment.", this);
|
||||
_warnedMissingPlayer = true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EnsurePlayerReference()
|
||||
{
|
||||
if (player != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (autoAssignPlayer)
|
||||
{
|
||||
var tagged = TryFindPlayerByTag(fallbackPlayerTag);
|
||||
if (tagged != null)
|
||||
{
|
||||
player = tagged;
|
||||
return true;
|
||||
}
|
||||
|
||||
var controller = FindObjectOfType<ThirdPersonCharacterController>();
|
||||
if (controller != null)
|
||||
{
|
||||
player = controller.transform;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fallbackToMainCamera && Camera.main != null)
|
||||
{
|
||||
player = Camera.main.transform;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Transform TryFindPlayerByTag(string tag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tag))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var taggedObject = GameObject.FindGameObjectWithTag(tag);
|
||||
return taggedObject != null ? taggedObject.transform : null;
|
||||
}
|
||||
catch (UnityException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Game/Scripts/Runtime/Spawning/EnemySpawner.cs.meta
Normal file
2
Game/Scripts/Runtime/Spawning/EnemySpawner.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7f3cf4476796bb0095ba2dde7810079
|
||||
8
Game/Scripts/Vfx.meta
Normal file
8
Game/Scripts/Vfx.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1209dfd97a994300a8ac4097474c87ce
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
206
Game/Scripts/Vfx/ProjectileImpactVfxService.cs
Normal file
206
Game/Scripts/Vfx/ProjectileImpactVfxService.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using MegaKoop.Game.Networking;
|
||||
using Steamworks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Vfx
|
||||
{
|
||||
[DefaultExecutionOrder(-50)]
|
||||
public class ProjectileImpactVfxService : MonoBehaviour
|
||||
{
|
||||
private const float MinLifetime = 0.5f;
|
||||
|
||||
private static ProjectileImpactVfxService instance;
|
||||
|
||||
private SteamCoopNetworkManager networkManager;
|
||||
private bool isRegistered;
|
||||
private ProjectileImpactVfxSettings settings;
|
||||
|
||||
public static ProjectileImpactVfxService Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (instance == null)
|
||||
{
|
||||
instance = FindObjectOfType<ProjectileImpactVfxService>();
|
||||
if (instance == null)
|
||||
{
|
||||
var go = new GameObject(nameof(ProjectileImpactVfxService));
|
||||
instance = go.AddComponent<ProjectileImpactVfxService>();
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void AutoCreate()
|
||||
{
|
||||
_ = Instance;
|
||||
}
|
||||
|
||||
public static void ReportImpact(ProjectileImpactKind kind, Vector3 position, Vector3 normal)
|
||||
{
|
||||
Instance.InternalReportImpact(kind, position, normal);
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (instance != null && instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
TryBindNetworkManager();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
TryBindNetworkManager();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
UnregisterHandler();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (instance == this)
|
||||
{
|
||||
instance = null;
|
||||
}
|
||||
|
||||
UnregisterHandler();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
if (settings != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settings = ProjectileImpactVfxSettings.LoadFromResources();
|
||||
if (settings == null)
|
||||
{
|
||||
Debug.LogWarning($"[ProjectileImpactVfxService] Could not locate Resources/{ProjectileImpactVfxSettings.ResourcePath}. Impact particles will be skipped.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryBindNetworkManager()
|
||||
{
|
||||
var current = SteamCoopNetworkManager.Instance;
|
||||
if (current != networkManager)
|
||||
{
|
||||
UnregisterHandler();
|
||||
networkManager = current;
|
||||
}
|
||||
|
||||
if (networkManager != null && !isRegistered)
|
||||
{
|
||||
networkManager.RegisterHandler(NetworkMessageType.ProjectileImpact, HandleImpactMessage);
|
||||
isRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnregisterHandler()
|
||||
{
|
||||
if (isRegistered && networkManager != null)
|
||||
{
|
||||
networkManager.UnregisterHandler(NetworkMessageType.ProjectileImpact, HandleImpactMessage);
|
||||
}
|
||||
|
||||
isRegistered = false;
|
||||
}
|
||||
|
||||
private void HandleImpactMessage(NetworkMessage message)
|
||||
{
|
||||
ProjectileImpactMessage impact = ProjectileImpactMessage.Deserialize(message.Payload);
|
||||
SpawnImpact(impact.Kind, impact.Position, impact.Normal);
|
||||
}
|
||||
|
||||
private void InternalReportImpact(ProjectileImpactKind kind, Vector3 position, Vector3 normal)
|
||||
{
|
||||
SpawnImpact(kind, position, normal);
|
||||
|
||||
TryBindNetworkManager();
|
||||
if (networkManager == null || !networkManager.IsHost)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var impactMessage = new ProjectileImpactMessage(kind, position, normal);
|
||||
byte[] payload = ProjectileImpactMessage.Serialize(impactMessage);
|
||||
networkManager.SendToAll(NetworkMessageType.ProjectileImpact, payload, EP2PSend.k_EP2PSendUnreliableNoDelay);
|
||||
}
|
||||
|
||||
private void SpawnImpact(ProjectileImpactKind kind, Vector3 position, Vector3 normal)
|
||||
{
|
||||
LoadSettings();
|
||||
if (settings == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject prefab = kind switch
|
||||
{
|
||||
ProjectileImpactKind.Hero => settings.HeroImpactPrefab,
|
||||
ProjectileImpactKind.Enemy => settings.EnemyImpactPrefab,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (prefab == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Quaternion rotation = normal.sqrMagnitude > 0.001f ? Quaternion.LookRotation(normal) : Quaternion.identity;
|
||||
GameObject instance = Instantiate(prefab, position, rotation);
|
||||
float lifetime = EstimateLifetime(instance);
|
||||
if (lifetime > 0f)
|
||||
{
|
||||
Destroy(instance, lifetime);
|
||||
}
|
||||
}
|
||||
|
||||
private float EstimateLifetime(GameObject effectInstance)
|
||||
{
|
||||
float fallback = settings != null ? settings.FallbackLifetime : 4f;
|
||||
ParticleSystem[] particleSystems = effectInstance.GetComponentsInChildren<ParticleSystem>();
|
||||
if (particleSystems == null || particleSystems.Length == 0)
|
||||
{
|
||||
return Mathf.Max(MinLifetime, fallback);
|
||||
}
|
||||
|
||||
float maxLifetime = fallback;
|
||||
foreach (ParticleSystem system in particleSystems)
|
||||
{
|
||||
var main = system.main;
|
||||
if (main.loop)
|
||||
{
|
||||
maxLifetime = Mathf.Max(maxLifetime, fallback);
|
||||
continue;
|
||||
}
|
||||
|
||||
float candidate = main.duration + main.startLifetime.constantMax;
|
||||
if (candidate <= 0f)
|
||||
{
|
||||
candidate = fallback;
|
||||
}
|
||||
|
||||
maxLifetime = Mathf.Max(maxLifetime, candidate);
|
||||
}
|
||||
|
||||
return Mathf.Max(MinLifetime, maxLifetime);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Game/Scripts/Vfx/ProjectileImpactVfxService.cs.meta
Normal file
11
Game/Scripts/Vfx/ProjectileImpactVfxService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b53745466234c758a467889ee0d0f4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
23
Game/Scripts/Vfx/ProjectileImpactVfxSettings.cs
Normal file
23
Game/Scripts/Vfx/ProjectileImpactVfxSettings.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.Vfx
|
||||
{
|
||||
[CreateAssetMenu(fileName = "ProjectileImpactVfxSettings", menuName = "MegaKoop/VFX/Projectile Impact Settings")]
|
||||
public class ProjectileImpactVfxSettings : ScriptableObject
|
||||
{
|
||||
public const string ResourcePath = "ProjectileImpactVfxSettings";
|
||||
|
||||
[SerializeField] private GameObject heroImpactPrefab;
|
||||
[SerializeField] private GameObject enemyImpactPrefab;
|
||||
[SerializeField] private float fallbackLifetime = 4f;
|
||||
|
||||
public GameObject HeroImpactPrefab => heroImpactPrefab;
|
||||
public GameObject EnemyImpactPrefab => enemyImpactPrefab;
|
||||
public float FallbackLifetime => Mathf.Max(0.5f, fallbackLifetime);
|
||||
|
||||
public static ProjectileImpactVfxSettings LoadFromResources()
|
||||
{
|
||||
return Resources.Load<ProjectileImpactVfxSettings>(ResourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Game/Scripts/Vfx/ProjectileImpactVfxSettings.cs.meta
Normal file
11
Game/Scripts/Vfx/ProjectileImpactVfxSettings.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 73e0c2d11a1f4e08a0e6df44c95c1cee
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -38,6 +38,9 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
private bool hasTarget;
|
||||
private Collider[] ownerColliders;
|
||||
private Health ownerHealth;
|
||||
private bool isLocallyControlled = true;
|
||||
private bool hasDamageOverride;
|
||||
private float damageOverrideValue = -1f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -81,6 +84,11 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isLocallyControlled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (equippedWeapons.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -99,6 +107,41 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLocalAuthority(bool allowLocalControl)
|
||||
{
|
||||
if (isLocallyControlled == allowLocalControl)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isLocallyControlled = allowLocalControl;
|
||||
|
||||
if (!isLocallyControlled)
|
||||
{
|
||||
hasTarget = false;
|
||||
retargetTimer = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDamageOverride(float damage)
|
||||
{
|
||||
if (damage > 0f)
|
||||
{
|
||||
damageOverrideValue = damage;
|
||||
hasDamageOverride = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearDamageOverride();
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearDamageOverride()
|
||||
{
|
||||
hasDamageOverride = false;
|
||||
damageOverrideValue = -1f;
|
||||
}
|
||||
|
||||
public void Equip(WeaponDefinition definition, Transform mount)
|
||||
{
|
||||
if (definition == null || mount == null)
|
||||
@@ -140,6 +183,7 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
|
||||
equippedWeapons.Clear();
|
||||
hasTarget = false;
|
||||
ClearDamageOverride();
|
||||
}
|
||||
|
||||
private void UpdateTargetSelection(float deltaTime)
|
||||
@@ -323,9 +367,10 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
|
||||
Quaternion rotation = direction.sqrMagnitude > 0f ? Quaternion.LookRotation(direction) : muzzle.rotation;
|
||||
Projectile projectile = Instantiate(projectilePrefab, muzzle.position, rotation);
|
||||
projectile.Initialize(direction, weapon.Definition.ProjectileSpeed, weapon.Definition.Damage, weapon.Definition.ProjectileLifetime, gameObject, ownerTeam, ownerColliders, weapon.Definition.HitMask);
|
||||
float resolvedDamage = hasDamageOverride ? damageOverrideValue : weapon.Definition.Damage;
|
||||
projectile.Initialize(direction, weapon.Definition.ProjectileSpeed, resolvedDamage, weapon.Definition.ProjectileLifetime, gameObject, ownerTeam, ownerColliders, weapon.Definition.HitMask, true);
|
||||
|
||||
ProjectileSpawned?.Invoke(new ProjectileSpawnEvent(weapon.Index, muzzle.position, direction, weapon.Definition.ProjectileSpeed, weapon.Definition.ProjectileLifetime, weapon.Definition.Damage));
|
||||
ProjectileSpawned?.Invoke(new ProjectileSpawnEvent(weapon.Index, muzzle.position, direction, weapon.Definition.ProjectileSpeed, weapon.Definition.ProjectileLifetime, resolvedDamage));
|
||||
}
|
||||
|
||||
public void SpawnNetworkProjectile(int weaponIndex, Vector3 position, Vector3 direction, float speed, float lifetime, float damage)
|
||||
@@ -507,8 +552,8 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
float resolvedSpeed = speed > 0f ? speed : Definition.ProjectileSpeed;
|
||||
float resolvedLifetime = lifetime > 0f ? lifetime : Definition.ProjectileLifetime;
|
||||
float resolvedDamage = damage > 0f ? damage : Definition.Damage;
|
||||
projectile.Initialize(direction, resolvedSpeed, resolvedDamage, resolvedLifetime, owner, ownerTeam, ownerColliders, Definition.HitMask);
|
||||
}
|
||||
projectile.Initialize(direction, resolvedSpeed, resolvedDamage, resolvedLifetime, owner, ownerTeam, ownerColliders, Definition.HitMask, false);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ProjectileSpawnEvent
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using MegaKoop.Game.Combat;
|
||||
using MegaKoop.Game.Networking;
|
||||
using MegaKoop.Game.Vfx;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MegaKoop.Game.WeaponSystem
|
||||
@@ -17,8 +19,9 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
private Team sourceTeam;
|
||||
private GameObject owner;
|
||||
private Collider[] projectileColliders;
|
||||
private bool isAuthoritative = true;
|
||||
|
||||
public void Initialize(Vector3 shotDirection, float projectileSpeed, float damageAmount, float projectileLifetime, GameObject projectileOwner, Team ownerTeam, Collider[] ownerColliders, LayerMask mask)
|
||||
public void Initialize(Vector3 shotDirection, float projectileSpeed, float damageAmount, float projectileLifetime, GameObject projectileOwner, Team ownerTeam, Collider[] ownerColliders, LayerMask mask, bool authoritative = true)
|
||||
{
|
||||
direction = shotDirection.sqrMagnitude > 0f ? shotDirection.normalized : transform.forward;
|
||||
speed = Mathf.Max(0f, projectileSpeed);
|
||||
@@ -28,6 +31,7 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
sourceTeam = ownerTeam;
|
||||
hitMask = mask;
|
||||
timeAlive = 0f;
|
||||
isAuthoritative = authoritative;
|
||||
|
||||
projectileColliders ??= GetComponentsInChildren<Collider>();
|
||||
if (ownerColliders != null && projectileColliders != null)
|
||||
@@ -102,6 +106,8 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
}
|
||||
|
||||
IDamageable damageable = other.GetComponentInParent<IDamageable>();
|
||||
ProjectileImpactKind? impactKind = null;
|
||||
|
||||
if (damageable != null && damageable.IsAlive)
|
||||
{
|
||||
bool isFriendly = damageable.Team == sourceTeam && damageable.Team != Team.Neutral;
|
||||
@@ -110,13 +116,27 @@ namespace MegaKoop.Game.WeaponSystem
|
||||
return;
|
||||
}
|
||||
|
||||
if (damage > 0f)
|
||||
if (damageable.Team == Team.Heroes)
|
||||
{
|
||||
impactKind = ProjectileImpactKind.Hero;
|
||||
}
|
||||
else if (damageable.Team == Team.Enemies)
|
||||
{
|
||||
impactKind = ProjectileImpactKind.Enemy;
|
||||
}
|
||||
|
||||
if (isAuthoritative && damage > 0f)
|
||||
{
|
||||
var payload = new DamagePayload(damage, hitPoint, hitNormal, owner, sourceTeam, other.gameObject);
|
||||
damageable.ApplyDamage(payload);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthoritative && impactKind.HasValue)
|
||||
{
|
||||
ProjectileImpactVfxService.ReportImpact(impactKind.Value, hitPoint, hitNormal);
|
||||
}
|
||||
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user