feat(core): implement mob AI state machine for wandering and targeting
This commit is contained in:
1 parent
a3fc5f6731
commit
74649636fb
1 file changed
+335
@@ -0,0 +1,335 @@
|
||||
#include "vf/Mob.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace vf {
|
||||
|
||||
namespace {
|
||||
|
||||
uint32_t xorshift(uint32_t& state) {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
return state;
|
||||
}
|
||||
|
||||
float unitFloat(uint32_t& rng) { return static_cast<float>(xorshift(rng)) / 4294967295.0f; }
|
||||
|
||||
float horizontalDistance(const Vec3& a, const Vec3& b) {
|
||||
float dx = a.x - b.x, dz = a.z - b.z;
|
||||
return std::sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
Vec3i floorCell(const Vec3& position) {
|
||||
return {static_cast<int>(std::floor(position.x)), static_cast<int>(std::floor(position.y)),
|
||||
static_cast<int>(std::floor(position.z))};
|
||||
}
|
||||
|
||||
Vec3 randomHeading(uint32_t& rng) {
|
||||
float angle = unitFloat(rng) * 6.2831853f;
|
||||
return {std::cos(angle), 0.0f, std::sin(angle)};
|
||||
}
|
||||
|
||||
// Follows an A* path toward `goalPos` for one step, returning the heading to
|
||||
// the next path cell. Falls back to `fallbackDir` when no multi-step path
|
||||
// exists (adjacent goal or unreachable target).
|
||||
Vec3 pathTowardWishDir(const World& world, const Mob& body, const Vec3& goalPos,
|
||||
const PathConfig& pathConfig, const Vec3& fallbackDir) {
|
||||
Vec3i start = mobFeetCell(body);
|
||||
Vec3i goal = floorCell(goalPos);
|
||||
std::vector<Vec3i> path = findPath(world, start, goal, pathConfig);
|
||||
if (path.size() >= 2) {
|
||||
Vec3 center{static_cast<float>(path[1].x) + 0.5f, body.position.y,
|
||||
static_cast<float>(path[1].z) + 0.5f};
|
||||
Vec3 toNext{center.x - body.position.x, 0.0f, center.z - body.position.z};
|
||||
return normalize(toNext);
|
||||
}
|
||||
if (path.size() == 1) return {0.0f, 0.0f, 0.0f};
|
||||
return fallbackDir;
|
||||
}
|
||||
|
||||
SpeciesStats makeStats(float maxHealth, float width, float height, float walkSpeed,
|
||||
float sprintMultiplier, float attackDamage, float attackRange,
|
||||
float attackCooldown, float alertRadius, float disengageRadius,
|
||||
int spawnLightMax, int spawnLightMin) {
|
||||
SpeciesStats stats;
|
||||
stats.maxHealth = maxHealth;
|
||||
stats.width = width;
|
||||
stats.height = height;
|
||||
stats.walkSpeed = walkSpeed;
|
||||
stats.sprintMultiplier = sprintMultiplier;
|
||||
stats.attackDamage = attackDamage;
|
||||
stats.attackRange = attackRange;
|
||||
stats.attackCooldown = attackCooldown;
|
||||
stats.alertRadius = alertRadius;
|
||||
stats.disengageRadius = disengageRadius;
|
||||
stats.spawnLightMax = spawnLightMax;
|
||||
stats.spawnLightMin = spawnLightMin;
|
||||
return stats;
|
||||
}
|
||||
|
||||
std::array<SpeciesStats, static_cast<std::size_t>(Species::Count)> buildSpeciesTable() {
|
||||
std::array<SpeciesStats, static_cast<std::size_t>(Species::Count)> table{};
|
||||
table[static_cast<std::size_t>(Species::Sheep)] =
|
||||
makeStats(10.0f, 0.9f, 1.3f, 2.0f, 1.6f, 0.0f, 0.0f, 0.0f, 8.0f, 12.0f, 15, 9);
|
||||
table[static_cast<std::size_t>(Species::Cow)] =
|
||||
makeStats(10.0f, 0.9f, 1.4f, 2.0f, 1.6f, 0.0f, 0.0f, 0.0f, 8.0f, 12.0f, 15, 9);
|
||||
table[static_cast<std::size_t>(Species::Pig)] =
|
||||
makeStats(10.0f, 0.9f, 0.9f, 2.0f, 1.6f, 0.0f, 0.0f, 0.0f, 8.0f, 12.0f, 15, 9);
|
||||
table[static_cast<std::size_t>(Species::Zombie)] =
|
||||
makeStats(20.0f, 0.6f, 1.95f, 2.3f, 1.4f, 3.0f, 1.4f, 1.0f, 16.0f, 20.0f, 7, 0);
|
||||
table[static_cast<std::size_t>(Species::Skeleton)] =
|
||||
makeStats(20.0f, 0.6f, 1.95f, 2.3f, 1.4f, 2.0f, 8.0f, 1.4f, 16.0f, 20.0f, 7, 0);
|
||||
table[static_cast<std::size_t>(Species::Creeper)] =
|
||||
makeStats(20.0f, 0.6f, 1.7f, 2.5f, 1.3f, 6.0f, 1.6f, 1.5f, 16.0f, 20.0f, 7, 0);
|
||||
return table;
|
||||
}
|
||||
|
||||
constexpr int fullSkyLight = 15;
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isHostile(Species species) {
|
||||
switch (species) {
|
||||
case Species::Zombie:
|
||||
case Species::Skeleton:
|
||||
case Species::Creeper:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SpeciesStats& speciesStats(Species species) {
|
||||
static const std::array<SpeciesStats, static_cast<std::size_t>(Species::Count)> table =
|
||||
buildSpeciesTable();
|
||||
return table[static_cast<std::size_t>(species)];
|
||||
}
|
||||
|
||||
Creature spawnCreature(Species species, const Vec3& position, uint32_t seed) {
|
||||
const SpeciesStats& stats = speciesStats(species);
|
||||
Creature creature;
|
||||
creature.species = species;
|
||||
creature.body.position = position;
|
||||
creature.body.width = stats.width;
|
||||
creature.body.height = stats.height;
|
||||
creature.health = stats.maxHealth;
|
||||
creature.rng = seed != 0 ? seed : 0x9e3779b9u;
|
||||
creature.state = CreatureState::Idle;
|
||||
return creature;
|
||||
}
|
||||
|
||||
void updateCreature(const World& world, Creature& creature, const Vec3& threatPos, bool hasThreat,
|
||||
const CreatureConfig& config, float dt) {
|
||||
if (!creature.alive) return;
|
||||
|
||||
if (creature.attackCooldownRemaining > 0.0f)
|
||||
creature.attackCooldownRemaining = std::max(0.0f, creature.attackCooldownRemaining - dt);
|
||||
|
||||
const SpeciesStats& stats = speciesStats(creature.species);
|
||||
float distToThreat = hasThreat ? horizontalDistance(creature.body.position, threatPos) : 0.0f;
|
||||
bool wasEngaged = creature.state == CreatureState::Chase || creature.state == CreatureState::Attack ||
|
||||
creature.state == CreatureState::Flee;
|
||||
float triggerRadius = wasEngaged ? stats.disengageRadius : stats.alertRadius;
|
||||
bool engaged = hasThreat && distToThreat <= triggerRadius;
|
||||
|
||||
if (isHostile(creature.species)) {
|
||||
creature.state = !engaged ? CreatureState::Wander
|
||||
: (distToThreat <= stats.attackRange ? CreatureState::Attack : CreatureState::Chase);
|
||||
} else {
|
||||
creature.state = engaged ? CreatureState::Flee : CreatureState::Wander;
|
||||
}
|
||||
|
||||
Vec3 wishDir{0.0f, 0.0f, 0.0f};
|
||||
float speed = 0.0f;
|
||||
|
||||
switch (creature.state) {
|
||||
case CreatureState::Idle:
|
||||
break;
|
||||
case CreatureState::Wander: {
|
||||
creature.stateTimer -= dt;
|
||||
if (creature.stateTimer <= 0.0f || length(creature.body.wanderDir) < 0.1f) {
|
||||
creature.body.wanderDir = randomHeading(creature.rng);
|
||||
creature.stateTimer = 1.5f + unitFloat(creature.rng) * 2.5f;
|
||||
}
|
||||
wishDir = creature.body.wanderDir;
|
||||
speed = stats.walkSpeed;
|
||||
break;
|
||||
}
|
||||
case CreatureState::Chase: {
|
||||
wishDir = pathTowardWishDir(world, creature.body, threatPos, config.physics.path,
|
||||
creature.body.wanderDir);
|
||||
creature.body.wanderDir = wishDir;
|
||||
speed = stats.walkSpeed * stats.sprintMultiplier;
|
||||
break;
|
||||
}
|
||||
case CreatureState::Flee: {
|
||||
Vec3 away{creature.body.position.x - threatPos.x, 0.0f,
|
||||
creature.body.position.z - threatPos.z};
|
||||
away = normalize(away);
|
||||
if (length(away) < 0.1f) away = randomHeading(creature.rng);
|
||||
Vec3 fleeTarget = creature.body.position + away * 8.0f;
|
||||
wishDir = pathTowardWishDir(world, creature.body, fleeTarget, config.physics.path, away);
|
||||
creature.body.wanderDir = wishDir;
|
||||
speed = stats.walkSpeed * stats.sprintMultiplier;
|
||||
break;
|
||||
}
|
||||
case CreatureState::Attack:
|
||||
wishDir = {0.0f, 0.0f, 0.0f};
|
||||
speed = 0.0f;
|
||||
break;
|
||||
}
|
||||
|
||||
MobConfig physics = config.physics;
|
||||
physics.moveSpeed = speed;
|
||||
stepMob(world, creature.body, wishDir, physics, dt);
|
||||
}
|
||||
|
||||
bool damageCreature(Creature& creature, float amount) {
|
||||
if (!creature.alive) return false;
|
||||
creature.health -= amount;
|
||||
if (creature.health <= 0.0f) {
|
||||
creature.health = 0.0f;
|
||||
creature.alive = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool creatureCanAttack(const Creature& creature, const Vec3& targetPos) {
|
||||
if (!creature.alive || creature.attackCooldownRemaining > 0.0f) return false;
|
||||
const SpeciesStats& stats = speciesStats(creature.species);
|
||||
return horizontalDistance(creature.body.position, targetPos) <= stats.attackRange;
|
||||
}
|
||||
|
||||
void creatureAttack(Creature& creature) {
|
||||
creature.attackCooldownRemaining = speciesStats(creature.species).attackCooldown;
|
||||
}
|
||||
|
||||
std::vector<DropEntry> dropsFor(Species species) {
|
||||
switch (species) {
|
||||
case Species::Sheep:
|
||||
return {{ItemId::RawMutton, 1, 2, 1.0f}};
|
||||
case Species::Cow:
|
||||
return {{ItemId::RawBeef, 1, 3, 1.0f}};
|
||||
case Species::Pig:
|
||||
return {{ItemId::RawPorkchop, 1, 3, 1.0f}};
|
||||
case Species::Zombie:
|
||||
return {{ItemId::RottenFlesh, 0, 2, 0.8f}};
|
||||
case Species::Skeleton:
|
||||
return {{ItemId::Stick, 0, 2, 0.7f}};
|
||||
case Species::Creeper:
|
||||
return {{ItemId::Gunpowder, 0, 2, 1.0f}};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ItemSlot> rollDrops(Species species, uint32_t& rng) {
|
||||
std::vector<ItemSlot> drops;
|
||||
for (const DropEntry& entry : dropsFor(species)) {
|
||||
if (unitFloat(rng) > entry.chance) continue;
|
||||
int spread = entry.maxCount > entry.minCount
|
||||
? static_cast<int>(xorshift(rng) % static_cast<uint32_t>(entry.maxCount - entry.minCount + 1))
|
||||
: 0;
|
||||
int count = entry.minCount + spread;
|
||||
if (count > 0) drops.push_back(ItemSlot{entry.item, count, 0});
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
|
||||
int mobSpawnLightLevel(const World& world, const Vec3i& cell, int searchRadius) {
|
||||
bool skyOpen = true;
|
||||
for (int y = cell.y + 1; y <= cell.y + 64; ++y) {
|
||||
if (world.solidAt(cell.x, y, cell.z)) {
|
||||
skyOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int sky = skyOpen ? fullSkyLight : 0;
|
||||
|
||||
int emitterBest = 0;
|
||||
for (int dx = -searchRadius; dx <= searchRadius; ++dx) {
|
||||
for (int dy = -searchRadius; dy <= searchRadius; ++dy) {
|
||||
for (int dz = -searchRadius; dz <= searchRadius; ++dz) {
|
||||
int manhattan = std::abs(dx) + std::abs(dy) + std::abs(dz);
|
||||
if (manhattan > searchRadius) continue;
|
||||
Block block = world.blockAt(cell.x + dx, cell.y + dy, cell.z + dz);
|
||||
int emission = blockLightEmission(block);
|
||||
if (emission <= 0) continue;
|
||||
int level = emission - manhattan;
|
||||
if (level > emitterBest) emitterBest = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return std::max(sky, emitterBest);
|
||||
}
|
||||
|
||||
bool speciesAllowsBiome(Species species, Biome biome) {
|
||||
switch (species) {
|
||||
case Species::Sheep:
|
||||
case Species::Cow:
|
||||
case Species::Pig:
|
||||
return biome == Biome::Plains || biome == Biome::Forest;
|
||||
case Species::Zombie:
|
||||
case Species::Skeleton:
|
||||
return biome != Biome::Ocean;
|
||||
case Species::Creeper:
|
||||
return biome != Biome::Ocean && biome != Biome::Beach;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool canSpawnAt(const World& world, const Vec3i& cell, Species species, Biome biome,
|
||||
const Vec3& playerPos, int currentPopulationInArea, const SpawnConfig& config) {
|
||||
if (currentPopulationInArea >= config.maxPerArea) return false;
|
||||
if (!speciesAllowsBiome(species, biome)) return false;
|
||||
if (!isStandable(world, cell)) return false;
|
||||
|
||||
float dist = horizontalDistance(
|
||||
{static_cast<float>(cell.x) + 0.5f, 0.0f, static_cast<float>(cell.z) + 0.5f}, playerPos);
|
||||
if (dist < config.minPlayerDistance || dist > config.maxPlayerDistance) return false;
|
||||
|
||||
int light = mobSpawnLightLevel(world, cell);
|
||||
const SpeciesStats& stats = speciesStats(species);
|
||||
if (isHostile(species)) {
|
||||
if (light > stats.spawnLightMax) return false;
|
||||
} else {
|
||||
if (light < stats.spawnLightMin) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
SpawnAttempt trySpawn(const World& world, const Vec3i& cell, Biome biome, const Vec3& playerPos,
|
||||
int currentPopulationInArea, uint32_t& rng, const SpawnConfig& config) {
|
||||
std::array<Species, static_cast<std::size_t>(Species::Count)> candidates{};
|
||||
int candidateCount = 0;
|
||||
for (int i = 0; i < static_cast<int>(Species::Count); ++i) {
|
||||
Species species = static_cast<Species>(i);
|
||||
if (canSpawnAt(world, cell, species, biome, playerPos, currentPopulationInArea, config))
|
||||
candidates[static_cast<std::size_t>(candidateCount++)] = species;
|
||||
}
|
||||
if (candidateCount == 0) return {};
|
||||
|
||||
uint32_t pick = xorshift(rng) % static_cast<uint32_t>(candidateCount);
|
||||
SpawnAttempt attempt;
|
||||
attempt.spawned = true;
|
||||
attempt.species = candidates[pick];
|
||||
attempt.position = {static_cast<float>(cell.x) + 0.5f, static_cast<float>(cell.y),
|
||||
static_cast<float>(cell.z) + 0.5f};
|
||||
return attempt;
|
||||
}
|
||||
|
||||
bool advanceSpawnCycle(SpawnCycle& cycle, float dt) {
|
||||
cycle.timer += dt;
|
||||
if (cycle.timer < cycle.interval) return false;
|
||||
cycle.timer -= cycle.interval;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace vf
|
||||
Reference in new issue
Block a user