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

122
server/Spawns.cpp Normal file
View 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