Merge branch origin/codex/add-villagers-with-schedules-and-spawner into main

This commit is contained in:
Alexa Amundson
2025-11-28 23:16:39 -06:00
3 changed files with 398 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
using System.Collections;
using UnityEngine;
using BlackRoad.Worldbuilder.Environment;
namespace BlackRoad.Worldbuilder.Villagers
{
/// <summary>
/// Simple villager behaviour:
/// - Sleep at home during sleep window.
/// - Go to work location during work hours.
/// - Wander around village during wander window.
/// Uses DayNightCycle.timeOfDay as clock.
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class VillagerAgent : MonoBehaviour
{
public enum VillagerState
{
Sleeping,
GoingHome,
AtHome,
GoingToWork,
AtWork,
Wandering
}
[Header("Schedule")]
[SerializeField] private VillagerSchedule schedule;
[SerializeField] private DayNightCycle dayNight;
[Header("Locations")]
[SerializeField] private Transform homeAnchor;
[SerializeField] private Transform workAnchor;
[SerializeField] private float arriveDistance = 1.5f;
[Header("Movement")]
[SerializeField] private float walkSpeed = 2.5f;
[SerializeField] private float turnSpeed = 6f;
[SerializeField] private float gravity = 9.81f;
[SerializeField] private float wanderRadius = 8f;
[SerializeField] private LayerMask groundMask = ~0;
public VillagerState State { get; private set; }
public VillagerSchedule Schedule { get => schedule; set => schedule = value; }
public DayNightCycle DayNight { get => dayNight; set => dayNight = value; }
public Transform HomeAnchor { get => homeAnchor; set => homeAnchor = value; }
public Transform WorkAnchor { get => workAnchor; set => workAnchor = value; }
private CharacterController _controller;
private Vector3 _velocity;
private Vector3 _wanderCenter;
private Vector3 _wanderTarget;
private Coroutine _logicRoutine;
private void Awake()
{
_controller = GetComponent<CharacterController>();
}
private void Start()
{
if (dayNight == null)
dayNight = FindObjectOfType<DayNightCycle>();
// Default wander center = home
_wanderCenter = homeAnchor != null ? homeAnchor.position : transform.position;
_logicRoutine = StartCoroutine(StateLoop());
}
private IEnumerator StateLoop()
{
while (true)
{
float t = GetTimeOfDay();
if (schedule != null && schedule.IsSleepTime(t))
{
yield return SleepRoutine();
}
else if (schedule != null && schedule.IsWorkTime(t) && workAnchor != null)
{
// work time
yield return WorkRoutine();
}
else if (schedule != null && schedule.IsWanderTime(t))
{
// wander near home
yield return WanderRoutine();
}
else
{
// default = "at home" (idle)
yield return AtHomeRoutine();
}
yield return null;
}
}
private float GetTimeOfDay()
{
if (dayNight != null)
return dayNight.timeOfDay;
return 0.5f; // midday default
}
private IEnumerator SleepRoutine()
{
State = VillagerState.Sleeping;
// Move to home if far away
if (homeAnchor != null)
{
yield return MoveTo(homeAnchor.position, VillagerState.GoingHome);
}
// Then sleep: minimal movement, maybe tiny idle
while (schedule != null && schedule.IsSleepTime(GetTimeOfDay()))
{
// shrink velocity
_velocity = Vector3.zero;
// subtle breathing bob
float bob = Mathf.Sin(Time.time * 0.5f) * 0.02f;
var pos = transform.position;
pos.y += bob;
transform.position = pos;
yield return null;
}
}
private IEnumerator WorkRoutine()
{
State = VillagerState.GoingToWork;
if (workAnchor != null)
{
yield return MoveTo(workAnchor.position, VillagerState.GoingToWork);
}
State = VillagerState.AtWork;
while (schedule != null && schedule.IsWorkTime(GetTimeOfDay()))
{
// You could add simple pacing, animation, etc. Here we just idle.
_velocity = new Vector3(0f, _velocity.y, 0f);
yield return null;
}
}
private IEnumerator WanderRoutine()
{
State = VillagerState.Wandering;
_wanderCenter = homeAnchor != null ? homeAnchor.position : transform.position;
float endTime = Time.time + 60f; // don't wander forever in case schedule changes
while (Time.time < endTime && schedule != null && schedule.IsWanderTime(GetTimeOfDay()))
{
if ((_wanderTarget - transform.position).sqrMagnitude < 1f)
{
// Pick a new wander target
if (!TryGetWanderPoint(out _wanderTarget))
{
yield return null;
continue;
}
}
yield return MoveStepTowards(_wanderTarget, walkSpeed);
yield return null;
}
}
private IEnumerator AtHomeRoutine()
{
State = VillagerState.AtHome;
if (homeAnchor != null)
{
yield return MoveTo(homeAnchor.position, VillagerState.GoingHome);
}
float timer = 0f;
while (schedule != null && !schedule.IsSleepTime(GetTimeOfDay()) &&
!schedule.IsWorkTime(GetTimeOfDay()) &&
!schedule.IsWanderTime(GetTimeOfDay()))
{
timer += Time.deltaTime;
_velocity = new Vector3(0f, _velocity.y, 0f);
yield return null;
}
}
private IEnumerator MoveTo(Vector3 target, VillagerState movingState)
{
State = movingState;
while (true)
{
Vector3 flatPos = new Vector3(transform.position.x, 0f, transform.position.z);
Vector3 flatTarget = new Vector3(target.x, 0f, target.z);
Vector3 toTarget = flatTarget - flatPos;
float dist = toTarget.magnitude;
if (dist <= arriveDistance)
yield break;
Vector3 dir = toTarget.normalized;
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRot,
turnSpeed * Time.deltaTime);
}
Vector3 move = transform.forward * walkSpeed;
_velocity.x = move.x;
_velocity.z = move.z;
yield return null;
}
}
private IEnumerator MoveStepTowards(Vector3 target, float speed)
{
Vector3 flatPos = new Vector3(transform.position.x, 0f, transform.position.z);
Vector3 flatTarget = new Vector3(target.x, 0f, target.z);
Vector3 toTarget = flatTarget - flatPos;
float dist = toTarget.magnitude;
if (dist < 0.5f) yield break;
Vector3 dir = toTarget.normalized;
if (dir.sqrMagnitude > 0.0001f)
{
Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.Slerp(
transform.rotation,
targetRot,
turnSpeed * Time.deltaTime);
}
Vector3 move = transform.forward * speed;
_velocity.x = move.x;
_velocity.z = move.z;
yield return null;
}
private bool TryGetWanderPoint(out Vector3 result)
{
for (int i = 0; i < 8; i++)
{
float angle = Random.Range(0f, Mathf.PI * 2f);
float dist = Random.Range(1f, wanderRadius);
Vector3 candidate = _wanderCenter +
new Vector3(Mathf.Cos(angle), 0f, Mathf.Sin(angle)) * dist;
// Sample ground via raycast
if (Physics.Raycast(candidate + Vector3.up * 30f, Vector3.down,
out RaycastHit hit, 100f, groundMask))
{
result = hit.point;
return true;
}
}
result = _wanderCenter;
return false;
}
private void Update()
{
// Apply gravity
if (_controller.isGrounded)
_velocity.y = -1f;
else
_velocity.y -= gravity * Time.deltaTime;
_controller.Move(_velocity * Time.deltaTime);
}
}
}

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Villagers
{
/// <summary>
/// Represents a simple daily schedule for a villager.
/// Times are normalized [0..1] over one simulated day (0 = 0:00, 0.5 = 12:00).
/// </summary>
[CreateAssetMenu(
fileName = "VillagerSchedule",
menuName = "BlackRoad/Worldbuilder/VillagerSchedule",
order = 0)]
public class VillagerSchedule : ScriptableObject
{
[Header("Home / Sleep")]
[Range(0f, 1f)] public float sleepStart = 0.80f; // 19:12
[Range(0f, 1f)] public float sleepEnd = 0.20f; // 04:48
[Header("Work")]
[Range(0f, 1f)] public float workStart = 0.30f; // ~7:12
[Range(0f, 1f)] public float workEnd = 0.65f; // ~15:36
[Header("Wander")]
[Range(0f, 1f)] public float wanderStart = 0.20f;
[Range(0f, 1f)] public float wanderEnd = 0.30f;
public bool IsSleepTime(float t)
{
if (sleepStart < sleepEnd)
return t >= sleepStart && t <= sleepEnd;
return t >= sleepStart || t <= sleepEnd;
}
public bool IsWorkTime(float t)
{
return t >= workStart && t <= workEnd;
}
public bool IsWanderTime(float t)
{
return t >= wanderStart && t <= wanderEnd;
}
}
}

View File

@@ -0,0 +1,65 @@
using UnityEngine;
namespace BlackRoad.Worldbuilder.Villagers
{
/// <summary>
/// Spawns villagers at each provided home anchor, optionally pairing them
/// with matching work anchors by index.
/// </summary>
public class VillagerSpawner : MonoBehaviour
{
[Header("Refs")]
[SerializeField] private VillagerAgent villagerPrefab;
[SerializeField] private VillagerSchedule defaultSchedule;
[Header("Anchors")]
[SerializeField] private Transform[] homeAnchors;
[SerializeField] private Transform[] workAnchors;
private void Reset()
{
// Try to auto-find anchors in children
homeAnchors = new Transform[0];
workAnchors = new Transform[0];
}
[ContextMenu("Spawn Villagers")]
public void SpawnVillagers()
{
if (villagerPrefab == null)
{
Debug.LogError("[VillagerSpawner] No villagerPrefab assigned.");
return;
}
for (int i = 0; i < homeAnchors.Length; i++)
{
Transform home = homeAnchors[i];
if (home == null) continue;
// Slight random offset
Vector3 pos = home.position + new Vector3(
Random.Range(-0.5f, 0.5f),
0f,
Random.Range(-0.5f, 0.5f));
var villager = Instantiate(villagerPrefab, pos, Quaternion.identity, transform);
var agent = villager;
agent.HomeAnchor = home;
if (i < workAnchors.Length)
{
agent.WorkAnchor = workAnchors[i];
}
if (agent.Schedule == null && defaultSchedule != null)
{
agent.Schedule = defaultSchedule;
}
}
Debug.Log($"[VillagerSpawner] Spawned {homeAnchors.Length} villagers.");
}
}
}