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

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