#include "World.h" #include "hd2d/Log.h" #include #include 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(); 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 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 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(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(); 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 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::labels() const { std::vector