Brandling Safari: LAN safari-zone creature catching on the HD-2D engine

- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass /
  water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon
- Client: title / join / character creator / play screens, prediction + reconciliation,
  interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection)
- Shared deterministic zone generator, movement, protocol, roster, appearance
- Paper-doll character art pipeline (PixelLab base walk + per-item animation edits)
- Brandling roster from the company list (logo colors, jev types), Blender safari props
- Headless test bot
This commit is contained in:
Gabe
2026-10-02 15:20:27 -05:00
commit 238cd9c6c3
3409 changed files with 100493 additions and 0 deletions

139
shared/Appearance.cpp Normal file
View File

@@ -0,0 +1,139 @@
#include "Appearance.h"
#include <nlohmann/json.hpp>
#include <cstdio>
#include <fstream>
namespace bs {
namespace {
const char* kSlotNames[kSlotCount] = {"shoes", "bottom", "top", "hair", "hat"};
}
const char* slotName(int slot) { return slot >= 0 && slot < kSlotCount ? kSlotNames[slot] : "?"; }
int slotFromName(const std::string& s) {
for (int i = 0; i < kSlotCount; ++i)
if (s == kSlotNames[i]) return i;
return -1;
}
uint32_t parseHexColor(const std::string& s, uint32_t fallback) {
std::string h = s;
if (!h.empty() && h[0] == '#') h.erase(0, 1);
if (h.size() != 6) return fallback;
char* end = nullptr;
unsigned long v = std::strtoul(h.c_str(), &end, 16);
return end && *end == '\0' ? uint32_t(v) : fallback;
}
std::string hexColor(uint32_t rgb) {
char buf[8];
std::snprintf(buf, sizeof buf, "#%06x", rgb & 0xffffffu);
return buf;
}
bool CharacterManifest::load(const std::string& path, std::string* error) {
std::ifstream f(path);
if (!f) {
if (error) *error = "cannot open " + path;
return false;
}
try {
nlohmann::json j = nlohmann::json::parse(f);
cell = j.value("cell", 64);
framesPerDirection = j.value("framesPerDirection", 5);
walkFrames = j.value("walkFrames", 4);
shades = j.value("shades", 4);
directions = j.value("directions", std::vector<std::string>{});
for (auto& [k, v] : j.at("slots").items()) {
int s = slotFromName(k);
if (s >= 0) slotZ[s] = v.get<int>();
}
for (auto& c : j.at("skinTones")) skinTones.push_back(parseHexColor(c.get<std::string>()));
for (auto& c : j.value("palette", nlohmann::json::array())) palette.push_back(parseHexColor(c.get<std::string>()));
for (auto& [id, v] : j.at("items").items()) {
ItemDef d;
d.id = id;
d.label = v.value("label", id);
d.slot = slotFromName(v.at("slot").get<std::string>());
d.color = parseHexColor(v.value("color", "#ffffff"));
d.fromBody = v.value("fromBody", false);
if (d.slot >= 0) items[id] = d;
}
for (auto& [id, v] : j.at("bodies").items()) {
BodyDef b;
b.id = id;
b.label = v.value("label", id);
b.anchorRow = v.value("anchorRow", cell - 1);
b.items = v.value("items", std::vector<std::string>{});
bodies[id] = b;
}
if (j.contains("npcs"))
for (auto& [id, v] : j["npcs"].items())
npcs[id] = {v.value("sheet", ""), v.value("portrait", ""), v.value("anchorRow", cell - 1)};
} catch (const std::exception& e) {
if (error) *error = path + ": " + e.what();
return false;
}
if (bodies.empty() || skinTones.empty()) {
if (error) *error = path + ": no bodies or skin tones";
return false;
}
return true;
}
const ItemDef* CharacterManifest::item(const std::string& id) const {
auto it = items.find(id);
return it == items.end() ? nullptr : &it->second;
}
bool CharacterManifest::bodyHas(const std::string& body, const std::string& id) const {
auto it = bodies.find(body);
if (it == bodies.end()) return false;
for (const std::string& s : it->second.items)
if (s == id) return true;
return false;
}
Appearance CharacterManifest::defaultLook(const std::string& body) const {
Appearance a;
a.body = bodies.count(body) ? body : bodies.begin()->first;
a.skin = 1;
auto pick = [&](int slot, const char* preferred) {
if (bodyHas(a.body, preferred)) a.items[slot] = preferred;
else
for (const std::string& id : bodies.at(a.body).items)
if (item(id)->slot == slot) { a.items[slot] = id; break; }
};
pick(Top, "top_tee");
pick(Bottom, "bottom_shorts");
pick(Hair, "hair_short");
pick(Shoes, "shoes_sneakers");
for (int s = 0; s < kSlotCount; ++s)
a.colors[s] = a.items[s].empty() ? 0xffffff : item(a.items[s])->color;
return a;
}
bool CharacterManifest::sanitize(Appearance& a) const {
bool ok = true;
if (!bodies.count(a.body)) {
a = defaultLook();
return false;
}
if (a.skin >= skinTones.size()) a.skin = 0, ok = false;
for (int s = 0; s < kSlotCount; ++s) {
const std::string& id = a.items[s];
if (id.empty()) continue;
const ItemDef* d = item(id);
if (!d || d->slot != s || !bodyHas(a.body, id)) a.items[s].clear(), ok = false;
a.colors[s] &= 0xffffffu;
}
Appearance def = defaultLook(a.body);
for (int s : {int(Top), int(Bottom)}) // always dressed
if (a.items[s].empty()) a.items[s] = def.items[s], a.colors[s] = def.colors[s], ok = false;
return ok;
}
} // namespace bs

67
shared/Appearance.h Normal file
View File

@@ -0,0 +1,67 @@
#pragma once
// Player look: base body, skin tone, one item per slot and a color per slot.
// The layer art and the list of valid items come from
// assets/characters/manifest.json (written by tools/character_art.py).
#include <array>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace bs {
enum Slot : int { Shoes = 0, Bottom, Top, Hair, Hat, kSlotCount };
const char* slotName(int slot); // manifest slot keys: shoes bottom top hair hat
int slotFromName(const std::string& s); // -1 if unknown
struct Appearance {
std::string body = "male";
uint8_t skin = 0; // index into manifest skinTones
std::array<std::string, kSlotCount> items; // "" = nothing in that slot
std::array<uint32_t, kSlotCount> colors{}; // 0xRRGGBB per slot
};
struct ItemDef {
std::string id, label;
int slot = 0;
uint32_t color = 0; // default color
bool fromBody = false; // carved out of the base body (tee, shorts)
};
struct BodyDef {
std::string id, label;
int anchorRow = 63; // lowest opaque row of a 64 px frame
std::vector<std::string> items;
};
struct NpcArt {
std::string sheet, portrait;
int anchorRow = 63;
};
// assets/characters/manifest.json
struct CharacterManifest {
int cell = 64, framesPerDirection = 5, walkFrames = 4, shades = 4;
std::vector<std::string> directions; // sheet rows: south, east, north (west = east mirrored)
std::array<int, kSlotCount> slotZ{};
std::vector<uint32_t> skinTones;
std::vector<uint32_t> palette;
std::map<std::string, BodyDef> bodies;
std::map<std::string, ItemDef> items;
std::map<std::string, NpcArt> npcs;
bool load(const std::string& path, std::string* error = nullptr);
const ItemDef* item(const std::string& id) const;
bool bodyHas(const std::string& body, const std::string& item) const;
Appearance defaultLook(const std::string& body = "male") const;
// Fixes anything invalid (unknown body/item, wrong slot, missing top/bottom, skin out of
// range). Returns false if something had to be changed.
bool sanitize(Appearance& a) const;
};
uint32_t parseHexColor(const std::string& s, uint32_t fallback = 0xffffff);
std::string hexColor(uint32_t rgb);
} // namespace bs

49
shared/Movement.h Normal file
View File

@@ -0,0 +1,49 @@
#pragma once
// Player movement, run identically by the server (authoritative) and the
// client (prediction + reconciliation). Pure function of state + input.
#include "Protocol.h"
#include "Zone.h"
namespace bs {
constexpr float kWalkSpeed = 4.0f; // world units / second
constexpr float kRunSpeed = 6.6f;
struct MoveState {
glm::vec2 pos{0.0f};
float yaw = 0.0f;
bool moving = false;
bool running = false;
};
inline void stepMovement(const Zone& zone, MoveState& s, const InputCmd& in, float dt) {
glm::vec2 dir = in.move();
float amount = std::min(glm::length(dir), 1.0f);
s.moving = amount > 0.05f;
s.running = s.moving && in.run();
if (!s.moving) return;
dir = glm::normalize(dir) * amount;
s.yaw = yawFromDir(dir);
glm::vec2 step = dir * (s.running ? kRunSpeed : kWalkSpeed) * dt;
// Axis-separated resolution slides along obstacles.
glm::vec2 p = s.pos;
if (zone.walkable(p, p + glm::vec2(step.x, 0.0f))) p.x += step.x;
if (zone.walkable(p, p + glm::vec2(0.0f, step.y))) p.y += step.y;
s.pos = p;
}
// Quantizes a world direction exactly like the network does, so prediction
// uses the same input the server will see.
inline InputCmd makeInput(uint32_t seq, glm::vec2 worldDir, bool run) {
InputCmd c;
c.seq = seq;
float len = glm::length(worldDir);
if (len > 1.0f) worldDir /= len;
c.moveX = int8_t(std::lround(glm::clamp(worldDir.x, -1.0f, 1.0f) * 127.0f));
c.moveZ = int8_t(std::lround(glm::clamp(worldDir.y, -1.0f, 1.0f) * 127.0f));
c.flags = run ? 1 : 0;
return c;
}
} // namespace bs

236
shared/Net.cpp Normal file
View File

@@ -0,0 +1,236 @@
#include "Net.h"
#include <enet/enet.h>
#include <cstdio>
#include <cstring>
namespace bs::net {
namespace {
bool gInit = false;
constexpr const char* kQuery = "BSAFARI?";
constexpr const char* kReply = "BSAFARI!";
// ENetBuffer's field order differs per platform (it aliases iovec / WSABUF).
ENetBuffer buffer(void* data, size_t size) {
ENetBuffer b;
b.data = data;
b.dataLength = size;
return b;
}
} // namespace
bool initialize() {
if (gInit) return true;
if (enet_initialize() != 0) return false;
gInit = true;
return true;
}
void shutdown() {
if (gInit) enet_deinitialize();
gInit = false;
}
Host::~Host() {
if (host_) {
for (auto& [id, p] : peers_) enet_peer_disconnect_now(p, 0);
enet_host_flush(host_);
enet_host_destroy(host_);
}
}
bool Host::listen(uint16_t port, size_t maxPeers) {
ENetAddress addr;
addr.host = ENET_HOST_ANY;
addr.port = port;
host_ = enet_host_create(&addr, maxPeers, kChannelCount, 0, 0);
return host_ != nullptr;
}
bool Host::connect(const std::string& address, uint16_t port) {
if (!host_) host_ = enet_host_create(nullptr, 1, kChannelCount, 0, 0);
if (!host_) return false;
ENetAddress addr;
if (enet_address_set_host(&addr, address.c_str()) != 0) return false;
addr.port = port;
ENetPeer* p = enet_host_connect(host_, &addr, kChannelCount, 0);
if (!p) return false;
uint32_t id = nextId_++;
p->data = reinterpret_cast<void*>(uintptr_t(id));
peers_[id] = p;
enet_peer_timeout(p, 0, 4000, 8000);
return true;
}
bool Host::poll(Event& ev, uint32_t timeoutMs) {
ev = Event{};
if (!host_) return false;
ENetEvent e;
int r = enet_host_service(host_, &e, timeoutMs);
if (r <= 0) return false;
switch (e.type) {
case ENET_EVENT_TYPE_CONNECT: {
uint32_t id = uint32_t(uintptr_t(e.peer->data));
if (!id) { // incoming connection
id = nextId_++;
e.peer->data = reinterpret_cast<void*>(uintptr_t(id));
peers_[id] = e.peer;
enet_peer_timeout(e.peer, 0, 5000, 10000);
}
ev.type = Event::Connect;
ev.peer = id;
return true;
}
case ENET_EVENT_TYPE_DISCONNECT: {
uint32_t id = uint32_t(uintptr_t(e.peer->data));
peers_.erase(id);
e.peer->data = nullptr;
ev.type = Event::Disconnect;
ev.peer = id;
return true;
}
case ENET_EVENT_TYPE_RECEIVE: {
ev.type = Event::Receive;
ev.peer = uint32_t(uintptr_t(e.peer->data));
ev.data.assign(e.packet->data, e.packet->data + e.packet->dataLength);
enet_packet_destroy(e.packet);
return true;
}
default: return false;
}
}
void Host::send(uint32_t peer, Channel ch, const std::vector<uint8_t>& data) {
auto it = peers_.find(peer);
if (it == peers_.end() || data.empty()) return;
ENetPacket* pk = enet_packet_create(data.data(), data.size(), ch == Reliable ? ENET_PACKET_FLAG_RELIABLE : 0);
if (enet_peer_send(it->second, ch, pk) != 0) enet_packet_destroy(pk);
}
void Host::disconnect(uint32_t peer) {
auto it = peers_.find(peer);
if (it != peers_.end()) enet_peer_disconnect_later(it->second, 0);
}
void Host::flush() {
if (host_) enet_host_flush(host_);
}
std::string Host::address(uint32_t peer) const {
auto it = peers_.find(peer);
if (it == peers_.end()) return "?";
char buf[64] = {};
enet_address_get_host_ip(&it->second->address, buf, sizeof buf);
return buf;
}
uint32_t Host::roundTripMs(uint32_t peer) const {
auto it = peers_.find(peer);
return it == peers_.end() ? 0 : it->second->roundTripTime;
}
// ------------------------------------------------------------------ LAN discovery
Beacon::~Beacon() {
if (sock_ >= 0) enet_socket_destroy(ENetSocket(sock_));
}
bool Beacon::open(uint16_t discoveryPort) {
ENetSocket s = enet_socket_create(ENET_SOCKET_TYPE_DATAGRAM);
if (s == ENET_SOCKET_NULL) return false;
enet_socket_set_option(s, ENET_SOCKOPT_REUSEADDR, 1);
enet_socket_set_option(s, ENET_SOCKOPT_NONBLOCK, 1);
enet_socket_set_option(s, ENET_SOCKOPT_BROADCAST, 1);
ENetAddress addr;
addr.host = ENET_HOST_ANY;
addr.port = discoveryPort;
if (enet_socket_bind(s, &addr) != 0) {
enet_socket_destroy(s);
return false;
}
sock_ = intptr_t(s);
return true;
}
void Beacon::poll(uint16_t gamePort, const std::string& serverName, int players) {
if (sock_ < 0) return;
char buf[256];
for (int i = 0; i < 16; ++i) {
ENetAddress from;
ENetBuffer b = buffer(buf, sizeof buf - 1);
int n = enet_socket_receive(ENetSocket(sock_), &from, &b, 1);
if (n <= 0) return;
buf[n] = 0;
if (std::strncmp(buf, kQuery, std::strlen(kQuery)) != 0) continue;
char reply[256];
int len = std::snprintf(reply, sizeof reply, "%s%u|%d|%s", kReply, unsigned(gamePort), players,
serverName.substr(0, 120).c_str());
ENetBuffer out = buffer(reply, size_t(len));
enet_socket_send(ENetSocket(sock_), &from, &out, 1);
}
}
Scanner::~Scanner() {
if (sock_ >= 0) enet_socket_destroy(ENetSocket(sock_));
}
bool Scanner::open() {
ENetSocket s = enet_socket_create(ENET_SOCKET_TYPE_DATAGRAM);
if (s == ENET_SOCKET_NULL) return false;
enet_socket_set_option(s, ENET_SOCKOPT_NONBLOCK, 1);
enet_socket_set_option(s, ENET_SOCKOPT_BROADCAST, 1);
ENetAddress addr;
addr.host = ENET_HOST_ANY;
addr.port = 0;
enet_socket_bind(s, &addr);
sock_ = intptr_t(s);
return true;
}
void Scanner::ping(uint16_t discoveryPort) {
if (sock_ < 0) return;
ENetBuffer b = buffer(const_cast<char*>(kQuery), std::strlen(kQuery));
ENetAddress to;
to.host = ENET_HOST_BROADCAST;
to.port = discoveryPort;
enet_socket_send(ENetSocket(sock_), &to, &b, 1);
// also ask localhost directly (broadcast does not always loop back)
enet_address_set_host(&to, "127.0.0.1");
enet_socket_send(ENetSocket(sock_), &to, &b, 1);
}
void Scanner::poll(std::vector<ServerInfo>& found) {
if (sock_ < 0) return;
char buf[256];
for (int i = 0; i < 32; ++i) {
ENetAddress from;
ENetBuffer b = buffer(buf, sizeof buf - 1);
int n = enet_socket_receive(ENetSocket(sock_), &from, &b, 1);
if (n <= 0) return;
buf[n] = 0;
size_t rl = std::strlen(kReply);
if (std::strncmp(buf, kReply, rl) != 0) continue;
ServerInfo info;
char ip[64] = {};
enet_address_get_host_ip(&from, ip, sizeof ip);
info.address = ip;
unsigned port = 0;
int players = 0;
int consumed = 0;
if (std::sscanf(buf + rl, "%u|%d|%n", &port, &players, &consumed) < 2) continue;
info.port = uint16_t(port);
info.players = players;
info.name = buf + rl + consumed;
bool dup = false;
for (ServerInfo& s : found)
if (s.port == info.port && (s.address == info.address || s.name == info.name)) {
s.players = info.players;
dup = true;
}
if (!dup) found.push_back(info);
}
}
} // namespace bs::net

81
shared/Net.h Normal file
View File

@@ -0,0 +1,81 @@
#pragma once
// Thin ENet wrapper (reliable channel + unreliable channel) and a UDP
// broadcast beacon for finding servers on the LAN.
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
struct _ENetHost;
struct _ENetPeer;
namespace bs::net {
enum Channel : uint8_t { Reliable = 0, Unreliable = 1, kChannelCount = 2 };
struct Event {
enum Type { None, Connect, Disconnect, Receive } type = None;
uint32_t peer = 0;
std::vector<uint8_t> data;
};
bool initialize(); // once per process (enet_initialize); safe to call repeatedly
void shutdown();
class Host {
public:
Host() = default;
Host(const Host&) = delete;
Host& operator=(const Host&) = delete;
~Host();
bool listen(uint16_t port, size_t maxPeers);
// Starts connecting; a Connect (or Disconnect on failure) event follows.
bool connect(const std::string& address, uint16_t port);
// Next event; waits up to timeoutMs for the first one.
bool poll(Event& ev, uint32_t timeoutMs = 0);
void send(uint32_t peer, Channel ch, const std::vector<uint8_t>& data);
void disconnect(uint32_t peer);
void flush();
std::string address(uint32_t peer) const;
uint32_t roundTripMs(uint32_t peer) const;
bool valid() const { return host_ != nullptr; }
private:
_ENetHost* host_ = nullptr;
uint32_t nextId_ = 1;
std::unordered_map<uint32_t, _ENetPeer*> peers_;
};
struct ServerInfo {
std::string address; // ip the reply came from
uint16_t port = 0;
std::string name;
int players = 0;
};
// Server side: answers "BSAFARI?" broadcasts on kDiscoveryPort.
class Beacon {
public:
~Beacon();
bool open(uint16_t discoveryPort);
void poll(uint16_t gamePort, const std::string& serverName, int players);
private:
intptr_t sock_ = -1;
};
// Client side: broadcasts a query and collects replies.
class Scanner {
public:
~Scanner();
bool open();
void ping(uint16_t discoveryPort);
void poll(std::vector<ServerInfo>& found);
private:
intptr_t sock_ = -1;
};
} // namespace bs::net

143
shared/Protocol.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "Protocol.h"
#include <algorithm>
namespace bs {
namespace {
const char* kWeatherNames[] = {"clear", "cloudy", "rain", "storm", "fog"};
}
const char* weatherName(Weather w) {
int i = int(w);
return i >= 0 && i < int(Weather::Count) ? kWeatherNames[i] : "clear";
}
bool parseWeather(const std::string& s, Weather& out) {
for (int i = 0; i < int(Weather::Count); ++i)
if (s == kWeatherNames[i]) {
out = Weather(i);
return true;
}
return false;
}
void writeAppearance(Writer& w, const Appearance& a) {
w.str(a.body);
w.u8(a.skin);
for (int s = 0; s < kSlotCount; ++s) {
w.str(a.items[s]);
w.u32(a.colors[s]);
}
}
Appearance readAppearance(Reader& r) {
Appearance a;
a.body = r.str();
a.skin = r.u8();
for (int s = 0; s < kSlotCount; ++s) {
a.items[s] = r.str();
a.colors[s] = r.u32();
}
return a;
}
void InputMsg::write(Writer& w) const {
size_t n = std::min<size_t>(cmds.size(), 4);
w.u8(uint8_t(n));
for (size_t i = cmds.size() - n; i < cmds.size(); ++i) {
w.u32(cmds[i].seq);
w.i8(cmds[i].moveX);
w.i8(cmds[i].moveZ);
w.u8(cmds[i].flags);
}
}
void InputMsg::read(Reader& r) {
uint8_t n = std::min<uint8_t>(r.u8(), 4);
cmds.resize(n);
for (InputCmd& c : cmds) {
c.seq = r.u32();
c.moveX = r.i8();
c.moveZ = r.i8();
c.flags = r.u8();
}
}
void ProfileMsg::write(Writer& w) const {
w.u32(entityId);
w.str(name);
writeAppearance(w, look);
w.i32(capsules);
w.u8(introDone);
w.u32(seen);
w.u32(caught);
w.u32(uint32_t(creatures.size()));
for (const OwnedCreature& c : creatures) {
w.u32(c.uid);
w.u16(c.species);
w.u8(c.inParty);
w.i64(c.caughtAt);
}
}
void ProfileMsg::read(Reader& r) {
entityId = r.u32();
name = r.str();
look = readAppearance(r);
capsules = r.i32();
introDone = r.u8();
seen = r.u32();
caught = r.u32();
uint32_t n = std::min<uint32_t>(r.u32(), 100000);
creatures.clear();
for (uint32_t i = 0; i < n && r.ok(); ++i) {
OwnedCreature c;
c.uid = r.u32();
c.species = r.u16();
c.inParty = r.u8();
c.caughtAt = r.i64();
creatures.push_back(c);
}
}
void SnapshotMsg::write(Writer& w) const {
w.u32(tick);
w.u32(ackInput);
w.f32(hour);
w.u8(uint8_t(weather));
w.f32(weatherBlend);
w.u16(uint16_t(entities.size()));
for (const EntityState& e : entities) {
w.u32(e.id);
w.u8(uint8_t(e.kind));
w.u8(uint8_t(e.anim));
w.u16(e.species);
w.u32(e.owner);
w.vec3(e.pos);
w.f32(e.yaw);
w.u8(e.order);
}
}
void SnapshotMsg::read(Reader& r) {
tick = r.u32();
ackInput = r.u32();
hour = r.f32();
weather = Weather(std::min<uint8_t>(r.u8(), uint8_t(Weather::Count) - 1));
weatherBlend = r.f32();
uint16_t n = r.u16();
entities.resize(n);
for (EntityState& e : entities) {
e.id = r.u32();
e.kind = EntityKind(r.u8());
e.anim = Anim(r.u8());
e.species = r.u16();
e.owner = r.u32();
e.pos = r.vec3();
e.yaw = r.f32();
e.order = r.u8();
}
}
} // namespace bs

265
shared/Protocol.h Normal file
View File

@@ -0,0 +1,265 @@
#pragma once
// Wire protocol shared by server and client. Every message is one ENet packet:
// [u8 Msg][payload]. Payloads are little-endian, strings are u16 length + bytes.
// The server is authoritative: clients only send intents (inputs, actions).
#include "Appearance.h"
#include <glm/glm.hpp>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
namespace bs {
constexpr uint16_t kProtocolVersion = 3;
constexpr uint16_t kDefaultPort = 27420;
constexpr uint16_t kDiscoveryPort = 27421;
constexpr int kTickRate = 30; // simulation ticks per second (server and client prediction)
constexpr float kTickDt = 1.0f / float(kTickRate);
constexpr int kMaxParty = 6; // creatures following a player
constexpr int kStartCapsules = 30;
enum class Msg : uint8_t {
// client -> server
Hello = 1, // HelloMsg
CreateCharacter, // CreateCharacterMsg
Input, // InputMsg (unreliable)
Action, // ActionMsg
Chat, // string
DialogueDone, // string script id
// server -> client
Welcome = 64, // WelcomeMsg
Reject, // string reason
Profile, // ProfileMsg (owner only)
PlayerInfo, // PlayerInfoMsg (name + look of a player entity)
PlayerLeft, // u32 entity id
Snapshot, // SnapshotMsg (unreliable)
Event, // GameEvent
Dialogue, // string script id (server-triggered NPC talk)
ChatLine, // u32 entity id, string name, string text
Toast, // string text
};
enum class EntityKind : uint8_t { Player = 0, Wild = 1, Follower = 2, Npc = 3 };
enum class Anim : uint8_t {
Idle = 0,
Walk,
Run,
Fish, // player holding a cast line
Throw, // player throwing a capsule
Emerge, // wild creature popping out of grass / water
Swim, // water creature at the surface
Capturing, // inside a capsule (hidden by the client)
Flee, // running away before despawning
Hooked, // pulled out of the water on a line
Alert, // noticed a player; stands still facing them
};
enum class Weather : uint8_t { Clear = 0, Cloudy, Rain, Storm, Fog, Count };
const char* weatherName(Weather w);
bool parseWeather(const std::string& s, Weather& out);
enum class ActionKind : uint8_t {
Throw = 0, // target = wild entity
Cast, // x,z = facing direction (bobber is placed by the server)
Reel, // pull the line in (hooks a creature if it is biting)
Interact, // talk to the NPC in front (target = npc entity)
ToggleParty, // target = creature uid: move between party and box
};
// ------------------------------------------------------------ serialization
class Writer {
public:
explicit Writer(Msg m) { u8(uint8_t(m)); }
void u8(uint8_t v) { buf_.push_back(v); }
void u16(uint16_t v) { raw(&v, 2); }
void u32(uint32_t v) { raw(&v, 4); }
void i32(int32_t v) { raw(&v, 4); }
void i64(int64_t v) { raw(&v, 8); }
void f32(float v) { raw(&v, 4); }
void i8(int8_t v) { raw(&v, 1); }
void vec3(const glm::vec3& v) { f32(v.x), f32(v.y), f32(v.z); }
void str(const std::string& s) {
uint16_t n = uint16_t(std::min<size_t>(s.size(), 4000));
u16(n);
raw(s.data(), n);
}
const std::vector<uint8_t>& data() const { return buf_; }
private:
void raw(const void* p, size_t n) {
const auto* b = static_cast<const uint8_t*>(p);
buf_.insert(buf_.end(), b, b + n);
}
std::vector<uint8_t> buf_;
};
// Reads past the end yield zeros and set `ok() == false`, so malformed packets
// can be rejected after parsing instead of crashing.
class Reader {
public:
Reader(const uint8_t* data, size_t size) : p_(data), end_(data + size) {}
explicit Reader(const std::vector<uint8_t>& v) : Reader(v.data(), v.size()) {}
uint8_t u8() { uint8_t v = 0; raw(&v, 1); return v; }
uint16_t u16() { uint16_t v = 0; raw(&v, 2); return v; }
uint32_t u32() { uint32_t v = 0; raw(&v, 4); return v; }
int32_t i32() { int32_t v = 0; raw(&v, 4); return v; }
int64_t i64() { int64_t v = 0; raw(&v, 8); return v; }
int8_t i8() { int8_t v = 0; raw(&v, 1); return v; }
float f32() { float v = 0; raw(&v, 4); return v; }
glm::vec3 vec3() { glm::vec3 v; v.x = f32(); v.y = f32(); v.z = f32(); return v; }
std::string str() {
uint16_t n = u16();
if (size_t(end_ - p_) < n) { ok_ = false; p_ = end_; return {}; }
std::string s(reinterpret_cast<const char*>(p_), n);
p_ += n;
return s;
}
bool ok() const { return ok_; }
bool atEnd() const { return p_ >= end_; }
private:
void raw(void* out, size_t n) {
if (size_t(end_ - p_) < n) { ok_ = false; p_ = end_; std::memset(out, 0, n); return; }
std::memcpy(out, p_, n);
p_ += n;
}
const uint8_t* p_;
const uint8_t* end_;
bool ok_ = true;
};
void writeAppearance(Writer& w, const Appearance& a);
Appearance readAppearance(Reader& r);
// ------------------------------------------------------------ messages
struct HelloMsg {
uint16_t version = kProtocolVersion;
std::string token; // persistent client identity (random hex), the account key
void write(Writer& w) const { w.u16(version); w.str(token); }
void read(Reader& r) { version = r.u16(); token = r.str(); }
};
struct CreateCharacterMsg {
std::string name;
Appearance look;
void write(Writer& w) const { w.str(name); writeAppearance(w, look); }
void read(Reader& r) { name = r.str(); look = readAppearance(r); }
};
// One simulation tick of movement intent. `move` is a world-space direction
// (x east, z south) scaled to [-127, 127]; the server clamps its length.
struct InputCmd {
uint32_t seq = 0;
int8_t moveX = 0, moveZ = 0;
uint8_t flags = 0; // bit 0 = run
glm::vec2 move() const { return {float(moveX) / 127.0f, float(moveZ) / 127.0f}; }
bool run() const { return flags & 1; }
};
// Clients resend the last few inputs in every packet so a lost datagram does
// not drop movement; the server skips sequence numbers it already applied.
struct InputMsg {
std::vector<InputCmd> cmds; // oldest first, at most 4
void write(Writer& w) const;
void read(Reader& r);
};
struct ActionMsg {
ActionKind kind = ActionKind::Interact;
uint32_t target = 0;
float x = 0, z = 0;
void write(Writer& w) const { w.u8(uint8_t(kind)); w.u32(target); w.f32(x); w.f32(z); }
void read(Reader& r) { kind = ActionKind(r.u8()); target = r.u32(); x = r.f32(); z = r.f32(); }
};
struct WelcomeMsg {
uint32_t entityId = 0; // this client's player entity (0 until the character exists)
bool hasCharacter = false;
std::string zoneId; // e.g. "meadow"
uint32_t zoneSeed = 0;
std::string serverName;
void write(Writer& w) const { w.u32(entityId); w.u8(hasCharacter); w.str(zoneId); w.u32(zoneSeed); w.str(serverName); }
void read(Reader& r) { entityId = r.u32(); hasCharacter = r.u8(); zoneId = r.str(); zoneSeed = r.u32(); serverName = r.str(); }
};
struct OwnedCreature {
uint32_t uid = 0;
uint16_t species = 0;
bool inParty = false;
int64_t caughtAt = 0; // unix seconds
};
struct ProfileMsg {
uint32_t entityId = 0;
std::string name;
Appearance look;
int32_t capsules = 0;
bool introDone = false;
uint32_t seen = 0, caught = 0; // distinct species
std::vector<OwnedCreature> creatures;
void write(Writer& w) const;
void read(Reader& r);
};
struct PlayerInfoMsg {
uint32_t entityId = 0;
std::string name;
Appearance look;
void write(Writer& w) const { w.u32(entityId); w.str(name); writeAppearance(w, look); }
void read(Reader& r) { entityId = r.u32(); name = r.str(); look = readAppearance(r); }
};
struct EntityState {
uint32_t id = 0;
EntityKind kind = EntityKind::Wild;
Anim anim = Anim::Idle;
uint16_t species = 0; // wild / follower species id
uint32_t owner = 0; // follower: owning player entity; wild: player it is fleeing/hooked by
glm::vec3 pos{0.0f};
float yaw = 0.0f; // facing, radians: 0 = north (-z), pi/2 = east (+x)
uint8_t order = 0; // follower position in the party chain
};
struct SnapshotMsg {
uint32_t tick = 0;
uint32_t ackInput = 0; // last input seq applied for the receiving client
float hour = 12.0f; // world clock, [0, 24)
Weather weather = Weather::Clear;
float weatherBlend = 1.0f; // reserved: server-side transition progress
std::vector<EntityState> entities;
void write(Writer& w) const;
void read(Reader& r);
};
enum class EventKind : uint8_t {
Emerge = 0, // a = entity, i = 0 grass / 1 water, p = position
Splash, // p = position
Throw, // a = player, b = target, p = from, q = to, f = flight seconds
CaptureResult, // a = player, b = target, i = 1 success, j = wobbles
Fled, // a = entity, p = position
Cast, // a = player, p = bobber position
Bite, // a = player, p = bobber
Hooked, // a = player, b = hooked entity, p = bobber
LineLost, // a = player (reeled too early / too late)
Caught, // a = player, b = species, i = first catch of that species for the player
Rustle, // p = position (tall grass shakes without anything coming out)
};
struct GameEvent {
EventKind kind = EventKind::Splash;
uint32_t a = 0, b = 0;
int32_t i = 0, j = 0;
glm::vec3 p{0.0f}, q{0.0f};
float f = 0.0f;
void write(Writer& w) const { w.u8(uint8_t(kind)); w.u32(a); w.u32(b); w.i32(i); w.i32(j); w.vec3(p); w.vec3(q); w.f32(f); }
void read(Reader& r) { kind = EventKind(r.u8()); a = r.u32(); b = r.u32(); i = r.i32(); j = r.i32(); p = r.vec3(); q = r.vec3(); f = r.f32(); }
};
} // namespace bs

52
shared/Rng.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include <cstdint>
namespace bs {
// Small deterministic PRNG (SplitMix64 seeding + xoshiro128**). Unlike <random>
// distributions its output is identical on every platform, so the server and
// all clients generate the same zone from the same seed.
class Rng {
public:
explicit Rng(uint64_t seed = 1) { reseed(seed); }
void reseed(uint64_t seed) {
for (uint32_t& s : s_) {
seed += 0x9E3779B97F4A7C15ull;
uint64_t z = seed;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
s = uint32_t(z ^ (z >> 31)) | 1u;
}
}
uint32_t next() {
const uint32_t result = rotl(s_[1] * 5, 7) * 9;
const uint32_t t = s_[1] << 9;
s_[2] ^= s_[0];
s_[3] ^= s_[1];
s_[1] ^= s_[2];
s_[0] ^= s_[3];
s_[2] ^= t;
s_[3] = rotl(s_[3], 11);
return result;
}
float uniform() { return float(next() >> 8) * (1.0f / 16777216.0f); } // [0, 1)
float range(float a, float b) { return a + (b - a) * uniform(); }
int range(int a, int bInclusive) { return a + int(next() % uint32_t(bInclusive - a + 1)); }
bool chance(float p) { return uniform() < p; }
private:
static uint32_t rotl(uint32_t x, int k) { return (x << k) | (x >> (32 - k)); }
uint32_t s_[4];
};
// Stable 32-bit hash for strings/ids (FNV-1a).
inline uint32_t hash32(const char* s) {
uint32_t h = 2166136261u;
for (; *s; ++s) h = (h ^ uint8_t(*s)) * 16777619u;
return h;
}
} // namespace bs

96
shared/Roster.cpp Normal file
View File

@@ -0,0 +1,96 @@
#include "Roster.h"
#include "Appearance.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <fstream>
#include <map>
namespace bs {
namespace {
std::string str(const nlohmann::json& j, const char* key, const std::string& def = {}) {
auto it = j.find(key);
return it != j.end() && it->is_string() ? it->get<std::string>() : def;
}
float num(const nlohmann::json& j, const char* key, float def) {
auto it = j.find(key);
return it != j.end() && it->is_number() ? it->get<float>() : def;
}
} // namespace
bool Roster::load(const std::string& path, std::string* error) {
std::ifstream f(path);
if (!f) {
if (error) *error = "cannot open " + path;
return false;
}
try {
nlohmann::json j = nlohmann::json::parse(f);
species_.clear();
for (const auto& s : j.at("species")) {
Species sp;
sp.id = s.at("id").get<uint16_t>();
sp.slug = str(s, "slug");
sp.name = str(s, "name", sp.slug);
sp.domain = str(s, "domain");
sp.type = str(s, "type", "normal");
sp.type2 = str(s, "type2");
sp.body = str(s, "body");
std::string r = str(s, "rarity", "common");
sp.rarity = r == "legendary" ? Rarity::Legendary : r == "rare" ? Rarity::Rare
: r == "uncommon" ? Rarity::Uncommon : Rarity::Common;
std::string h = str(s, "habitat", "grass");
sp.habitat = h == "water" ? Habitat::Water : h == "both" ? Habitat::Both : Habitat::Grass;
sp.catchRate = num(s, "catchRate", 0.5f);
sp.fleeRate = num(s, "fleeRate", 0.1f);
if (s.contains("colors") && s["colors"].is_object()) {
sp.mainColor = parseHexColor(str(s["colors"], "main", "#888888"));
sp.accentColor = parseHexColor(str(s["colors"], "accent", "#cccccc"));
}
sp.sprite = str(s, "sprite");
sp.anchorRow = int(num(s, "anchorRow", 63.0f));
sp.retired = s.contains("retired") && s["retired"].is_boolean() && s["retired"].get<bool>();
species_.push_back(sp);
}
// ids index the vector directly
std::sort(species_.begin(), species_.end(), [](const Species& a, const Species& b) { return a.id < b.id; });
for (size_t i = 0; i < species_.size(); ++i)
if (species_[i].id != i) {
if (error) *error = path + ": species ids must be 0..N-1";
return false;
}
} catch (const std::exception& e) {
if (error) *error = path + ": " + e.what();
return false;
}
if (species_.empty()) {
if (error) *error = path + ": no species";
return false;
}
return true;
}
const char* rarityName(Rarity r) {
switch (r) {
case Rarity::Uncommon: return "Uncommon";
case Rarity::Rare: return "Rare";
case Rarity::Legendary: return "Legendary";
default: return "Common";
}
}
uint32_t typeColor(const std::string& type) {
static const std::map<std::string, uint32_t> colors = {
{"normal", 0xa8a878}, {"fire", 0xf08030}, {"water", 0x6890f0}, {"grass", 0x78c850},
{"electric", 0xf8d030}, {"ice", 0x98d8d8}, {"fighting", 0xc03028}, {"poison", 0xa040a0},
{"ground", 0xe0c068}, {"flying", 0xa890f0}, {"psychic", 0xf85888}, {"bug", 0xa8b820},
{"rock", 0xb8a038}, {"ghost", 0x705898}, {"dragon", 0x7038f8},
};
auto it = colors.find(type);
return it == colors.end() ? 0xa8a878 : it->second;
}
} // namespace bs

41
shared/Roster.h Normal file
View File

@@ -0,0 +1,41 @@
#pragma once
// Creature species, loaded from assets/creatures/roster.json (written by
// tools/build_roster.py from the company list).
#include <cstdint>
#include <string>
#include <vector>
namespace bs {
enum class Rarity : uint8_t { Common = 0, Uncommon, Rare, Legendary };
enum class Habitat : uint8_t { Grass = 0, Water, Both };
struct Species {
uint16_t id = 0;
std::string slug, name, domain, type, type2, body;
Rarity rarity = Rarity::Common;
Habitat habitat = Habitat::Grass;
float catchRate = 0.5f, fleeRate = 0.1f;
uint32_t mainColor = 0x888888, accentColor = 0xcccccc;
std::string sprite; // relative to assets/creatures/
int anchorRow = 63;
bool retired = false; // company left the list: id and catches stay valid, it no longer spawns
};
class Roster {
public:
bool load(const std::string& path, std::string* error = nullptr);
size_t size() const { return species_.size(); }
const Species* get(uint16_t id) const { return id < species_.size() ? &species_[id] : nullptr; }
const std::vector<Species>& all() const { return species_; }
private:
std::vector<Species> species_;
};
const char* rarityName(Rarity r);
// Display color of a type (for UI chips).
uint32_t typeColor(const std::string& type);
} // namespace bs

487
shared/Zone.cpp Normal file
View File

@@ -0,0 +1,487 @@
#include "Zone.h"
#include <algorithm>
#include <cmath>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
// Distance from a wobbly blob's center, normalized so the edge sits at `radius`.
float blob(glm::vec2 p, glm::vec2 c, float radius, float seed) {
glm::vec2 d = p - c;
float a = std::atan2(d.y, d.x);
float wobble = 1.0f + 0.16f * std::sin(a * 3.0f + seed) + 0.08f * std::sin(a * 5.0f + seed * 2.3f);
return glm::length(d) / (radius * wobble);
}
float ridgeLow(float x) { return 13.0f + 2.2f * std::sin(x * 0.19f) + 1.3f * std::sin(x * 0.47f + 1.1f); }
float ridgeHigh(float x) { return 6.5f + 1.6f * std::sin(x * 0.15f + 2.0f) + 1.0f * std::sin(x * 0.58f); }
float segDist(glm::vec2 p, glm::vec2 a, glm::vec2 b) {
glm::vec2 ab = b - a;
float t = glm::clamp(glm::dot(p - a, ab) / std::max(glm::dot(ab, ab), 1e-6f), 0.0f, 1.0f);
return glm::length(p - (a + ab * t));
}
// Meadow landmarks (tile coordinates, +z = south).
constexpr int kW = 96, kD = 88;
const glm::vec2 kLake{24.0f, 46.0f};
constexpr float kLakeR = 10.5f;
const glm::vec2 kPond{76.0f, 64.0f};
constexpr float kPondR = 4.4f;
const glm::vec2 kPlateau{80.0f, 30.0f};
constexpr float kPlateauR = 13.0f;
const glm::vec2 kPlazaMin{40.0f, 70.0f}, kPlazaMax{56.0f, 82.0f};
const glm::vec2 kGate{48.0f, 85.4f};
const glm::vec2 kTent{41.0f, 72.6f};
const ZonePatch kPatches[] = {{{58, 67}, 5.5f}, {{32, 64}, 5.0f}, {{41, 35}, 4.0f}, {{62, 46}, 4.5f},
{{83, 27}, 5.0f}, {{30, 10}, 4.0f}, {{13, 60}, 3.8f}, {{69, 75}, 4.0f}};
// 10 units: four 2-unit "dock" segments plus a "dock_end" (models from tools/blender_safari.py);
// the east end rests on the sand so it is reachable on foot.
const ZoneDock kDock{{27.6f, 45.2f}, {37.6f, 46.8f}, 0.05f};
bool inPlaza(glm::vec2 p, float margin) {
return p.x > kPlazaMin.x - margin && p.x < kPlazaMax.x + margin && p.y > kPlazaMin.y - margin &&
p.y < kPlazaMax.y + margin;
}
} // namespace
bool Zone::generate(const std::string& id, uint32_t seed) {
if (id != "meadow") return false;
id_ = id;
seed_ = seed;
props.clear();
walls.clear();
docks.clear();
tallGrassPatches.clear();
buildMeadow(seed);
return true;
}
int Zone::level(int x, int z) const {
x = std::clamp(x, 0, w_ - 1);
z = std::clamp(z, 0, d_ - 1);
return levels_[size_t(z) * w_ + x];
}
uint8_t Zone::material(int x, int z) const {
x = std::clamp(x, 0, w_ - 1);
z = std::clamp(z, 0, d_ - 1);
return mats_[size_t(z) * w_ + x];
}
bool Zone::tallGrassTile(int x, int z) const { return inBounds(x, z) && grass_[size_t(z) * w_ + x]; }
void Zone::setTile(int x, int z, int lvl, uint8_t mat) {
if (!inBounds(x, z)) return;
levels_[size_t(z) * w_ + x] = lvl;
mats_[size_t(z) * w_ + x] = mat;
}
float Zone::terrainHeight(float x, float z) const {
return float(level(int(std::floor(x)), int(std::floor(z)))) * kLevelHeight;
}
bool Zone::onDock(glm::vec2 p) const {
for (const ZoneDock& d : docks)
if (p.x >= d.min.x && p.x <= d.max.x && p.y >= d.min.y && p.y <= d.max.y) return true;
return false;
}
float Zone::groundHeight(glm::vec2 p) const {
for (const ZoneDock& d : docks)
if (p.x >= d.min.x && p.x <= d.max.x && p.y >= d.min.y && p.y <= d.max.y) return d.deckY;
return terrainHeight(p.x, p.y);
}
bool Zone::isWater(glm::vec2 p) const {
int x = int(std::floor(p.x)), z = int(std::floor(p.y));
return inBounds(x, z) && level(x, z) < 0 && !onDock(p);
}
bool Zone::isDeepWater(glm::vec2 p) const {
int x = int(std::floor(p.x)), z = int(std::floor(p.y));
return inBounds(x, z) && level(x, z) <= -2 && !onDock(p);
}
bool Zone::inTallGrass(glm::vec2 p) const { return tallGrassTile(int(std::floor(p.x)), int(std::floor(p.y))); }
bool Zone::blocked(glm::vec2 p, float r) const {
for (const ZoneProp& o : props)
if (o.radius > 0.0f && glm::length(glm::vec2(o.pos.x, o.pos.z) - p) < o.radius + r) return true;
for (const ZoneWall& w : walls)
if (segDist(p, w.a, w.b) < w.halfWidth + r) return true;
return false;
}
bool Zone::walkable(glm::vec2 from, glm::vec2 to, float radius) const {
if (to.x < 1.0f || to.y < 1.0f || to.x > float(w_ - 1) || to.y > float(d_ - 1)) return false;
bool dockTo = onDock(to), dockFrom = onDock(from);
if (!dockTo) {
int fl = level(int(std::floor(from.x)), int(std::floor(from.y)));
int tl = level(int(std::floor(to.x)), int(std::floor(to.y)));
if (tl < 0) return false; // water
if (!dockFrom && (tl > fl + 1 || tl < fl - 2)) return false; // climb one level, drop at most two
}
// Only block motion that goes deeper into an obstacle, so nobody gets stuck.
for (const ZoneProp& o : props) {
if (o.radius <= 0.0f) continue;
glm::vec2 c(o.pos.x, o.pos.z);
float r = o.radius + radius;
float dt = glm::length(c - to);
if (dt < r && dt < glm::length(c - from)) return false;
}
for (const ZoneWall& w : walls) {
float r = w.halfWidth + radius;
float dt = segDist(to, w.a, w.b);
if (dt < r && dt < segDist(from, w.a, w.b)) return false;
}
return true;
}
bool Zone::creatureWalkable(glm::vec2 p, bool water) const {
if (p.x < 1.5f || p.y < 1.5f || p.x > float(w_) - 1.5f || p.y > float(d_) - 1.5f) return false;
if (water) return isWater(p);
if (isWater(p) || onDock(p)) return false;
return !blocked(p, 0.3f);
}
bool Zone::fishingTarget(glm::vec2 from, glm::vec2 dir, glm::vec2& bobber) const {
if (glm::length(dir) < 1e-3f || isWater(from)) return false;
dir = glm::normalize(dir);
for (float t = 1.6f; t <= 4.5f; t += 0.2f) {
glm::vec2 p = from + dir * t;
if (isWater(p)) {
// land a bit beyond the shoreline so the bobber sits in open water
glm::vec2 q = p + dir * 0.6f;
bobber = isWater(q) ? q : p;
return true;
}
}
return false;
}
bool Zone::randomTallGrassNear(Rng& rng, glm::vec2 c, float rmin, float rmax, glm::vec2& out) const {
for (int i = 0; i < 40; ++i) {
float a = rng.range(0.0f, 2.0f * kPi), r = rng.range(rmin, rmax);
glm::vec2 p = c + glm::vec2(std::cos(a), std::sin(a)) * r;
if (inTallGrass(p) && creatureWalkable(p, false)) {
out = p;
return true;
}
}
return false;
}
bool Zone::randomWater(Rng& rng, glm::vec2& out, bool deep) const {
for (int i = 0; i < 60; ++i) {
glm::vec2 p(rng.range(waterMin.x, waterMax.x), rng.range(waterMin.y, waterMax.y));
if (deep ? isDeepWater(p) : isWater(p)) {
out = p;
return true;
}
}
return false;
}
bool Zone::randomWaterNear(Rng& rng, glm::vec2 c, float rmax, glm::vec2& out) const {
for (int i = 0; i < 40; ++i) {
float a = rng.range(0.0f, 2.0f * kPi), r = rng.range(0.5f, rmax);
glm::vec2 p = c + glm::vec2(std::cos(a), std::sin(a)) * r;
if (isWater(p)) {
out = p;
return true;
}
}
return false;
}
bool Zone::randomWanderSpot(Rng& rng, glm::vec2& out) const {
for (int i = 0; i < 80; ++i) {
glm::vec2 p(rng.range(3.0f, float(w_) - 3.0f), rng.range(3.0f, float(d_) - 3.0f));
int x = int(std::floor(p.x)), z = int(std::floor(p.y));
uint8_t m = material(x, z);
if (m != MatGrass && m != MatMeadow) continue;
if (inPlaza(p, 7.0f) || !creatureWalkable(p, false)) continue;
out = p;
return true;
}
return false;
}
// ------------------------------------------------------------------ meadow
void Zone::paintPath(const std::vector<glm::vec2>& pts, float halfWidth, bool clearGrass) {
for (size_t i = 0; i + 1 < pts.size(); ++i) {
glm::vec2 a = pts[i], b = pts[i + 1];
int steps = std::max(1, int(glm::length(b - a) * 4.0f));
for (int s = 0; s <= steps; ++s) {
glm::vec2 q = glm::mix(a, b, float(s) / float(steps));
int r = int(std::ceil(halfWidth)) + 1;
for (int dz = -r; dz <= r; ++dz)
for (int dx = -r; dx <= r; ++dx) {
int x = int(std::floor(q.x)) + dx, z = int(std::floor(q.y)) + dz;
if (!inBounds(x, z) || level(x, z) < 0) continue;
if (glm::length(glm::vec2(float(x) + 0.5f, float(z) + 0.5f) - q) > halfWidth) continue;
if (material(x, z) != MatPlaza) mats_[size_t(z) * w_ + x] = MatPath;
if (clearGrass) grass_[size_t(z) * w_ + x] = 0;
}
}
}
}
bool Zone::clearForProp(glm::vec2 p, float r) const {
int x = int(std::floor(p.x)), z = int(std::floor(p.y));
if (!inBounds(x, z) || level(x, z) < 0) return false;
uint8_t m = material(x, z);
if (m == MatPath || m == MatPlaza || m == MatStone || m == MatSand) return false;
if (grass_[size_t(z) * w_ + x]) return false;
if (onDock(p) || inPlaza(p, 2.5f)) return false;
if (glm::length(p - kTent) < 3.5f || glm::length(p - kGate) < 3.0f) return false;
if (p.x > kDock.min.x - 1.0f && p.x < kDock.max.x + 2.5f && p.y > kDock.min.y - 1.5f && p.y < kDock.max.y + 1.5f)
return false;
return !blocked(p, r);
}
void Zone::addProp(const std::string& model, glm::vec2 p, float yaw, float scale, float radius, float tint,
float yOffset) {
ZoneProp o;
o.model = model;
o.pos = glm::vec3(p.x, groundHeight(p) + yOffset, p.y);
o.yaw = yaw;
o.scale = glm::vec3(scale);
o.tint = tint;
o.radius = radius;
props.push_back(o);
}
void Zone::addFence(glm::vec2 a, glm::vec2 b) {
glm::vec2 d = b - a;
float len = glm::length(d);
int n = std::max(1, int(std::round(len / 2.0f)));
glm::vec2 step = d / float(n);
// fence model: 2 units along +X. yaw rotates +X toward the segment direction (mesh.vert convention).
float yaw = std::atan2(-step.y, step.x);
for (int i = 0; i < n; ++i) {
glm::vec2 c = a + step * (float(i) + 0.5f);
ZoneProp o;
o.model = "fence";
o.pos = glm::vec3(c.x, groundHeight(c), c.y);
o.yaw = yaw;
o.scale = glm::vec3(glm::length(step) / 2.0f, 1.0f, 1.0f);
props.push_back(o);
}
walls.push_back({a, b, 0.12f});
}
void Zone::buildMeadow(uint32_t seed) {
w_ = kW;
d_ = kD;
levels_.assign(size_t(w_) * d_, 0);
mats_.assign(size_t(w_) * d_, MatGrass);
grass_.assign(size_t(w_) * d_, 0);
Rng rng(seed ^ 0x5AFA21u);
// --- terrain: north ridge, east plateau, lake, pond, plaza
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x) {
glm::vec2 p(float(x) + 0.5f, float(z) + 0.5f);
int lvl = 0;
uint8_t mat = MatGrass;
if (p.y < ridgeLow(p.x)) lvl = 2, mat = MatRidge;
if (p.y < ridgeHigh(p.x)) lvl = 4, mat = MatRidge;
if (lvl == 0 && blob(p, kPlateau, kPlateauR, 1.9f) < 1.0f) lvl = 2, mat = MatRidge;
if (blob(p, {15.0f, 74.0f}, 4.2f, 0.3f) < 1.0f) lvl = 1; // small hill by the west fence
float lake = blob(p, kLake, kLakeR, 0.7f);
if (lake < 1.16f) lvl = 0, mat = MatSand;
if (lake < 1.0f) lvl = -1;
if (lake < 0.68f) lvl = -3;
float pond = blob(p, kPond, kPondR, 2.4f);
if (pond < 1.22f) lvl = 0, mat = MatSand;
if (pond < 1.0f) lvl = -1;
if (pond < 0.6f) lvl = -2;
if (inPlaza(p, 0.0f)) {
float cx = std::max({kPlazaMin.x - p.x, p.x - kPlazaMax.x, 0.0f});
float cz = std::max({kPlazaMin.y - p.y, p.y - kPlazaMax.y, 0.0f});
(void)cx, (void)cz;
// round the corners
glm::vec2 c = glm::clamp(p, kPlazaMin + 2.5f, kPlazaMax - 2.5f);
if (glm::length(p - c) < 2.5f) lvl = 0, mat = MatPlaza;
}
setTile(x, z, lvl, mat);
}
// --- tall grass patches (Meadow material under them)
for (const ZonePatch& tg : kPatches) {
tallGrassPatches.push_back(tg);
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x) {
glm::vec2 p(float(x) + 0.5f, float(z) + 0.5f);
if (blob(p, tg.c, tg.r, tg.c.x * 0.37f) >= 1.0f) continue;
if (level(x, z) < 0 || material(x, z) == MatSand || material(x, z) == MatPlaza) continue;
mats_[size_t(z) * w_ + x] = MatMeadow;
grass_[size_t(z) * w_ + x] = 1;
}
}
// --- paths from the gate through the plaza to every area
const glm::vec2 cross{48.0f, 60.0f};
paintPath({{48, 87}, {48, 76}, cross}, 1.35f);
paintPath({cross, {41, 54}, {37, 48.5f}, {36, 46}}, 1.25f);
paintPath({cross, {50, 46}, {52, 30}, {52, 2}}, 1.3f);
paintPath({cross, {60, 56}, {67, 49}, {72, 41}, {78, 33}, {84, 30}}, 1.25f);
paintPath({{36, 46}, {34, 52}, {33, 58}, {38, 66}, {44, 70}}, 1.0f); // lake loop back to the plaza
// Stairs: a path tile next to a path tile two levels higher becomes a one-level step.
for (int pass = 0; pass < 3; ++pass) {
std::vector<std::pair<int, int>> raise;
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x) {
if (material(x, z) != MatPath && material(x, z) != MatStone) continue;
int l = level(x, z);
const int nx[4] = {1, -1, 0, 0}, nz[4] = {0, 0, 1, -1};
for (int k = 0; k < 4; ++k) {
int xx = x + nx[k], zz = z + nz[k];
if (!inBounds(xx, zz)) continue;
uint8_t m = material(xx, zz);
if ((m == MatPath || m == MatStone) && level(xx, zz) >= l + 2) {
raise.push_back({x, z});
break;
}
}
}
for (auto [x, z] : raise) setTile(x, z, level(x, z) + 1, MatStone);
}
// --- landmarks
docks.push_back(kDock);
plaza = (kPlazaMin + kPlazaMax) * 0.5f;
henry = {44.6f, 73.4f};
spawn = {48.2f, 76.6f};
henryYaw = yawFromDir(spawn - henry);
spawnYaw = yawFromDir(henry - spawn);
// dock segments (2 units along +X each) from the shore out over the lake
for (float x = kDock.max.x - 1.0f; x > kDock.min.x + 2.5f; x -= 2.0f) {
ZoneProp o;
o.model = "dock";
o.pos = glm::vec3(x, kDock.deckY, (kDock.min.y + kDock.max.y) * 0.5f);
props.push_back(o);
}
{
ZoneProp o;
o.model = "dock_end";
o.pos = glm::vec3(kDock.min.x + 1.0f, kDock.deckY, (kDock.min.y + kDock.max.y) * 0.5f);
o.yaw = 3.14159265f; // the end board (+X in the model) faces out over the lake
props.push_back(o);
}
addProp("gate", kGate, 0.0f, 1.0f, 0.0f);
props.back().radius = 0.0f;
// gate pillars collide (the arch itself is overhead)
walls.push_back({kGate + glm::vec2(-1.95f, 0.0f), kGate + glm::vec2(-1.45f, 0.0f), 0.3f});
walls.push_back({kGate + glm::vec2(1.45f, 0.0f), kGate + glm::vec2(1.95f, 0.0f), 0.3f});
{ // door (+Z in the model) faces the plaza; mesh yaw maps local +Z to (sin yaw, cos yaw)
glm::vec2 d = plaza - kTent;
addProp("tent", kTent, std::atan2(d.x, d.y), 1.0f, 1.55f);
}
for (glm::vec2 p : {glm::vec2{42.6f, 80.6f}, {53.4f, 80.6f}, {53.4f, 71.4f}, {46.1f, 64.0f}, {50.0f, 50.0f},
{37.8f, 49.6f}, {66.2f, 51.6f}, {53.6f, 36.0f}, {75.6f, 37.6f}})
addProp("lamp", p, 0.0f, 1.0f, 0.22f);
addProp("signpost", {50.9f, 62.2f}, -0.4f, 1.0f, 0.2f);
addProp("signpost", {39.4f, 52.2f}, 0.8f, 1.0f, 0.2f);
addProp("signpost", {70.6f, 46.6f}, -0.9f, 1.0f, 0.2f);
addProp("crate", {38.9f, 74.9f}, 0.3f, 1.0f, 0.45f);
addProp("crate", {38.6f, 76.1f}, 1.1f, 0.85f, 0.4f, 0.9f);
addProp("barrel", {39.9f, 76.3f}, 0.0f, 1.0f, 0.35f);
addProp("hay_bale", {55.0f, 75.6f}, 0.2f, 1.0f, 0.6f);
addProp("hay_bale", {55.6f, 77.0f}, 1.4f, 1.0f, 0.6f, 0.92f);
addProp("log_bench", {52.6f, 73.2f}, 0.0f, 1.0f, 0.5f);
addProp("log_bench", {36.4f, 40.6f}, 1.57f, 1.0f, 0.5f);
addProp("crate", {38.6f, 47.6f}, 0.6f, 0.8f, 0.4f);
// fences along the southern border, either side of the gate
addFence({33.0f, 85.4f}, {45.9f, 85.4f});
addFence({50.1f, 85.4f}, {63.0f, 85.4f});
// paddock fence west of the main path
addFence({44.6f, 68.6f}, {44.6f, 62.6f});
// --- trees: dense border forest, groves on the ridge, sparse elsewhere
const char* oaks[] = {"oak_a", "oak_b"};
const char* pines[] = {"pine_a", "pine_b"};
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x) {
int edge = std::min({x, z, w_ - 1 - x, d_ - 1 - z});
float chance = edge < 5 ? 0.5f - float(edge) * 0.07f : (level(x, z) >= 4 ? 0.16f : 0.014f);
if (level(x, z) == 2 && material(x, z) == MatRidge) chance = std::max(chance, 0.03f);
if (!rng.chance(chance)) continue;
glm::vec2 p(float(x) + rng.range(0.2f, 0.8f), float(z) + rng.range(0.2f, 0.8f));
if (std::abs(p.x - kGate.x) < 4.0f && p.y > 80.0f) continue;
float scale = rng.range(0.8f, 1.12f);
if (!clearForProp(p, 1.0f * scale)) continue;
if (blob(p, kLake, kLakeR, 0.7f) < 1.4f || blob(p, kPond, kPondR, 2.4f) < 1.5f) continue;
bool pine = rng.chance(level(x, z) >= 2 || edge < 5 ? 0.55f : 0.3f);
const char* model = pine ? pines[rng.range(0, 1)] : oaks[rng.range(0, 1)];
ZoneProp o;
o.model = model;
o.pos = glm::vec3(p.x, terrainHeight(p.x, p.y), p.y);
o.yaw = rng.range(0.0f, 6.2832f);
o.scale = glm::vec3(scale, scale * rng.range(0.92f, 1.08f), scale);
o.tint = rng.range(0.86f, 1.08f);
o.radius = 0.45f * scale;
props.push_back(o);
}
// --- rocks (gathering around water and the plateau edge) and bushes
for (int z = 1; z < d_ - 1; ++z)
for (int x = 1; x < w_ - 1; ++x) {
glm::vec2 p(float(x) + 0.5f + rng.range(-0.2f, 0.2f), float(z) + 0.5f + rng.range(-0.2f, 0.2f));
float lake = blob(p, kLake, kLakeR, 0.7f), pond = blob(p, kPond, kPondR, 2.4f);
bool shore = (lake > 1.16f && lake < 1.45f) || (pond > 1.22f && pond < 1.55f);
bool cliff = level(x, z) >= 2 && (level(x + 1, z) < level(x, z) || level(x, z + 1) < level(x, z));
float r = rng.uniform();
float s = rng.range(0.8f, 1.3f);
if (r < (shore ? 0.07f : cliff ? 0.06f : 0.006f)) {
if (!clearForProp(p, 0.6f * s)) continue;
ZoneProp o;
o.model = rng.chance(0.5f) ? "rock_a" : "rock_b";
o.pos = glm::vec3(p.x, terrainHeight(p.x, p.y) - 0.02f, p.y);
o.yaw = rng.range(0.0f, 6.2832f);
o.scale = glm::vec3(s, s * rng.range(0.8f, 1.2f), s);
o.tint = rng.range(0.86f, 1.08f);
o.radius = 0.42f * s;
props.push_back(o);
} else if (r < (shore ? 0.12f : 0.022f)) {
float bs = rng.range(0.8f, 1.25f);
if (!clearForProp(p, 0.65f * bs)) continue;
ZoneProp o;
o.model = rng.chance(0.5f) ? "bush_a" : "bush_b";
o.pos = glm::vec3(p.x, terrainHeight(p.x, p.y) - 0.03f, p.y);
o.yaw = rng.range(0.0f, 6.2832f);
o.scale = glm::vec3(bs);
o.tint = rng.range(0.86f, 1.08f);
o.radius = 0.5f * bs;
props.push_back(o);
}
}
// --- water bounds for the water plane and spawns
waterMin = glm::vec2(float(w_), float(d_));
waterMax = glm::vec2(0.0f);
for (int z = 0; z < d_; ++z)
for (int x = 0; x < w_; ++x)
if (level(x, z) < 0) {
waterMin = glm::min(waterMin, glm::vec2(float(x), float(z)));
waterMax = glm::max(waterMax, glm::vec2(float(x + 1), float(z + 1)));
}
waterMin -= glm::vec2(1.0f);
waterMax += glm::vec2(1.0f);
}
} // namespace bs

121
shared/Zone.h Normal file
View File

@@ -0,0 +1,121 @@
#pragma once
// Deterministic safari zone layout. The server and every client call
// Zone::generate(id, seed) and get the exact same terrain, props, water,
// tall grass and landmarks, so only the zone id + seed travel over the wire.
// Everything that matters for gameplay (collision, heights, water, tall
// grass, NPC and spawn points) lives here; purely visual decoration (grass
// tufts, flowers, the tall-grass sprites themselves) is added by the client.
#include "Rng.h"
#include <glm/glm.hpp>
#include <cstdint>
#include <cmath>
#include <string>
#include <vector>
namespace bs {
// Terrain materials (client maps them to texture layers).
enum ZoneMat : uint8_t { MatGrass = 0, MatMeadow = 1, MatPath = 2, MatSand = 3, MatStone = 4, MatRidge = 5, MatPlaza = 6 };
// One placed 3D model. `radius` > 0 makes it a round obstacle.
struct ZoneProp {
std::string model; // model name in scenery.glb / safari.glb
glm::vec3 pos{0.0f};
float yaw = 0.0f;
glm::vec3 scale{1.0f};
float tint = 1.0f;
float radius = 0.0f;
};
// Thin straight obstacle (fences, tent walls) as a capsule from a to b.
struct ZoneWall {
glm::vec2 a, b;
float halfWidth = 0.15f;
};
// Walkable wooden deck over water.
struct ZoneDock {
glm::vec2 min, max;
float deckY = 0.05f;
};
struct ZonePatch {
glm::vec2 c;
float r;
};
class Zone {
public:
static constexpr float kWaterLevel = -0.2f;
static constexpr float kLevelHeight = 0.5f;
// Known ids: "meadow" (the grassy safari zone). Returns false for unknown ids.
bool generate(const std::string& id, uint32_t seed);
const std::string& id() const { return id_; }
uint32_t seed() const { return seed_; }
int width() const { return w_; }
int depth() const { return d_; }
bool inBounds(int x, int z) const { return x >= 0 && z >= 0 && x < w_ && z < d_; }
int level(int x, int z) const;
uint8_t material(int x, int z) const;
bool tallGrassTile(int x, int z) const;
// Same rule as hd2d::Terrain::heightAt (flat tile tops).
float terrainHeight(float x, float z) const;
float groundHeight(glm::vec2 p) const; // dock deck or terrain
bool onDock(glm::vec2 p) const;
bool isWater(glm::vec2 p) const; // open water (not under a dock)
bool isDeepWater(glm::vec2 p) const; // basin, at least 1 level below the shelf
bool inTallGrass(glm::vec2 p) const;
bool walkable(glm::vec2 from, glm::vec2 to, float radius = 0.28f) const;
// Where a line cast from `from` along `dir` lands: first open water point
// 1.6 .. 4.5 units ahead. False when there is no water in range.
bool fishingTarget(glm::vec2 from, glm::vec2 dir, glm::vec2& bobber) const;
bool randomTallGrassNear(Rng& rng, glm::vec2 c, float rmin, float rmax, glm::vec2& out) const;
bool randomWater(Rng& rng, glm::vec2& out, bool deep = true) const;
bool randomWaterNear(Rng& rng, glm::vec2 c, float rmax, glm::vec2& out) const;
// Open grass / meadow for wandering creatures (away from the entrance plaza).
bool randomWanderSpot(Rng& rng, glm::vec2& out) const;
// True when a creature may stand / walk here (land creatures stay out of water).
bool creatureWalkable(glm::vec2 p, bool water) const;
std::vector<ZoneProp> props;
std::vector<ZoneWall> walls;
std::vector<ZoneDock> docks;
std::vector<ZonePatch> tallGrassPatches;
glm::vec2 spawn{0.0f}; // where new players appear
float spawnYaw = 0.0f;
glm::vec2 henry{0.0f}; // Professor Henry's post
float henryYaw = 0.0f;
glm::vec2 plaza{0.0f}; // entrance plaza center (no wild spawns nearby)
glm::vec2 waterMin{0.0f}, waterMax{0.0f}; // bounds of all water tiles (+ margin)
private:
void buildMeadow(uint32_t seed);
void setTile(int x, int z, int level, uint8_t mat);
void paintPath(const std::vector<glm::vec2>& pts, float halfWidth, bool clearGrass = true);
bool blocked(glm::vec2 p, float r) const; // overlaps a prop or wall
bool clearForProp(glm::vec2 p, float r) const;
void addProp(const std::string& model, glm::vec2 p, float yaw, float scale, float radius, float tint = 1.0f,
float yOffset = 0.0f);
void addFence(glm::vec2 a, glm::vec2 b);
std::string id_;
uint32_t seed_ = 0;
int w_ = 0, d_ = 0;
std::vector<int> levels_;
std::vector<uint8_t> mats_;
std::vector<uint8_t> grass_;
};
// 0 = north (-z), pi/2 = east (+x).
inline float yawFromDir(glm::vec2 d) { return std::atan2(d.x, -d.y); }
inline glm::vec2 dirFromYaw(float yaw) { return {std::sin(yaw), -std::cos(yaw)}; }
} // namespace bs