Files
Gabrieal Jimmy d31689cf86 Windows support
2026-10-02 16:04:30 -05:00

990 lines
43 KiB
C++

#include "App.h"
#include "hd2d/Log.h"
#include <GLFW/glfw3.h>
#include <imgui.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <random>
#include <thread>
namespace bs {
namespace fs = std::filesystem;
namespace ui = hd2d::ui;
namespace {
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 = userDataDir() / (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 = userDataDir() / (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", gameAssetDir().c_str());
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<std::string> 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<float>(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 (localServer_.running()) return true;
if (!localServer_.start(opt_.port, std::string(nameBuf_) + "'s Safari", message_)) return false;
std::this_thread::sleep_for(std::chrono::milliseconds(250)); // let it bind (the client retries anyway)
return true;
}
void App::stopLocalServer() { localServer_.stop(); }
// ------------------------------------------------------------------ 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 && localServer_.running() && 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<ui::DialogueLine> 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<std::string> 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<std::string> 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<UiTexture>();
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<OwnedCreature> 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<std::string> 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<OwnedCreature> 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>(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