feat(life): add critter agent and spawner

This commit is contained in:
Alexa Amundson
2025-11-25 14:44:44 -06:00
parent ffc2842bd0
commit 73c9046f60
2 changed files with 320 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
using System.Collections;
using UnityEngine;
using BlackRoad.Worldbuilder.Environment; // for DayNightCycle
namespace BlackRoad.Worldbuilder.Life
{
/// <summary>
/// Simple wandering creature that walks around a home area,
/// occasionally idles, and goes to sleep during night hours
/// according to the DayNightCycle.
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class CritterAgent : MonoBehaviour
{
public enum CritterState
{
Sleeping,
Idle,
Walking
}
[Header("Movement")]
[SerializeField] private float walkSpeed = 2f;
[SerializeField] private float turnSpeed = 5f;
[SerializeField] private float gravity = 9.81f;
[Header("Home Range")]
[SerializeField] private float homeRadius = 25f;
[SerializeField] private float minWanderDistance = 5f;
[SerializeField] private float maxWanderDistance = 20f;
[Header("Idle Behaviour")]
[SerializeField] private Vector2 idleTimeRange = new Vector2(1.5f, 4f);
[Header("Sleep Schedule")]
[Tooltip("Normalized time-of-day range where the critter sleeps (01).")]
[SerializeField] private float sleepStart = 0.80f; // 19:12
[SerializeField] private float sleepEnd = 0.20f; // 04:48
[Header("Environment")]
[SerializeField] private DayNightCycle dayNight;
[SerializeField] private LayerMask groundMask = ~0;
public CritterState State { get; private set; }
private CharacterController _controller;
private Vector3 _homePos;
private Vector3 _targetPos;
private Vector3 _velocity;
private Coroutine _stateRoutine;
private void Awake()
{
_controller = GetComponent<CharacterController>();
_homePos = transform.position;
}
private void Start()
{
if (dayNight == null)
dayNight = FindObjectOfType<DayNightCycle>();
// Start behaviour loop
_stateRoutine = StartCoroutine(StateLoop());
}
private IEnumerator StateLoop()
{
while (true)
{
if (IsSleepTime())
{
State = CritterState.Sleeping;
yield return SleepRoutine();
}
else
{
// pick either walk or idle randomly
if (Random.value < 0.5f)
{
State = CritterState.Idle;
yield return IdleRoutine();
}
else
{
State = CritterState.Walking;
yield return WalkRoutine();
}
}
yield return null;
}
}
private bool IsSleepTime()
{
if (dayNight == null) return false;
float t = dayNight.timeOfDay;
// sleep window may wrap around 0
if (sleepStart < sleepEnd)
return t >= sleepStart && t <= sleepEnd;
return t >= sleepStart || t <= sleepEnd;
}
private IEnumerator SleepRoutine()
{
// stand mostly still; tiny sway so they don't look frozen
float timer = 0f;
while (IsSleepTime())
{
timer += Time.deltaTime;
float bob = Mathf.Sin(timer * 0.5f) * 0.01f;
var p = transform.position;
p.y += bob;
transform.position = p;
// zero velocity / gravity handled in Update
_velocity = Vector3.zero;
yield return null;
}
}
private IEnumerator IdleRoutine()
{
float idleDuration = Random.Range(idleTimeRange.x, idleTimeRange.y);
float timer = 0f;
while (timer < idleDuration && !IsSleepTime())
{
timer += Time.deltaTime;
_velocity = new Vector3(0f, _velocity.y, 0f); // keep gravity only
yield return null;
}
}
private IEnumerator WalkRoutine()
{
if (!TryGetWanderTarget(out _targetPos))
{
// If we fail to find a target, just idle instead.
yield break;
}
while (!IsSleepTime())
{
// Flatten movement on XZ, we'll handle vertical via gravity
Vector3 flatPos = new Vector3(transform.position.x, 0f, transform.position.z);
Vector3 flatTarget = new Vector3(_targetPos.x, 0f, _targetPos.z);
Vector3 toTarget = flatTarget - flatPos;
float dist = toTarget.magnitude;
if (dist < 0.5f)
yield break; // reached target
Vector3 dir = toTarget.normalized;
// Rotate toward target
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRot,
turnSpeed * Time.deltaTime
);
}
// Move forward
Vector3 move = transform.forward * walkSpeed;
_velocity.x = move.x;
_velocity.z = move.z;
yield return null;
}
}
private bool TryGetWanderTarget(out Vector3 result)
{
// Pick a random direction + distance around home
for (int i = 0; i < 10; i++)
{
float angle = Random.Range(0f, Mathf.PI * 2f);
float dist = Random.Range(minWanderDistance, maxWanderDistance);
Vector3 offset = new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * dist;
Vector3 candidate = _homePos + offset;
// Sample ground height via raycast
if (Physics.Raycast(
candidate + Vector3.up * 50f,
Vector3.down,
out RaycastHit hit,
100f,
groundMask))
{
candidate = hit.point;
result = candidate;
return true;
}
}
result = Vector3.zero;
return false;
}
private void Update()
{
// Apply gravity (except very lightly while sleeping)
if (State == CritterState.Sleeping)
{
_velocity.y = Mathf.MoveTowards(_velocity.y, 0f, gravity * Time.deltaTime);
}
else
{
if (_controller.isGrounded)
_velocity.y = -1f;
else
_velocity.y -= gravity * Time.deltaTime;
}
_controller.Move(_velocity * Time.deltaTime);
}
}
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Generic;
using UnityEngine;
namespace BlackRoad.Worldbuilder.Life
{
/// <summary>
/// Spawns a number of CritterAgent instances on a Terrain,
/// obeying basic height & slope limits so they don't spawn on cliffs.
/// </summary>
public class CritterSpawner : MonoBehaviour
{
[Header("Terrain")]
[SerializeField] private Terrain targetTerrain;
[Header("Critter")]
[SerializeField] private CritterAgent critterPrefab;
[SerializeField] private int count = 20;
[Header("Placement")]
[Range(0f, 1f)]
[SerializeField] private float minHeight = 0f;
[Range(0f, 1f)]
[SerializeField] private float maxHeight = 1f;
[Range(0f, 90f)]
[SerializeField] private float maxSlope = 35f;
[SerializeField] private int randomSeed = 2025;
private TerrainData _terrainData;
private Vector3 _terrainPos;
private void OnValidate()
{
if (targetTerrain == null)
targetTerrain = GetComponent<Terrain>();
}
[ContextMenu("Spawn Critters")]
public void SpawnCritters()
{
if (targetTerrain == null || critterPrefab == null)
{
Debug.LogError("[CritterSpawner] Missing terrain or critterPrefab.");
return;
}
_terrainData = targetTerrain.terrainData;
_terrainPos = targetTerrain.transform.position;
Random.InitState(randomSeed);
int spawned = 0;
int attempts = 0;
const int maxAttempts = 5000;
while (spawned < count && attempts < maxAttempts)
{
attempts++;
float rx = Random.value;
float rz = Random.value;
float height = _terrainData.GetInterpolatedHeight(rx, rz);
float worldHeight = height + _terrainPos.y;
float normHeight = Mathf.InverseLerp(
_terrainData.bounds.min.y + _terrainPos.y,
_terrainData.bounds.max.y + _terrainPos.y,
worldHeight
);
if (normHeight < minHeight || normHeight > maxHeight)
continue;
Vector3 normal = _terrainData.GetInterpolatedNormal(rx, rz);
float slope = Vector3.Angle(normal, Vector3.up);
if (slope > maxSlope)
continue;
float worldX = _terrainPos.x + rx * _terrainData.size.x;
float worldZ = _terrainPos.z + rz * _terrainData.size.z;
Vector3 pos = new Vector3(worldX, worldHeight, worldZ);
var critter = Instantiate(critterPrefab, pos, Quaternion.identity, transform);
spawned++;
}
Debug.Log($"[CritterSpawner] Spawned {spawned}/{count} critters.");
}
}
}