Files
brandling-safari/client/World.cpp
Gabe 238cd9c6c3 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
2026-10-02 15:20:27 -05:00

930 lines
39 KiB
C++

#include "World.h"
#include "hd2d/Log.h"
#include <algorithm>
#include <cmath>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
constexpr glm::ivec2 kCell{64, 64};
// Terrain texture array layers (Vibe-HD-2D/tools/assets.json sources).
const char* kLayers[] = {
"ground_grass_0", "ground_grass_1", "ground_grass_2", "ground_grass_3", // 0-3 plain grass
"ground_grass_4", "ground_grass_5", "ground_grass_8", // 4-6 flowers / clover
"ground_grass_12", "ground_grass_13", // 7-8 dark meadow grass
"ground_extra_1", // 9 earth path
"ground_extra_4", "ground_extra_5", // 10-11 earth cliff
"ground_misc_4", "ground_misc_5", // 12-13 mossy stone cliff
"ground_extra_8", "ground_extra_9", // 14-15 sand
"ground_misc_12", "ground_misc_13", // 16-17 cobblestone
};
float easeOutBack(float t) {
t = std::clamp(t, 0.0f, 1.0f);
const float c1 = 1.70158f, c3 = c1 + 1.0f;
return 1.0f + c3 * std::pow(t - 1.0f, 3.0f) + c1 * std::pow(t - 1.0f, 2.0f);
}
float lerpAngle(float a, float b, float t) { return a + wrapAngle(b - a) * t; }
// --- tiny procedural pixel art for effects (16x16 cells, GL orientation: row 0 = bottom)
hd2d::Image blank(int w, int h) {
hd2d::Image img;
img.width = w;
img.height = h;
img.rgba.assign(size_t(w) * h * 4, 0);
return img;
}
void put(hd2d::Image& img, int x, int yTop, uint32_t rgb) {
int y = img.height - 1 - yTop;
if (x < 0 || y < 0 || x >= img.width || y >= img.height) return;
unsigned char* p = &img.rgba[(size_t(y) * img.width + x) * 4];
p[0] = (rgb >> 16) & 255, p[1] = (rgb >> 8) & 255, p[2] = rgb & 255, p[3] = 255;
}
hd2d::Image capsuleImage() { // safari capsule: olive top, cream bottom, dark band and button
hd2d::Image img = blank(16, 16);
for (int y = 0; y < 12; ++y)
for (int x = 0; x < 12; ++x) {
float dx = float(x) - 5.5f, dy = float(y) - 5.5f;
float d = std::sqrt(dx * dx + dy * dy);
if (d > 5.9f) continue;
uint32_t c;
if (d > 4.9f) c = 0x1c1a14;
else if (std::abs(dy) < 0.8f) c = 0x2a2620;
else if (dy < 0) c = (dx < -1.5f && dy < -2.0f) ? 0xb6c46a : 0x6f8a3a;
else c = dy > 3.0f ? 0xb9ae8e : 0xe8dfc4;
if (d < 1.6f) c = d < 0.9f ? 0xf4f0e0 : 0x2a2620;
put(img, x + 2, y + 4, c);
}
return img;
}
hd2d::Image bobberImage() {
hd2d::Image img = blank(16, 16);
for (int y = 0; y < 6; ++y)
for (int x = 0; x < 6; ++x) {
float dx = float(x) - 2.5f, dy = float(y) - 2.5f;
if (dx * dx + dy * dy > 7.5f) continue;
put(img, x + 5, y + 8, dy < 0 ? 0xe0402c : 0xf2efe6);
}
put(img, 7, 7, 0x302820);
put(img, 8, 7, 0x302820);
return img;
}
hd2d::Image dotImage() {
hd2d::Image img = blank(16, 16);
for (int y = 13; y <= 15; ++y)
for (int x = 7; x <= 8; ++x) put(img, x, y, y == 15 ? 0xb8b4aa : 0xf4f2ea);
return img;
}
struct ModelFlags {
float groundAO;
bool occluderFade;
bool castShadows;
};
ModelFlags flagsFor(const std::string& name) {
if (name.rfind("oak", 0) == 0 || name.rfind("pine", 0) == 0) return {1.0f, true, true};
if (name.rfind("bush", 0) == 0) return {0.8f, false, true};
if (name.rfind("rock", 0) == 0) return {0.7f, false, true};
if (name == "tent" || name == "gate") return {0.9f, true, true};
if (name == "dock" || name == "dock_end") return {0.0f, false, true};
return {0.5f, false, true};
}
} // namespace
// ------------------------------------------------------------------ setup
bool World::init() {
if (assetsLoaded_) return true;
if (!scenery_.load(engineAsset("models/scenery.glb"))) return false;
if (!safari_.load(gameAsset("models/safari.glb"))) return false;
std::string err;
if (!manifest_.load(gameAsset("characters/manifest.json"), &err)) {
HD2D_ERR("%s", err.c_str());
return false;
}
if (!charArt_.load(gameAsset("characters"), manifest_)) return false;
if (!roster_.load(gameAsset("creatures/roster.json"), &err)) HD2D_ERR("roster: %s", err.c_str());
auto npc = manifest_.npcs.find("henry");
if (npc != manifest_.npcs.end() && hd2d::loadImage(gameAsset("characters/" + npc->second.sheet), henryImage_))
henry_.texture.create(henryImage_);
henry_.emissive = 0.05f;
const std::string tex = engineAsset("textures/");
if (!tallGrass_.loadTexture(tex + "tall_grass.png") || !tufts_.loadTexture(tex + "grass_tuft.png") ||
!flowers_.loadTexture(tex + "flowers.png"))
return false;
tufts_.inReflections = false;
flowers_.inReflections = false;
if (!creatureAtlas_.init(kCell, 16, 16)) return false;
creatures_.sharedTexture = &creatureAtlas_.texture();
creatures_.emissive = 0.05f;
fxAtlas_.init({16, 16}, 4, 4);
fxCapsule_ = fxAtlas_.add(capsuleImage());
fxBobber_ = fxAtlas_.add(bobberImage());
fxDot_ = fxAtlas_.add(dotImage());
fx_.sharedTexture = &fxAtlas_.texture();
fx_.castShadows = false;
fx_.emissive = 0.15f;
assetsLoaded_ = true;
return true;
}
bool World::buildZone(const std::string& id, uint32_t seed) {
if (built_ && zone_.id() == id && zone_.seed() == seed) return true;
if (!zone_.generate(id, seed)) {
HD2D_ERR("unknown zone '%s'", id.c_str());
return false;
}
buildTerrain();
buildProps();
buildDecor();
water_.build(zone_.waterMin, zone_.waterMax, Zone::kWaterLevel);
water_.buildShoreField(terrain_);
particles_ = std::make_unique<hd2d::ParticleSystem>();
float W = float(zone_.width()), D = float(zone_.depth());
particles_->addField(hd2d::ParticleSystem::Kind::Firefly, {4, 0.2f, 4}, {W - 4, 1.8f, D - 4}, 80);
particles_->addField(hd2d::ParticleSystem::Kind::Mote, {4, 0.0f, 4}, {W - 4, 7.0f, D - 4}, 900);
std::vector<hd2d::ModelBatch*> occluders;
for (auto& [name, b] : models_)
if (b->groundOcclusion > 0.0f) occluders.push_back(b.get());
groundAO_.build({0.0f, 0.0f}, {W, D}, occluders);
scene = hd2d::Scene();
scene.terrain = &terrain_;
scene.water = &water_;
scene.particles = particles_.get();
scene.groundOcclusion = &groundAO_;
for (auto& [name, b] : models_) scene.models.push_back(b.get());
scene.interactorRadius = 1.0f;
scene.blobShadowRadius = 0.55f;
weather = hd2d::Weather();
weather.nightBrightness = 0.42f; // keep night rain readable around the trainers
hourShown_ = -1.0f;
built_ = true;
reset();
HD2D_LOG("zone %s/%u: %zu props, %zu tall grass sprites", id.c_str(), seed, zone_.props.size(),
tallGrass_.instances.size());
return true;
}
void World::buildTerrain() {
hd2d::Terrain& t = terrain_;
if (t.textures.layers() == 0) {
std::vector<std::string> layers;
for (const char* l : kLayers) layers.push_back(engineAsset(std::string("textures/") + l + ".png"));
t.textures.load(layers);
}
t.init(zone_.width(), zone_.depth(), Zone::kLevelHeight);
t.baseLevel = -8;
// topFirst, topCount, accentFirst, accentCount, accentChance, sideFirst, sideCount, lipLayer
t.materials[MatGrass] = {0, 4, 4, 3, 0.10f, 10, 2, 0};
t.materials[MatMeadow] = {7, 2, 0, 4, 0.15f, 10, 2, 7};
t.materials[MatPath] = {9, 1, 0, 0, 0.0f, 10, 2, -1};
t.materials[MatSand] = {14, 2, 0, 0, 0.0f, 14, 2, -1};
t.materials[MatStone] = {16, 2, 0, 0, 0.0f, 12, 2, -1};
t.materials[MatRidge] = {0, 4, 7, 2, 0.2f, 12, 2, 0};
t.materials[MatPlaza] = {16, 2, 9, 1, 0.08f, 12, 2, -1};
for (int z = 0; z < zone_.depth(); ++z)
for (int x = 0; x < zone_.width(); ++x) {
t.setLevel(x, z, zone_.level(x, z));
t.setMaterial(x, z, zone_.material(x, z));
}
t.build();
}
void World::buildProps() {
models_.clear();
lampLights_.clear();
for (const ZoneProp& p : zone_.props) {
auto it = models_.find(p.model);
if (it == models_.end()) {
// engine scenery (trees, bushes, rocks) vs game props (tools/blender_safari.py)
bool engineModel = p.model.rfind("oak", 0) == 0 || p.model.rfind("pine", 0) == 0 ||
p.model.rfind("bush", 0) == 0 || p.model.rfind("rock", 0) == 0;
const hd2d::Model* m = engineModel ? scenery_.get(p.model) : safari_.get(p.model);
if (!m) {
HD2D_ERR("zone prop model '%s' not found", p.model.c_str());
continue;
}
auto b = std::make_unique<hd2d::ModelBatch>(m);
ModelFlags f = flagsFor(p.model);
b->groundOcclusion = f.groundAO;
b->occluderFade = f.occluderFade;
b->castShadows = f.castShadows;
it = models_.emplace(p.model, std::move(b)).first;
}
hd2d::ModelInstance inst{p.pos, p.yaw, p.scale, p.tint};
it->second->instances.push_back(inst);
for (const auto& [sock, local] : it->second->model->sockets)
if (sock.rfind("light", 0) == 0) lampLights_.push_back({inst.toWorld(local), 6.5f, glm::vec3(3.4f, 1.9f, 0.75f)});
}
for (auto& [name, b] : models_) b->upload();
}
void World::buildDecor() {
Rng rng(zone_.seed() * 7919u + 17u);
tallGrass_.instances.clear();
tufts_.instances.clear();
flowers_.instances.clear();
const Zone& z = zone_;
for (int tz = 0; tz < z.depth(); ++tz)
for (int tx = 0; tx < z.width(); ++tx) {
if (z.tallGrassTile(tx, tz)) { // dense overlapping rows of swaying clumps
for (int row = 0; row < 2; ++row)
for (int col = 0; col < 2; ++col) {
glm::vec2 p(float(tx) + 0.25f + 0.5f * float(col) + rng.range(-0.08f, 0.08f),
float(tz) + 0.3f + 0.5f * float(row) + rng.range(-0.05f, 0.05f));
float s = rng.range(1.0f, 1.2f);
tallGrass_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.03f, p.y}, 1.0f,
glm::vec2(1.1f) * s, rng.chance(0.5f) ? 1.0f : 0.0f,
rng.range(0.86f, 1.08f)});
}
continue;
}
uint8_t m = z.material(tx, tz);
if (z.level(tx, tz) < 0 || (m != MatGrass && m != MatRidge)) continue;
float cluster = 0.5f + 0.5f * std::sin(float(tx) * 0.35f + 1.7f) * std::sin(float(tz) * 0.29f + 0.4f);
int n = int(rng.uniform() * (0.3f + 2.6f * cluster * cluster));
for (int i = 0; i < n; ++i) {
glm::vec2 p(float(tx) + rng.uniform(), float(tz) + rng.uniform());
if (z.onDock(p)) continue;
float s = rng.range(0.6f, 1.05f);
tufts_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.02f, p.y}, 1.0f,
tufts_.sizeForDensity(kPixelsPerUnit) * s, rng.chance(0.5f) ? 1.0f : 0.0f,
rng.range(0.62f, 0.8f)});
}
if (rng.chance(0.07f)) {
glm::vec2 p(float(tx) + rng.uniform(), float(tz) + rng.uniform());
flowers_.instances.push_back({{p.x, z.terrainHeight(p.x, p.y) - 0.02f, p.y}, 0.8f,
flowers_.sizeForDensity(kPixelsPerUnit) * rng.range(0.7f, 1.0f),
rng.chance(0.5f) ? 1.0f : 0.0f, 1.0f});
}
}
for (hd2d::BillboardBatch* b : {&tallGrass_, &tufts_, &flowers_}) b->upload();
}
void World::reset() {
visuals_.clear();
playerArt_.clear();
capsules_.clear();
bobbers_.clear();
pending_.clear();
localSpawned_ = false;
haveTick_ = false;
correction_ = glm::vec2(0.0f);
target_ = 0;
biteTimer_ = 0.0f;
localId_ = 0;
}
// ------------------------------------------------------------------ assets on demand
int World::creatureSlot(uint16_t species) {
auto it = speciesSlot_.find(species);
if (it != speciesSlot_.end()) {
slotUsed_[it->second] = time_;
return it->second;
}
const Species* sp = roster_.get(species);
hd2d::Image img;
if (!sp || !hd2d::loadImage(gameAsset("creatures/" + sp->sprite), img)) return -1;
int slot = creatureAtlas_.add(img);
if (slot < 0) { // full: evict the least recently drawn species
int victim = -1;
float oldest = 1e30f;
for (auto& [s, t] : slotUsed_)
if (t < oldest) oldest = t, victim = s;
for (auto i = speciesSlot_.begin(); i != speciesSlot_.end(); ++i)
if (i->second == victim) {
speciesSlot_.erase(i);
break;
}
creatureAtlas_.set(victim, img);
slot = victim;
}
speciesSlot_[species] = slot;
slotUsed_[slot] = time_;
return slot;
}
hd2d::BillboardBatch* World::playerBatch(uint32_t entity, const NetClient& net) {
auto info = net.players.find(entity);
if (info == net.players.end()) return nullptr;
PlayerArt& art = playerArt_[entity];
const Appearance& look = info->second.look;
bool same = art.batch && art.look.body == look.body && art.look.skin == look.skin && art.look.items == look.items &&
art.look.colors == look.colors;
if (!same) {
art.look = look;
art.batch = std::make_unique<hd2d::BillboardBatch>();
art.batch->texture.create(charArt_.compose(look));
art.batch->emissive = 0.06f;
art.batch->instances.resize(1);
}
return art.batch.get();
}
// ------------------------------------------------------------------ networking
void World::applySnapshots(NetClient& net) {
localId_ = net.localEntity();
while (!net.snapshots.empty()) {
SnapshotMsg s = std::move(net.snapshots.front());
net.snapshots.pop_front();
float tick = float(s.tick);
if (!haveTick_ || std::abs(serverTick_ - tick) > 15.0f) serverTick_ = tick, haveTick_ = true;
else serverTick_ += (tick - serverTick_) * 0.08f;
hour_ = s.hour;
weatherState_ = s.weather;
for (const EntityState& es : s.entities) {
if (es.id == localId_ && es.kind == EntityKind::Player) {
localServerAnim_ = es.anim;
glm::vec2 server(es.pos.x, es.pos.z);
if (!localSpawned_) {
predicted_.pos = server;
predicted_.yaw = es.yaw;
prevPredicted_ = predicted_;
pending_.clear();
localSpawned_ = true;
localRender_ = es.pos;
continue;
}
while (!pending_.empty() && pending_.front().seq <= s.ackInput) pending_.pop_front();
MoveState replay = predicted_;
replay.pos = server;
for (const InputCmd& c : pending_) stepMovement(zone_, replay, c, kTickDt);
glm::vec2 err = predicted_.pos - replay.pos;
if (glm::length(err) > 2.5f) {
predicted_ = replay;
prevPredicted_ = replay;
correction_ = glm::vec2(0.0f);
} else if (glm::length(err) > 1e-4f) {
correction_ += err;
prevPredicted_.pos -= err;
predicted_.pos = replay.pos;
}
continue;
}
Visual& v = visuals_[es.id];
if (v.id == 0) {
v.id = es.id;
v.born = time_;
v.pos = es.pos;
v.lastPos = es.pos;
v.yaw = es.yaw;
v.flip = v.flipTarget = 0.0f;
}
v.kind = es.kind;
v.species = es.species;
v.owner = es.owner;
v.lastSeen = time_;
v.history.push_back({tick, es});
while (v.history.size() > 24) v.history.pop_front();
}
}
// entities that stopped appearing in snapshots are gone
for (auto it = visuals_.begin(); it != visuals_.end();)
it = time_ - it->second.lastSeen > 0.6f ? visuals_.erase(it) : std::next(it);
for (uint32_t id : net.playersLeft) playerArt_.erase(id);
net.playersLeft.clear();
}
void World::handleEvent(const GameEvent& e, NetClient& net) {
const bool mine = e.a == net.localEntity();
switch (e.kind) {
case EventKind::Emerge:
if (e.i == 1) particles_->burst(e.p + glm::vec3(0, 0.1f, 0), 28, {0.75f, 0.88f, 1.0f}, 2.6f, 0.06f, 0.9f);
else {
particles_->burst(e.p + glm::vec3(0, 0.45f, 0), 26, {0.22f, 0.46f, 0.12f}, 2.4f, 0.07f, 0.9f);
particles_->burst(e.p + glm::vec3(0, 0.3f, 0), 10, {0.45f, 0.62f, 0.2f}, 1.4f, 0.05f, 0.7f);
}
if (auto it = visuals_.find(e.a); it != visuals_.end()) it->second.born = time_;
break;
case EventKind::Rustle:
particles_->burst(e.p + glm::vec3(0, 0.4f, 0), 9, {0.25f, 0.5f, 0.14f}, 1.3f, 0.06f, 0.6f);
break;
case EventKind::Splash:
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 22, {0.8f, 0.9f, 1.0f}, 2.2f, 0.05f,
0.8f);
break;
case EventKind::Throw: {
Capsule c;
c.thrower = e.a;
c.target = e.b;
c.from = e.p;
c.to = e.q;
c.start = time_;
c.flight = std::max(e.f, 0.15f);
if (mine) c.from = localRender_ + glm::vec3(0, 1.0f, 0);
capsules_.push_back(c);
break;
}
case EventKind::CaptureResult:
for (Capsule& c : capsules_)
if (c.target == e.b && c.result < 0) {
c.result = e.i ? 1 : 0;
c.resultAt = time_;
glm::vec3 at = c.to + glm::vec3(0, 0.25f, 0);
if (e.i) {
particles_->burst(at, 40, {1.6f, 1.3f, 0.5f}, 2.2f, 0.05f, 1.2f);
} else {
particles_->burst(at, 26, {1.4f, 1.4f, 1.4f}, 2.8f, 0.05f, 0.7f);
if (auto it = visuals_.find(e.b); it != visuals_.end()) {
it->second.hide = 0.0f;
it->second.flash = 1.0f;
}
}
}
break;
case EventKind::Fled:
particles_->burst(e.p + glm::vec3(0, 0.2f, 0), 18, {0.55f, 0.48f, 0.38f}, 1.8f, 0.07f, 0.8f);
break;
case EventKind::Cast:
bobbers_.erase(std::remove_if(bobbers_.begin(), bobbers_.end(), [&](const Bobber& b) { return b.player == e.a; }),
bobbers_.end());
bobbers_.push_back({e.a, e.p});
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 8, {0.8f, 0.9f, 1.0f}, 1.0f, 0.04f,
0.5f);
break;
case EventKind::Bite:
for (Bobber& b : bobbers_)
if (b.player == e.a) b.bite = time_;
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 14, {0.8f, 0.9f, 1.0f}, 1.6f, 0.05f,
0.6f);
if (mine) biteTimer_ = 1.3f;
break;
case EventKind::Hooked:
particles_->burst(glm::vec3(e.p.x, Zone::kWaterLevel + 0.05f, e.p.z), 36, {0.8f, 0.92f, 1.0f}, 3.4f, 0.06f,
1.0f);
[[fallthrough]];
case EventKind::LineLost:
bobbers_.erase(std::remove_if(bobbers_.begin(), bobbers_.end(), [&](const Bobber& b) { return b.player == e.a; }),
bobbers_.end());
if (mine) biteTimer_ = 0.0f;
break;
default: break;
}
}
void World::predict(float dt, const FrameInput& in, NetClient& net, const hd2d::Camera& cam) {
glm::vec3 right = cam.right();
glm::vec2 r2(right.x, right.z), f2(right.z, -right.x);
glm::vec2 dir = r2 * in.move.x + f2 * in.move.y;
if (glm::length(dir) > 1.0f) dir = glm::normalize(dir);
tickAccum_ += dt;
int steps = 0;
while (tickAccum_ >= kTickDt && steps < 5) {
tickAccum_ -= kTickDt;
++steps;
prevPredicted_ = predicted_;
InputCmd cmd = makeInput(++inputSeq_, dir, in.run);
if (localSpawned_) {
stepMovement(zone_, predicted_, cmd, kTickDt);
pending_.push_back(cmd);
while (pending_.size() > 90) pending_.pop_front();
}
net.sendInput(cmd);
}
if (steps == 5) tickAccum_ = 0.0f;
float alpha = std::clamp(tickAccum_ / kTickDt, 0.0f, 1.0f);
correction_ *= std::exp(-dt * 12.0f);
glm::vec2 p = glm::mix(prevPredicted_.pos, predicted_.pos, alpha) + correction_;
float ground = groundAt(p);
localRender_.x = p.x;
localRender_.z = p.y;
localRender_.y += (ground - localRender_.y) * std::min(1.0f, dt * 14.0f);
if (std::abs(localRender_.y - ground) > 2.0f) localRender_.y = ground;
if (predicted_.moving) localPhase_ += dt * (predicted_.running ? 11.0f : 7.5f);
else localPhase_ = 0.0f;
// footstep particles (grass shakes via the scene interactor)
stepTimer_ -= dt;
bool inGrass = zone_.inTallGrass(p);
if (predicted_.moving && stepTimer_ <= 0.0f) {
stepTimer_ = inGrass ? (predicted_.running ? 0.07f : 0.12f) : (predicted_.running ? 0.16f : 0.25f);
glm::vec3 feet(p.x, ground + 0.05f, p.y);
if (inGrass) particles_->burst(feet + glm::vec3(0, 0.3f, 0), predicted_.running ? 5 : 3, {0.2f, 0.42f, 0.1f}, 1.6f,
0.05f, 0.7f);
else if (zone_.onDock(p)) particles_->burst(feet, 1, {0.35f, 0.28f, 0.2f}, 0.4f, 0.04f, 0.3f);
else particles_->burst(feet, 2, {0.32f, 0.27f, 0.2f}, 0.7f, 0.06f, 0.5f);
}
}
void World::sampleEntities(float dt) {
float renderTick = serverTick_ - 3.0f; // ~100 ms interpolation delay
for (auto& [id, v] : visuals_) {
if (v.history.empty()) continue;
const Snap* a = &v.history.front();
const Snap* b = a;
for (size_t i = 0; i + 1 < v.history.size(); ++i)
if (v.history[i].tick <= renderTick) {
a = &v.history[i];
b = &v.history[i + 1];
}
if (renderTick >= v.history.back().tick) a = b = &v.history.back();
float t = b->tick > a->tick ? std::clamp((renderTick - a->tick) / (b->tick - a->tick), 0.0f, 1.0f) : 1.0f;
v.lastPos = v.pos;
v.pos = glm::mix(a->s.pos, b->s.pos, t);
v.yaw = lerpAngle(a->s.yaw, b->s.yaw, t);
v.anim = b->s.anim;
float moved = glm::length(glm::vec2(v.pos.x - v.lastPos.x, v.pos.z - v.lastPos.z));
float sp = dt > 0.0f ? moved / dt : 0.0f;
v.speed += (sp - v.speed) * std::min(1.0f, dt * 10.0f);
if (v.speed > 0.15f) v.walkPhase += moved * 2.4f;
else v.walkPhase = 0.0f;
v.flash = std::max(0.0f, v.flash - dt * 2.5f);
v.chatAge += dt;
}
}
// ------------------------------------------------------------------ per frame
void World::update(float dt, float time, const FrameInput& in, NetClient& net, const hd2d::Camera& cam) {
time_ = time;
applySnapshots(net);
for (const GameEvent& e : net.events) handleEvent(e, net);
net.events.clear();
predict(dt, in, net, cam);
serverTick_ += dt * float(kTickRate);
sampleEntities(dt);
biteTimer_ = std::max(0.0f, biteTimer_ - dt);
// auto-target: nearest catchable creature, preferring the facing direction
target_ = 0;
float best = 1e9f;
glm::vec2 me(localRender_.x, localRender_.z), face = dirFromYaw(predicted_.yaw);
for (auto& [id, v] : visuals_) {
if (v.kind != EntityKind::Wild || v.anim == Anim::Capturing || v.anim == Anim::Flee) continue;
glm::vec2 d(v.pos.x - me.x, v.pos.z - me.y);
float dist = glm::length(d);
if (dist > 8.0f) continue;
float facing = dist > 1e-3f ? glm::dot(d / dist, face) : 1.0f;
float score = dist * (facing > 0.35f ? 1.0f : 2.2f);
if (score < best) best = score, target_ = id;
}
updateDayWeather(dt);
updateSprites(dt, cam, &net);
particles_->update(dt, time);
updateLights();
scene.interactor = localRender_;
scene.blobShadowPos = glm::vec3(localRender_.x, groundAt(me), localRender_.z);
scene.focusPoint = localRender_ + glm::vec3(0, 0.6f, 0);
}
void World::updateBackdrop(float dt, float time, float hour, Weather w) {
time_ = time;
hour_ = hour;
weatherState_ = w;
localRender_ = glm::vec3(zone_.plaza.x, 0.0f, zone_.plaza.y);
updateDayWeather(dt);
hd2d::Camera dummy;
updateSprites(dt, dummy, nullptr);
particles_->update(dt, time);
updateLights();
scene.interactor = glm::vec3(-100.0f);
scene.blobShadowRadius = 0.0f;
scene.focusPoint = localRender_ + glm::vec3(0, 0.6f, 0);
}
void World::updateDayWeather(float dt) {
if (hourShown_ < 0.0f) hourShown_ = hour_;
float dh = hour_ - hourShown_;
if (dh > 12.0f) dh -= 24.0f;
if (dh < -12.0f) dh += 24.0f;
hourShown_ = std::fmod(hourShown_ + dh * std::min(1.0f, dt * 2.0f) + 24.0f, 24.0f);
weather.setClearLook(day.look(hourShown_));
float rain = 0, overcast = 0, fog = 0;
switch (weatherState_) {
case Weather::Cloudy: overcast = 0.85f; break;
case Weather::Rain: rain = 0.55f, overcast = 0.7f; break;
case Weather::Storm: rain = 1.0f, overcast = 1.0f; break;
case Weather::Fog: fog = 1.0f, overcast = 0.35f; break;
default: break;
}
weather.setRain(rain);
weather.setOvercast(overcast);
weather.setFog(fog);
weather.setLightning(weatherState_ == Weather::Storm);
}
void World::updateLights() {
scene.lights.clear();
float night = day.night(hourShown_);
float dusk = std::clamp(1.0f - day.daylight(hourShown_) * 1.6f, 0.0f, 1.0f);
float lampOn = std::max(night, dusk * 0.8f);
lampOn = std::max(lampOn, weather.overcast() * 0.35f);
int lamps = 0;
if (lampOn > 0.02f) {
std::vector<const hd2d::PointLight*> near;
for (const hd2d::PointLight& l : lampLights_) near.push_back(&l);
glm::vec3 c = localRender_;
std::sort(near.begin(), near.end(), [&](auto* a, auto* b) {
return glm::length(a->position - c) < glm::length(b->position - c);
});
float flicker = 0.92f + 0.08f * std::sin(time_ * 13.0f) * std::sin(time_ * 7.3f);
for (size_t i = 0; i < near.size() && i < 8; ++i) {
hd2d::PointLight l = *near[i];
l.color *= lampOn * flicker;
scene.lights.push_back(l);
++lamps;
}
}
// Rangers carry a lantern after dark so the trainer and party stay readable (Octopath-style
// warm pool of light in a deep blue night).
float lantern = std::max(night, weather.rain() * 0.5f * (1.0f - day.daylight(hourShown_)));
if (lantern > 0.05f && localSpawned_) {
scene.lights.push_back({localRender_ + glm::vec3(0.0f, 1.3f, 0.0f), 5.5f,
glm::vec3(2.2f, 1.45f, 0.75f) * lantern});
++lamps;
}
scene.volumetricLights = lamps;
particles_->collectLights(scene.lights, 16 - lamps);
}
void World::drawCharacter(hd2d::BillboardBatch& batch, glm::vec3 pos, float yaw, bool moving, bool running,
float phase, const hd2d::Camera& cam, int anchorRow, float alpha) {
glm::ivec2 texSize = batch.texture.size();
bool flip = false;
int row = SheetLayout::rowFor(yaw - cam.yaw, flip);
int col = moving ? 1 + (int(std::floor(phase)) % manifest_.walkFrames) : 0;
hd2d::BillboardInstance& bi = batch.instances[0];
bi.uv = hd2d::Texture::cellUV(texSize, kCell, col, row);
bi.anchor = hd2d::Texture::cellAnchor(texSize, kCell, row, anchorRow);
bi.size = hd2d::BillboardBatch::cellSize(kCell, kPixelsPerUnit);
if (!moving) bi.size.y *= 1.0f + 0.012f * std::sin(time_ * 2.4f + pos.x); // breathing
float hop = moving && running ? std::abs(std::sin(phase * kPi * 0.5f)) * 0.05f : 0.0f;
bi.pos = pos + glm::vec3(0, hop - 0.02f, 0);
bi.flip = flip ? 1.0f : 0.0f;
bi.alpha = alpha;
bi.tint = 1.0f;
batch.upload();
}
void World::updateSprites(float dt, const hd2d::Camera& cam, NetClient* net) {
scene.billboards = {&tallGrass_, &tufts_, &flowers_};
glm::vec3 camRight = cam.right();
// players (local predicted, remote interpolated)
if (net) {
if (localSpawned_) {
if (auto* b = playerBatch(localId_, *net)) {
bool moving = predicted_.moving;
drawCharacter(*b, localRender_, predicted_.yaw, moving, predicted_.running, localPhase_, cam,
manifest_.bodies.count(playerArt_[localId_].look.body)
? manifest_.bodies.at(playerArt_[localId_].look.body).anchorRow
: 60);
scene.billboards.push_back(b);
}
}
for (auto& [id, v] : visuals_) {
if (v.kind != EntityKind::Player || id == localId_) continue;
hd2d::BillboardBatch* b = playerBatch(id, *net);
if (!b) continue;
bool moving = v.anim == Anim::Walk || v.anim == Anim::Run;
int anchor = manifest_.bodies.count(playerArt_[id].look.body) ? manifest_.bodies.at(playerArt_[id].look.body).anchorRow : 60;
glm::vec3 p = v.pos;
p.y = groundAt({p.x, p.z});
drawCharacter(*b, p, v.yaw, moving, v.anim == Anim::Run, v.walkPhase, cam, anchor,
std::clamp((time_ - v.born) * 3.0f, 0.0f, 1.0f));
scene.billboards.push_back(b);
}
}
// Professor Henry
henry_.instances.clear();
for (auto& [id, v] : visuals_)
if (v.kind == EntityKind::Npc && henry_.texture.id()) {
henry_.instances.resize(1);
glm::vec3 p = v.pos;
p.y = groundAt({p.x, p.z});
int anchor = manifest_.npcs.count("henry") ? manifest_.npcs.at("henry").anchorRow : 60;
drawCharacter(henry_, p, v.yaw, v.speed > 0.2f, false, v.walkPhase, cam, anchor);
}
if (net && henry_.instances.empty()) henry_.upload();
if (!net && henry_.texture.id()) { // backdrop: Henry waits at his post
henry_.instances.resize(1);
hd2d::Camera c = cam;
glm::vec2 hp = zone_.henry;
drawCharacter(henry_, glm::vec3(hp.x, groundAt(hp), hp.y), zone_.henryYaw, false, false, 0.0f, c,
manifest_.npcs.count("henry") ? manifest_.npcs.at("henry").anchorRow : 60);
}
scene.billboards.push_back(&henry_);
// creatures (wild + followers) from the shared atlas
creatures_.instances.clear();
glm::vec2 csize = hd2d::BillboardBatch::cellSize(kCell, kCreaturePixelsPerUnit);
for (auto& [id, v] : visuals_) {
if (v.kind != EntityKind::Wild && v.kind != EntityKind::Follower) continue;
int slot = creatureSlot(v.species);
if (slot < 0) continue;
// facing: sprites look left by default; mirror when heading right on screen
glm::vec2 fd = dirFromYaw(v.yaw);
float side = fd.x * camRight.x + fd.y * camRight.z;
float want = side > 0.08f ? 1.0f : side < -0.08f ? 0.0f : v.flipTarget;
if (want != v.flipTarget) {
v.flipTarget = want;
v.turnAnim = 1.0f;
}
v.turnAnim = std::max(0.0f, v.turnAnim - dt / 0.22f);
float tt = 1.0f - v.turnAnim;
if (tt >= 0.5f) v.flip = v.flipTarget;
float turn = v.turnAnim > 0.0f ? std::sin(tt * kPi) * 1.45f : 0.0f;
hd2d::BillboardInstance bi;
bi.uv = creatureAtlas_.uv(slot);
bi.anchor = creatureAtlas_.anchor(slot);
bi.size = csize;
bi.flip = v.flip;
bi.turn = turn;
bi.flash = v.flash;
glm::vec3 p = v.pos;
bool water = v.anim == Anim::Swim;
float ground = groundAt({p.x, p.z});
if (water) p.y = Zone::kWaterLevel - 0.28f + 0.05f * std::sin(time_ * 2.2f + float(id));
else if (v.anim == Anim::Hooked) p.y = std::max(p.y, ground);
else p.y = ground;
bool moving = v.speed > 0.2f && !water;
if (moving) {
float ph = v.walkPhase * 1.4f;
p.y += std::abs(std::sin(ph * kPi)) * 0.16f;
bi.size.y *= 1.0f + 0.05f * std::cos(ph * 2.0f * kPi);
bi.size.x *= 1.0f - 0.03f * std::cos(ph * 2.0f * kPi);
} else {
bi.size.y *= 1.0f + 0.018f * std::sin(time_ * 2.0f + float(id) * 1.3f);
}
float age = time_ - v.born;
if (v.anim == Anim::Emerge || age < 0.6f) {
float k = easeOutBack(age / 0.5f);
bi.size *= std::max(0.05f, k);
p.y -= (1.0f - std::clamp(age / 0.5f, 0.0f, 1.0f)) * 0.35f;
if (age < 0.15f) bi.flash = std::max(bi.flash, 1.0f - age / 0.15f);
}
if (v.anim == Anim::Alert && age < 4.0f) p.y += std::max(0.0f, std::sin((time_ - v.born) * 9.0f)) * 0.05f;
if (v.anim == Anim::Flee) bi.alpha = 0.75f;
if (v.anim == Anim::Capturing) {
// vanish into the capsule once it lands
for (const Capsule& c : capsules_)
if (c.target == id && time_ > c.start + c.flight) {
v.hide = std::min(1.0f, v.hide + dt * 4.0f);
v.flash = std::max(v.flash, 1.0f - v.hide);
}
bi.size *= 1.0f - 0.8f * v.hide;
bi.alpha = 1.0f - v.hide;
bi.flash = std::max(bi.flash, v.flash);
} else {
v.hide = 0.0f;
}
bi.pos = p;
bi.tint = 1.0f;
if (bi.alpha > 0.01f) creatures_.instances.push_back(bi);
}
creatures_.upload();
scene.billboards.push_back(&creatures_);
// effects: capsules, bobbers + fishing lines
fx_.instances.clear();
glm::vec2 fxSize = hd2d::BillboardBatch::cellSize({16, 16}, kPixelsPerUnit);
for (auto it = capsules_.begin(); it != capsules_.end();) {
Capsule& c = *it;
float t = (time_ - c.start) / c.flight;
hd2d::BillboardInstance bi;
bi.uv = fxAtlas_.uv(fxCapsule_);
bi.anchor = fxAtlas_.anchor(fxCapsule_);
bi.size = fxSize;
glm::vec3 land = c.to;
land.y = groundAt({c.to.x, c.to.z});
if (const Visual* v = visuals_.count(c.target) ? &visuals_[c.target] : nullptr; v && v->anim == Anim::Swim)
land.y = Zone::kWaterLevel + 0.02f;
if (t < 1.0f) {
float h = 1.0f + glm::length(c.to - c.from) * 0.08f;
bi.pos = glm::mix(c.from, land, t) + glm::vec3(0, 4.0f * h * t * (1.0f - t), 0);
bi.flip = std::fmod(time_ * 12.0f, 2.0f) < 1.0f ? 1.0f : 0.0f;
} else {
bi.pos = land;
float since = time_ - (c.start + c.flight);
float cyc = std::fmod(since, 0.55f) / 0.55f;
if (c.result < 0 || c.result == 1) bi.turn = since > 0.3f && cyc < 0.4f ? std::sin(cyc / 0.4f * 2.0f * kPi) * 0.6f : 0.0f;
if (c.result == 1) {
float k = time_ - c.resultAt;
bi.turn = 0.0f;
bi.flash = std::max(0.0f, 1.0f - k * 3.0f);
bi.alpha = std::clamp(1.4f - k, 0.0f, 1.0f);
if (k > 1.4f) {
it = capsules_.erase(it);
continue;
}
} else if (c.result == 0 || time_ - c.start > 12.0f) {
it = capsules_.erase(it);
continue;
}
}
fx_.instances.push_back(bi);
++it;
}
for (const Bobber& b : bobbers_) {
hd2d::BillboardInstance bi;
bi.uv = fxAtlas_.uv(fxBobber_);
bi.anchor = fxAtlas_.anchor(fxBobber_);
bi.size = fxSize * 1.25f;
bool biting = b.bite >= 0.0f && time_ - b.bite < 1.3f;
float dip = biting ? -0.06f - 0.05f * std::abs(std::sin(time_ * 18.0f)) : 0.015f * std::sin(time_ * 3.0f);
bi.pos = glm::vec3(b.pos.x, Zone::kWaterLevel + dip - 0.04f, b.pos.z);
fx_.instances.push_back(bi);
// fishing line: dots from the angler's hand with a little sag
glm::vec3 hand;
if (net && b.player == localId_) hand = localRender_;
else if (auto v = visuals_.find(b.player); v != visuals_.end()) hand = v->second.pos;
else continue;
glm::vec2 face = glm::normalize(glm::vec2(b.pos.x - hand.x, b.pos.z - hand.z) + glm::vec2(1e-4f));
hand += glm::vec3(face.x * 0.35f, 1.25f, face.y * 0.35f);
glm::vec3 end = bi.pos + glm::vec3(0, 0.12f, 0);
for (int i = 1; i < 28; ++i) {
float s = float(i) / 28.0f;
hd2d::BillboardInstance d;
d.uv = fxAtlas_.uv(fxDot_);
d.anchor = fxAtlas_.anchor(fxDot_);
d.size = fxSize * 0.6f;
d.pos = glm::mix(hand, end, s) - glm::vec3(0, (biting ? 0.1f : 0.45f) * 4.0f * s * (1.0f - s), 0);
d.tint = 0.9f;
fx_.instances.push_back(d);
}
}
fx_.upload();
scene.billboards.push_back(&fx_);
}
// ------------------------------------------------------------------ queries
uint32_t World::nearbyNpc() const {
glm::vec2 me(localRender_.x, localRender_.z);
for (const auto& [id, v] : visuals_)
if (v.kind == EntityKind::Npc && glm::length(glm::vec2(v.pos.x, v.pos.z) - me) < 2.5f) return id;
return 0;
}
bool World::canFish(glm::vec2& bobber) const {
return zone_.fishingTarget({localRender_.x, localRender_.z}, dirFromYaw(predicted_.yaw), bobber);
}
bool World::lineOut() const {
for (const Bobber& b : bobbers_)
if (b.player == localId_) return true;
return localServerAnim_ == Anim::Fish;
}
void World::showChatBubble(uint32_t entity, const std::string& text) {
if (auto it = visuals_.find(entity); it != visuals_.end()) {
it->second.chat = text;
it->second.chatAge = 0.0f;
} else if (entity == localId_) {
Visual& v = visuals_[entity]; // placeholder so the local bubble has a home
v.chat = text;
v.chatAge = 0.0f;
v.lastSeen = time_ + 6.0f;
}
}
std::vector<World::Label> World::labels() const {
std::vector<Label> out;
for (const auto& [id, v] : visuals_) {
glm::vec3 head = v.pos;
if (v.kind == EntityKind::Player && id != localId_) {
head.y = groundAt({v.pos.x, v.pos.z}) + 1.75f;
out.push_back({head, "", 0x8ec8ff, false, id});
} else if (v.kind == EntityKind::Npc) {
head.y = groundAt({v.pos.x, v.pos.z}) + 1.8f;
out.push_back({head, "Professor Henry", 0xe6be5a});
} else if (id == target_) {
const Species* sp = roster_.get(v.species);
head.y = (v.anim == Anim::Swim ? Zone::kWaterLevel : groundAt({v.pos.x, v.pos.z})) + 1.45f;
out.push_back({head, sp ? sp->name : "???", sp ? typeColor(sp->type) : 0xffffffu});
} else if (v.kind == EntityKind::Wild && v.anim == Anim::Alert && time_ - v.born < 3.5f) {
head.y = groundAt({v.pos.x, v.pos.z}) + 1.45f;
out.push_back({head, "!", 0xff6a4a});
}
if (v.chatAge < 5.0f && !v.chat.empty()) {
glm::vec3 p = id == localId_ ? localRender_ : v.pos;
p.y = groundAt({p.x, p.z}) + 2.2f;
out.push_back({p, v.chat, 0xffffff, true, id});
}
}
return out;
}
} // namespace bs