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:
559
server/Creatures.cpp
Normal file
559
server/Creatures.cpp
Normal file
@@ -0,0 +1,559 @@
|
||||
// Wild creature population and behaviour, tall-grass encounters, captures and
|
||||
// party followers.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include "Log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
|
||||
namespace bs {
|
||||
|
||||
namespace {
|
||||
constexpr float kPi = 3.14159265f;
|
||||
constexpr int kWanderers = 16;
|
||||
constexpr int kSwimmers = 5;
|
||||
constexpr int kMaxEmergedPerPlayer = 4;
|
||||
constexpr float kWanderSpeed = 1.4f;
|
||||
constexpr float kSwimSpeed = 0.35f;
|
||||
constexpr float kFleeSpeed = 4.5f;
|
||||
} // namespace
|
||||
|
||||
Creature& Game::addCreature(uint16_t species, glm::vec2 pos, COrigin origin) {
|
||||
uint32_t id = nextEntity_++;
|
||||
Creature& c = creatures_[id];
|
||||
c.id = id;
|
||||
c.species = species;
|
||||
c.origin = origin;
|
||||
c.pos = pos;
|
||||
c.y = zone_.groundHeight(pos);
|
||||
c.yaw = rng_.range(-kPi, kPi);
|
||||
return c;
|
||||
}
|
||||
|
||||
void Game::removeCreature(uint32_t id) {
|
||||
auto it = creatures_.find(id);
|
||||
if (it != creatures_.end()) it->second.dead = true;
|
||||
}
|
||||
|
||||
float Game::nearestPlayerDist(glm::vec2 p, Player** who) {
|
||||
float best = 1e9f;
|
||||
if (who) *who = nullptr;
|
||||
for (auto& [peer, pl] : players_) {
|
||||
if (!pl->spawned) continue;
|
||||
float d = glm::length(pl->move.pos - p);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
if (who) *who = pl.get();
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void Game::announceAppeared(const Creature& c) {
|
||||
for (auto& [peer, p] : players_) {
|
||||
if (!p->spawned || glm::length(p->move.pos - c.pos) > 10.0f) continue;
|
||||
auto it = creatures_.find(c.id);
|
||||
if (it != creatures_.end()) it->second.seenBy.insert(p->entity);
|
||||
noteSeen(*p, c.species);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::faceNearestPlayer(Creature& c) {
|
||||
Player* who = nullptr;
|
||||
float d = nearestPlayerDist(c.pos, &who);
|
||||
if (who && d < 12.0f && d > 0.05f) c.yaw = yawFromDir(who->move.pos - c.pos);
|
||||
}
|
||||
|
||||
// Grass creatures that popped up for this player and are still around it (stale ones left
|
||||
// behind in another patch do not block new encounters).
|
||||
int Game::emergedCount(const Player& p) const {
|
||||
int n = 0;
|
||||
for (auto& [id, c] : creatures_)
|
||||
n += !c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Grass && c.emergedFor == p.entity &&
|
||||
glm::length(c.pos - p.move.pos) < 15.0f;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ population
|
||||
|
||||
bool Game::spawnWanderer() {
|
||||
uint16_t species;
|
||||
if (!tables_.pick(rng_, SpawnHabitat::Land, species)) return false;
|
||||
glm::vec2 spot, fallback;
|
||||
bool have = false, haveFallback = false;
|
||||
for (int i = 0; i < 8 && !have; ++i) {
|
||||
glm::vec2 p;
|
||||
if (!zone_.randomWanderSpot(rng_, p)) continue;
|
||||
if (nearestPlayerDist(p) > 14.0f) spot = p, have = true;
|
||||
else if (!haveFallback) fallback = p, haveFallback = true;
|
||||
}
|
||||
if (!have && !haveFallback) return false;
|
||||
Creature& c = addCreature(species, have ? spot : fallback, COrigin::Wanderer);
|
||||
c.mode = CMode::Wander;
|
||||
c.anim = Anim::Idle;
|
||||
c.timer = rng_.range(0.5f, 4.0f);
|
||||
c.lifetime = rng_.range(120.0f, 300.0f);
|
||||
announceAppeared(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Game::spawnSwimmer(bool announce) {
|
||||
uint16_t species;
|
||||
glm::vec2 p;
|
||||
if (!tables_.pick(rng_, SpawnHabitat::Water, species) || !zone_.randomWater(rng_, p, true)) return false;
|
||||
Creature& c = addCreature(species, p, COrigin::Swimmer);
|
||||
c.y = Zone::kWaterLevel;
|
||||
c.lifetime = 1e9f; // swimmers rotate by diving
|
||||
if (announce) {
|
||||
c.mode = CMode::Emerge;
|
||||
c.anim = Anim::Emerge;
|
||||
c.timer = 0.9f;
|
||||
GameEvent s;
|
||||
s.kind = EventKind::Splash;
|
||||
s.p = glm::vec3(p.x, Zone::kWaterLevel, p.y);
|
||||
sendEvent(s);
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Emerge;
|
||||
e.a = c.id;
|
||||
e.i = 1;
|
||||
e.p = s.p;
|
||||
sendEvent(e);
|
||||
} else {
|
||||
c.mode = CMode::Swim;
|
||||
c.anim = Anim::Swim;
|
||||
}
|
||||
announceAppeared(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Game::maintainPopulation(float dt) {
|
||||
int wanderers = 0, swimmers = 0;
|
||||
for (auto& [id, c] : creatures_) {
|
||||
if (c.dead || c.kind != EntityKind::Wild) continue;
|
||||
wanderers += c.origin == COrigin::Wanderer;
|
||||
swimmers += c.origin == COrigin::Swimmer;
|
||||
}
|
||||
wanderSpawnTimer_ -= dt;
|
||||
if (wanderers < kWanderers && wanderSpawnTimer_ <= 0.0f) {
|
||||
spawnWanderer();
|
||||
wanderSpawnTimer_ = 1.5f;
|
||||
}
|
||||
|
||||
// One swimmer dives (Splash, despawn) every 10-25 s and resurfaces elsewhere a few seconds later.
|
||||
swimmerTimer_ -= dt;
|
||||
if (swimmerTimer_ <= 0.0f) {
|
||||
swimmerTimer_ = rng_.range(10.0f, 25.0f);
|
||||
std::vector<uint32_t> candidates;
|
||||
for (auto& [id, c] : creatures_)
|
||||
if (!c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Swimmer && c.mode == CMode::Swim)
|
||||
candidates.push_back(id);
|
||||
if (!candidates.empty()) {
|
||||
Creature& c = creatures_[candidates[size_t(rng_.range(0, int(candidates.size()) - 1))]];
|
||||
GameEvent s;
|
||||
s.kind = EventKind::Splash;
|
||||
s.p = glm::vec3(c.pos.x, Zone::kWaterLevel, c.pos.y);
|
||||
sendEvent(s);
|
||||
removeCreature(c.id);
|
||||
--swimmers;
|
||||
swimmerRespawn_ = rng_.range(2.0f, 5.0f);
|
||||
}
|
||||
}
|
||||
if (swimmerRespawn_ > 0.0f) {
|
||||
swimmerRespawn_ -= dt;
|
||||
if (swimmerRespawn_ <= 0.0f && swimmers < kSwimmers) spawnSwimmer(true), ++swimmers;
|
||||
} else if (swimmers < kSwimmers) {
|
||||
swimmerRespawn_ = rng_.range(2.0f, 5.0f); // replace caught swimmers
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tall grass
|
||||
|
||||
void Game::grassEncounters(Player& p) {
|
||||
while (p.grassDist >= 0.5f) {
|
||||
p.grassDist -= 0.5f;
|
||||
float chance = 0.07f * (p.move.running ? 2.2f : 1.0f);
|
||||
uint16_t species;
|
||||
glm::vec2 spot;
|
||||
if (rng_.chance(chance) && emergedCount(p) < kMaxEmergedPerPlayer &&
|
||||
tables_.pick(rng_, SpawnHabitat::Land, species) &&
|
||||
zone_.randomTallGrassNear(rng_, p.move.pos, 2.0f, 6.0f, spot)) {
|
||||
Creature& c = addCreature(species, spot, COrigin::Grass);
|
||||
c.mode = CMode::Emerge;
|
||||
c.anim = Anim::Emerge;
|
||||
c.timer = 0.9f;
|
||||
c.emergedFor = p.entity;
|
||||
c.yaw = yawFromDir(p.move.pos - spot);
|
||||
c.lifetime = rng_.range(60.0f, 120.0f);
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Emerge;
|
||||
e.a = c.id;
|
||||
e.i = 0;
|
||||
e.p = glm::vec3(spot.x, c.y, spot.y);
|
||||
sendEvent(e);
|
||||
announceAppeared(c);
|
||||
const Species* sp = roster_.get(species);
|
||||
logf("a wild %s (entity %u) emerged near %s", sp ? sp->name.c_str() : "?", c.id, p.row.name.c_str());
|
||||
continue;
|
||||
}
|
||||
if (p.rustleCooldown <= 0.0f && rng_.chance(0.25f) &&
|
||||
zone_.randomTallGrassNear(rng_, p.move.pos, 1.5f, 6.0f, spot)) {
|
||||
p.rustleCooldown = rng_.range(0.8f, 1.6f);
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Rustle;
|
||||
e.p = glm::vec3(spot.x, zone_.groundHeight(spot), spot.y);
|
||||
sendEvent(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ behaviour
|
||||
|
||||
bool Game::pickWanderTarget(Creature& c) {
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
glm::vec2 t;
|
||||
if (c.origin == COrigin::Grass && zone_.inTallGrass(c.pos)) {
|
||||
if (!zone_.randomTallGrassNear(rng_, c.pos, 2.0f, 6.0f, t)) continue;
|
||||
} else {
|
||||
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 6.0f);
|
||||
t = c.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
|
||||
}
|
||||
bool ok = zone_.creatureWalkable(t, false);
|
||||
for (int k = 1; k < 5 && ok; ++k) ok = zone_.creatureWalkable(glm::mix(c.pos, t, float(k) / 5.0f), false);
|
||||
if (!ok) continue;
|
||||
c.target = t;
|
||||
c.walking = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Game::wander(Creature& c, float dt, float speed) {
|
||||
if (c.walking) {
|
||||
glm::vec2 d = c.target - c.pos;
|
||||
float len = glm::length(d);
|
||||
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, speed * dt);
|
||||
if (len < 0.05f || !zone_.creatureWalkable(next, false)) {
|
||||
c.walking = false;
|
||||
c.timer = rng_.range(1.0f, 4.0f);
|
||||
c.anim = Anim::Idle;
|
||||
} else {
|
||||
c.pos = next;
|
||||
c.yaw = yawFromDir(d);
|
||||
c.anim = Anim::Walk;
|
||||
}
|
||||
} else {
|
||||
c.anim = Anim::Idle;
|
||||
c.timer -= dt;
|
||||
if (c.timer <= 0.0f && !pickWanderTarget(c)) c.timer = 1.0f;
|
||||
}
|
||||
c.y = zone_.groundHeight(c.pos);
|
||||
}
|
||||
|
||||
void Game::updateWild(Creature& c, float dt) {
|
||||
// dex: wanderers (and anything else) register as seen once a player comes within 6 units
|
||||
for (auto& [peer, p] : players_) {
|
||||
if (!p->spawned || c.seenBy.count(p->entity) || glm::length(p->move.pos - c.pos) > 6.0f) continue;
|
||||
c.seenBy.insert(p->entity);
|
||||
noteSeen(*p, c.species);
|
||||
}
|
||||
|
||||
switch (c.mode) {
|
||||
case CMode::Emerge:
|
||||
c.timer -= dt;
|
||||
if (c.timer <= 0.0f) {
|
||||
if (c.origin == COrigin::Swimmer) {
|
||||
c.mode = CMode::Swim;
|
||||
c.anim = Anim::Swim;
|
||||
} else {
|
||||
c.mode = CMode::Alert;
|
||||
c.anim = Anim::Alert;
|
||||
c.timer = 2.5f;
|
||||
Player* who = byEntity(c.emergedFor);
|
||||
if (who) c.yaw = yawFromDir(who->move.pos - c.pos);
|
||||
else faceNearestPlayer(c);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CMode::Alert:
|
||||
c.anim = Anim::Alert;
|
||||
faceNearestPlayer(c);
|
||||
c.timer -= dt;
|
||||
if (c.timer <= 0.0f) {
|
||||
c.hookBonus = false;
|
||||
c.walking = false;
|
||||
c.timer = rng_.range(0.5f, 2.0f);
|
||||
bool water = zone_.isWater(c.pos);
|
||||
c.mode = water ? CMode::Swim : CMode::Wander;
|
||||
c.anim = water ? Anim::Swim : Anim::Idle;
|
||||
}
|
||||
break;
|
||||
case CMode::Wander:
|
||||
wander(c, dt, kWanderSpeed);
|
||||
break;
|
||||
case CMode::Swim: {
|
||||
c.anim = Anim::Swim;
|
||||
c.y = Zone::kWaterLevel;
|
||||
if (!c.walking) {
|
||||
glm::vec2 t;
|
||||
if (zone_.randomWaterNear(rng_, c.pos, 3.0f, t) && zone_.isWater(glm::mix(c.pos, t, 0.5f))) {
|
||||
c.target = t;
|
||||
c.walking = true;
|
||||
}
|
||||
} else {
|
||||
glm::vec2 d = c.target - c.pos;
|
||||
float len = glm::length(d);
|
||||
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, kSwimSpeed * dt);
|
||||
if (len < 0.05f || !zone_.isWater(next)) c.walking = false;
|
||||
else c.pos = next, c.yaw = yawFromDir(d);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CMode::Capturing:
|
||||
c.captureTimer -= dt;
|
||||
if (c.captureTimer <= 0.0f) resolveCapture(c);
|
||||
break;
|
||||
case CMode::Flee: {
|
||||
c.timer -= dt;
|
||||
glm::vec2 d = c.target - c.pos;
|
||||
float len = glm::length(d);
|
||||
if (len > 0.05f) {
|
||||
glm::vec2 next = c.pos + d / len * std::min(len, kFleeSpeed * dt);
|
||||
bool water = zone_.isWater(c.pos);
|
||||
if (zone_.creatureWalkable(next, water)) c.pos = next;
|
||||
c.yaw = yawFromDir(d);
|
||||
}
|
||||
if (!zone_.isWater(c.pos)) c.y = zone_.groundHeight(c.pos);
|
||||
if (c.timer <= 0.0f) removeCreature(c.id);
|
||||
break;
|
||||
}
|
||||
case CMode::Hooked: {
|
||||
c.timer -= dt;
|
||||
float t = std::clamp(1.0f - c.timer / 1.0f, 0.0f, 1.0f);
|
||||
c.pos = glm::mix(c.arcFrom, c.arcTo, t);
|
||||
float groundY = zone_.isWater(c.arcTo) ? Zone::kWaterLevel : zone_.groundHeight(c.arcTo);
|
||||
c.y = glm::mix(c.arcFromY, groundY, t) + 1.6f * std::sin(kPi * t);
|
||||
if (glm::length(c.arcTo - c.arcFrom) > 0.05f) c.yaw = yawFromDir(c.arcTo - c.arcFrom);
|
||||
if (c.timer <= 0.0f) {
|
||||
c.pos = c.arcTo;
|
||||
c.y = groundY;
|
||||
c.mode = CMode::Alert;
|
||||
c.anim = Anim::Alert;
|
||||
c.timer = 6.0f;
|
||||
c.hookBonus = true;
|
||||
c.owner = 0;
|
||||
faceNearestPlayer(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CMode::Follow: break;
|
||||
}
|
||||
|
||||
// Idle creatures nobody is near eventually leave (captures/hooks/flee never time out here).
|
||||
if (c.mode == CMode::Wander || c.mode == CMode::Swim) {
|
||||
c.lifetime -= dt;
|
||||
if (c.lifetime <= 0.0f && nearestPlayerDist(c.pos) > 12.0f) removeCreature(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
void Game::updateCreatures(float dt) {
|
||||
for (auto& [id, c] : creatures_)
|
||||
if (!c.dead && c.kind == EntityKind::Wild) updateWild(c, dt);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ capture
|
||||
|
||||
void Game::resolveCapture(Creature& c) {
|
||||
Player* p = byEntity(c.owner);
|
||||
const Species* sp = roster_.get(c.species);
|
||||
if (!p || !sp) { // thrower left: the capsule pops open
|
||||
c.mode = c.resumeMode;
|
||||
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
|
||||
c.timer = 2.5f;
|
||||
c.owner = 0;
|
||||
return;
|
||||
}
|
||||
GameEvent r;
|
||||
r.kind = EventKind::CaptureResult;
|
||||
r.a = p->entity;
|
||||
r.b = c.id;
|
||||
r.i = c.captureSuccess ? 1 : 0;
|
||||
r.j = c.wobbles;
|
||||
r.p = glm::vec3(c.pos.x, c.y, c.pos.y);
|
||||
sendEvent(r);
|
||||
|
||||
if (c.captureSuccess) {
|
||||
int party = int(std::count_if(p->creatures.begin(), p->creatures.end(), [](auto& o) { return o.inParty; }));
|
||||
bool inParty = party < kMaxParty;
|
||||
bool first = p->caught.count(c.species) == 0;
|
||||
OwnedCreature oc;
|
||||
oc.species = c.species;
|
||||
oc.inParty = inParty;
|
||||
oc.caughtAt = int64_t(std::time(nullptr));
|
||||
oc.uid = db_.insertCreature(p->row.id, oc.species, oc.caughtAt, inParty);
|
||||
if (!oc.uid) {
|
||||
removeCreature(c.id);
|
||||
sendToast(*p, "The capsule malfunctioned and the creature escaped");
|
||||
return;
|
||||
}
|
||||
p->creatures.push_back(oc);
|
||||
p->caught.insert(c.species);
|
||||
p->seen.insert(c.species);
|
||||
db_.markCaught(p->row.id, c.species);
|
||||
sendProfile(*p);
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Caught;
|
||||
e.a = p->entity;
|
||||
e.b = c.species;
|
||||
e.i = first ? 1 : 0;
|
||||
e.p = r.p;
|
||||
sendEvent(e);
|
||||
logf("%s caught %s (uid %u, %s)%s", p->row.name.c_str(), sp->name.c_str(), oc.uid,
|
||||
inParty ? "party" : "box", first ? " - new species!" : "");
|
||||
if (inParty) {
|
||||
// the wild entity turns into the follower (same entity id)
|
||||
c.kind = EntityKind::Follower;
|
||||
c.origin = COrigin::Caught;
|
||||
c.mode = CMode::Follow;
|
||||
c.anim = Anim::Idle;
|
||||
c.owner = p->entity;
|
||||
c.uid = oc.uid;
|
||||
c.y = zone_.groundHeight(c.pos);
|
||||
c.hookBonus = false;
|
||||
syncFollowers(*p);
|
||||
} else {
|
||||
removeCreature(c.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
++c.failedThrows;
|
||||
float flee = sp->fleeRate * (c.failedThrows >= 2 ? 1.5f : 1.0f);
|
||||
if (rng_.chance(flee)) {
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Fled;
|
||||
e.a = c.id;
|
||||
e.p = r.p;
|
||||
sendEvent(e);
|
||||
c.mode = CMode::Flee;
|
||||
c.anim = Anim::Flee;
|
||||
c.timer = 1.5f;
|
||||
glm::vec2 away = c.pos - p->move.pos;
|
||||
if (glm::length(away) < 0.05f) away = dirFromYaw(c.yaw);
|
||||
c.target = c.pos + glm::normalize(away) * 8.0f;
|
||||
c.yaw = yawFromDir(away);
|
||||
c.owner = p->entity;
|
||||
logf("%s fled from %s", sp->name.c_str(), p->row.name.c_str());
|
||||
} else {
|
||||
c.mode = c.resumeMode;
|
||||
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
|
||||
c.timer = 2.5f;
|
||||
c.owner = 0;
|
||||
c.walking = false;
|
||||
if (c.mode == CMode::Alert) c.yaw = yawFromDir(p->move.pos - c.pos);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ followers
|
||||
|
||||
void Game::syncFollowers(Player& p) {
|
||||
std::vector<const OwnedCreature*> party;
|
||||
for (const OwnedCreature& o : p.creatures)
|
||||
if (o.inParty && party.size() < size_t(kMaxParty)) party.push_back(&o);
|
||||
for (auto& [id, c] : creatures_) {
|
||||
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
|
||||
auto it = std::find_if(party.begin(), party.end(), [&](const OwnedCreature* o) { return o->uid == c.uid; });
|
||||
if (it == party.end()) removeCreature(id);
|
||||
else c.order = uint8_t(it - party.begin());
|
||||
}
|
||||
for (size_t k = 0; k < party.size(); ++k) {
|
||||
bool exists = false;
|
||||
for (auto& [id, c] : creatures_)
|
||||
if (!c.dead && c.kind == EntityKind::Follower && c.owner == p.entity && c.uid == party[k]->uid) exists = true;
|
||||
if (exists) continue;
|
||||
// trail points are >= 0.15 apart (seeded 0.3 apart): ~3 points back per chain slot
|
||||
size_t back = p.trail.empty() ? 0 : std::min(p.trail.size() - 1, (k + 1) * 3);
|
||||
Creature& c = addCreature(party[k]->species, p.trail.empty() ? p.move.pos : p.trail[back], COrigin::Caught);
|
||||
c.kind = EntityKind::Follower;
|
||||
c.mode = CMode::Follow;
|
||||
c.anim = Anim::Idle;
|
||||
c.owner = p.entity;
|
||||
c.uid = party[k]->uid;
|
||||
c.order = uint8_t(k);
|
||||
c.yaw = p.move.yaw;
|
||||
c.lifetime = 1e9f;
|
||||
}
|
||||
}
|
||||
|
||||
void Game::updateFollowers(Player& p, float dt) {
|
||||
for (auto& [id, c] : creatures_) {
|
||||
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
|
||||
// target: ~0.95 * (order + 1) units back along the breadcrumb trail
|
||||
float want = 0.95f * float(c.order + 1);
|
||||
glm::vec2 prev = p.move.pos, target = prev;
|
||||
float acc = 0.0f;
|
||||
bool found = false;
|
||||
for (const glm::vec2& t : p.trail) {
|
||||
float seg = glm::length(t - prev);
|
||||
if (seg > 1e-4f && acc + seg >= want) {
|
||||
target = prev + (t - prev) * ((want - acc) / seg);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
acc += seg;
|
||||
prev = t;
|
||||
}
|
||||
if (!found) target = prev;
|
||||
|
||||
glm::vec2 d = target - c.pos;
|
||||
float len = glm::length(d);
|
||||
float moved = 0.0f;
|
||||
if (len > 8.0f) {
|
||||
c.pos = target;
|
||||
} else if (len > 0.08f) {
|
||||
float speed = (p.move.running ? kRunSpeed : kWalkSpeed) * 1.15f;
|
||||
moved = std::min(len, speed * dt);
|
||||
c.pos += d / len * moved;
|
||||
c.yaw = yawFromDir(d);
|
||||
}
|
||||
float v = moved / dt;
|
||||
c.anim = v < 0.3f ? Anim::Idle : v > kWalkSpeed * 1.1f ? Anim::Run : Anim::Walk;
|
||||
c.y = zone_.groundHeight(c.pos);
|
||||
}
|
||||
}
|
||||
|
||||
Creature* Game::spawnForConsole(uint16_t species) {
|
||||
Player* first = nullptr;
|
||||
for (auto& [peer, p] : players_)
|
||||
if (p->spawned) {
|
||||
first = p.get();
|
||||
break;
|
||||
}
|
||||
if (!first) return nullptr;
|
||||
glm::vec2 spot;
|
||||
bool ok = zone_.randomTallGrassNear(rng_, first->move.pos, 2.0f, 5.0f, spot);
|
||||
for (int i = 0; i < 40 && !ok; ++i) {
|
||||
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 5.0f);
|
||||
spot = first->move.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
|
||||
ok = zone_.creatureWalkable(spot, false);
|
||||
}
|
||||
if (!ok) spot = first->move.pos + dirFromYaw(first->move.yaw) * 2.0f;
|
||||
Creature& c = addCreature(species, spot, COrigin::Console);
|
||||
c.mode = CMode::Emerge;
|
||||
c.anim = Anim::Emerge;
|
||||
c.timer = 0.9f;
|
||||
c.emergedFor = first->entity;
|
||||
c.yaw = yawFromDir(first->move.pos - spot);
|
||||
c.lifetime = rng_.range(120.0f, 300.0f);
|
||||
GameEvent e;
|
||||
e.kind = EventKind::Emerge;
|
||||
e.a = c.id;
|
||||
e.p = glm::vec3(spot.x, c.y, spot.y);
|
||||
sendEvent(e);
|
||||
announceAppeared(c);
|
||||
return &c;
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
320
server/Database.cpp
Normal file
320
server/Database.cpp
Normal file
@@ -0,0 +1,320 @@
|
||||
#include "Database.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
|
||||
namespace bs {
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kSchema = R"SQL(
|
||||
CREATE TABLE IF NOT EXISTS players(
|
||||
id INTEGER PRIMARY KEY,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
appearance TEXT NOT NULL,
|
||||
pos_x REAL NOT NULL,
|
||||
pos_z REAL NOT NULL,
|
||||
yaw REAL NOT NULL DEFAULT 0,
|
||||
capsules INTEGER NOT NULL DEFAULT 0,
|
||||
intro_done INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS creatures(
|
||||
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner INTEGER NOT NULL REFERENCES players(id),
|
||||
species INTEGER NOT NULL,
|
||||
caught_at INTEGER NOT NULL,
|
||||
in_party INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE INDEX IF NOT EXISTS creatures_owner ON creatures(owner);
|
||||
CREATE TABLE IF NOT EXISTS dex(
|
||||
owner INTEGER NOT NULL REFERENCES players(id),
|
||||
species INTEGER NOT NULL,
|
||||
seen INTEGER NOT NULL DEFAULT 0,
|
||||
caught INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(owner, species));
|
||||
CREATE TABLE IF NOT EXISTS world(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL);
|
||||
)SQL";
|
||||
|
||||
std::string text(sqlite3_stmt* s, int col) {
|
||||
const unsigned char* t = sqlite3_column_text(s, col);
|
||||
return t ? reinterpret_cast<const char*>(t) : "";
|
||||
}
|
||||
|
||||
void bindText(sqlite3_stmt* s, int i, const std::string& v) {
|
||||
sqlite3_bind_text(s, i, v.data(), int(v.size()), SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string appearanceToJson(const Appearance& a) {
|
||||
nlohmann::json j;
|
||||
j["body"] = a.body;
|
||||
j["skin"] = a.skin;
|
||||
nlohmann::json items = nlohmann::json::object(), colors = nlohmann::json::object();
|
||||
for (int s = 0; s < kSlotCount; ++s) {
|
||||
items[slotName(s)] = a.items[s];
|
||||
colors[slotName(s)] = hexColor(a.colors[s]);
|
||||
}
|
||||
j["items"] = items;
|
||||
j["colors"] = colors;
|
||||
return j.dump();
|
||||
}
|
||||
|
||||
Appearance appearanceFromJson(const std::string& s) {
|
||||
Appearance a;
|
||||
try {
|
||||
nlohmann::json j = nlohmann::json::parse(s);
|
||||
a.body = j.value("body", a.body);
|
||||
a.skin = uint8_t(j.value("skin", 0));
|
||||
for (int i = 0; i < kSlotCount; ++i) {
|
||||
if (j.contains("items")) a.items[i] = j["items"].value(slotName(i), "");
|
||||
if (j.contains("colors")) a.colors[i] = parseHexColor(j["colors"].value(slotName(i), "#ffffff"));
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Database::~Database() { close(); }
|
||||
|
||||
bool Database::open(const std::string& path, std::string* error) {
|
||||
std::error_code ec;
|
||||
std::filesystem::path p(path);
|
||||
if (p.has_parent_path()) std::filesystem::create_directories(p.parent_path(), ec);
|
||||
if (sqlite3_open(path.c_str(), &db_) != SQLITE_OK) {
|
||||
if (error) *error = db_ ? sqlite3_errmsg(db_) : "sqlite3_open failed";
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL;");
|
||||
exec("PRAGMA synchronous=NORMAL;");
|
||||
exec("PRAGMA foreign_keys=ON;");
|
||||
if (!exec(kSchema)) {
|
||||
if (error) *error = sqlite3_errmsg(db_);
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Database::close() {
|
||||
for (auto& [sql, s] : stmts_) sqlite3_finalize(s);
|
||||
stmts_.clear();
|
||||
if (db_) sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
bool Database::exec(const char* sql) {
|
||||
char* err = nullptr;
|
||||
if (sqlite3_exec(db_, sql, nullptr, nullptr, &err) != SQLITE_OK) {
|
||||
std::fprintf(stderr, "[db] %s\n", err ? err : "error");
|
||||
sqlite3_free(err);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
sqlite3_stmt* Database::prepare(const char* sql) {
|
||||
if (!db_) return nullptr;
|
||||
auto it = stmts_.find(sql);
|
||||
if (it != stmts_.end()) {
|
||||
sqlite3_reset(it->second);
|
||||
sqlite3_clear_bindings(it->second);
|
||||
return it->second;
|
||||
}
|
||||
sqlite3_stmt* s = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &s, nullptr) != SQLITE_OK) {
|
||||
std::fprintf(stderr, "[db] prepare failed: %s\n", sqlite3_errmsg(db_));
|
||||
return nullptr;
|
||||
}
|
||||
stmts_[sql] = s;
|
||||
return s;
|
||||
}
|
||||
|
||||
void Database::begin() {
|
||||
if (db_) exec("BEGIN;");
|
||||
}
|
||||
|
||||
void Database::commit() {
|
||||
if (db_) exec("COMMIT;");
|
||||
}
|
||||
|
||||
bool Database::loadPlayer(const std::string& token, PlayerRow& out) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"SELECT id, name, appearance, pos_x, pos_z, yaw, capsules, intro_done, created_at, last_seen "
|
||||
"FROM players WHERE token = ?");
|
||||
if (!s) return false;
|
||||
bindText(s, 1, token);
|
||||
bool found = sqlite3_step(s) == SQLITE_ROW;
|
||||
if (found) {
|
||||
out.id = sqlite3_column_int64(s, 0);
|
||||
out.token = token;
|
||||
out.name = text(s, 1);
|
||||
out.look = appearanceFromJson(text(s, 2));
|
||||
out.x = float(sqlite3_column_double(s, 3));
|
||||
out.z = float(sqlite3_column_double(s, 4));
|
||||
out.yaw = float(sqlite3_column_double(s, 5));
|
||||
out.capsules = sqlite3_column_int(s, 6);
|
||||
out.introDone = sqlite3_column_int(s, 7) != 0;
|
||||
out.createdAt = sqlite3_column_int64(s, 8);
|
||||
out.lastSeen = sqlite3_column_int64(s, 9);
|
||||
}
|
||||
sqlite3_reset(s);
|
||||
return found;
|
||||
}
|
||||
|
||||
int64_t Database::createPlayer(const PlayerRow& r) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"INSERT INTO players(token, name, appearance, pos_x, pos_z, yaw, capsules, intro_done, created_at, last_seen) "
|
||||
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
if (!s) return 0;
|
||||
bindText(s, 1, r.token);
|
||||
bindText(s, 2, r.name);
|
||||
bindText(s, 3, appearanceToJson(r.look));
|
||||
sqlite3_bind_double(s, 4, r.x);
|
||||
sqlite3_bind_double(s, 5, r.z);
|
||||
sqlite3_bind_double(s, 6, r.yaw);
|
||||
sqlite3_bind_int(s, 7, r.capsules);
|
||||
sqlite3_bind_int(s, 8, r.introDone);
|
||||
sqlite3_bind_int64(s, 9, r.createdAt);
|
||||
sqlite3_bind_int64(s, 10, r.lastSeen);
|
||||
int64_t id = 0;
|
||||
if (sqlite3_step(s) == SQLITE_DONE) id = sqlite3_last_insert_rowid(db_);
|
||||
else std::fprintf(stderr, "[db] createPlayer: %s\n", sqlite3_errmsg(db_));
|
||||
sqlite3_reset(s);
|
||||
return id;
|
||||
}
|
||||
|
||||
void Database::savePlayer(const PlayerRow& r) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"UPDATE players SET name = ?, appearance = ?, pos_x = ?, pos_z = ?, yaw = ?, capsules = ?, intro_done = ?, "
|
||||
"last_seen = ? WHERE id = ?");
|
||||
if (!s) return;
|
||||
bindText(s, 1, r.name);
|
||||
bindText(s, 2, appearanceToJson(r.look));
|
||||
sqlite3_bind_double(s, 3, r.x);
|
||||
sqlite3_bind_double(s, 4, r.z);
|
||||
sqlite3_bind_double(s, 5, r.yaw);
|
||||
sqlite3_bind_int(s, 6, r.capsules);
|
||||
sqlite3_bind_int(s, 7, r.introDone);
|
||||
sqlite3_bind_int64(s, 8, r.lastSeen);
|
||||
sqlite3_bind_int64(s, 9, r.id);
|
||||
if (sqlite3_step(s) != SQLITE_DONE) std::fprintf(stderr, "[db] savePlayer: %s\n", sqlite3_errmsg(db_));
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
void Database::savePosition(int64_t id, float x, float z, float yaw) {
|
||||
sqlite3_stmt* s = prepare("UPDATE players SET pos_x = ?, pos_z = ?, yaw = ?, last_seen = strftime('%s','now') WHERE id = ?");
|
||||
if (!s) return;
|
||||
sqlite3_bind_double(s, 1, x);
|
||||
sqlite3_bind_double(s, 2, z);
|
||||
sqlite3_bind_double(s, 3, yaw);
|
||||
sqlite3_bind_int64(s, 4, id);
|
||||
sqlite3_step(s);
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
std::vector<OwnedCreature> Database::loadCreatures(int64_t owner) {
|
||||
std::vector<OwnedCreature> out;
|
||||
sqlite3_stmt* s = prepare("SELECT uid, species, in_party, caught_at FROM creatures WHERE owner = ? ORDER BY uid");
|
||||
if (!s) return out;
|
||||
sqlite3_bind_int64(s, 1, owner);
|
||||
while (sqlite3_step(s) == SQLITE_ROW) {
|
||||
OwnedCreature c;
|
||||
c.uid = uint32_t(sqlite3_column_int64(s, 0));
|
||||
c.species = uint16_t(sqlite3_column_int(s, 1));
|
||||
c.inParty = sqlite3_column_int(s, 2) != 0;
|
||||
c.caughtAt = sqlite3_column_int64(s, 3);
|
||||
out.push_back(c);
|
||||
}
|
||||
sqlite3_reset(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
uint32_t Database::insertCreature(int64_t owner, uint16_t species, int64_t caughtAt, bool inParty) {
|
||||
sqlite3_stmt* s = prepare("INSERT INTO creatures(owner, species, caught_at, in_party) VALUES(?, ?, ?, ?)");
|
||||
if (!s) return 0;
|
||||
sqlite3_bind_int64(s, 1, owner);
|
||||
sqlite3_bind_int(s, 2, species);
|
||||
sqlite3_bind_int64(s, 3, caughtAt);
|
||||
sqlite3_bind_int(s, 4, inParty);
|
||||
uint32_t uid = 0;
|
||||
if (sqlite3_step(s) == SQLITE_DONE) uid = uint32_t(sqlite3_last_insert_rowid(db_));
|
||||
else std::fprintf(stderr, "[db] insertCreature: %s\n", sqlite3_errmsg(db_));
|
||||
sqlite3_reset(s);
|
||||
return uid;
|
||||
}
|
||||
|
||||
void Database::setInParty(uint32_t uid, bool inParty) {
|
||||
sqlite3_stmt* s = prepare("UPDATE creatures SET in_party = ? WHERE uid = ?");
|
||||
if (!s) return;
|
||||
sqlite3_bind_int(s, 1, inParty);
|
||||
sqlite3_bind_int64(s, 2, uid);
|
||||
sqlite3_step(s);
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
void Database::loadDex(int64_t owner, std::set<uint16_t>& seen, std::set<uint16_t>& caught) {
|
||||
seen.clear();
|
||||
caught.clear();
|
||||
sqlite3_stmt* s = prepare("SELECT species, seen, caught FROM dex WHERE owner = ?");
|
||||
if (!s) return;
|
||||
sqlite3_bind_int64(s, 1, owner);
|
||||
while (sqlite3_step(s) == SQLITE_ROW) {
|
||||
uint16_t sp = uint16_t(sqlite3_column_int(s, 0));
|
||||
if (sqlite3_column_int(s, 1)) seen.insert(sp);
|
||||
if (sqlite3_column_int(s, 2)) caught.insert(sp);
|
||||
}
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
void Database::markSeen(int64_t owner, uint16_t species) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"INSERT INTO dex(owner, species, seen, caught) VALUES(?, ?, 1, 0) "
|
||||
"ON CONFLICT(owner, species) DO UPDATE SET seen = 1");
|
||||
if (!s) return;
|
||||
sqlite3_bind_int64(s, 1, owner);
|
||||
sqlite3_bind_int(s, 2, species);
|
||||
sqlite3_step(s);
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
void Database::markCaught(int64_t owner, uint16_t species) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"INSERT INTO dex(owner, species, seen, caught) VALUES(?, ?, 1, 1) "
|
||||
"ON CONFLICT(owner, species) DO UPDATE SET seen = 1, caught = 1");
|
||||
if (!s) return;
|
||||
sqlite3_bind_int64(s, 1, owner);
|
||||
sqlite3_bind_int(s, 2, species);
|
||||
sqlite3_step(s);
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
bool Database::getWorld(const std::string& key, std::string& value) {
|
||||
sqlite3_stmt* s = prepare("SELECT value FROM world WHERE key = ?");
|
||||
if (!s) return false;
|
||||
bindText(s, 1, key);
|
||||
bool found = sqlite3_step(s) == SQLITE_ROW;
|
||||
if (found) value = text(s, 0);
|
||||
sqlite3_reset(s);
|
||||
return found;
|
||||
}
|
||||
|
||||
void Database::setWorld(const std::string& key, const std::string& value) {
|
||||
sqlite3_stmt* s = prepare(
|
||||
"INSERT INTO world(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
||||
if (!s) return;
|
||||
bindText(s, 1, key);
|
||||
bindText(s, 2, value);
|
||||
sqlite3_step(s);
|
||||
sqlite3_reset(s);
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
69
server/Database.h
Normal file
69
server/Database.h
Normal file
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
// SQLite persistence (WAL): accounts/characters, owned creatures, dex and a
|
||||
// small key/value table for world state (clock, weather).
|
||||
|
||||
#include "Appearance.h"
|
||||
#include "Protocol.h"
|
||||
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
namespace bs {
|
||||
|
||||
struct PlayerRow {
|
||||
int64_t id = 0;
|
||||
std::string token, name;
|
||||
Appearance look;
|
||||
float x = 0, z = 0, yaw = 0;
|
||||
int capsules = 0;
|
||||
bool introDone = false;
|
||||
int64_t createdAt = 0, lastSeen = 0;
|
||||
};
|
||||
|
||||
class Database {
|
||||
public:
|
||||
Database() = default;
|
||||
Database(const Database&) = delete;
|
||||
Database& operator=(const Database&) = delete;
|
||||
~Database();
|
||||
|
||||
bool open(const std::string& path, std::string* error);
|
||||
void close();
|
||||
|
||||
bool loadPlayer(const std::string& token, PlayerRow& out);
|
||||
int64_t createPlayer(const PlayerRow& row); // returns the new id, 0 on failure
|
||||
void savePlayer(const PlayerRow& row); // everything but token / created_at
|
||||
void savePosition(int64_t id, float x, float z, float yaw);
|
||||
|
||||
std::vector<OwnedCreature> loadCreatures(int64_t owner);
|
||||
uint32_t insertCreature(int64_t owner, uint16_t species, int64_t caughtAt, bool inParty); // uid, 0 on failure
|
||||
void setInParty(uint32_t uid, bool inParty);
|
||||
|
||||
void loadDex(int64_t owner, std::set<uint16_t>& seen, std::set<uint16_t>& caught);
|
||||
void markSeen(int64_t owner, uint16_t species);
|
||||
void markCaught(int64_t owner, uint16_t species);
|
||||
|
||||
bool getWorld(const std::string& key, std::string& value);
|
||||
void setWorld(const std::string& key, const std::string& value);
|
||||
|
||||
// Groups many writes into one transaction (shutdown, periodic saves).
|
||||
void begin();
|
||||
void commit();
|
||||
|
||||
private:
|
||||
sqlite3_stmt* prepare(const char* sql); // cached, reset before reuse
|
||||
bool exec(const char* sql);
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::unordered_map<const char*, sqlite3_stmt*> stmts_; // keyed by the SQL literal
|
||||
};
|
||||
|
||||
std::string appearanceToJson(const Appearance& a);
|
||||
Appearance appearanceFromJson(const std::string& s);
|
||||
|
||||
} // namespace bs
|
||||
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
|
||||
219
server/Game.h
Normal file
219
server/Game.h
Normal file
@@ -0,0 +1,219 @@
|
||||
#pragma once
|
||||
// Authoritative game simulation: sessions/accounts, player movement, wild
|
||||
// creatures, fishing, catching, followers, Professor Henry, chat, world clock.
|
||||
|
||||
#include "Appearance.h"
|
||||
#include "Database.h"
|
||||
#include "Movement.h"
|
||||
#include "Net.h"
|
||||
#include "Protocol.h"
|
||||
#include "Rng.h"
|
||||
#include "Roster.h"
|
||||
#include "Spawns.h"
|
||||
#include "Zone.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
struct GameConfig {
|
||||
std::string serverName = "Brandling Safari";
|
||||
uint32_t seed = 1;
|
||||
std::string zoneId = "meadow";
|
||||
float dayMinutes = 30.0f;
|
||||
float hour = -1.0f; // < 0: resume the saved clock (default 9:00)
|
||||
int weather = -1; // < 0: resume the saved weather
|
||||
};
|
||||
|
||||
// Professor Henry always has this entity id; dynamic entities start above it.
|
||||
constexpr uint32_t kHenryEntityId = 1;
|
||||
|
||||
enum class LineState { None, Waiting, Biting };
|
||||
|
||||
struct Player {
|
||||
uint32_t peer = 0;
|
||||
uint32_t entity = 0; // 0 until the character is spawned
|
||||
bool helloDone = false;
|
||||
bool hasCharacter = false;
|
||||
bool spawned = false;
|
||||
PlayerRow row; // persistent fields (id, token, name, look, capsules, intro)
|
||||
MoveState move;
|
||||
uint32_t lastSeq = 0;
|
||||
float inputBudget = 2.0f;
|
||||
int idleTicks = 0; // ticks without an applied input (stops the walk anim)
|
||||
|
||||
std::vector<OwnedCreature> creatures;
|
||||
std::set<uint16_t> seen, caught;
|
||||
|
||||
float throwTimer = 0.0f;
|
||||
LineState line = LineState::None;
|
||||
glm::vec2 bobber{0.0f}, castPos{0.0f};
|
||||
float lineTimer = 0.0f, fishCooldown = 0.0f;
|
||||
|
||||
float grassDist = 0.0f;
|
||||
float rustleCooldown = 0.0f; // misses shake the grass at most about once a second
|
||||
std::deque<glm::vec2> trail; // newest first
|
||||
float introTimer = -1.0f; // > 0: henry_intro dialogue pending
|
||||
bool introOffered = false; // henry_intro was sent and not answered yet
|
||||
double lastChat = -10.0;
|
||||
bool posDirty = false;
|
||||
};
|
||||
|
||||
enum class CMode { Wander, Emerge, Alert, Capturing, Flee, Hooked, Swim, Follow };
|
||||
enum class COrigin { Wanderer, Grass, Swimmer, Fished, Console, Caught };
|
||||
|
||||
struct Creature {
|
||||
uint32_t id = 0;
|
||||
uint16_t species = 0;
|
||||
EntityKind kind = EntityKind::Wild;
|
||||
COrigin origin = COrigin::Wanderer;
|
||||
CMode mode = CMode::Wander;
|
||||
Anim anim = Anim::Idle;
|
||||
glm::vec2 pos{0.0f};
|
||||
float y = 0.0f;
|
||||
float yaw = 0.0f;
|
||||
uint32_t owner = 0; // follower owner / player hooking, capturing or scaring it
|
||||
uint8_t order = 0;
|
||||
uint32_t uid = 0; // follower: owned creature uid
|
||||
bool dead = false; // removed at the end of the tick
|
||||
uint32_t emergedFor = 0; // grass spawn: player entity it popped up for (per-player cap)
|
||||
|
||||
float timer = 0.0f; // time left in the current mode / pause
|
||||
bool walking = false;
|
||||
glm::vec2 target{0.0f};
|
||||
float lifetime = 120.0f; // despawn once this runs out and nobody is within 12 units
|
||||
bool hookBonus = false; // Alert after being fished out
|
||||
int failedThrows = 0;
|
||||
std::set<uint32_t> seenBy; // player entities that already had it registered as seen
|
||||
|
||||
// capture in flight
|
||||
float captureTimer = 0.0f;
|
||||
bool captureSuccess = false;
|
||||
int wobbles = 0;
|
||||
CMode resumeMode = CMode::Wander;
|
||||
|
||||
// hook arc
|
||||
glm::vec2 arcFrom{0.0f}, arcTo{0.0f};
|
||||
float arcFromY = 0.0f;
|
||||
};
|
||||
|
||||
class Game {
|
||||
public:
|
||||
Game(net::Host& host, Database& db, Zone& zone, const Roster& roster, const CharacterManifest& manifest,
|
||||
GameConfig cfg);
|
||||
|
||||
void onConnect(uint32_t peer);
|
||||
void onDisconnect(uint32_t peer);
|
||||
void onReceive(uint32_t peer, const std::vector<uint8_t>& data);
|
||||
void tick();
|
||||
void saveAll();
|
||||
// Console command; returns false for "quit".
|
||||
bool console(const std::string& line);
|
||||
|
||||
int playerCount() const;
|
||||
const std::string& serverName() const { return cfg_.serverName; }
|
||||
|
||||
private:
|
||||
// --- sessions
|
||||
Player* byPeer(uint32_t peer);
|
||||
Player* byEntity(uint32_t entity);
|
||||
void handleHello(Player& p, Reader& r);
|
||||
void handleCreate(Player& p, Reader& r);
|
||||
void handleInput(Player& p, Reader& r);
|
||||
void handleAction(Player& p, Reader& r);
|
||||
void handleChat(Player& p, Reader& r);
|
||||
void handleDialogueDone(Player& p, Reader& r);
|
||||
void spawnPlayer(Player& p);
|
||||
void despawnPlayer(Player& p); // save + PlayerLeft + remove followers
|
||||
void reject(uint32_t peer, const std::string& reason);
|
||||
|
||||
// --- messaging
|
||||
void send(const Player& p, const Writer& w, bool reliable = true);
|
||||
void broadcast(const Writer& w, bool reliable = true);
|
||||
void sendProfile(Player& p);
|
||||
void sendEvent(const GameEvent& e);
|
||||
void sendToast(Player& p, const std::string& text);
|
||||
void sendDialogue(Player& p, const std::string& id);
|
||||
PlayerInfoMsg playerInfo(const Player& p) const;
|
||||
void sendSnapshots();
|
||||
|
||||
// --- persistence
|
||||
void savePlayer(Player& p);
|
||||
void saveWorld();
|
||||
|
||||
// --- players
|
||||
void applyInput(Player& p, const InputCmd& c);
|
||||
void updatePlayer(Player& p, float dt);
|
||||
void cancelLine(Player& p);
|
||||
void noteSeen(Player& p, uint16_t species);
|
||||
glm::vec3 pos3(glm::vec2 p) const { return {p.x, zone_.groundHeight(p), p.y}; }
|
||||
Anim playerAnim(const Player& p) const;
|
||||
|
||||
// --- creatures
|
||||
Creature& addCreature(uint16_t species, glm::vec2 pos, COrigin origin);
|
||||
void removeCreature(uint32_t id);
|
||||
void announceAppeared(const Creature& c);
|
||||
void updateCreatures(float dt);
|
||||
void updateWild(Creature& c, float dt);
|
||||
void updateFollowers(Player& p, float dt);
|
||||
void wander(Creature& c, float dt, float speed);
|
||||
bool pickWanderTarget(Creature& c);
|
||||
void faceNearestPlayer(Creature& c);
|
||||
float nearestPlayerDist(glm::vec2 p, Player** who = nullptr);
|
||||
int emergedCount(const Player& p) const;
|
||||
void maintainPopulation(float dt);
|
||||
bool spawnWanderer();
|
||||
bool spawnSwimmer(bool announce);
|
||||
void grassEncounters(Player& p);
|
||||
void resolveCapture(Creature& c);
|
||||
void syncFollowers(Player& p);
|
||||
Creature* spawnForConsole(uint16_t species);
|
||||
|
||||
// --- actions
|
||||
void actionThrow(Player& p, uint32_t target);
|
||||
void actionCast(Player& p, glm::vec2 dir);
|
||||
void actionReel(Player& p);
|
||||
void actionInteract(Player& p, uint32_t target);
|
||||
void actionToggleParty(Player& p, uint32_t uid);
|
||||
void finishIntro(Player& p);
|
||||
bool landingSpot(glm::vec2 bobber, glm::vec2 player, glm::vec2& out) const;
|
||||
|
||||
// --- world
|
||||
void updateWorld(float dt);
|
||||
void updateHenry(float dt);
|
||||
void setWeather(Weather w);
|
||||
|
||||
net::Host& host_;
|
||||
Database& db_;
|
||||
Zone& zone_;
|
||||
const Roster& roster_;
|
||||
const CharacterManifest& manifest_;
|
||||
GameConfig cfg_;
|
||||
Rng rng_;
|
||||
SpawnTables tables_;
|
||||
|
||||
std::map<uint32_t, std::unique_ptr<Player>> players_; // by peer
|
||||
std::map<uint32_t, Creature> creatures_; // wild + followers by entity id
|
||||
uint32_t nextEntity_ = kHenryEntityId + 1;
|
||||
uint32_t tick_ = 0;
|
||||
double time_ = 0.0; // seconds since start
|
||||
|
||||
float hour_ = 9.0f;
|
||||
Weather weather_ = Weather::Clear;
|
||||
float weatherTimer_ = 300.0f;
|
||||
float weatherBlend_ = 1.0f;
|
||||
float saveTimer_ = 10.0f;
|
||||
|
||||
float henryYaw_ = 0.0f;
|
||||
float wanderSpawnTimer_ = 0.0f;
|
||||
float swimmerTimer_ = 10.0f; // next surface/dive
|
||||
float swimmerRespawn_ = -1.0f; // > 0: a dived swimmer resurfaces elsewhere
|
||||
};
|
||||
|
||||
} // namespace bs
|
||||
30
server/Log.h
Normal file
30
server/Log.h
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
// Timestamped console log lines for the dedicated server.
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace bs {
|
||||
|
||||
#if defined(__GNUC__)
|
||||
__attribute__((format(printf, 1, 2)))
|
||||
#endif
|
||||
inline void logf(const char* fmt, ...) {
|
||||
std::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm, &t);
|
||||
#else
|
||||
localtime_r(&t, &tm);
|
||||
#endif
|
||||
std::printf("[%02d:%02d:%02d] ", tm.tm_hour, tm.tm_min, tm.tm_sec);
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
std::vprintf(fmt, ap);
|
||||
va_end(ap);
|
||||
std::printf("\n");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
122
server/Spawns.cpp
Normal file
122
server/Spawns.cpp
Normal file
@@ -0,0 +1,122 @@
|
||||
#include "Spawns.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
namespace bs {
|
||||
|
||||
namespace {
|
||||
|
||||
float rarityWeight(Rarity r) {
|
||||
switch (r) {
|
||||
case Rarity::Uncommon: return 0.45f;
|
||||
case Rarity::Rare: return 0.12f;
|
||||
case Rarity::Legendary: return 0.025f;
|
||||
default: return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
float landTypeWeight(const std::string& zoneId, const std::string& type) {
|
||||
// Only "meadow" exists today; other zones fall back to the same table.
|
||||
(void)zoneId;
|
||||
static const std::map<std::string, float> meadow = {
|
||||
{"grass", 3.0f}, {"bug", 3.0f}, {"normal", 3.0f}, {"ground", 2.0f}, {"flying", 2.0f},
|
||||
{"rock", 1.0f}, {"poison", 1.0f}, {"fighting", 1.0f}, {"electric", 1.0f}, {"psychic", 0.6f},
|
||||
{"fire", 0.6f}, {"ice", 0.3f}, {"ghost", 0.4f}, {"dragon", 0.2f}, {"water", 0.5f},
|
||||
};
|
||||
auto it = meadow.find(type);
|
||||
return it == meadow.end() ? 0.5f : it->second;
|
||||
}
|
||||
|
||||
float waterTypeWeight(const std::string& type) {
|
||||
if (type == "water") return 5.0f;
|
||||
if (type == "ice") return 1.0f;
|
||||
return 0.2f;
|
||||
}
|
||||
|
||||
float conditionWeight(const std::string& type, bool night, Weather w) {
|
||||
float m = 1.0f;
|
||||
if (night) {
|
||||
if (type == "ghost") m *= 3.0f;
|
||||
else if (type == "psychic") m *= 2.0f;
|
||||
else if (type == "bug") m *= 1.5f;
|
||||
else if (type == "fire") m *= 0.6f;
|
||||
} else {
|
||||
if (type == "fire") m *= 1.4f;
|
||||
else if (type == "grass") m *= 1.2f;
|
||||
}
|
||||
if (w == Weather::Rain || w == Weather::Storm) {
|
||||
if (type == "water") m *= 2.0f;
|
||||
if (type == "electric") m *= w == Weather::Storm ? 3.0f : 1.4f;
|
||||
}
|
||||
if (w == Weather::Fog && type == "ghost") m *= 2.0f;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SpawnTables::update(const Roster& roster, const std::string& zoneId, float hour, Weather weather) {
|
||||
bool night = isNight(hour);
|
||||
int key = int(night) * int(Weather::Count) + int(weather);
|
||||
if (key == key_ && roster_ == &roster) return;
|
||||
key_ = key;
|
||||
roster_ = &roster;
|
||||
for (int h = 0; h < 2; ++h) {
|
||||
Table& t = tables_[h];
|
||||
t.cumulative.clear();
|
||||
t.ids.clear();
|
||||
float total = 0.0f;
|
||||
for (const Species& s : roster.all()) {
|
||||
bool ok = h == int(SpawnHabitat::Land) ? s.habitat != Habitat::Water : s.habitat != Habitat::Grass;
|
||||
if (!ok || s.retired) continue; // retired companies keep their id but no longer spawn
|
||||
auto typeW = [&](const std::string& type) {
|
||||
float z = h == int(SpawnHabitat::Land) ? landTypeWeight(zoneId, type) : waterTypeWeight(type);
|
||||
return z * conditionWeight(type, night, weather);
|
||||
};
|
||||
float tw = typeW(s.type);
|
||||
if (!s.type2.empty()) tw = std::max(tw, 0.6f * typeW(s.type2));
|
||||
float w = rarityWeight(s.rarity) * tw;
|
||||
if (w <= 0.0f) continue;
|
||||
total += w;
|
||||
t.cumulative.push_back(total);
|
||||
t.ids.push_back(s.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool SpawnTables::pick(Rng& rng, SpawnHabitat h, uint16_t& species) const {
|
||||
const Table& t = tables_[int(h)];
|
||||
if (t.ids.empty()) return false;
|
||||
float r = rng.uniform() * t.cumulative.back();
|
||||
size_t i = size_t(std::upper_bound(t.cumulative.begin(), t.cumulative.end(), r) - t.cumulative.begin());
|
||||
species = t.ids[std::min(i, t.ids.size() - 1)];
|
||||
return true;
|
||||
}
|
||||
|
||||
Weather nextWeather(Rng& rng, Weather cur, float hour) {
|
||||
bool morning = hour >= 4.0f && hour <= 8.0f; // fog season
|
||||
float w[int(Weather::Count)] = {};
|
||||
auto& clear = w[int(Weather::Clear)];
|
||||
auto& cloudy = w[int(Weather::Cloudy)];
|
||||
auto& rain = w[int(Weather::Rain)];
|
||||
auto& storm = w[int(Weather::Storm)];
|
||||
auto& fog = w[int(Weather::Fog)];
|
||||
switch (cur) {
|
||||
case Weather::Clear: clear = 5, cloudy = 3, rain = 0.2f, storm = 0.03f, fog = morning ? 3.5f : 0.2f; break;
|
||||
case Weather::Cloudy: clear = 3, cloudy = 2, rain = 3, storm = 0.3f, fog = morning ? 2.0f : 0.3f; break;
|
||||
case Weather::Rain: clear = 1, cloudy = 3, rain = 2, storm = 0.5f, fog = morning ? 1.0f : 0.1f; break;
|
||||
case Weather::Storm: clear = 0.3f, cloudy = 1.5f, rain = 3, storm = 0.4f; break;
|
||||
case Weather::Fog: clear = 3, cloudy = 2, rain = 0.3f, fog = morning ? 3.0f : 0.4f; break;
|
||||
default: clear = 1; break;
|
||||
}
|
||||
float total = 0;
|
||||
for (float v : w) total += v;
|
||||
float r = rng.uniform() * total;
|
||||
for (int i = 0; i < int(Weather::Count); ++i) {
|
||||
if (r < w[i]) return Weather(i);
|
||||
r -= w[i];
|
||||
}
|
||||
return Weather::Clear;
|
||||
}
|
||||
|
||||
} // namespace bs
|
||||
38
server/Spawns.h
Normal file
38
server/Spawns.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
// Species selection for wild spawns (habitat, rarity, zone type weights and
|
||||
// time/weather conditions) and the weather Markov chain.
|
||||
|
||||
#include "Protocol.h"
|
||||
#include "Rng.h"
|
||||
#include "Roster.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace bs {
|
||||
|
||||
enum class SpawnHabitat { Land = 0, Water = 1 };
|
||||
|
||||
inline bool isNight(float hour) { return hour < 5.5f || hour > 19.5f; }
|
||||
|
||||
class SpawnTables {
|
||||
public:
|
||||
// Rebuilds the cumulative tables when (night, weather) differs from the cached condition.
|
||||
void update(const Roster& roster, const std::string& zoneId, float hour, Weather weather);
|
||||
// Returns false when no species fits the habitat.
|
||||
bool pick(Rng& rng, SpawnHabitat h, uint16_t& species) const;
|
||||
|
||||
private:
|
||||
struct Table {
|
||||
std::vector<float> cumulative;
|
||||
std::vector<uint16_t> ids;
|
||||
};
|
||||
Table tables_[2];
|
||||
int key_ = -1;
|
||||
const Roster* roster_ = nullptr;
|
||||
};
|
||||
|
||||
// Next weather state; called every 3-8 real minutes.
|
||||
Weather nextWeather(Rng& rng, Weather current, float hour);
|
||||
|
||||
} // namespace bs
|
||||
246
server/main.cpp
Normal file
246
server/main.cpp
Normal file
@@ -0,0 +1,246 @@
|
||||
// Brandling Safari dedicated server.
|
||||
// safari_server [--port 27420] [--name "Brandling Safari"] [--db <path>] [--seed N] [--zone meadow]
|
||||
// [--roster <path>] [--manifest <path>] [--day-minutes 30] [--hour H]
|
||||
// [--weather clear|cloudy|rain|storm|fog]
|
||||
|
||||
#include "Database.h"
|
||||
#include "Game.h"
|
||||
#include "Log.h"
|
||||
#include "Net.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#else
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
using namespace bs;
|
||||
|
||||
namespace {
|
||||
|
||||
std::atomic<bool> gStop{false};
|
||||
|
||||
void onSignal(int) { gStop = true; }
|
||||
|
||||
std::string defaultDbPath() {
|
||||
std::string base;
|
||||
if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = x;
|
||||
#if defined(_WIN32)
|
||||
else if (const char* a = std::getenv("APPDATA"); a && *a) base = a;
|
||||
#endif
|
||||
else if (const char* h = std::getenv("HOME"); h && *h) base = std::string(h) + "/.local/share";
|
||||
else base = ".";
|
||||
return base + "/brandling-safari/world.db";
|
||||
}
|
||||
|
||||
// Non-blocking stdin lines for the console. POSIX polls fd 0 from the tick loop (a reader thread
|
||||
// blocked inside stdio would deadlock glibc's exit-time stream flush); Windows uses a detached
|
||||
// reader thread with a process-lifetime queue.
|
||||
class ConsoleInput {
|
||||
public:
|
||||
bool pop(std::string& out) {
|
||||
#if defined(_WIN32)
|
||||
static bool started = false;
|
||||
if (!started) {
|
||||
started = true;
|
||||
std::thread([] {
|
||||
std::string line;
|
||||
while (std::getline(std::cin, line)) {
|
||||
std::lock_guard<std::mutex> lock(mutex());
|
||||
queue().push_back(line);
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex());
|
||||
if (queue().empty()) return false;
|
||||
out = std::move(queue().front());
|
||||
queue().pop_front();
|
||||
return true;
|
||||
#else
|
||||
while (open_) {
|
||||
pollfd p{0, POLLIN, 0};
|
||||
if (::poll(&p, 1, 0) <= 0 || !(p.revents & (POLLIN | POLLHUP))) break;
|
||||
char buf[512];
|
||||
ssize_t n = ::read(0, buf, sizeof buf);
|
||||
if (n <= 0) {
|
||||
open_ = false; // EOF (or error): no console, keep serving
|
||||
break;
|
||||
}
|
||||
pending_.append(buf, size_t(n));
|
||||
}
|
||||
size_t nl = pending_.find('\n');
|
||||
if (nl == std::string::npos) return false;
|
||||
out = pending_.substr(0, nl);
|
||||
pending_.erase(0, nl + 1);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
private:
|
||||
#if defined(_WIN32)
|
||||
static std::mutex& mutex() { static std::mutex m; return m; }
|
||||
static std::deque<std::string>& queue() { static std::deque<std::string> q; return q; }
|
||||
#else
|
||||
bool open_ = true;
|
||||
std::string pending_;
|
||||
#endif
|
||||
};
|
||||
|
||||
void usage() {
|
||||
std::printf(
|
||||
"usage: safari_server [--port N] [--name NAME] [--db PATH] [--seed N] [--zone meadow]\n"
|
||||
" [--roster PATH] [--manifest PATH] [--day-minutes M] [--hour H]\n"
|
||||
" [--weather clear|cloudy|rain|storm|fog]\n");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
uint16_t port = kDefaultPort;
|
||||
GameConfig cfg;
|
||||
std::string dbPath = defaultDbPath();
|
||||
std::string rosterPath = std::string(BS_ASSET_DIR) + "/creatures/roster.json";
|
||||
std::string manifestPath = std::string(BS_ASSET_DIR) + "/characters/manifest.json";
|
||||
bool seedGiven = false;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
auto next = [&]() -> std::string {
|
||||
if (i + 1 >= argc) {
|
||||
std::fprintf(stderr, "missing value for %s\n", a.c_str());
|
||||
std::exit(2);
|
||||
}
|
||||
return argv[++i];
|
||||
};
|
||||
if (a == "--port") port = uint16_t(std::atoi(next().c_str()));
|
||||
else if (a == "--name") cfg.serverName = next().substr(0, 64);
|
||||
else if (a == "--db") dbPath = next();
|
||||
else if (a == "--seed") cfg.seed = uint32_t(std::strtoul(next().c_str(), nullptr, 10)), seedGiven = true;
|
||||
else if (a == "--zone") cfg.zoneId = next();
|
||||
else if (a == "--roster") rosterPath = next();
|
||||
else if (a == "--manifest") manifestPath = next();
|
||||
else if (a == "--day-minutes") cfg.dayMinutes = std::max(0.1f, std::strtof(next().c_str(), nullptr));
|
||||
else if (a == "--hour") cfg.hour = std::clamp(std::strtof(next().c_str(), nullptr), 0.0f, 23.999f);
|
||||
else if (a == "--weather") {
|
||||
Weather w;
|
||||
std::string v = next();
|
||||
if (!parseWeather(v, w)) {
|
||||
std::fprintf(stderr, "unknown weather '%s'\n", v.c_str());
|
||||
return 2;
|
||||
}
|
||||
cfg.weather = int(w);
|
||||
} else if (a == "--help" || a == "-h") {
|
||||
usage();
|
||||
return 0;
|
||||
} else {
|
||||
std::fprintf(stderr, "unknown argument '%s'\n", a.c_str());
|
||||
usage();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
std::string err;
|
||||
Roster roster;
|
||||
if (!roster.load(rosterPath, &err)) {
|
||||
std::fprintf(stderr, "roster: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
CharacterManifest manifest;
|
||||
if (!manifest.load(manifestPath, &err)) {
|
||||
std::fprintf(stderr, "manifest: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
Database db;
|
||||
if (!db.open(dbPath, &err)) {
|
||||
std::fprintf(stderr, "database %s: %s\n", dbPath.c_str(), err.c_str());
|
||||
return 1;
|
||||
}
|
||||
// The zone seed is part of the world: keep the first one unless --seed overrides it.
|
||||
std::string saved;
|
||||
if (!seedGiven && db.getWorld("seed", saved)) cfg.seed = uint32_t(std::strtoul(saved.c_str(), nullptr, 10));
|
||||
db.setWorld("seed", std::to_string(cfg.seed));
|
||||
Zone zone;
|
||||
if (!zone.generate(cfg.zoneId, cfg.seed)) {
|
||||
std::fprintf(stderr, "unknown zone '%s'\n", cfg.zoneId.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!net::initialize()) {
|
||||
std::fprintf(stderr, "enet initialization failed\n");
|
||||
return 1;
|
||||
}
|
||||
{
|
||||
net::Host host;
|
||||
if (!host.listen(port, 32)) {
|
||||
std::fprintf(stderr, "cannot listen on UDP port %u\n", unsigned(port));
|
||||
net::shutdown();
|
||||
return 1;
|
||||
}
|
||||
net::Beacon beacon;
|
||||
bool lan = beacon.open(kDiscoveryPort);
|
||||
|
||||
Game game(host, db, zone, roster, manifest, cfg);
|
||||
logf("\"%s\" listening on UDP %u (LAN discovery %s), db %s", cfg.serverName.c_str(), unsigned(port),
|
||||
lan ? "on" : "unavailable", dbPath.c_str());
|
||||
logf("type 'help' for console commands");
|
||||
|
||||
std::signal(SIGINT, onSignal);
|
||||
std::signal(SIGTERM, onSignal);
|
||||
ConsoleInput input;
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
const auto step = std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(kTickDt));
|
||||
auto next = Clock::now();
|
||||
bool running = true;
|
||||
while (running && !gStop) {
|
||||
auto now = Clock::now();
|
||||
uint32_t waitMs = 0;
|
||||
if (next > now)
|
||||
waitMs = uint32_t(std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count());
|
||||
net::Event ev;
|
||||
bool got = host.poll(ev, waitMs);
|
||||
while (got) {
|
||||
switch (ev.type) {
|
||||
case net::Event::Connect: game.onConnect(ev.peer); break;
|
||||
case net::Event::Disconnect: game.onDisconnect(ev.peer); break;
|
||||
case net::Event::Receive: game.onReceive(ev.peer, ev.data); break;
|
||||
default: break;
|
||||
}
|
||||
got = host.poll(ev, 0);
|
||||
}
|
||||
std::string line;
|
||||
while (running && input.pop(line)) running = game.console(line);
|
||||
|
||||
now = Clock::now();
|
||||
int steps = 0;
|
||||
while (now >= next && steps < 5) {
|
||||
game.tick();
|
||||
next += step;
|
||||
++steps;
|
||||
}
|
||||
if (now - next > std::chrono::seconds(1)) next = now; // fell far behind: drop the backlog
|
||||
beacon.poll(port, cfg.serverName, game.playerCount());
|
||||
host.flush();
|
||||
}
|
||||
logf("shutting down, saving...");
|
||||
game.saveAll();
|
||||
host.flush();
|
||||
}
|
||||
db.close();
|
||||
net::shutdown();
|
||||
logf("bye");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user