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
This commit is contained in:
Gabe
2026-10-02 15:20:27 -05:00
commit 238cd9c6c3
3409 changed files with 100493 additions and 0 deletions

868
server/Game.cpp Normal file
View File

@@ -0,0 +1,868 @@
#include "Game.h"
#include "Log.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <ctime>
#include <sstream>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
constexpr size_t kSnapshotBudget = 1200;
constexpr size_t kSnapshotHeader = 1 + 4 + 4 + 4 + 1 + 4 + 2;
constexpr size_t kEntityBytes = 4 + 1 + 1 + 2 + 4 + 12 + 4 + 1;
constexpr size_t kMaxSnapshotEntities = (kSnapshotBudget - kSnapshotHeader) / kEntityBytes; // 40
constexpr float kCullDist = 45.0f;
int64_t unixNow() { return int64_t(std::time(nullptr)); }
bool validToken(const std::string& t) {
if (t.size() < 16 || t.size() > 64) return false;
return std::all_of(t.begin(), t.end(), [](char c) { return std::isxdigit(uint8_t(c)) != 0; });
}
std::string lower(std::string s) {
for (char& c : s) c = char(std::tolower(uint8_t(c)));
return s;
}
std::string trim(const std::string& s) {
size_t a = s.find_first_not_of(" \t\r\n");
if (a == std::string::npos) return {};
size_t b = s.find_last_not_of(" \t\r\n");
return s.substr(a, b - a + 1);
}
// Printable ASCII only, single spaces, at most 16 characters.
std::string cleanName(const std::string& in) {
std::string out;
for (char ch : in) {
uint8_t c = uint8_t(ch);
if (c < 0x20 || c > 0x7e) continue;
if (c == ' ' && (out.empty() || out.back() == ' ')) continue;
out.push_back(char(c));
if (out.size() >= 16) break;
}
out = trim(out);
return out.empty() ? "Ranger" : out;
}
// Control characters become spaces; trimmed; cut to 120 bytes on a UTF-8 boundary.
std::string cleanChat(const std::string& in) {
std::string s = in;
for (char& ch : s)
if (uint8_t(ch) < 0x20 || uint8_t(ch) == 0x7f) ch = ' ';
s = trim(s);
if (s.size() > 120) {
size_t n = 120;
while (n > 0 && (uint8_t(s[n]) & 0xC0) == 0x80) --n;
s = trim(s.substr(0, n));
}
return s;
}
float wrapAngle(float a) {
while (a > kPi) a -= 2.0f * kPi;
while (a < -kPi) a += 2.0f * kPi;
return a;
}
} // namespace
Game::Game(net::Host& host, Database& db, Zone& zone, const Roster& roster, const CharacterManifest& manifest,
GameConfig cfg)
: host_(host), db_(db), zone_(zone), roster_(roster), manifest_(manifest), cfg_(std::move(cfg)),
rng_(uint64_t(unixNow()) ^ 0xB5A1u) {
std::string v;
if (cfg_.hour >= 0.0f) hour_ = std::fmod(cfg_.hour, 24.0f);
else if (db_.getWorld("hour", v)) hour_ = std::clamp(std::strtof(v.c_str(), nullptr), 0.0f, 23.999f);
Weather w = Weather::Clear;
if (cfg_.weather >= 0) weather_ = Weather(cfg_.weather);
else if (db_.getWorld("weather", v) && parseWeather(v, w)) weather_ = w;
weatherTimer_ = rng_.range(180.0f, 480.0f);
if (cfg_.weather < 0 && db_.getWorld("weather_timer", v))
weatherTimer_ = std::clamp(std::strtof(v.c_str(), nullptr), 5.0f, 480.0f);
henryYaw_ = zone_.henryYaw;
tables_.update(roster_, zone_.id(), hour_, weather_);
for (int i = 0; i < 16; ++i) spawnWanderer();
for (int i = 0; i < 5; ++i) spawnSwimmer(false);
logf("world: %s seed %u, %.2fh, %s, %zu species, %zu wild", zone_.id().c_str(), zone_.seed(), hour_,
weatherName(weather_), roster_.size(), creatures_.size());
}
// ------------------------------------------------------------------ sessions
Player* Game::byPeer(uint32_t peer) {
auto it = players_.find(peer);
return it == players_.end() ? nullptr : it->second.get();
}
Player* Game::byEntity(uint32_t entity) {
if (!entity) return nullptr;
for (auto& [peer, p] : players_)
if (p->spawned && p->entity == entity) return p.get();
return nullptr;
}
int Game::playerCount() const {
int n = 0;
for (auto& [peer, p] : players_) n += p->spawned;
return n;
}
void Game::onConnect(uint32_t peer) {
auto p = std::make_unique<Player>();
p->peer = peer;
players_[peer] = std::move(p);
logf("peer %u connected from %s", peer, host_.address(peer).c_str());
}
void Game::onDisconnect(uint32_t peer) {
Player* p = byPeer(peer);
if (!p) return;
if (p->spawned) logf("%s (entity %u) left", p->row.name.c_str(), p->entity);
else logf("peer %u disconnected", peer);
despawnPlayer(*p);
players_.erase(peer);
}
void Game::reject(uint32_t peer, const std::string& reason) {
Writer w(Msg::Reject);
w.str(reason);
host_.send(peer, net::Reliable, w.data());
if (Player* p = byPeer(peer)) despawnPlayer(*p);
host_.disconnect(peer);
players_.erase(peer);
logf("rejected peer %u: %s", peer, reason.c_str());
}
void Game::onReceive(uint32_t peer, const std::vector<uint8_t>& data) {
Player* p = byPeer(peer);
if (!p || data.empty()) return;
Reader r(data);
Msg m = Msg(r.u8());
if (m == Msg::Hello) return handleHello(*p, r);
if (!p->helloDone) return;
if (m == Msg::CreateCharacter) return handleCreate(*p, r);
if (!p->spawned) return;
switch (m) {
case Msg::Input: handleInput(*p, r); break;
case Msg::Action: handleAction(*p, r); break;
case Msg::Chat: handleChat(*p, r); break;
case Msg::DialogueDone: handleDialogueDone(*p, r); break;
default: break;
}
}
void Game::handleHello(Player& p, Reader& r) {
HelloMsg h;
h.read(r);
if (!r.ok() || p.helloDone) return;
uint32_t peer = p.peer;
if (h.version != kProtocolVersion) {
return reject(peer, "Version mismatch: the server runs protocol v" + std::to_string(kProtocolVersion) +
", your game uses v" + std::to_string(h.version) + ". Please update.");
}
if (!validToken(h.token)) return reject(peer, "Invalid account token.");
std::string token = lower(h.token);
// Same account connecting again: the older connection is kicked.
std::vector<uint32_t> older;
for (auto& [op, other] : players_)
if (op != peer && other->helloDone && other->row.token == token) older.push_back(op);
for (uint32_t op : older) reject(op, "Signed in from another location.");
p.helloDone = true;
p.row.token = token;
p.hasCharacter = db_.loadPlayer(token, p.row);
if (p.hasCharacter) p.entity = nextEntity_++;
WelcomeMsg wm;
wm.entityId = p.entity;
wm.hasCharacter = p.hasCharacter;
wm.zoneId = zone_.id();
wm.zoneSeed = zone_.seed();
wm.serverName = cfg_.serverName;
Writer w(Msg::Welcome);
wm.write(w);
send(p, w);
logf("peer %u hello (%s account)", peer, p.hasCharacter ? "returning" : "new");
if (p.hasCharacter) spawnPlayer(p);
}
void Game::handleCreate(Player& p, Reader& r) {
CreateCharacterMsg m;
m.read(r);
if (!r.ok() || p.hasCharacter) return;
p.row.name = cleanName(m.name);
p.row.look = m.look;
manifest_.sanitize(p.row.look);
p.row.x = zone_.spawn.x;
p.row.z = zone_.spawn.y;
p.row.yaw = zone_.spawnYaw;
p.row.capsules = 0;
p.row.introDone = false;
p.row.createdAt = p.row.lastSeen = unixNow();
p.row.id = db_.createPlayer(p.row);
if (!p.row.id) return reject(p.peer, "The server could not create your character.");
p.hasCharacter = true;
p.entity = nextEntity_++;
logf("new character \"%s\" (account %lld)", p.row.name.c_str(), (long long)p.row.id);
spawnPlayer(p);
}
void Game::spawnPlayer(Player& p) {
p.creatures = db_.loadCreatures(p.row.id);
db_.loadDex(p.row.id, p.seen, p.caught);
glm::vec2 pos(p.row.x, p.row.z);
bool ok = std::isfinite(pos.x) && std::isfinite(pos.y) && zone_.walkable(pos, pos) &&
(zone_.onDock(pos) || !zone_.isWater(pos));
if (!ok) pos = zone_.spawn;
p.move = MoveState{};
p.move.pos = pos;
p.move.yaw = std::isfinite(p.row.yaw) ? p.row.yaw : zone_.spawnYaw;
p.spawned = true;
p.lastSeq = 0;
p.inputBudget = 2.0f;
p.trail.clear();
p.trail.push_back(pos);
// Seed the trail behind the player so followers start in a line.
glm::vec2 back = -dirFromYaw(p.move.yaw);
for (float d = 0.3f; d <= 6.0f; d += 0.3f) p.trail.push_back(pos + back * d);
sendProfile(p);
Writer self(Msg::PlayerInfo);
playerInfo(p).write(self);
broadcast(self);
for (auto& [peer, o] : players_) {
if (o.get() == &p || !o->spawned) continue;
Writer w(Msg::PlayerInfo);
playerInfo(*o).write(w);
send(p, w);
}
syncFollowers(p);
if (!p.row.introDone) p.introTimer = 1.2f;
logf("%s spawned as entity %u at (%.1f, %.1f), %d capsules, %zu creatures, intro %s", p.row.name.c_str(),
p.entity, pos.x, pos.y, p.row.capsules, p.creatures.size(), p.row.introDone ? "done" : "pending");
}
void Game::despawnPlayer(Player& p) {
if (!p.spawned) return;
savePlayer(p);
p.spawned = false;
Writer w(Msg::PlayerLeft);
w.u32(p.entity);
broadcast(w);
for (auto& [id, c] : creatures_) {
if (c.dead) continue;
if (c.kind == EntityKind::Follower && c.owner == p.entity) removeCreature(id);
else if (c.owner == p.entity && c.mode != CMode::Capturing) c.owner = 0; // captures resolve as aborted
if (c.emergedFor == p.entity) c.emergedFor = 0;
}
}
// ------------------------------------------------------------------ messaging
void Game::send(const Player& p, const Writer& w, bool reliable) {
host_.send(p.peer, reliable ? net::Reliable : net::Unreliable, w.data());
}
void Game::broadcast(const Writer& w, bool reliable) {
for (auto& [peer, p] : players_)
if (p->spawned) send(*p, w, reliable);
}
PlayerInfoMsg Game::playerInfo(const Player& p) const {
PlayerInfoMsg m;
m.entityId = p.entity;
m.name = p.row.name;
m.look = p.row.look;
return m;
}
void Game::sendProfile(Player& p) {
if (!p.spawned) return;
ProfileMsg m;
m.entityId = p.entity;
m.name = p.row.name;
m.look = p.row.look;
m.capsules = p.row.capsules;
m.introDone = p.row.introDone;
m.seen = uint32_t(p.seen.size());
m.caught = uint32_t(p.caught.size());
m.creatures = p.creatures;
Writer w(Msg::Profile);
m.write(w);
send(p, w);
}
void Game::sendEvent(const GameEvent& e) {
Writer w(Msg::Event);
e.write(w);
broadcast(w);
}
void Game::sendToast(Player& p, const std::string& text) {
Writer w(Msg::Toast);
w.str(text);
send(p, w);
}
void Game::sendDialogue(Player& p, const std::string& id) {
Writer w(Msg::Dialogue);
w.str(id);
send(p, w);
}
Anim Game::playerAnim(const Player& p) const {
if (p.line != LineState::None) return Anim::Fish;
if (p.throwTimer > 0.0f) return Anim::Throw;
if (p.move.moving) return p.move.running ? Anim::Run : Anim::Walk;
return Anim::Idle;
}
void Game::sendSnapshots() {
struct Item {
EntityState s;
glm::vec2 p;
bool always;
};
std::vector<Item> all;
all.reserve(creatures_.size() + players_.size() + 1);
{
EntityState h;
h.id = kHenryEntityId;
h.kind = EntityKind::Npc;
h.species = 0;
h.pos = pos3(zone_.henry);
h.yaw = henryYaw_;
all.push_back({h, zone_.henry, true});
}
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
EntityState s;
s.id = p->entity;
s.kind = EntityKind::Player;
s.anim = playerAnim(*p);
s.pos = pos3(p->move.pos);
s.yaw = p->move.yaw;
all.push_back({s, p->move.pos, true});
}
for (auto& [id, c] : creatures_) {
if (c.dead) continue;
EntityState s;
s.id = c.id;
s.kind = c.kind;
s.anim = c.anim;
s.species = c.species;
s.owner = c.owner;
s.pos = glm::vec3(c.pos.x, c.y, c.pos.y);
s.yaw = c.yaw;
s.order = c.order;
all.push_back({s, c.pos, false});
}
std::vector<std::pair<float, size_t>> near;
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
SnapshotMsg m;
m.tick = tick_;
m.ackInput = p->lastSeq;
m.hour = hour_;
m.weather = weather_;
m.weatherBlend = weatherBlend_;
near.clear();
for (size_t i = 0; i < all.size(); ++i) {
if (all[i].always) {
if (m.entities.size() < kMaxSnapshotEntities) m.entities.push_back(all[i].s);
continue;
}
float d = glm::length(all[i].p - p->move.pos);
if (d <= kCullDist) near.push_back({d, i});
}
std::sort(near.begin(), near.end());
for (auto& [d, i] : near) {
if (m.entities.size() >= kMaxSnapshotEntities) break;
m.entities.push_back(all[i].s);
}
Writer w(Msg::Snapshot);
m.write(w);
send(*p, w, false);
}
}
// ------------------------------------------------------------------ persistence
void Game::savePlayer(Player& p) {
if (!p.hasCharacter || !p.row.id) return;
if (p.spawned) {
p.row.x = p.move.pos.x;
p.row.z = p.move.pos.y;
p.row.yaw = p.move.yaw;
}
p.row.lastSeen = unixNow();
db_.savePlayer(p.row);
p.posDirty = false;
}
void Game::saveWorld() {
char buf[32];
std::snprintf(buf, sizeof buf, "%.4f", hour_);
db_.setWorld("hour", buf);
db_.setWorld("weather", weatherName(weather_));
std::snprintf(buf, sizeof buf, "%.1f", weatherTimer_);
db_.setWorld("weather_timer", buf);
}
void Game::saveAll() {
db_.begin();
for (auto& [peer, p] : players_)
if (p->spawned) savePlayer(*p);
saveWorld();
db_.commit();
}
// ------------------------------------------------------------------ players
void Game::handleInput(Player& p, Reader& r) {
InputMsg m;
m.read(r);
if (!r.ok()) return;
for (const InputCmd& c : m.cmds) {
if (c.seq <= p.lastSeq) continue;
if (p.inputBudget < 1.0f) break; // faster than real time: drop the rest
p.inputBudget -= 1.0f;
applyInput(p, c);
p.lastSeq = c.seq;
}
}
void Game::applyInput(Player& p, const InputCmd& c) {
glm::vec2 prev = p.move.pos;
stepMovement(zone_, p.move, c, kTickDt);
p.idleTicks = 0;
float moved = glm::length(p.move.pos - prev);
if (moved <= 0.0f) return;
p.posDirty = true;
if (zone_.inTallGrass(p.move.pos)) p.grassDist += moved;
if (p.trail.empty() || glm::length(p.move.pos - p.trail.front()) > 0.15f) {
p.trail.push_front(p.move.pos);
if (p.trail.size() > 64) p.trail.pop_back();
}
if (p.line != LineState::None && glm::length(p.move.pos - p.castPos) > 0.6f) cancelLine(p);
}
void Game::cancelLine(Player& p) {
if (p.line == LineState::None) return;
p.line = LineState::None;
p.fishCooldown = 1.0f;
GameEvent e;
e.kind = EventKind::LineLost;
e.a = p.entity;
e.p = glm::vec3(p.bobber.x, Zone::kWaterLevel, p.bobber.y);
sendEvent(e);
}
void Game::noteSeen(Player& p, uint16_t species) {
if (!p.spawned || !p.seen.insert(species).second) return;
db_.markSeen(p.row.id, species);
sendProfile(p);
}
void Game::updatePlayer(Player& p, float dt) {
p.inputBudget = std::min(6.0f, p.inputBudget + 1.0f);
if (++p.idleTicks > 4) p.move.moving = p.move.running = false;
p.throwTimer = std::max(0.0f, p.throwTimer - dt);
p.fishCooldown = std::max(0.0f, p.fishCooldown - dt);
p.rustleCooldown = std::max(0.0f, p.rustleCooldown - dt);
if (p.introTimer > 0.0f) {
p.introTimer -= dt;
if (p.introTimer <= 0.0f) {
p.introTimer = -1.0f;
if (!p.row.introDone) {
p.introOffered = true;
sendDialogue(p, "henry_intro");
}
}
}
if (p.line != LineState::None) {
p.lineTimer -= dt;
if (p.lineTimer <= 0.0f) {
GameEvent e;
e.a = p.entity;
e.p = glm::vec3(p.bobber.x, Zone::kWaterLevel, p.bobber.y);
if (p.line == LineState::Waiting) {
p.line = LineState::Biting;
p.lineTimer = 1.3f;
e.kind = EventKind::Bite;
sendEvent(e);
} else {
cancelLine(p); // too late
}
}
}
grassEncounters(p);
}
// ------------------------------------------------------------------ actions
void Game::handleAction(Player& p, Reader& r) {
ActionMsg a;
a.read(r);
if (!r.ok()) return;
switch (a.kind) {
case ActionKind::Throw: actionThrow(p, a.target); break;
case ActionKind::Cast: actionCast(p, {a.x, a.z}); break;
case ActionKind::Reel: actionReel(p); break;
case ActionKind::Interact: actionInteract(p, a.target); break;
case ActionKind::ToggleParty: actionToggleParty(p, a.target); break;
default: break;
}
}
void Game::actionCast(Player& p, glm::vec2 dir) {
if (p.line != LineState::None || p.fishCooldown > 0.0f) return;
if (!std::isfinite(dir.x) || !std::isfinite(dir.y) || glm::length(dir) < 0.1f) return;
if (zone_.isWater(p.move.pos)) return;
glm::vec2 bobber;
if (!zone_.fishingTarget(p.move.pos, dir, bobber)) {
sendToast(p, "There's no water in reach to cast into");
return;
}
p.line = LineState::Waiting;
p.lineTimer = rng_.range(2.0f, 7.0f);
p.castPos = p.move.pos;
p.bobber = bobber;
p.move.yaw = yawFromDir(glm::normalize(dir));
GameEvent e;
e.kind = EventKind::Cast;
e.a = p.entity;
e.p = glm::vec3(bobber.x, Zone::kWaterLevel, bobber.y);
sendEvent(e);
logf("%s cast a line at (%.1f, %.1f)", p.row.name.c_str(), bobber.x, bobber.y);
}
bool Game::landingSpot(glm::vec2 bobber, glm::vec2 player, glm::vec2& out) const {
glm::vec2 d = player - bobber;
float len = glm::length(d);
if (len > 1e-3f) {
for (float t = 0.25f; t <= len; t += 0.25f) {
glm::vec2 q = bobber + d / len * t;
if (zone_.creatureWalkable(q, false)) {
out = q;
return true;
}
}
}
// Player stands on a dock or the shore is behind them: nearest land around the player.
for (float r = 1.0f; r <= 10.0f; r += 0.5f)
for (int k = 0; k < 24; ++k) {
float a = float(k) / 24.0f * 2.0f * kPi;
glm::vec2 q = player + glm::vec2(std::cos(a), std::sin(a)) * r;
if (zone_.creatureWalkable(q, false)) {
out = q;
return true;
}
}
return false;
}
void Game::actionReel(Player& p) {
if (p.line == LineState::None) return;
if (p.line == LineState::Waiting) {
logf("%s reeled in too early", p.row.name.c_str());
return cancelLine(p);
}
uint16_t species;
tables_.update(roster_, zone_.id(), hour_, weather_);
if (!tables_.pick(rng_, SpawnHabitat::Water, species)) return cancelLine(p);
glm::vec2 bobber = p.bobber;
p.line = LineState::None;
p.fishCooldown = 1.0f;
Creature& c = addCreature(species, bobber, COrigin::Fished);
c.mode = CMode::Hooked;
c.anim = Anim::Hooked;
c.owner = p.entity;
c.timer = 1.0f;
c.arcFrom = bobber;
c.arcFromY = Zone::kWaterLevel;
c.y = Zone::kWaterLevel;
c.arcTo = bobber;
glm::vec2 land;
if (landingSpot(bobber, p.move.pos, land)) c.arcTo = land;
c.yaw = yawFromDir(p.move.pos - bobber);
c.lifetime = rng_.range(60.0f, 120.0f);
GameEvent e;
e.kind = EventKind::Hooked;
e.a = p.entity;
e.b = c.id;
e.p = glm::vec3(bobber.x, Zone::kWaterLevel, bobber.y);
sendEvent(e);
announceAppeared(c);
const Species* sp = roster_.get(species);
logf("%s hooked a %s (entity %u)", p.row.name.c_str(), sp ? sp->name.c_str() : "?", c.id);
}
void Game::actionThrow(Player& p, uint32_t target) {
auto it = creatures_.find(target);
if (it == creatures_.end() || it->second.dead || it->second.kind != EntityKind::Wild) return;
Creature& c = it->second;
if (c.mode == CMode::Capturing || c.mode == CMode::Flee || c.mode == CMode::Hooked) return;
float dist = glm::length(c.pos - p.move.pos);
if (dist > 8.0f) return;
if (p.row.capsules <= 0) {
sendToast(p, "You're out of capsules. Professor Henry can restock you.");
return;
}
const Species* sp = roster_.get(c.species);
if (!sp) return;
p.row.capsules -= 1;
savePlayer(p);
sendProfile(p);
float flight = dist / 10.0f + 0.15f;
p.throwTimer = 0.5f;
if (dist > 0.01f) p.move.yaw = yawFromDir(c.pos - p.move.pos);
bool still = c.mode == CMode::Alert || c.mode == CMode::Emerge || (c.mode == CMode::Wander && !c.walking);
float prob = sp->catchRate * (still ? 1.25f : 1.0f);
if (c.hookBonus && c.mode == CMode::Alert) prob *= 1.35f;
prob *= dist < 3.0f ? 1.1f : dist > 6.0f ? 0.85f : 1.0f;
prob = std::clamp(prob, 0.03f, 0.95f);
float roll = rng_.uniform();
c.captureSuccess = roll < prob;
if (c.captureSuccess) {
c.wobbles = 3;
} else {
float miss = (roll - prob) / std::max(1e-3f, 1.0f - prob); // 0 = nearly caught
c.wobbles = miss < 0.3f ? 2 : miss < 0.65f ? 1 : 0;
}
c.resumeMode = c.mode == CMode::Swim ? CMode::Swim : CMode::Alert;
c.mode = CMode::Capturing;
c.anim = Anim::Capturing;
c.walking = false;
c.owner = p.entity;
c.captureTimer = flight + float(c.wobbles) * 0.55f + 0.3f;
GameEvent e;
e.kind = EventKind::Throw;
e.a = p.entity;
e.b = c.id;
e.p = pos3(p.move.pos) + glm::vec3(0.0f, 1.0f, 0.0f);
e.q = glm::vec3(c.pos.x, c.y, c.pos.y);
e.f = flight;
sendEvent(e);
logf("%s threw a capsule at %s (entity %u, %.1f u, p=%.2f) -> %s", p.row.name.c_str(), sp->name.c_str(), c.id,
dist, prob, c.captureSuccess ? "caught" : "miss");
}
void Game::actionInteract(Player& p, uint32_t target) {
if (target != kHenryEntityId) return;
if (glm::length(p.move.pos - zone_.henry) > 2.6f) return;
if (!p.row.introDone) {
p.introOffered = true;
p.introTimer = -1.0f;
sendDialogue(p, "henry_intro");
return;
}
sendDialogue(p, "henry_tips");
if (p.row.capsules < kStartCapsules) {
p.row.capsules = kStartCapsules;
savePlayer(p);
sendProfile(p);
sendToast(p, "Professor Henry restocked your capsules");
}
logf("%s talked to Professor Henry", p.row.name.c_str());
}
void Game::actionToggleParty(Player& p, uint32_t uid) {
auto it = std::find_if(p.creatures.begin(), p.creatures.end(), [&](const OwnedCreature& c) { return c.uid == uid; });
if (it == p.creatures.end()) return;
if (!it->inParty) {
int party = int(std::count_if(p.creatures.begin(), p.creatures.end(), [](auto& c) { return c.inParty; }));
if (party >= kMaxParty) {
sendToast(p, "Your party is full (6). Send one to the box first.");
return;
}
}
it->inParty = !it->inParty;
db_.setInParty(uid, it->inParty);
sendProfile(p);
syncFollowers(p);
}
void Game::handleDialogueDone(Player& p, Reader& r) {
std::string id = r.str();
if (!r.ok()) return;
if (id == "henry_intro" && (p.introOffered || glm::length(p.move.pos - zone_.henry) <= 6.0f)) finishIntro(p);
}
void Game::finishIntro(Player& p) {
p.row.introDone = true;
p.introOffered = false;
p.introTimer = -1.0f;
int before = p.row.capsules;
p.row.capsules = std::max(p.row.capsules, kStartCapsules);
savePlayer(p);
sendProfile(p);
int given = p.row.capsules - before;
sendToast(p, given > 0 ? "Professor Henry gave you " + std::to_string(given) + " Safari Capsules"
: "Professor Henry wishes you luck out there");
logf("%s finished the intro (%d capsules)", p.row.name.c_str(), p.row.capsules);
}
void Game::handleChat(Player& p, Reader& r) {
std::string text = r.str();
if (!r.ok()) return;
text = cleanChat(text);
if (text.empty() || time_ - p.lastChat < 1.0) return;
p.lastChat = time_;
Writer w(Msg::ChatLine);
w.u32(p.entity);
w.str(p.row.name);
w.str(text);
broadcast(w);
logf("<%s> %s", p.row.name.c_str(), text.c_str());
}
// ------------------------------------------------------------------ world
void Game::setWeather(Weather w) {
weatherTimer_ = rng_.range(180.0f, 480.0f);
if (w == weather_) return;
weather_ = w;
weatherBlend_ = 0.0f;
logf("weather is now %s", weatherName(w));
}
void Game::updateWorld(float dt) {
hour_ += dt * 24.0f / std::max(0.1f, cfg_.dayMinutes * 60.0f);
if (hour_ >= 24.0f) hour_ -= 24.0f;
weatherBlend_ = std::min(1.0f, weatherBlend_ + dt / 20.0f);
weatherTimer_ -= dt;
if (weatherTimer_ <= 0.0f) setWeather(nextWeather(rng_, weather_, hour_));
tables_.update(roster_, zone_.id(), hour_, weather_);
saveTimer_ -= dt;
if (saveTimer_ <= 0.0f) {
saveTimer_ = 10.0f;
db_.begin();
for (auto& [peer, p] : players_) {
if (!p->spawned || !p->posDirty) continue;
db_.savePosition(p->row.id, p->move.pos.x, p->move.pos.y, p->move.yaw);
p->posDirty = false;
}
saveWorld();
db_.commit();
}
}
void Game::updateHenry(float dt) {
Player* who = nullptr;
float d = nearestPlayerDist(zone_.henry, &who);
float want = zone_.henryYaw;
if (who && d <= 5.0f && d > 0.05f) want = yawFromDir(who->move.pos - zone_.henry);
float diff = wrapAngle(want - henryYaw_);
float step = 5.0f * dt;
henryYaw_ = wrapAngle(henryYaw_ + std::clamp(diff, -step, step));
}
void Game::tick() {
const float dt = kTickDt;
++tick_;
time_ += dt;
updateWorld(dt);
for (auto& [peer, p] : players_)
if (p->spawned) updatePlayer(*p, dt);
updateHenry(dt);
updateCreatures(dt);
for (auto& [peer, p] : players_)
if (p->spawned) updateFollowers(*p, dt);
maintainPopulation(dt);
for (auto it = creatures_.begin(); it != creatures_.end();)
it = it->second.dead ? creatures_.erase(it) : std::next(it);
sendSnapshots();
}
// ------------------------------------------------------------------ console
bool Game::console(const std::string& lineIn) {
std::istringstream in(trim(lineIn));
std::vector<std::string> args;
for (std::string a; in >> a;) args.push_back(a);
if (args.empty()) return true;
const std::string cmd = lower(args[0]);
if (cmd == "help") {
logf("commands: help | players | time <h> | weather <clear|cloudy|rain|storm|fog> | "
"give <player> capsules <n> | spawn <speciesId|name> | save | quit");
} else if (cmd == "players") {
int n = 0;
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
++n;
int party = int(std::count_if(p->creatures.begin(), p->creatures.end(), [](auto& c) { return c.inParty; }));
logf(" %-16s entity %-4u (%.1f, %.1f) capsules %d party %d box %d dex %zu/%zu intro %s [%s, %ums]",
p->row.name.c_str(), p->entity, p->move.pos.x, p->move.pos.y, p->row.capsules, party,
int(p->creatures.size()) - party, p->caught.size(), p->seen.size(), p->row.introDone ? "yes" : "no",
host_.address(peer).c_str(), host_.roundTripMs(peer));
}
logf("%d player(s) online, %zu creatures in the world", n, creatures_.size());
} else if (cmd == "time" && args.size() >= 2) {
float h = std::strtof(args[1].c_str(), nullptr);
hour_ = std::fmod(std::fmod(h, 24.0f) + 24.0f, 24.0f);
logf("time set to %.2fh", hour_);
} else if (cmd == "weather" && args.size() >= 2) {
Weather w;
if (!parseWeather(lower(args[1]), w)) logf("unknown weather '%s'", args[1].c_str());
else setWeather(w), logf("weather set to %s", weatherName(w));
} else if (cmd == "give" && args.size() >= 4 && lower(args[args.size() - 2]) == "capsules") {
std::string who;
for (size_t i = 1; i + 2 < args.size(); ++i) who += (who.empty() ? "" : " ") + args[i];
int n = std::atoi(args.back().c_str());
Player* target = nullptr;
for (auto& [peer, p] : players_)
if (p->spawned && (lower(p->row.name) == lower(who) || std::to_string(p->entity) == who)) target = p.get();
if (!target) {
logf("no player '%s' online", who.c_str());
} else {
target->row.capsules = std::clamp(target->row.capsules + n, 0, 9999);
savePlayer(*target);
sendProfile(*target);
sendToast(*target, "You received " + std::to_string(n) + " capsules");
logf("%s now has %d capsules", target->row.name.c_str(), target->row.capsules);
}
} else if (cmd == "spawn" && args.size() >= 2) {
std::string q;
for (size_t i = 1; i < args.size(); ++i) q += (q.empty() ? "" : " ") + args[i];
const Species* found = nullptr;
if (std::all_of(q.begin(), q.end(), [](char c) { return std::isdigit(uint8_t(c)); })) {
found = roster_.get(uint16_t(std::atoi(q.c_str())));
} else {
std::string lq = lower(q);
for (const Species& s : roster_.all())
if (lower(s.name) == lq || s.slug == lq) found = &s;
if (!found)
for (const Species& s : roster_.all())
if (lower(s.name).find(lq) != std::string::npos) {
found = &s;
break;
}
}
if (!found) logf("no species matches '%s'", q.c_str());
else if (Creature* c = spawnForConsole(found->id))
logf("spawned %s (#%u) as entity %u at (%.1f, %.1f)", found->name.c_str(), found->id, c->id, c->pos.x,
c->pos.y);
else logf("no player online to spawn near");
} else if (cmd == "save") {
saveAll();
logf("saved");
} else if (cmd == "quit" || cmd == "exit") {
return false;
} else {
logf("unknown command '%s' (try help)", lineIn.c_str());
}
return true;
}
} // namespace bs