#include "Game.h" #include "Log.h" #include #include #include #include #include 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(); 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& 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 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 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> 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 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 | weather | " "give capsules | spawn | 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