Merge branch origin/codex/add-food-ecology-for-critters into main

This commit is contained in:
Alexa Amundson
2025-11-28 23:17:33 -06:00
3 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace BlackRoad.Worldbuilder.Life
{
/// <summary>
/// High-level decision-maker for a critter:
/// - If not hungry: let CritterAgent handle wandering/idle/sleep.
/// - If hungry: look for nearby FoodSource and move toward it to eat.
/// </summary>
[RequireComponent(typeof(CritterAgent))]
[RequireComponent(typeof(CritterNeeds))]
public class CritterBrain : MonoBehaviour
{
[Header("Sensing")]
[SerializeField] private float senseRadius = 20f;
[SerializeField] private LayerMask foodMask = ~0;
[Header("Eating")]
[SerializeField] private float eatDistance = 1.5f;
[SerializeField] private float checkFoodInterval = 2f;
private CritterAgent _agent;
private CritterNeeds _needs;
private Coroutine _brainRoutine;
private FoodSource _currentTarget;
private void Awake()
{
_agent = GetComponent<CritterAgent>();
_needs = GetComponent<CritterNeeds>();
}
private void OnEnable()
{
_brainRoutine = StartCoroutine(BrainLoop());
}
private void OnDisable()
{
if (_brainRoutine != null)
{
StopCoroutine(_brainRoutine);
}
}
private IEnumerator BrainLoop()
{
while (true)
{
if (_needs.IsHungry && !IsSleeping())
{
yield return HungryRoutine();
}
else
{
// Let CritterAgent's own state machine do its thing.
yield return null;
}
yield return null;
}
}
private bool IsSleeping()
{
return _agent.State == CritterAgent.CritterState.Sleeping;
}
private IEnumerator HungryRoutine()
{
float timeSinceCheck = 0f;
while (_needs.IsHungry && !IsSleeping())
{
timeSinceCheck += Time.deltaTime;
if (timeSinceCheck >= checkFoodInterval || _currentTarget == null)
{
timeSinceCheck = 0f;
_currentTarget = FindNearestFood();
}
if (_currentTarget == null)
{
// No food nearby, just let normal wandering continue
yield return null;
}
else
{
yield return MoveTowardsAndEat(_currentTarget);
}
}
}
private FoodSource FindNearestFood()
{
Collider[] hits = Physics.OverlapSphere(transform.position, senseRadius, foodMask);
FoodSource nearest = null;
float bestSqr = float.MaxValue;
foreach (var hit in hits)
{
if (hit == null) continue;
var food = hit.GetComponent<FoodSource>();
if (food == null || food.Quantity <= 0f) continue;
float sqr = (food.transform.position - transform.position).sqrMagnitude;
if (sqr < bestSqr)
{
bestSqr = sqr;
nearest = food;
}
}
return nearest;
}
private IEnumerator MoveTowardsAndEat(FoodSource food)
{
if (food == null) yield break;
Transform t = transform;
var controller = GetComponent<CharacterController>();
while (food != null && food.Quantity > 0f && _needs.IsHungry && !IsSleeping())
{
Vector3 toFood = food.transform.position - t.position;
float dist = toFood.magnitude;
if (dist > eatDistance)
{
// Approach food manually: simple steering toward it
Vector3 dir = toFood.normalized;
dir.y = 0f;
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up);
t.rotation = Quaternion.Slerp(
t.rotation,
targetRot,
5f * Time.deltaTime
);
}
Vector3 move = dir * 1.5f; // approach speed
Vector3 velocity = move;
velocity.y = controller.isGrounded ? -1f : velocity.y - 9.81f * Time.deltaTime;
controller.Move(velocity * Time.deltaTime);
}
else
{
// Eat
float nutrition = food.Consume(Time.deltaTime);
if (nutrition > 0f)
{
_needs.ApplyNutrition(Time.deltaTime);
}
}
yield return null;
}
}
private void OnDrawGizmosSelected()
{
Gizmos.color = new Color(1f, 0.8f, 0.2f, 0.3f);
Gizmos.DrawWireSphere(transform.position, senseRadius);
}
}
}

View File

@@ -0,0 +1,46 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Life
{
/// <summary>
/// Tracks basic physiological needs for a critter (currently hunger only).
/// Hunger rises over time, and can be reduced by eating FoodSource objects.
/// </summary>
public class CritterNeeds : MonoBehaviour
{
[Header("Hunger")]
[SerializeField] private float hunger = 0f; // 0 = full, 1 = starving
[SerializeField] private float hungerIncreasePerSecond = 0.01f;
[SerializeField] private float hungerDecreasePerSecond = 0.3f;
[SerializeField] private float hungryThreshold = 0.4f;
[SerializeField] private float starvingThreshold = 0.8f;
public float Hunger => hunger;
public bool IsHungry => hunger >= hungryThreshold;
public bool IsStarving => hunger >= starvingThreshold;
private void Update()
{
// hunger goes up by default
hunger += hungerIncreasePerSecond * Time.deltaTime;
hunger = Mathf.Clamp01(hunger);
}
/// <summary>
/// Called while eating; amount is 0..1 fraction of "fullness", we convert to hunger reduction.
/// </summary>
public void ApplyNutrition(float deltaTime)
{
hunger -= hungerDecreasePerSecond * deltaTime;
hunger = Mathf.Clamp01(hunger);
}
/// <summary>
/// For debug / testing: instantly reset hunger.
/// </summary>
public void SetHunger(float value)
{
hunger = Mathf.Clamp01(value);
}
}
}

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Life
{
/// <summary>
/// A simple food source that critters can eat from.
/// When quantity reaches zero, it can optionally destroy itself.
/// </summary>
public class FoodSource : MonoBehaviour
{
[SerializeField] private float maxQuantity = 100f;
[SerializeField] private bool destroyWhenEmpty = true;
[Tooltip("Units of food restored per second while eating.")]
[SerializeField] private float nutritionPerSecond = 10f;
public float Quantity { get; private set; }
private void Awake()
{
Quantity = maxQuantity;
}
/// <summary>
/// Consume some amount of food over deltaTime.
/// Returns how much nutrition was actually provided.
/// </summary>
public float Consume(float deltaTime)
{
if (Quantity <= 0f) return 0f;
float requested = nutritionPerSecond * deltaTime;
float provided = Mathf.Min(requested, Quantity);
Quantity -= provided;
if (Quantity <= 0f && destroyWhenEmpty)
{
Destroy(gameObject);
}
return provided;
}
}
}