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

134
client/CharacterSprite.cpp Normal file
View File

@@ -0,0 +1,134 @@
#include "CharacterSprite.h"
#include "Common.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <vector>
namespace bs {
std::array<uint32_t, 4> colorRamp(uint32_t rgb) {
const float c[3] = {float((rgb >> 16) & 255), float((rgb >> 8) & 255), float(rgb & 255)};
struct Step { float k; float tint[3]; };
const Step table[3] = {{0.50f, {0.96f, 0.90f, 1.06f}}, {0.73f, {0.99f, 0.95f, 1.03f}}, {1.0f, {1.0f, 1.0f, 1.0f}}};
std::array<uint32_t, 4> out{};
auto pack = [](float r, float g, float b) {
auto q = [](float v) { return uint32_t(std::clamp(int(std::lround(v)), 0, 255)); };
return (q(r) << 16) | (q(g) << 8) | q(b);
};
for (int i = 0; i < 3; ++i)
out[i] = pack(c[0] * table[i].k * table[i].tint[0], c[1] * table[i].k * table[i].tint[1],
c[2] * table[i].k * table[i].tint[2]);
const float warm[3] = {255.0f, 250.0f, 232.0f};
out[3] = pack(c[0] + (warm[0] - c[0]) * 0.34f, c[1] + (warm[1] - c[1]) * 0.34f, c[2] + (warm[2] - c[2]) * 0.34f);
return out;
}
namespace {
bool loadOptional(const std::string& path, hd2d::Image& out) {
if (!std::filesystem::exists(path)) return false;
return hd2d::loadImage(path, out);
}
void paintShade(hd2d::Image& canvas, const hd2d::Image& shade, uint32_t color) {
if (shade.rgba.size() != canvas.rgba.size()) return;
auto ramp = colorRamp(color);
const size_t n = canvas.rgba.size() / 4;
for (size_t i = 0; i < n; ++i) {
const unsigned char* s = &shade.rgba[i * 4];
if (s[3] < 128) continue;
int lv = std::clamp((int(s[0]) + 30) / 60, 1, 4);
uint32_t c = ramp[size_t(lv - 1)];
unsigned char* d = &canvas.rgba[i * 4];
d[0] = (c >> 16) & 255, d[1] = (c >> 8) & 255, d[2] = c & 255, d[3] = 255;
}
}
void paintFixed(hd2d::Image& canvas, const hd2d::Image& fixed) {
if (fixed.rgba.size() != canvas.rgba.size()) return;
const size_t n = canvas.rgba.size() / 4;
for (size_t i = 0; i < n; ++i)
if (fixed.rgba[i * 4 + 3] >= 128) {
std::copy_n(&fixed.rgba[i * 4], 3, &canvas.rgba[i * 4]);
canvas.rgba[i * 4 + 3] = 255;
}
}
} // namespace
bool CharacterArt::load(const std::string& charDir, const CharacterManifest& manifest) {
manifest_ = &manifest;
for (const auto& [id, def] : manifest.bodies) {
Body b;
const std::string d = charDir + "/" + id + "/";
if (!hd2d::loadImage(d + "body.png", b.fixed)) {
HD2D_ERR("character art: missing %sbody.png", d.c_str());
return false;
}
loadOptional(d + "skin_shade.png", b.skin.shade);
for (const std::string& item : def.items) {
Layer l;
loadOptional(d + item + "_shade.png", l.shade);
loadOptional(d + item + ".png", l.fixed);
b.items[item] = std::move(l);
}
bodies_[id] = std::move(b);
}
return !bodies_.empty();
}
hd2d::Image CharacterArt::compose(const Appearance& look) const {
auto it = bodies_.find(look.body);
if (it == bodies_.end()) it = bodies_.begin();
const Body& b = it->second;
const CharacterManifest& m = *manifest_;
hd2d::Image canvas;
canvas.width = b.fixed.width;
canvas.height = b.fixed.height;
canvas.rgba.assign(b.fixed.rgba.size(), 0);
uint32_t skin = m.skinTones[std::min<size_t>(look.skin, m.skinTones.size() - 1)];
paintShade(canvas, b.skin.shade, skin);
// The base tee and shorts are always under everything; recolored only when chosen.
for (auto [baseItem, slot] : {std::pair{"top_tee", int(Top)}, std::pair{"bottom_shorts", int(Bottom)}}) {
auto li = b.items.find(baseItem);
if (li == b.items.end()) continue;
const ItemDef* def = m.item(baseItem);
uint32_t color = look.items[slot] == baseItem ? look.colors[slot] : (def ? def->color : 0xf0f0f0);
paintShade(canvas, li->second.shade, color);
}
paintFixed(canvas, b.fixed);
std::array<int, kSlotCount> order{};
for (int s = 0; s < kSlotCount; ++s) order[s] = s;
std::sort(order.begin(), order.end(), [&](int a, int c) { return m.slotZ[a] < m.slotZ[c]; });
for (int s : order) {
const std::string& id = look.items[s];
if (id.empty()) continue;
const ItemDef* def = m.item(id);
if (!def || def->fromBody) continue;
auto li = b.items.find(id);
if (li == b.items.end()) continue;
paintShade(canvas, li->second.shade, look.colors[s]);
paintFixed(canvas, li->second.fixed);
}
hd2d::bleedTransparentColors(canvas);
return canvas;
}
int SheetLayout::rowFor(float relYaw, bool& flip) {
// rows: 0 south (toward camera), 1 east (screen right), 2 north (away)
float a = wrapAngle(relYaw);
flip = false;
if (std::abs(a) <= 0.785f) return 2; // away from the camera
if (std::abs(a) >= 2.356f) return 0; // toward the camera
flip = a < 0.0f; // screen left = mirrored east
return 1;
}
} // namespace bs