Files
brandling-safari/server/Creatures.cpp
Gabe 238cd9c6c3 Brandling Safari: LAN safari-zone creature catching on the HD-2D engine
- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass /
  water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon
- Client: title / join / character creator / play screens, prediction + reconciliation,
  interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection)
- Shared deterministic zone generator, movement, protocol, roster, appearance
- Paper-doll character art pipeline (PixelLab base walk + per-item animation edits)
- Brandling roster from the company list (logo colors, jev types), Blender safari props
- Headless test bot
2026-10-02 15:20:27 -05:00

560 lines
20 KiB
C++

// Wild creature population and behaviour, tall-grass encounters, captures and
// party followers.
#include "Game.h"
#include "Log.h"
#include <algorithm>
#include <cmath>
#include <ctime>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
constexpr int kWanderers = 16;
constexpr int kSwimmers = 5;
constexpr int kMaxEmergedPerPlayer = 4;
constexpr float kWanderSpeed = 1.4f;
constexpr float kSwimSpeed = 0.35f;
constexpr float kFleeSpeed = 4.5f;
} // namespace
Creature& Game::addCreature(uint16_t species, glm::vec2 pos, COrigin origin) {
uint32_t id = nextEntity_++;
Creature& c = creatures_[id];
c.id = id;
c.species = species;
c.origin = origin;
c.pos = pos;
c.y = zone_.groundHeight(pos);
c.yaw = rng_.range(-kPi, kPi);
return c;
}
void Game::removeCreature(uint32_t id) {
auto it = creatures_.find(id);
if (it != creatures_.end()) it->second.dead = true;
}
float Game::nearestPlayerDist(glm::vec2 p, Player** who) {
float best = 1e9f;
if (who) *who = nullptr;
for (auto& [peer, pl] : players_) {
if (!pl->spawned) continue;
float d = glm::length(pl->move.pos - p);
if (d < best) {
best = d;
if (who) *who = pl.get();
}
}
return best;
}
void Game::announceAppeared(const Creature& c) {
for (auto& [peer, p] : players_) {
if (!p->spawned || glm::length(p->move.pos - c.pos) > 10.0f) continue;
auto it = creatures_.find(c.id);
if (it != creatures_.end()) it->second.seenBy.insert(p->entity);
noteSeen(*p, c.species);
}
}
void Game::faceNearestPlayer(Creature& c) {
Player* who = nullptr;
float d = nearestPlayerDist(c.pos, &who);
if (who && d < 12.0f && d > 0.05f) c.yaw = yawFromDir(who->move.pos - c.pos);
}
// Grass creatures that popped up for this player and are still around it (stale ones left
// behind in another patch do not block new encounters).
int Game::emergedCount(const Player& p) const {
int n = 0;
for (auto& [id, c] : creatures_)
n += !c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Grass && c.emergedFor == p.entity &&
glm::length(c.pos - p.move.pos) < 15.0f;
return n;
}
// ------------------------------------------------------------------ population
bool Game::spawnWanderer() {
uint16_t species;
if (!tables_.pick(rng_, SpawnHabitat::Land, species)) return false;
glm::vec2 spot, fallback;
bool have = false, haveFallback = false;
for (int i = 0; i < 8 && !have; ++i) {
glm::vec2 p;
if (!zone_.randomWanderSpot(rng_, p)) continue;
if (nearestPlayerDist(p) > 14.0f) spot = p, have = true;
else if (!haveFallback) fallback = p, haveFallback = true;
}
if (!have && !haveFallback) return false;
Creature& c = addCreature(species, have ? spot : fallback, COrigin::Wanderer);
c.mode = CMode::Wander;
c.anim = Anim::Idle;
c.timer = rng_.range(0.5f, 4.0f);
c.lifetime = rng_.range(120.0f, 300.0f);
announceAppeared(c);
return true;
}
bool Game::spawnSwimmer(bool announce) {
uint16_t species;
glm::vec2 p;
if (!tables_.pick(rng_, SpawnHabitat::Water, species) || !zone_.randomWater(rng_, p, true)) return false;
Creature& c = addCreature(species, p, COrigin::Swimmer);
c.y = Zone::kWaterLevel;
c.lifetime = 1e9f; // swimmers rotate by diving
if (announce) {
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
GameEvent s;
s.kind = EventKind::Splash;
s.p = glm::vec3(p.x, Zone::kWaterLevel, p.y);
sendEvent(s);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.i = 1;
e.p = s.p;
sendEvent(e);
} else {
c.mode = CMode::Swim;
c.anim = Anim::Swim;
}
announceAppeared(c);
return true;
}
void Game::maintainPopulation(float dt) {
int wanderers = 0, swimmers = 0;
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Wild) continue;
wanderers += c.origin == COrigin::Wanderer;
swimmers += c.origin == COrigin::Swimmer;
}
wanderSpawnTimer_ -= dt;
if (wanderers < kWanderers && wanderSpawnTimer_ <= 0.0f) {
spawnWanderer();
wanderSpawnTimer_ = 1.5f;
}
// One swimmer dives (Splash, despawn) every 10-25 s and resurfaces elsewhere a few seconds later.
swimmerTimer_ -= dt;
if (swimmerTimer_ <= 0.0f) {
swimmerTimer_ = rng_.range(10.0f, 25.0f);
std::vector<uint32_t> candidates;
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Swimmer && c.mode == CMode::Swim)
candidates.push_back(id);
if (!candidates.empty()) {
Creature& c = creatures_[candidates[size_t(rng_.range(0, int(candidates.size()) - 1))]];
GameEvent s;
s.kind = EventKind::Splash;
s.p = glm::vec3(c.pos.x, Zone::kWaterLevel, c.pos.y);
sendEvent(s);
removeCreature(c.id);
--swimmers;
swimmerRespawn_ = rng_.range(2.0f, 5.0f);
}
}
if (swimmerRespawn_ > 0.0f) {
swimmerRespawn_ -= dt;
if (swimmerRespawn_ <= 0.0f && swimmers < kSwimmers) spawnSwimmer(true), ++swimmers;
} else if (swimmers < kSwimmers) {
swimmerRespawn_ = rng_.range(2.0f, 5.0f); // replace caught swimmers
}
}
// ------------------------------------------------------------------ tall grass
void Game::grassEncounters(Player& p) {
while (p.grassDist >= 0.5f) {
p.grassDist -= 0.5f;
float chance = 0.07f * (p.move.running ? 2.2f : 1.0f);
uint16_t species;
glm::vec2 spot;
if (rng_.chance(chance) && emergedCount(p) < kMaxEmergedPerPlayer &&
tables_.pick(rng_, SpawnHabitat::Land, species) &&
zone_.randomTallGrassNear(rng_, p.move.pos, 2.0f, 6.0f, spot)) {
Creature& c = addCreature(species, spot, COrigin::Grass);
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
c.emergedFor = p.entity;
c.yaw = yawFromDir(p.move.pos - spot);
c.lifetime = rng_.range(60.0f, 120.0f);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.i = 0;
e.p = glm::vec3(spot.x, c.y, spot.y);
sendEvent(e);
announceAppeared(c);
const Species* sp = roster_.get(species);
logf("a wild %s (entity %u) emerged near %s", sp ? sp->name.c_str() : "?", c.id, p.row.name.c_str());
continue;
}
if (p.rustleCooldown <= 0.0f && rng_.chance(0.25f) &&
zone_.randomTallGrassNear(rng_, p.move.pos, 1.5f, 6.0f, spot)) {
p.rustleCooldown = rng_.range(0.8f, 1.6f);
GameEvent e;
e.kind = EventKind::Rustle;
e.p = glm::vec3(spot.x, zone_.groundHeight(spot), spot.y);
sendEvent(e);
}
}
}
// ------------------------------------------------------------------ behaviour
bool Game::pickWanderTarget(Creature& c) {
for (int i = 0; i < 6; ++i) {
glm::vec2 t;
if (c.origin == COrigin::Grass && zone_.inTallGrass(c.pos)) {
if (!zone_.randomTallGrassNear(rng_, c.pos, 2.0f, 6.0f, t)) continue;
} else {
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 6.0f);
t = c.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
}
bool ok = zone_.creatureWalkable(t, false);
for (int k = 1; k < 5 && ok; ++k) ok = zone_.creatureWalkable(glm::mix(c.pos, t, float(k) / 5.0f), false);
if (!ok) continue;
c.target = t;
c.walking = true;
return true;
}
return false;
}
void Game::wander(Creature& c, float dt, float speed) {
if (c.walking) {
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, speed * dt);
if (len < 0.05f || !zone_.creatureWalkable(next, false)) {
c.walking = false;
c.timer = rng_.range(1.0f, 4.0f);
c.anim = Anim::Idle;
} else {
c.pos = next;
c.yaw = yawFromDir(d);
c.anim = Anim::Walk;
}
} else {
c.anim = Anim::Idle;
c.timer -= dt;
if (c.timer <= 0.0f && !pickWanderTarget(c)) c.timer = 1.0f;
}
c.y = zone_.groundHeight(c.pos);
}
void Game::updateWild(Creature& c, float dt) {
// dex: wanderers (and anything else) register as seen once a player comes within 6 units
for (auto& [peer, p] : players_) {
if (!p->spawned || c.seenBy.count(p->entity) || glm::length(p->move.pos - c.pos) > 6.0f) continue;
c.seenBy.insert(p->entity);
noteSeen(*p, c.species);
}
switch (c.mode) {
case CMode::Emerge:
c.timer -= dt;
if (c.timer <= 0.0f) {
if (c.origin == COrigin::Swimmer) {
c.mode = CMode::Swim;
c.anim = Anim::Swim;
} else {
c.mode = CMode::Alert;
c.anim = Anim::Alert;
c.timer = 2.5f;
Player* who = byEntity(c.emergedFor);
if (who) c.yaw = yawFromDir(who->move.pos - c.pos);
else faceNearestPlayer(c);
}
}
break;
case CMode::Alert:
c.anim = Anim::Alert;
faceNearestPlayer(c);
c.timer -= dt;
if (c.timer <= 0.0f) {
c.hookBonus = false;
c.walking = false;
c.timer = rng_.range(0.5f, 2.0f);
bool water = zone_.isWater(c.pos);
c.mode = water ? CMode::Swim : CMode::Wander;
c.anim = water ? Anim::Swim : Anim::Idle;
}
break;
case CMode::Wander:
wander(c, dt, kWanderSpeed);
break;
case CMode::Swim: {
c.anim = Anim::Swim;
c.y = Zone::kWaterLevel;
if (!c.walking) {
glm::vec2 t;
if (zone_.randomWaterNear(rng_, c.pos, 3.0f, t) && zone_.isWater(glm::mix(c.pos, t, 0.5f))) {
c.target = t;
c.walking = true;
}
} else {
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, kSwimSpeed * dt);
if (len < 0.05f || !zone_.isWater(next)) c.walking = false;
else c.pos = next, c.yaw = yawFromDir(d);
}
break;
}
case CMode::Capturing:
c.captureTimer -= dt;
if (c.captureTimer <= 0.0f) resolveCapture(c);
break;
case CMode::Flee: {
c.timer -= dt;
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
if (len > 0.05f) {
glm::vec2 next = c.pos + d / len * std::min(len, kFleeSpeed * dt);
bool water = zone_.isWater(c.pos);
if (zone_.creatureWalkable(next, water)) c.pos = next;
c.yaw = yawFromDir(d);
}
if (!zone_.isWater(c.pos)) c.y = zone_.groundHeight(c.pos);
if (c.timer <= 0.0f) removeCreature(c.id);
break;
}
case CMode::Hooked: {
c.timer -= dt;
float t = std::clamp(1.0f - c.timer / 1.0f, 0.0f, 1.0f);
c.pos = glm::mix(c.arcFrom, c.arcTo, t);
float groundY = zone_.isWater(c.arcTo) ? Zone::kWaterLevel : zone_.groundHeight(c.arcTo);
c.y = glm::mix(c.arcFromY, groundY, t) + 1.6f * std::sin(kPi * t);
if (glm::length(c.arcTo - c.arcFrom) > 0.05f) c.yaw = yawFromDir(c.arcTo - c.arcFrom);
if (c.timer <= 0.0f) {
c.pos = c.arcTo;
c.y = groundY;
c.mode = CMode::Alert;
c.anim = Anim::Alert;
c.timer = 6.0f;
c.hookBonus = true;
c.owner = 0;
faceNearestPlayer(c);
}
break;
}
case CMode::Follow: break;
}
// Idle creatures nobody is near eventually leave (captures/hooks/flee never time out here).
if (c.mode == CMode::Wander || c.mode == CMode::Swim) {
c.lifetime -= dt;
if (c.lifetime <= 0.0f && nearestPlayerDist(c.pos) > 12.0f) removeCreature(c.id);
}
}
void Game::updateCreatures(float dt) {
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Wild) updateWild(c, dt);
}
// ------------------------------------------------------------------ capture
void Game::resolveCapture(Creature& c) {
Player* p = byEntity(c.owner);
const Species* sp = roster_.get(c.species);
if (!p || !sp) { // thrower left: the capsule pops open
c.mode = c.resumeMode;
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
c.timer = 2.5f;
c.owner = 0;
return;
}
GameEvent r;
r.kind = EventKind::CaptureResult;
r.a = p->entity;
r.b = c.id;
r.i = c.captureSuccess ? 1 : 0;
r.j = c.wobbles;
r.p = glm::vec3(c.pos.x, c.y, c.pos.y);
sendEvent(r);
if (c.captureSuccess) {
int party = int(std::count_if(p->creatures.begin(), p->creatures.end(), [](auto& o) { return o.inParty; }));
bool inParty = party < kMaxParty;
bool first = p->caught.count(c.species) == 0;
OwnedCreature oc;
oc.species = c.species;
oc.inParty = inParty;
oc.caughtAt = int64_t(std::time(nullptr));
oc.uid = db_.insertCreature(p->row.id, oc.species, oc.caughtAt, inParty);
if (!oc.uid) {
removeCreature(c.id);
sendToast(*p, "The capsule malfunctioned and the creature escaped");
return;
}
p->creatures.push_back(oc);
p->caught.insert(c.species);
p->seen.insert(c.species);
db_.markCaught(p->row.id, c.species);
sendProfile(*p);
GameEvent e;
e.kind = EventKind::Caught;
e.a = p->entity;
e.b = c.species;
e.i = first ? 1 : 0;
e.p = r.p;
sendEvent(e);
logf("%s caught %s (uid %u, %s)%s", p->row.name.c_str(), sp->name.c_str(), oc.uid,
inParty ? "party" : "box", first ? " - new species!" : "");
if (inParty) {
// the wild entity turns into the follower (same entity id)
c.kind = EntityKind::Follower;
c.origin = COrigin::Caught;
c.mode = CMode::Follow;
c.anim = Anim::Idle;
c.owner = p->entity;
c.uid = oc.uid;
c.y = zone_.groundHeight(c.pos);
c.hookBonus = false;
syncFollowers(*p);
} else {
removeCreature(c.id);
}
return;
}
++c.failedThrows;
float flee = sp->fleeRate * (c.failedThrows >= 2 ? 1.5f : 1.0f);
if (rng_.chance(flee)) {
GameEvent e;
e.kind = EventKind::Fled;
e.a = c.id;
e.p = r.p;
sendEvent(e);
c.mode = CMode::Flee;
c.anim = Anim::Flee;
c.timer = 1.5f;
glm::vec2 away = c.pos - p->move.pos;
if (glm::length(away) < 0.05f) away = dirFromYaw(c.yaw);
c.target = c.pos + glm::normalize(away) * 8.0f;
c.yaw = yawFromDir(away);
c.owner = p->entity;
logf("%s fled from %s", sp->name.c_str(), p->row.name.c_str());
} else {
c.mode = c.resumeMode;
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
c.timer = 2.5f;
c.owner = 0;
c.walking = false;
if (c.mode == CMode::Alert) c.yaw = yawFromDir(p->move.pos - c.pos);
}
}
// ------------------------------------------------------------------ followers
void Game::syncFollowers(Player& p) {
std::vector<const OwnedCreature*> party;
for (const OwnedCreature& o : p.creatures)
if (o.inParty && party.size() < size_t(kMaxParty)) party.push_back(&o);
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
auto it = std::find_if(party.begin(), party.end(), [&](const OwnedCreature* o) { return o->uid == c.uid; });
if (it == party.end()) removeCreature(id);
else c.order = uint8_t(it - party.begin());
}
for (size_t k = 0; k < party.size(); ++k) {
bool exists = false;
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Follower && c.owner == p.entity && c.uid == party[k]->uid) exists = true;
if (exists) continue;
// trail points are >= 0.15 apart (seeded 0.3 apart): ~3 points back per chain slot
size_t back = p.trail.empty() ? 0 : std::min(p.trail.size() - 1, (k + 1) * 3);
Creature& c = addCreature(party[k]->species, p.trail.empty() ? p.move.pos : p.trail[back], COrigin::Caught);
c.kind = EntityKind::Follower;
c.mode = CMode::Follow;
c.anim = Anim::Idle;
c.owner = p.entity;
c.uid = party[k]->uid;
c.order = uint8_t(k);
c.yaw = p.move.yaw;
c.lifetime = 1e9f;
}
}
void Game::updateFollowers(Player& p, float dt) {
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
// target: ~0.95 * (order + 1) units back along the breadcrumb trail
float want = 0.95f * float(c.order + 1);
glm::vec2 prev = p.move.pos, target = prev;
float acc = 0.0f;
bool found = false;
for (const glm::vec2& t : p.trail) {
float seg = glm::length(t - prev);
if (seg > 1e-4f && acc + seg >= want) {
target = prev + (t - prev) * ((want - acc) / seg);
found = true;
break;
}
acc += seg;
prev = t;
}
if (!found) target = prev;
glm::vec2 d = target - c.pos;
float len = glm::length(d);
float moved = 0.0f;
if (len > 8.0f) {
c.pos = target;
} else if (len > 0.08f) {
float speed = (p.move.running ? kRunSpeed : kWalkSpeed) * 1.15f;
moved = std::min(len, speed * dt);
c.pos += d / len * moved;
c.yaw = yawFromDir(d);
}
float v = moved / dt;
c.anim = v < 0.3f ? Anim::Idle : v > kWalkSpeed * 1.1f ? Anim::Run : Anim::Walk;
c.y = zone_.groundHeight(c.pos);
}
}
Creature* Game::spawnForConsole(uint16_t species) {
Player* first = nullptr;
for (auto& [peer, p] : players_)
if (p->spawned) {
first = p.get();
break;
}
if (!first) return nullptr;
glm::vec2 spot;
bool ok = zone_.randomTallGrassNear(rng_, first->move.pos, 2.0f, 5.0f, spot);
for (int i = 0; i < 40 && !ok; ++i) {
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 5.0f);
spot = first->move.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
ok = zone_.creatureWalkable(spot, false);
}
if (!ok) spot = first->move.pos + dirFromYaw(first->move.yaw) * 2.0f;
Creature& c = addCreature(species, spot, COrigin::Console);
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
c.emergedFor = first->entity;
c.yaw = yawFromDir(first->move.pos - spot);
c.lifetime = rng_.range(120.0f, 300.0f);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.p = glm::vec3(spot.x, c.y, spot.y);
sendEvent(e);
announceAppeared(c);
return &c;
}
} // namespace bs