#include "App.h" #include "hd2d/Log.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include extern char** environ; namespace bs { namespace fs = std::filesystem; namespace ui = hd2d::ui; namespace { fs::path dataDir() { const char* xdg = std::getenv("XDG_DATA_HOME"); fs::path base = xdg && *xdg ? fs::path(xdg) : fs::path(std::getenv("HOME") ? std::getenv("HOME") : ".") / ".local/share"; fs::path d = base / "brandling-safari"; std::error_code ec; fs::create_directories(d, ec); return d; } std::string randomToken() { std::random_device rd; static const char* hex = "0123456789abcdef"; std::string s; for (int i = 0; i < 32; ++i) s += hex[rd() & 15]; return s; } const char* timeOfDayName(float h) { if (h < 4.5f || h >= 21.0f) return "Night"; if (h < 7.0f) return "Dawn"; if (h < 11.0f) return "Morning"; if (h < 14.0f) return "Midday"; if (h < 17.5f) return "Afternoon"; return "Dusk"; } std::string weatherLabel(Weather w) { switch (w) { case Weather::Cloudy: return "Cloudy"; case Weather::Rain: return "Rain"; case Weather::Storm: return "Thunderstorm"; case Weather::Fog: return "Mist"; default: return "Clear"; } } std::string replaceAll(std::string s, const std::string& from, const std::string& to) { for (size_t p = s.find(from); p != std::string::npos; p = s.find(from, p + to.size())) s.replace(p, from.size(), to); return s; } std::string capitalize(std::string s) { if (!s.empty()) s[0] = char(std::toupper(unsigned(s[0]))); return s; } // `s` shortened with an ellipsis (on a UTF-8 boundary) so it fits in `maxWidth` pixels. std::string fitText(ImFont* font, float size, const std::string& s, float maxWidth) { if (ui::measure(font, size, s).x <= maxWidth) return s; std::string t = s; while (!t.empty()) { do t.pop_back(); while (!t.empty() && (uint8_t(t.back()) & 0xC0) == 0x80); while (!t.empty() && t.back() == ' ') t.pop_back(); if (ui::measure(font, size, t + "...").x <= maxWidth) return t + "..."; } return t; } ImU32 withAlpha(ImU32 c, float a) { return (c & 0x00ffffffu) | (ImU32(std::clamp(a, 0.0f, 1.0f) * float(c >> 24)) << 24); } int keyCode(const std::string& k) { if (k == "space") return GLFW_KEY_SPACE; if (k == "e") return GLFW_KEY_E; if (k == "f") return GLFW_KEY_F; if (k == "tab") return GLFW_KEY_TAB; if (k == "enter") return GLFW_KEY_ENTER; return -1; } } // namespace // ------------------------------------------------------------------ identity void App::loadIdentity() { fs::path p = dataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); std::ifstream f(p); if (f) { try { nlohmann::json j = nlohmann::json::parse(f); token_ = j.value("token", ""); lastHost_ = j.value("lastHost", lastHost_); lastPort_ = j.value("lastPort", lastPort_); savedName_ = j.value("name", ""); } catch (...) { } } if (token_.size() < 16) { token_ = randomToken(); saveIdentity(); } std::snprintf(addressBuf_, sizeof addressBuf_, "%s:%u", lastHost_.c_str(), unsigned(lastPort_)); } void App::saveIdentity() { fs::path p = dataDir() / (opt_.profile.empty() ? "client.json" : "client-" + opt_.profile + ".json"); nlohmann::json j = {{"token", token_}, {"lastHost", lastHost_}, {"lastPort", lastPort_}, {"name", savedName_}}; std::ofstream(p) << j.dump(2); } // ------------------------------------------------------------------ lifecycle bool App::init() { capture_ = !opt_.screenshot.empty(); bool visible = !capture_ || opt_.show; if (!window_.create(opt_.width, opt_.height, "Brandling Safari", true, !capture_, visible)) return false; if (!renderer_.init(engineAsset("shaders"), window_.width(), window_.height())) return false; if (capture_) { offscreen_.create(window_.width(), window_.height()); renderer_.setOutputFramebuffer(offscreen_.fbo()); } if (!debugUi_.init(window_)) return false; uiScale_ = ui::scaleFor(ImVec2(float(window_.width()), float(window_.height()))); ui::loadFonts(engineAsset("fonts"), uiScale_); ui::applyTheme(); if (capture_) ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouse; if (!world_.init()) { HD2D_ERR("failed to load game assets from %s", BS_ASSET_DIR); return false; } world_.buildZone("meadow", 1); // title backdrop; rebuilt with the server's seed on join // dialogue scripts + Henry's portrait try { std::ifstream f(gameAsset("data/dialogue.json")); nlohmann::json j = nlohmann::json::parse(f); for (auto& [id, lines] : j.items()) { DialogueScript s; for (auto& l : lines) s.lines.push_back({l.value("speaker", ""), l.value("text", "")}); scripts_[id] = std::move(s); } } catch (const std::exception& e) { HD2D_ERR("dialogue.json: %s", e.what()); } if (auto it = world_.manifest().npcs.find("henry"); it != world_.manifest().npcs.end()) { hd2d::Image img; if (hd2d::loadImage(gameAsset("characters/" + it->second.portrait), img)) henryPortrait_.upload(img); } loadIdentity(); scanner_.open(); look_ = world_.manifest().defaultLook("male"); std::snprintf(nameBuf_, sizeof nameBuf_, "%s", savedName_.empty() ? "Ranger" : savedName_.c_str()); if (!opt_.name.empty()) std::snprintf(nameBuf_, sizeof nameBuf_, "%s", opt_.name.c_str()); if (!opt_.look.empty()) { // body,skin,hair,hat,top,bottom,shoes std::vector f; std::string cur; for (char c : opt_.look + ",") { if (c == ',') f.push_back(cur), cur.clear(); else cur += c; } if (f.size() >= 7) { look_ = world_.manifest().defaultLook(f[0]); look_.skin = uint8_t(std::atoi(f[1].c_str())); const int slots[5] = {Hair, Hat, Top, Bottom, Shoes}; for (int i = 0; i < 5; ++i) { look_.items[slots[i]] = f[2 + i] == "-" ? "" : f[2 + i]; if (const ItemDef* d = world_.manifest().item(look_.items[slots[i]])) look_.colors[slots[i]] = d->color; } for (size_t i = 7; i < f.size() && i < 12; ++i) look_.colors[slots[i - 7]] = parseHexColor(f[i], look_.colors[slots[i - 7]]); world_.manifest().sanitize(look_); } } refreshPreview(); cam_.distance = 14.0f; cam_.pitch = 0.58f; if (opt_.hasYaw) cam_.yaw = camYawTarget_ = glm::radians(opt_.yaw); if (opt_.dist > 0.0f) cam_.distance = opt_.dist; if (opt_.screen == "creator") screen_ = Screen::Creator; if (opt_.host) { if (startLocalServer()) beginConnect("127.0.0.1", opt_.port); } else if (!opt_.connect.empty()) { std::string h = opt_.connect; uint16_t port = opt_.port; if (auto c = h.rfind(':'); c != std::string::npos) port = uint16_t(std::atoi(h.c_str() + c + 1)), h.resize(c); beginConnect(h, port); } return true; } void App::shutdown() { net_.disconnect(); stopLocalServer(); icons_.clear(); preview_.reset(); henryPortrait_.reset(); debugUi_.shutdown(); } int App::run() { if (!init()) { shutdown(); return 1; } using clock = std::chrono::steady_clock; auto last = clock::now(); int rc = 0; while (window_.pollEvents()) { auto now = clock::now(); float dt = std::min(std::chrono::duration(now - last).count(), 0.05f); last = now; if (capture_) { // Hidden windows have no vsync: pace to 60 fps so the client clock matches the // server's real-time 30 Hz simulation (input budget, interpolation). dt = 1.0f / 60.0f; static auto next = clock::now(); next += std::chrono::microseconds(16667); std::this_thread::sleep_until(next); last = clock::now(); } if (window_.resized()) { renderer_.resize(window_.width(), window_.height()); if (capture_) offscreen_.resize(window_.width(), window_.height()); } frame(dt); ++frame_; if (!capture_ && window_.keyPressed(GLFW_KEY_F12)) { char name[64]; std::time_t t = std::time(nullptr); std::strftime(name, sizeof name, "safari-%Y%m%d-%H%M%S.png", std::localtime(&t)); fs::create_directories("screenshots"); hd2d::saveScreenshot("screenshots/" + std::string(name), window_.width(), window_.height()); } if (capture_ && frame_ >= opt_.frames) { if (!offscreen_.save(opt_.screenshot)) rc = 1; HD2D_LOG("capture -> %s (screen %d, player %.1f %.1f)", opt_.screenshot.c_str(), int(screen_), world_.localPos().x, world_.localPos().z); break; } window_.swapBuffers(); } shutdown(); return rc; } bool App::pressed(int key) const { if (ImGui::GetCurrentContext() && ImGui::GetIO().WantTextInput) return false; for (const std::string& k : opt_.keys) { auto c = k.find(':'); if (c != std::string::npos && std::atoi(k.c_str()) == frame_ && keyCode(k.substr(c + 1)) == key) return true; } return window_.keyPressed(key); } bool App::scripted(const char* key) const { return pressed(keyCode(key)); } // ------------------------------------------------------------------ local server bool App::startLocalServer() { if (serverPid_ > 0) return true; std::error_code ec; fs::path exe = fs::read_symlink("/proc/self/exe", ec); fs::path server = exe.parent_path() / "safari_server"; if (ec || !fs::exists(server)) { message_ = "safari_server was not found next to the game"; return false; } std::string port = std::to_string(opt_.port); std::string name = std::string(nameBuf_) + "'s Safari"; std::vector args = {server.string(), "--port", port, "--name", name}; std::vector argv; for (std::string& a : args) argv.push_back(a.data()); argv.push_back(nullptr); pid_t pid = 0; if (posix_spawn(&pid, server.c_str(), nullptr, nullptr, argv.data(), environ) != 0) { message_ = "could not start the local server"; return false; } serverPid_ = int(pid); HD2D_LOG("started local server (pid %d) on port %s", serverPid_, port.c_str()); usleep(250000); // let it bind before we connect (the client retries anyway) return true; } void App::stopLocalServer() { if (serverPid_ <= 0) return; kill(pid_t(serverPid_), SIGTERM); int status = 0; for (int i = 0; i < 50 && waitpid(pid_t(serverPid_), &status, WNOHANG) == 0; ++i) usleep(100000); serverPid_ = -1; } // ------------------------------------------------------------------ flow void App::beginConnect(const std::string& host, uint16_t port) { lastHost_ = host; lastPort_ = port; saveIdentity(); world_.reset(); net_.connect(host, port, token_); screen_ = Screen::Connecting; connectTimer_ = 0.0f; message_.clear(); } void App::returnToTitle(const std::string& message) { net_.disconnect(); stopLocalServer(); world_.reset(); dialogue_.close(); collectionOpen_ = pauseOpen_ = chatOpen_ = false; creating_ = false; screen_ = Screen::Title; message_ = message; } void App::handleNet() { net_.update(); NetClient::State st = net_.state(); if (st == NetClient::State::Failed && screen_ != Screen::Title && screen_ != Screen::Join) { // a freshly started local server may not be listening yet: retry for a few seconds if (screen_ == Screen::Connecting && serverPid_ > 0 && connectTimer_ < 6.0f) { net_.connect(lastHost_, lastPort_, token_); return; } returnToTitle(net_.error().empty() ? "Connection lost" : net_.error()); return; } if (screen_ == Screen::Connecting && (st == NetClient::State::NeedCharacter || st == NetClient::State::InGame)) { world_.buildZone(net_.welcome().zoneId, net_.welcome().zoneSeed); if (st == NetClient::State::NeedCharacter) { if (opt_.autoCreate) { net_.sendCreate(nameBuf_, look_); creating_ = true; } else { screen_ = Screen::Creator; } } } if (net_.state() == NetClient::State::InGame && (screen_ == Screen::Connecting || screen_ == Screen::Creator) && net_.hasProfile()) { screen_ = Screen::Play; creating_ = false; savedName_ = net_.profile().name; saveIdentity(); } for (const std::string& d : net_.dialogues) startDialogue(d); net_.dialogues.clear(); for (const std::string& t : net_.toasts) toasts_.push(t); net_.toasts.clear(); for (ChatEntry& c : net_.chat) if (c.age == 0.0f) { world_.showChatBubble(c.entity, c.text); c.age = 1e-3f; } // event-driven toasts (World consumes the effects afterwards) uint32_t me = net_.localEntity(); for (const GameEvent& e : net_.events) { const Species* sp = world_.roster().get(uint16_t(e.b)); switch (e.kind) { case EventKind::Caught: if (e.a == me) toasts_.push("Gotcha! " + std::string(sp ? sp->name : "A Brandling") + " was caught!" + (e.i ? " (new!)" : ""), IM_COL32(240, 200, 90, 255), 4.5f); else if (auto p = net_.players.find(e.a); p != net_.players.end()) toasts_.push(p->second.name + " caught " + (sp ? sp->name : "a Brandling") + "!", IM_COL32(140, 190, 240, 255)); break; case EventKind::CaptureResult: if (e.a == me && !e.i) toasts_.push("Oh no! It broke free!", IM_COL32(230, 120, 90, 255), 2.5f); break; case EventKind::LineLost: if (e.a == me) toasts_.push("It got away...", IM_COL32(150, 170, 200, 255), 2.5f); break; case EventKind::Hooked: if (e.a == me) toasts_.push("Hooked one! Throw a capsule while it's dazed!", IM_COL32(110, 190, 240, 255)); break; default: break; } } } void App::startDialogue(const std::string& script) { auto it = scripts_.find(script); if (it == scripts_.end()) { net_.sendDialogueDone(script); return; } std::string name = net_.hasProfile() ? net_.profile().name : std::string(nameBuf_); std::vector lines; for (const auto& [speaker, text] : it->second.lines) { ui::DialogueLine l; l.speaker = speaker; l.text = replaceAll(text, "{name}", name); if (speaker == "Professor Henry" && henryPortrait_.valid()) l.portrait = henryPortrait_.id(); lines.push_back(std::move(l)); } dialogue_.start(std::move(lines)); dialogueScript_ = script; collectionOpen_ = pauseOpen_ = false; } // ------------------------------------------------------------------ frame void App::updateCamera(float dt) { if (screen_ == Screen::Play && world_.localSpawned()) { glm::vec3 desired = world_.localPos() + glm::vec3(0, 0.7f, 0); float k = capture_ ? 1.0f : std::min(1.0f, dt * 6.0f); cam_.target += (desired - cam_.target) * k; cam_.yaw += wrapAngle(camYawTarget_ - cam_.yaw) * std::min(1.0f, dt * 8.0f); } else if (screen_ == Screen::Creator) { glm::vec2 s = world_.zone().spawn; cam_.target = glm::vec3(s.x - 1.5f, 1.2f, s.y - 2.0f); cam_.yaw = -0.35f + 0.05f * std::sin(time_ * 0.2f); cam_.distance = 11.0f; cam_.pitch = 0.42f; } else { glm::vec2 c = world_.zone().plaza + glm::vec2(-2.0f, -10.0f); cam_.target = glm::vec3(c.x, 1.0f, c.y); cam_.yaw = -0.5f + time_ * 0.035f; cam_.distance = 26.0f; cam_.pitch = 0.5f; } } void App::frame(float dt) { time_ += dt; connectTimer_ += dt; handleNet(); // world simulation FrameInput in; if (screen_ == Screen::Play) playInput(dt), in.move = heldMove_, in.run = opt_.run || (!ImGui::GetIO().WantTextInput && (window_.keyDown(GLFW_KEY_LEFT_SHIFT) || window_.keyDown(GLFW_KEY_RIGHT_SHIFT))); if (dialogue_.isOpen() || collectionOpen_ || pauseOpen_ || chatFocus_) in.move = glm::vec2(0.0f), in.run = false; if (screen_ == Screen::Play || (screen_ == Screen::Connecting && net_.state() == NetClient::State::InGame)) world_.update(dt, time_, in, net_, cam_); else { float hour = opt_.hour >= 0.0f ? opt_.hour : 17.6f; Weather w = Weather::Clear; if (!opt_.weather.empty()) parseWeather(opt_.weather, w); world_.updateBackdrop(dt, time_, hour, w); } updateCamera(dt); if (capture_ && frame_ == 0) world_.weather.snap(); world_.weather.update(dt, world_.scene, renderer_.settings); renderer_.render(world_.scene, cam_, time_); // UI debugUi_.beginFrame(); ImVec2 vp(float(window_.width()), float(window_.height())); switch (screen_) { case Screen::Title: drawTitle(vp); break; case Screen::Join: drawJoin(vp); break; case Screen::Connecting: drawConnecting(vp); break; case Screen::Creator: drawCreator(vp); break; case Screen::Play: drawPlay(vp, dt); break; } toasts_.update(dt); toasts_.draw(vp); if (!capture_ && window_.keyPressed(GLFW_KEY_F1)) showDebug_ = !showDebug_; if (showDebug_) { hd2d::DebugUI::renderSettingsPanel(renderer_.settings, world_.scene, renderer_.gpuFrameMs()); hd2d::DebugUI::weatherPanel(world_.weather, &world_.day); } debugUi_.endFrame(); } // ------------------------------------------------------------------ title / join / connect void App::drawTitle(ImVec2 vp) { ImDrawList* dl = ImGui::GetForegroundDrawList(); float s = uiScale_; // soft vignette band behind the title dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vp.x, vp.y * 0.45f), IM_COL32(8, 10, 22, 170), IM_COL32(8, 10, 22, 170), IM_COL32(8, 10, 22, 0), IM_COL32(8, 10, 22, 0)); ui::titleText(dl, ImVec2(vp.x * 0.5f, vp.y * 0.2f), "Brandling Safari", ui::kTitleSize * s); ui::textCentered(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(vp.x * 0.5f, vp.y * 0.2f + 92.0f * s), ui::kTextColor, "Meadow Safari Zone"); std::vector items = {"Host & Play", "Join a LAN Game", "Quit"}; if (pressed(GLFW_KEY_UP) || pressed(GLFW_KEY_W)) titleSel_ = (titleSel_ + 2) % 3; if (pressed(GLFW_KEY_DOWN) || pressed(GLFW_KEY_S)) titleSel_ = (titleSel_ + 1) % 3; ui::menu(dl, ImVec2(vp.x * 0.5f, vp.y * 0.62f), items, titleSel_, {}, s); if (!message_.empty()) ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(vp.x * 0.5f, vp.y * 0.8f), IM_COL32(240, 150, 120, 255), message_); ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * 0.8f * s, ImVec2(vp.x * 0.5f, vp.y - 30.0f * s), ui::kTextMuted, "Arrow keys / Enter"); if (pressed(GLFW_KEY_ENTER) || pressed(GLFW_KEY_SPACE)) { message_.clear(); if (titleSel_ == 0) { if (startLocalServer()) beginConnect("127.0.0.1", opt_.port); } else if (titleSel_ == 1) { screen_ = Screen::Join; found_.clear(); scanTimer_ = 0.0f; } else { window_.requestClose(); } } if (pressed(GLFW_KEY_ESCAPE)) window_.requestClose(); } void App::drawJoin(ImVec2 vp) { float s = uiScale_; scanTimer_ -= ImGui::GetIO().DeltaTime; if (scanTimer_ <= 0.0f) { scanner_.ping(kDiscoveryPort); scanTimer_ = 1.5f; } scanner_.poll(found_); ImVec2 size(720 * s, 560 * s); ImGui::SetNextWindowPos(ImVec2(vp.x * 0.5f, vp.y * 0.55f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); ImGui::SetNextWindowSize(size, ImGuiCond_Always); ImGui::Begin("Join a LAN Game", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove); ImGui::PushFont(ui::fonts().body); ImGui::TextUnformatted("Servers on your network"); ImGui::Separator(); ImGui::BeginChild("servers", ImVec2(0, size.y * 0.5f), true); if (found_.empty()) ImGui::TextDisabled("Searching..."); for (size_t i = 0; i < found_.size(); ++i) { const net::ServerInfo& sv = found_[i]; char label[256]; std::snprintf(label, sizeof label, "%s (%s:%u) %d online##%zu", sv.name.c_str(), sv.address.c_str(), unsigned(sv.port), sv.players, i); if (ImGui::Selectable(label, false, ImGuiSelectableFlags_AllowDoubleClick)) { std::snprintf(addressBuf_, sizeof addressBuf_, "%s:%u", sv.address.c_str(), unsigned(sv.port)); if (ImGui::IsMouseDoubleClicked(0)) beginConnect(sv.address, sv.port); } } ImGui::EndChild(); ImGui::TextUnformatted("Address"); ImGui::SetNextItemWidth(-1); bool go = ImGui::InputText("##addr", addressBuf_, sizeof addressBuf_, ImGuiInputTextFlags_EnterReturnsTrue); ImGui::Spacing(); if (ImGui::Button("Connect", ImVec2(200 * s, 0)) || go) { std::string h = addressBuf_; uint16_t port = kDefaultPort; if (auto c = h.rfind(':'); c != std::string::npos) port = uint16_t(std::atoi(h.c_str() + c + 1)), h.resize(c); if (!h.empty()) beginConnect(h, port); } ImGui::SameLine(); if (ImGui::Button("Back", ImVec2(200 * s, 0)) || (!ImGui::GetIO().WantTextInput && pressed(GLFW_KEY_ESCAPE))) screen_ = Screen::Title; ImGui::PopFont(); ImGui::End(); } void App::drawConnecting(ImVec2 vp) { ImDrawList* dl = ImGui::GetForegroundDrawList(); float s = uiScale_; ImVec2 c(vp.x * 0.5f, vp.y * 0.5f); ui::panel(dl, ImVec2(c.x - 260 * s, c.y - 60 * s), ImVec2(c.x + 260 * s, c.y + 60 * s)); const char* dots[] = {"", ".", "..", "..."}; std::string msg = std::string(creating_ ? "Preparing your gear" : "Entering the Safari Zone") + dots[int(time_ * 3) % 4]; ui::textCentered(dl, ui::fonts().body, ui::kBodySize * s, c, ui::kTextColor, msg); if (pressed(GLFW_KEY_ESCAPE)) returnToTitle(); } // ------------------------------------------------------------------ character creator void App::refreshPreview() { preview_.upload(world_.characterArt().compose(look_)); } void App::drawCreator(ImVec2 vp) { const CharacterManifest& m = world_.manifest(); float s = uiScale_; ImDrawList* dl = ImGui::GetForegroundDrawList(); bool changed = false; // preview: walking in place, turning every few seconds ImVec2 pc(vp.x * 0.3f, vp.y * 0.52f); float box = std::min(vp.y * 0.62f, vp.x * 0.34f); ui::panel(dl, ImVec2(pc.x - box * 0.5f, pc.y - box * 0.55f), ImVec2(pc.x + box * 0.5f, pc.y + box * 0.55f)); ui::textCentered(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(pc.x, pc.y - box * 0.47f), ui::kGold, nameBuf_[0] ? nameBuf_ : "Ranger"); int dirOrder[4] = {0, 1, 2, 3}; // south, east, north, west int dir = dirOrder[(int(time_ / 2.2f) + previewDir_) % 4]; int row = dir == 3 ? 1 : dir; bool flip = dir == 3; int col = 1 + int(time_ * 7.0f) % m.walkFrames; if (preview_.valid()) { ImVec2 uv0, uv1; preview_.cellUV({m.cell, m.cell}, col, row, uv0, uv1); if (flip) std::swap(uv0.x, uv1.x); float px = std::floor(box * 0.85f / float(m.cell)) * float(m.cell); dl->AddImage(preview_.id(), ImVec2(pc.x - px * 0.5f, pc.y - px * 0.5f), ImVec2(pc.x + px * 0.5f, pc.y + px * 0.5f), uv0, uv1); } ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(pc.x, pc.y + box * 0.48f), ui::kTextMuted, "Q / E turn"); if (!ImGui::GetIO().WantTextInput) { if (pressed(GLFW_KEY_Q)) previewDir_ += 3; if (pressed(GLFW_KEY_E)) previewDir_ += 1; } // options panel ImVec2 size(vp.x * 0.42f, vp.y * 0.86f); ImGui::SetNextWindowPos(ImVec2(vp.x * 0.75f, vp.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); ImGui::SetNextWindowSize(size, ImGuiCond_Always); ImGui::Begin("Create your Ranger", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove); ImGui::PushFont(ui::fonts().body); float labelW = 150.0f * s; ImGui::TextUnformatted("Name"); ImGui::SameLine(labelW); ImGui::SetNextItemWidth(-1); ImGui::InputText("##name", nameBuf_, 17); ImGui::TextUnformatted("Body"); ImGui::SameLine(labelW); for (const auto& [id, b] : m.bodies) { if (ImGui::RadioButton(b.label.c_str(), look_.body == id) && look_.body != id) { Appearance next = m.defaultLook(id); next.skin = look_.skin; for (int sl = 0; sl < kSlotCount; ++sl) if (m.bodyHas(id, look_.items[sl]) || look_.items[sl].empty()) next.items[sl] = look_.items[sl], next.colors[sl] = look_.colors[sl]; look_ = next; m.sanitize(look_); changed = true; } ImGui::SameLine(); } ImGui::NewLine(); ImGui::TextUnformatted("Skin"); ImGui::SameLine(labelW); for (size_t i = 0; i < m.skinTones.size(); ++i) { uint32_t c = m.skinTones[i]; ImVec4 col(float((c >> 16) & 255) / 255.0f, float((c >> 8) & 255) / 255.0f, float(c & 255) / 255.0f, 1.0f); ImGui::PushID(int(i)); if (look_.skin == i) ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.95f, 0.8f, 0.45f, 1.0f)); ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, look_.skin == i ? 3.0f : 0.0f); if (ImGui::ColorButton("##skin", col, ImGuiColorEditFlags_NoTooltip, ImVec2(40 * s, 40 * s))) look_.skin = uint8_t(i), changed = true; ImGui::PopStyleVar(); if (look_.skin == i) ImGui::PopStyleColor(); ImGui::PopID(); ImGui::SameLine(); } ImGui::NewLine(); ImGui::Separator(); // garment rows scroll; the start / back buttons below always stay visible float footer = 56.0f * s + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y * 4.0f; ImGui::BeginChild("##slots", ImVec2(0, -footer), false); const int slots[] = {Hair, Hat, Top, Bottom, Shoes}; const char* names[] = {"Hair", "Hat", "Top", "Bottom", "Shoes"}; for (int k = 0; k < 5; ++k) { int sl = slots[k]; std::vector opts; bool optional = sl == Hair || sl == Hat || sl == Shoes; if (optional) opts.push_back(""); for (const std::string& id : m.bodies.at(look_.body).items) if (const ItemDef* d = m.item(id); d && d->slot == sl) opts.push_back(id); int cur = 0; for (size_t i = 0; i < opts.size(); ++i) if (opts[i] == look_.items[sl]) cur = int(i); ImGui::PushID(sl); ImGui::TextUnformatted(names[k]); ImGui::SameLine(labelW); if (ImGui::ArrowButton("##prev", ImGuiDir_Left)) cur = (cur + int(opts.size()) - 1) % int(opts.size()), changed = true; ImGui::SameLine(); const ItemDef* d = m.item(opts[size_t(cur)]); std::string label = d ? d->label : (sl == Hair ? "Bald" : sl == Shoes ? "Barefoot" : "None"); ImGui::Button(label.c_str(), ImVec2(220 * s, 0)); ImGui::SameLine(); if (ImGui::ArrowButton("##next", ImGuiDir_Right)) cur = (cur + 1) % int(opts.size()), changed = true; if (changed && look_.items[sl] != opts[size_t(cur)]) { look_.items[sl] = opts[size_t(cur)]; if (const ItemDef* nd = m.item(look_.items[sl])) look_.colors[sl] = nd->color; } if (!look_.items[sl].empty()) { ImGui::Dummy(ImVec2(labelW - ImGui::GetStyle().ItemSpacing.x, 1)); ImGui::SameLine(); for (size_t i = 0; i < m.palette.size(); ++i) { uint32_t c = m.palette[i]; ImVec4 col(float((c >> 16) & 255) / 255.0f, float((c >> 8) & 255) / 255.0f, float(c & 255) / 255.0f, 1.0f); ImGui::PushID(int(i)); if (ImGui::ColorButton("##c", col, ImGuiColorEditFlags_NoTooltip, ImVec2(24 * s, 24 * s))) look_.colors[sl] = c, changed = true; ImGui::PopID(); if ((i + 1) % 10 != 0) ImGui::SameLine(); else if (i + 1 < m.palette.size()) { ImGui::Dummy(ImVec2(labelW - ImGui::GetStyle().ItemSpacing.x, 1)); ImGui::SameLine(); } } float rgb[3] = {float((look_.colors[sl] >> 16) & 255) / 255.0f, float((look_.colors[sl] >> 8) & 255) / 255.0f, float(look_.colors[sl] & 255) / 255.0f}; ImGui::SameLine(); if (ImGui::ColorEdit3("##custom", rgb, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel)) { look_.colors[sl] = (uint32_t(rgb[0] * 255.0f + 0.5f) << 16) | (uint32_t(rgb[1] * 255.0f + 0.5f) << 8) | uint32_t(rgb[2] * 255.0f + 0.5f); changed = true; } } ImGui::PopID(); ImGui::Spacing(); } ImGui::EndChild(); ImGui::Separator(); ImGui::Spacing(); bool ready = net_.state() == NetClient::State::NeedCharacter && !creating_; if (!ready) ImGui::BeginDisabled(); if (ImGui::Button(creating_ ? "Preparing..." : "Begin your Safari", ImVec2(-1, 56 * s)) && ready) { std::string name = nameBuf_; if (name.empty()) name = "Ranger"; net_.sendCreate(name, look_); creating_ = true; } if (!ready) ImGui::EndDisabled(); if (ImGui::Button("Back to title", ImVec2(-1, 0))) returnToTitle(); ImGui::PopFont(); ImGui::End(); if (changed) refreshPreview(); } // ------------------------------------------------------------------ play UiTexture* App::creatureIcon(uint16_t species) { auto it = icons_.find(species); if (it != icons_.end()) return it->second.get(); auto tex = std::make_unique(); const Species* sp = world_.roster().get(species); hd2d::Image img; if (sp && hd2d::loadImage(gameAsset("creatures/" + sp->sprite), img)) tex->upload(img); return (icons_[species] = std::move(tex)).get(); } void App::playInput(float dt) { heldMove_ = opt_.move; bool typing = ImGui::GetIO().WantTextInput; if (dialogue_.isOpen()) { if (pressed(GLFW_KEY_UP)) dialogue_.moveChoice(-1); if (pressed(GLFW_KEY_DOWN)) dialogue_.moveChoice(1); bool autoAdvance = opt_.autoDialogue && frame_ % 8 == 0; if (autoAdvance || pressed(GLFW_KEY_SPACE) || pressed(GLFW_KEY_ENTER) || pressed(GLFW_KEY_E)) { if (dialogue_.advance()) { net_.sendDialogueDone(dialogueScript_); dialogueScript_.clear(); } } return; } if (typing) return; if (pauseOpen_) { if (pressed(GLFW_KEY_UP) || pressed(GLFW_KEY_W)) pauseSel_ = (pauseSel_ + 2) % 3; if (pressed(GLFW_KEY_DOWN) || pressed(GLFW_KEY_S)) pauseSel_ = (pauseSel_ + 1) % 3; if (pressed(GLFW_KEY_ESCAPE)) pauseOpen_ = false; if (pressed(GLFW_KEY_ENTER) || pressed(GLFW_KEY_SPACE)) { if (pauseSel_ == 0) pauseOpen_ = false; else if (pauseSel_ == 1) returnToTitle(); else window_.requestClose(); } return; } if (collectionOpen_) { int n = int(net_.profile().creatures.size()); if (pressed(GLFW_KEY_UP) || pressed(GLFW_KEY_W)) collectionSel_ = std::max(0, collectionSel_ - 1); if (pressed(GLFW_KEY_DOWN) || pressed(GLFW_KEY_S)) collectionSel_ = std::min(std::max(n - 1, 0), collectionSel_ + 1); if ((pressed(GLFW_KEY_ENTER) || pressed(GLFW_KEY_SPACE)) && n > 0) { std::vector list = net_.profile().creatures; std::sort(list.begin(), list.end(), [](auto& a, auto& b) { return a.caughtAt > b.caughtAt; }); net_.sendAction(ActionKind::ToggleParty, list[size_t(std::clamp(collectionSel_, 0, n - 1))].uid); } if (pressed(GLFW_KEY_TAB) || pressed(GLFW_KEY_ESCAPE)) collectionOpen_ = false; return; } if (!opt_.move.x && !opt_.move.y) { if (window_.keyDown(GLFW_KEY_W) || window_.keyDown(GLFW_KEY_UP)) heldMove_.y += 1.0f; if (window_.keyDown(GLFW_KEY_S) || window_.keyDown(GLFW_KEY_DOWN)) heldMove_.y -= 1.0f; if (window_.keyDown(GLFW_KEY_D) || window_.keyDown(GLFW_KEY_RIGHT)) heldMove_.x += 1.0f; if (window_.keyDown(GLFW_KEY_A) || window_.keyDown(GLFW_KEY_LEFT)) heldMove_.x -= 1.0f; } if (window_.keyDown(GLFW_KEY_Q)) camYawTarget_ -= dt * 1.4f; if (window_.keyDown(GLFW_KEY_E) && !world_.nearbyNpc()) camYawTarget_ += dt * 1.4f; if (!debugUi_.wantsMouse()) { cam_.distance = std::clamp(cam_.distance * std::pow(0.9f, window_.scrollDelta()), 7.0f, 30.0f); if (window_.mouseDown(GLFW_MOUSE_BUTTON_RIGHT)) { glm::vec2 d = window_.mouseDelta(); camYawTarget_ += d.x * 0.005f; cam_.pitch = std::clamp(cam_.pitch + d.y * 0.004f, 0.25f, 1.25f); } } if (pressed(GLFW_KEY_SPACE) && world_.targetCreature()) { if (net_.profile().capsules > 0) net_.sendAction(ActionKind::Throw, world_.targetCreature()); else toasts_.push("You're out of Safari Capsules! Ask Professor Henry for more.", IM_COL32(230, 140, 90, 255)); } if (pressed(GLFW_KEY_E)) if (uint32_t npc = world_.nearbyNpc()) net_.sendAction(ActionKind::Interact, npc); if (pressed(GLFW_KEY_F)) { glm::vec2 bobber; if (world_.lineOut()) net_.sendAction(ActionKind::Reel); else if (world_.canFish(bobber)) { glm::vec2 d = dirFromYaw(world_.localYaw()); net_.sendAction(ActionKind::Cast, 0, d.x, d.y); } else toasts_.push("Face the water to cast a line.", IM_COL32(120, 170, 220, 255), 2.0f); } if (pressed(GLFW_KEY_TAB)) collectionOpen_ = true, collectionSel_ = 0; if (pressed(GLFW_KEY_ESCAPE)) pauseOpen_ = true, pauseSel_ = 0; if (pressed(GLFW_KEY_ENTER) || pressed(GLFW_KEY_T)) chatOpen_ = true, chatFocus_ = true; } void App::drawPlay(ImVec2 vp, float dt) { for (ChatEntry& c : net_.chat) c.age += dt; drawLabels(vp); drawHud(vp); drawChat(vp); if (collectionOpen_) drawCollection(vp); if (pauseOpen_) drawPause(vp); dialogue_.update(dt); if (dialogue_.isOpen()) dialogue_.draw(vp); } void App::drawLabels(ImVec2 vp) { ImDrawList* dl = ImGui::GetBackgroundDrawList(); for (const World::Label& l : world_.labels()) { glm::vec2 px; if (!cam_.worldToScreen(l.world, glm::vec2(vp.x, vp.y), px)) continue; std::string text = l.text; if (text.empty() && l.entity) { auto p = net_.players.find(l.entity); if (p == net_.players.end()) continue; text = p->second.name; } if (l.chat) { ImVec2 sz = ui::measure(ui::fonts().small, ui::kSmallSize * uiScale_, text, 360.0f * uiScale_); ImVec2 mn(px.x - sz.x * 0.5f - 14 * uiScale_, px.y - sz.y - 22 * uiScale_); ImVec2 mx(px.x + sz.x * 0.5f + 14 * uiScale_, px.y - 6 * uiScale_); ui::PanelStyle ps; ps.ornate = false; ps.alpha = 0.8f; ui::panel(dl, mn, mx, ps); ui::textWrapped(dl, ui::fonts().small, ui::kSmallSize * uiScale_, ImVec2(mn.x + 14 * uiScale_, mn.y + 8 * uiScale_), ui::kTextColor, text, 360.0f * uiScale_); continue; } ui::nameplate(dl, ImVec2(px.x, px.y), text, imColor(l.accent), uiScale_); } } void App::drawHud(ImVec2 vp) { ImDrawList* dl = ImGui::GetBackgroundDrawList(); float s = uiScale_; const ProfileMsg& p = net_.profile(); ui::PanelStyle ps; ps.alpha = 0.78f; // top-left: ranger, capsules, party ImVec2 a(24 * s, 24 * s), b(a.x + 430 * s, a.y + 150 * s); ui::panel(dl, a, b, ps); ui::text(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(a.x + 20 * s, a.y + 14 * s), ui::kGold, p.name); char buf[128]; std::snprintf(buf, sizeof buf, "Safari Capsules %d", p.capsules); ui::text(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(a.x + 20 * s, a.y + 50 * s), ui::kTextColor, buf); float x = a.x + 18 * s; int shown = 0; for (const OwnedCreature& c : p.creatures) { if (!c.inParty || shown >= kMaxParty) continue; UiTexture* icon = creatureIcon(c.species); ImVec2 mn(x, a.y + 86 * s), mx(x + 56 * s, a.y + 142 * s); dl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 22), 6 * s); if (icon && icon->valid()) dl->AddImage(icon->id(), mn, mx); x += 66 * s; ++shown; } if (!shown) ui::text(dl, ui::fonts().small, ui::kSmallSize * 0.9f * s, ImVec2(a.x + 20 * s, a.y + 100 * s), ui::kTextMuted, "No Brandlings following yet"); // top-right: clock + weather + dex float h = world_.hour(); int hh = int(h), mm = int((h - float(hh)) * 60.0f); ImVec2 c(vp.x - 24 * s - 330 * s, 24 * s), d(vp.x - 24 * s, 24 * s + 112 * s); ui::panel(dl, c, d, ps); std::snprintf(buf, sizeof buf, "%02d:%02d %s", hh, mm, timeOfDayName(h)); ui::text(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(c.x + 20 * s, c.y + 14 * s), ui::kGold, buf); std::snprintf(buf, sizeof buf, "%s | Seen %u Caught %u", weatherLabel(world_.weatherState()).c_str(), p.seen, p.caught); ui::text(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(c.x + 20 * s, c.y + 58 * s), ui::kTextColor, buf); // bottom prompts std::vector prompts; if (world_.biting()) prompts.push_back("F Reel in now!"); else if (world_.lineOut()) prompts.push_back("F Reel in"); if (uint32_t t = world_.targetCreature()) { (void)t; prompts.push_back("Space Throw a Safari Capsule"); } if (world_.nearbyNpc()) prompts.push_back("E Talk to Professor Henry"); glm::vec2 bob; if (!world_.lineOut() && world_.canFish(bob)) prompts.push_back("F Cast a line"); if (!dialogue_.isOpen() && !collectionOpen_ && !pauseOpen_) { float y = vp.y - 40 * s; for (auto it = prompts.rbegin(); it != prompts.rend(); ++it) { ImVec2 sz = ui::measure(ui::fonts().small, ui::kSmallSize * s, *it); ImVec2 mn(vp.x * 0.5f - sz.x * 0.5f - 22 * s, y - sz.y - 12 * s), mx(vp.x * 0.5f + sz.x * 0.5f + 22 * s, y); ui::PanelStyle pp; pp.ornate = false; pp.alpha = 0.72f; bool urgent = world_.biting() && it->rfind("F Reel in now", 0) == 0; if (urgent) pp.tint = IM_COL32(255, 150, 90, 255); ui::panel(dl, mn, mx, pp); ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(vp.x * 0.5f, (mn.y + mx.y) * 0.5f), urgent ? IM_COL32(255, 210, 150, 255) : ui::kTextColor, *it); y = mn.y - 10 * s; } ui::text(dl, ui::fonts().small, ui::kSmallSize * 0.78f * s, ImVec2(24 * s, vp.y - 34 * s), ui::kTextMuted, "WASD move Shift run Q/E camera Tab Brandlings Enter chat Esc menu"); } } void App::drawCollection(ImVec2 vp) { ImDrawList* dl = ImGui::GetForegroundDrawList(); float s = uiScale_; ImVec2 a(vp.x * 0.5f - 640 * s, vp.y * 0.5f - 380 * s), b(vp.x * 0.5f + 640 * s, vp.y * 0.5f + 380 * s); dl->AddRectFilled(ImVec2(0, 0), vp, IM_COL32(4, 6, 14, 120)); ui::panel(dl, a, b); const ProfileMsg& p = net_.profile(); char buf[160]; std::snprintf(buf, sizeof buf, "Brandlings (seen %u, caught %u)", p.seen, p.caught); ui::text(dl, ui::fonts().name, ui::kNameSize * s, ImVec2(a.x + 32 * s, a.y + 24 * s), ui::kGold, buf); std::vector list = p.creatures; std::sort(list.begin(), list.end(), [](auto& x, auto& y) { return x.caughtAt > y.caughtAt; }); if (list.empty()) { ui::textCentered(dl, ui::fonts().body, ui::kBodySize * s, ImVec2((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f), ui::kTextMuted, "You haven't caught any Brandlings yet. Try the tall grass!"); return; } collectionSel_ = std::clamp(collectionSel_, 0, int(list.size()) - 1); int rows = 11; int first = std::clamp(collectionSel_ - rows / 2, 0, std::max(0, int(list.size()) - rows)); float y = a.y + 80 * s, rowH = 56 * s; for (int i = first; i < std::min(int(list.size()), first + rows); ++i) { const OwnedCreature& c = list[size_t(i)]; const Species* sp = world_.roster().get(c.species); ImVec2 mn(a.x + 24 * s, y), mx(a.x + 640 * s, y + rowH - 6 * s); if (i == collectionSel_) dl->AddRectFilled(mn, mx, IM_COL32(222, 189, 120, 52), 6 * s); if (UiTexture* icon = creatureIcon(c.species); icon && icon->valid()) dl->AddImage(icon->id(), ImVec2(mn.x + 4 * s, mn.y), ImVec2(mn.x + 54 * s, mn.y + 50 * s)); ui::text(dl, ui::fonts().body, ui::kBodySize * 0.82f * s, ImVec2(mn.x + 70 * s, mn.y + 8 * s), ui::kTextColor, fitText(ui::fonts().body, ui::kBodySize * 0.82f * s, sp ? sp->name : "???", 400 * s)); if (c.inParty) ui::text(dl, ui::fonts().small, ui::kSmallSize * 0.85f * s, ImVec2(mx.x - 110 * s, mn.y + 12 * s), ui::kGold, "Party"); y += rowH; } // detail const OwnedCreature& c = list[size_t(collectionSel_)]; const Species* sp = world_.roster().get(c.species); ImVec2 dc(a.x + 960 * s, a.y + 290 * s); if (UiTexture* icon = creatureIcon(c.species); icon && icon->valid()) dl->AddImage(icon->id(), ImVec2(dc.x - 160 * s, dc.y - 180 * s), ImVec2(dc.x + 160 * s, dc.y + 140 * s)); if (sp) { ui::textCentered(dl, ui::fonts().name, ui::kNameSize * 1.1f * s, ImVec2(dc.x, dc.y + 180 * s), ui::kGold, sp->name); std::string types = capitalize(sp->type) + (sp->type2.empty() ? "" : " / " + capitalize(sp->type2)); ui::textCentered(dl, ui::fonts().body, ui::kBodySize * 0.85f * s, ImVec2(dc.x, dc.y + 226 * s), imColor(typeColor(sp->type)), types); std::snprintf(buf, sizeof buf, "%s %s", rarityName(sp->rarity), sp->domain.c_str()); ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * s, ImVec2(dc.x, dc.y + 268 * s), ui::kTextMuted, buf); } ui::textCentered(dl, ui::fonts().small, ui::kSmallSize * 0.85f * s, ImVec2((a.x + b.x) * 0.5f, b.y - 30 * s), ui::kTextMuted, c.inParty ? "Enter: send to the box Tab: close" : "Enter: add to party Tab: close"); } void App::drawChat(ImVec2 vp) { float s = uiScale_; ImDrawList* dl = ImGui::GetBackgroundDrawList(); float y = vp.y - 90 * s; int shown = 0; for (auto it = net_.chat.rbegin(); it != net_.chat.rend() && shown < 6; ++it, ++shown) { float alpha = chatOpen_ ? 1.0f : std::clamp(12.0f - it->age, 0.0f, 1.0f); if (alpha <= 0.0f) break; std::string line = it->name + ": " + it->text; ui::text(dl, ui::fonts().small, ui::kSmallSize * 0.9f * s, ImVec2(28 * s, y - 30 * s), withAlpha(ui::kTextColor, alpha), line); y -= 32 * s; } if (!chatOpen_) return; ImGui::SetNextWindowPos(ImVec2(24 * s, vp.y - 80 * s), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(620 * s, 0), ImGuiCond_Always); ImGui::Begin("##chat", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove); if (chatFocus_) ImGui::SetKeyboardFocusHere(); chatFocus_ = false; ImGui::SetNextItemWidth(-1); if (ImGui::InputText("##msg", chatBuf_, sizeof chatBuf_, ImGuiInputTextFlags_EnterReturnsTrue)) { if (chatBuf_[0]) net_.sendChat(chatBuf_); chatBuf_[0] = 0; chatOpen_ = false; } if (ImGui::IsKeyPressed(ImGuiKey_Escape)) chatOpen_ = false, chatBuf_[0] = 0; ImGui::End(); } void App::drawPause(ImVec2 vp) { ImDrawList* dl = ImGui::GetForegroundDrawList(); dl->AddRectFilled(ImVec2(0, 0), vp, IM_COL32(4, 6, 14, 110)); ui::menu(dl, ImVec2(vp.x * 0.5f, vp.y * 0.5f), {"Resume", "Leave the Safari", "Quit Game"}, pauseSel_, "Paused", uiScale_); } } // namespace bs