- 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
930 lines
39 KiB
C++
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
|