- 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
1029 lines
44 KiB
C++
1029 lines
44 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 <csignal>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <random>
|
|
#include <thread>
|
|
#include <spawn.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
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<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 (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<std::string> args = {server.string(), "--port", port, "--name", name};
|
|
std::vector<char*> 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<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
|