// Wild creature population and behaviour, tall-grass encounters, captures and // party followers. #include "Game.h" #include "Log.h" #include #include #include 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 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 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