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:
868
server/Game.cpp
Normal file
868
server/Game.cpp
Normal 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
|
||||
Reference in New Issue
Block a user