Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Empty file.
Empty file.
192 changes: 192 additions & 0 deletions Assets/HorrorCoopGame/Scripts/AI/EnemyAI.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
using HorrorCoopGame.Player;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;

namespace HorrorCoopGame.AI
{
/// <summary>
/// Server-only enemy AI driven by a simple state machine. Pathfinding
/// destination updates are throttled to keep WebGL/mobile CPU low.
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
public sealed class EnemyAI : NetworkBehaviour
{
private enum State
{
Patrol,
Chase,
Attack
}

[Header("Behaviour")]
[SerializeField] private float detectionRadius = 12f;
[SerializeField] private float loseSightRadius = 18f;
[SerializeField] private float attackRange = 1.8f;
[SerializeField] private float attackCooldown = 1.25f;
[SerializeField] private float attackDamage = 12f;
[SerializeField] private float patrolRadius = 8f;

[Header("Optimization")]
[SerializeField] private float destinationUpdateInterval = 0.2f;

private NavMeshAgent agent;
private State state = State.Patrol;
private float nextPathUpdateTime;
private float nextAttackTime;
private Vector3 patrolAnchor;
private Vector3 patrolTarget;
private Transform chaseTarget;

public override void OnNetworkSpawn()
{
if (!IsServer)
{
// Pathfinding is server-authoritative only.
if (TryGetComponent(out NavMeshAgent localAgent))
{
localAgent.enabled = false;
}

enabled = false;
return;
}

agent = GetComponent<NavMeshAgent>();
patrolAnchor = transform.position;
ChoosePatrolTarget();
}

private void Update()
{
if (Time.time < nextPathUpdateTime)
{
return;
}

nextPathUpdateTime = Time.time + destinationUpdateInterval;

switch (state)
{
case State.Patrol:
TickPatrol();
break;
case State.Chase:
TickChase();
break;
case State.Attack:
TickAttack();
break;
}
}

private void TickPatrol()
{
if (TryFindClosestPlayer(detectionRadius, out Transform player))
{
chaseTarget = player;
state = State.Chase;
return;
}

if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance + 0.1f)
{
ChoosePatrolTarget();
}

agent.SetDestination(patrolTarget);
}

private void TickChase()
{
if (chaseTarget == null)
{
state = State.Patrol;
return;
}

float distance = Vector3.Distance(transform.position, chaseTarget.position);
if (distance > loseSightRadius)
{
chaseTarget = null;
state = State.Patrol;
return;
}

if (distance <= attackRange)
{
state = State.Attack;
return;
}

agent.SetDestination(chaseTarget.position);
}

private void TickAttack()
{
if (chaseTarget == null)
{
state = State.Patrol;
return;
}

float distance = Vector3.Distance(transform.position, chaseTarget.position);
if (distance > attackRange + 0.5f)
{
state = State.Chase;
return;
}

agent.SetDestination(transform.position);

if (Time.time < nextAttackTime)
{
return;
}

nextAttackTime = Time.time + attackCooldown;
if (chaseTarget.TryGetComponent(out PlayerStats stats))
{
stats.TakeDamageServerRpc(attackDamage);
}
}

private bool TryFindClosestPlayer(float radius, out Transform closest)
{
closest = null;
float bestSqr = radius * radius;

foreach (var kvp in NetworkManager.Singleton.ConnectedClients)
{
NetworkObject playerObject = kvp.Value.PlayerObject;
if (playerObject == null)
{
continue;
}

float sqr = (playerObject.transform.position - transform.position).sqrMagnitude;
if (sqr <= bestSqr)
{
bestSqr = sqr;
closest = playerObject.transform;
}
}

return closest != null;
}

private void ChoosePatrolTarget()
{
Vector2 random = Random.insideUnitCircle * patrolRadius;
Vector3 candidate = patrolAnchor + new Vector3(random.x, 0f, random.y);
if (NavMesh.SamplePosition(candidate, out NavMeshHit hit, patrolRadius, NavMesh.AllAreas))
{
patrolTarget = hit.position;
}
else
{
patrolTarget = patrolAnchor;
}
}
}
}
106 changes: 106 additions & 0 deletions Assets/HorrorCoopGame/Scripts/AI/SanityDrain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using HorrorCoopGame.Player;
using Unity.Netcode;
using UnityEngine;

namespace HorrorCoopGame.AI
{
/// <summary>
/// Drains the player's Sanity NetworkVariable while in darkness.
/// Uses light probes / sampling at a low cadence to avoid hot-loop costs.
/// Audio hallucinations are played client-side via a ClientRpc.
/// </summary>
[RequireComponent(typeof(PlayerStats))]
public sealed class SanityDrain : NetworkBehaviour
{
[SerializeField] private float drainPerSecond = 1.5f;
[SerializeField] private float regenPerSecond = 0.75f;
[SerializeField] private float darknessThreshold = 0.25f;
[SerializeField] private float evaluationInterval = 0.5f;
[SerializeField] private AudioClip[] hallucinationClips;
[SerializeField] private AudioSource hallucinationSource;
[SerializeField] private float hallucinationMinInterval = 8f;
[SerializeField] private float hallucinationMaxInterval = 18f;

private PlayerStats stats;
private float nextEvaluationTime;
private float nextHallucinationTime;

public override void OnNetworkSpawn()
{
stats = GetComponent<PlayerStats>();

if (!IsServer && !IsOwner)
{
enabled = false;
}

ScheduleNextHallucination();
}

private void Update()
{
if (IsServer && Time.time >= nextEvaluationTime)
{
nextEvaluationTime = Time.time + evaluationInterval;
EvaluateSanityServer();
}

if (IsOwner)
{
TryPlayHallucination();
}
}

private void EvaluateSanityServer()
{
float light = SampleAmbientLight();
float delta = light < darknessThreshold
? -drainPerSecond * evaluationInterval
: regenPerSecond * evaluationInterval;

stats.Sanity.Value = Mathf.Clamp(stats.Sanity.Value + delta, 0f, 100f);
}

private float SampleAmbientLight()
{
// Cheap, allocation-free probe of baked ambient + sky light intensity.
Color ambient = RenderSettings.ambientLight;
float intensity = (ambient.r + ambient.g + ambient.b) / 3f;
intensity = Mathf.Max(intensity, RenderSettings.ambientIntensity);

if (RenderSettings.sun != null)
{
intensity += RenderSettings.sun.intensity * Mathf.Clamp01(Vector3.Dot(Vector3.up, -RenderSettings.sun.transform.forward));
}

return Mathf.Clamp01(intensity);
}

private void TryPlayHallucination()
{
if (Time.time < nextHallucinationTime || hallucinationSource == null || hallucinationClips == null || hallucinationClips.Length == 0)
{
return;
}

if (stats.Sanity.Value > 60f)
{
ScheduleNextHallucination();
return;
}

AudioClip clip = hallucinationClips[Random.Range(0, hallucinationClips.Length)];
if (clip != null)
{
hallucinationSource.PlayOneShot(clip);
}

ScheduleNextHallucination();
}

private void ScheduleNextHallucination()
{
nextHallucinationTime = Time.time + Random.Range(hallucinationMinInterval, hallucinationMaxInterval);
}
}
}
16 changes: 16 additions & 0 deletions Assets/HorrorCoopGame/Scripts/Building/BuildableData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using UnityEngine;

namespace HorrorCoopGame.Building
{
[CreateAssetMenu(fileName = "NewBuildableData", menuName = "Survival Horror/Buildable Data")]
public sealed class BuildableData : ScriptableObject
{
public string buildableName;
public GameObject prefab;
public GameObject ghostPrefab;
public string requiredItemName = "ScrapMetal";
public int requiredItemAmount = 4;
public float gridSize = 1f;
[Range(0.05f, 5f)] public float yawSnap = 90f;
}
}
Loading
Loading