Sign in Sign up
kretrod/lodestone-cpp Public
Branches
main
415 lines (374 loc) · 16.4 KB Raw
// The menu itself: the Enigma layout, drawn from the module table.
//
// One window with three panes — navigation rail, content, status — rather than
// panels that drift apart. Everything the rail shows comes from `modules()`, so
// adding a module to that table is all it takes to appear here, get a keybind
// and be saved.
//
// The widgets are hand-drawn rather than ImGui's defaults because the look is
// the point: a filled accent square with a white tick, not a framed checkmark.
#pragma once

#include <cmath>
#include <cstdio>
#include <string>

#include "config.hpp"
#include "imgui.h"
#include "log.hpp"
#include "state.hpp"

namespace lodestone::menu {

namespace {

// Enigma's palette. Only the accent is the user's to change.
constexpr ImU32 kText = IM_COL32(232, 232, 238, 255);
constexpr ImU32 kDim = IM_COL32(118, 118, 133, 255);
constexpr ImU32 kBorder = IM_COL32(43, 43, 51, 255);
constexpr ImU32 kCheckOff = IM_COL32(87, 87, 102, 255);
constexpr ImU32 kRowHover = IM_COL32(255, 255, 255, 9);
constexpr ImU32 kWhite = IM_COL32(255, 255, 255, 255);

float g_scale = 1.0f;
float px(float v) { return v * g_scale; }

ImU32 col(const state::Colour& c, float alpha = 1.0f) {
    return ImGui::ColorConvertFloat4ToU32(ImVec4(c[0], c[1], c[2], c[3] * alpha));
}

/// Short label for a virtual-key code.
std::string key_name(uint32_t vk) {
    char buf[16];
    switch (vk) {
        case 0x08: return "BKSP";
        case 0x09: return "TAB";
        case 0x0D: return "ENTER";
        case 0x10: return "SHIFT";
        case 0x11: return "CTRL";
        case 0x12: return "ALT";
        case 0x20: return "SPACE";
        case 0x2D: return "INS";
        case 0x2E: return "DEL";
        default: break;
    }
    if (vk >= '0' && vk <= '9') return std::string(1, static_cast<char>(vk));
    if (vk >= 'A' && vk <= 'Z') return std::string(1, static_cast<char>(vk));
    if (vk >= 0x70 && vk <= 0x7B) {
        std::snprintf(buf, sizeof(buf), "F%u", vk - 0x70 + 1);
        return buf;
    }
    std::snprintf(buf, sizeof(buf), "%#04x", static_cast<unsigned>(vk));
    return buf;
}

/// A filled accent square with a white tick — the Enigma checkbox. Returns true
/// when it was toggled.
bool check(const char* id, const char* label, bool* on, const state::Colour& accent) {
    const float row = px(26.0f), box = px(17.0f);
    ImVec2 start = ImGui::GetCursorScreenPos();
    float width = ImGui::GetContentRegionAvail().x;
    bool clicked = ImGui::InvisibleButton(id, ImVec2(width > box ? width : box, row));
    if (clicked) *on = !*on;
    bool hovered = ImGui::IsItemHovered();

    ImDrawList* dl = ImGui::GetWindowDrawList();
    if (hovered) {
        dl->AddRectFilled(ImVec2(start.x - px(6), start.y),
                          ImVec2(start.x + width + px(6), start.y + row), kRowHover, px(4));
    }
    float by = start.y + (row - box) * 0.5f;
    if (*on) {
        dl->AddRectFilled(ImVec2(start.x, by), ImVec2(start.x + box, by + box), col(accent), px(4));
        // The tick, drawn rather than typed so it scales cleanly.
        ImVec2 a(start.x + box * 0.24f, by + box * 0.52f);
        ImVec2 b(start.x + box * 0.42f, by + box * 0.72f);
        ImVec2 c(start.x + box * 0.76f, by + box * 0.28f);
        dl->AddLine(a, b, kWhite, px(2));
        dl->AddLine(b, c, kWhite, px(2));
    } else {
        dl->AddRect(ImVec2(start.x, by), ImVec2(start.x + box, by + box),
                    hovered ? kDim : kCheckOff, px(4), 0, px(1.6f));
    }
    dl->AddText(ImVec2(start.x + box + px(11), start.y + (row - ImGui::GetTextLineHeight()) * 0.5f),
                *on ? kText : kDim, label);
    return clicked;
}

/// A nav rail entry.
bool nav_item(const char* label, bool active, const state::Colour& accent) {
    const float h = px(34.0f);
    ImVec2 start = ImGui::GetCursorScreenPos();
    float w = ImGui::GetContentRegionAvail().x;
    bool clicked = ImGui::InvisibleButton(label, ImVec2(w, h));
    bool hovered = ImGui::IsItemHovered();

    ImDrawList* dl = ImGui::GetWindowDrawList();
    if (active) {
        dl->AddRectFilled(ImVec2(start.x - px(6), start.y), ImVec2(start.x + w + px(6), start.y + h),
                          col(accent, 0.10f), px(6));
        dl->AddRectFilled(ImVec2(start.x - px(6), start.y + px(7)),
                          ImVec2(start.x - px(3), start.y + h - px(7)), col(accent), px(2));
    } else if (hovered) {
        dl->AddRectFilled(ImVec2(start.x - px(6), start.y), ImVec2(start.x + w + px(6), start.y + h),
                          kRowHover, px(6));
    }
    ImU32 c = active ? col(accent) : (hovered ? kText : kDim);
    dl->AddCircleFilled(ImVec2(start.x + px(8), start.y + h * 0.5f), px(3.5f), c);
    dl->AddText(ImVec2(start.x + px(22), start.y + (h - ImGui::GetTextLineHeight()) * 0.5f), c,
                label);
    return clicked;
}

void section(const char* title) {
    ImGui::Dummy(ImVec2(0, px(4)));
    ImVec2 p = ImGui::GetCursorScreenPos();
    float w = ImGui::GetContentRegionAvail().x;
    ImDrawList* dl = ImGui::GetWindowDrawList();
    dl->AddText(p, kText, title);
    dl->AddLine(ImVec2(p.x, p.y + px(20)), ImVec2(p.x + w, p.y + px(20)), kBorder);
    ImGui::Dummy(ImVec2(0, px(28)));
}

void stat_row(const char* label, const std::string& value) {
    ImVec2 p = ImGui::GetCursorScreenPos();
    float w = ImGui::GetContentRegionAvail().x;
    ImGui::Dummy(ImVec2(w, px(21)));
    ImDrawList* dl = ImGui::GetWindowDrawList();
    dl->AddText(ImVec2(p.x, p.y + px(3)), kDim, label);
    float vw = ImGui::CalcTextSize(value.c_str()).x;
    dl->AddText(ImVec2(p.x + w - vw, p.y + px(3)), kText, value.c_str());
}

std::string fmt(const char* f, double a, double b = 0, double c = 0) {
    char buf[96];
    std::snprintf(buf, sizeof(buf), f, a, b, c);
    return buf;
}

/// The global UI size, committed only when the slider is released so the whole
/// menu does not re-layout under the cursor mid-drag.
void scale_slider(float* value, const state::Colour& accent) {
    static bool dragging = false;
    static float pending = 1.0f;
    const float min = 0.6f, max = 2.5f;
    float width = ImGui::GetContentRegionAvail().x;
    const float label_h = px(16), track = px(5);

    ImVec2 start = ImGui::GetCursorScreenPos();
    ImGui::InvisibleButton("##uiscale", ImVec2(width, label_h + px(9) + track));
    bool active = ImGui::IsItemActive();
    float shown = dragging ? pending : *value;
    if (active) {
        float t = (ImGui::GetIO().MousePos.x - start.x) / (width > 1 ? width : 1);
        t = t < 0 ? 0 : (t > 1 ? 1 : t);
        pending = min + (max - min) * t;
        dragging = true;
        shown = pending;
    } else if (dragging) {
        *value = pending;  // released: commit once
        dragging = false;
        shown = pending;
    }

    float t = (shown - min) / (max - min);
    ImDrawList* dl = ImGui::GetWindowDrawList();
    dl->AddText(start, kDim, "UI size");
    std::string v = fmt("%.2fx", shown);
    dl->AddText(ImVec2(start.x + width - ImGui::CalcTextSize(v.c_str()).x, start.y), kText,
                v.c_str());
    float ty = start.y + label_h + px(7);
    dl->AddRectFilled(ImVec2(start.x, ty), ImVec2(start.x + width, ty + track),
                      IM_COL32(255, 255, 255, 20), track * 0.5f);
    dl->AddRectFilled(ImVec2(start.x, ty), ImVec2(start.x + width * t, ty + track), col(accent),
                      track * 0.5f);
    dl->AddCircleFilled(ImVec2(start.x + width * t, ty + track * 0.5f), px(5), kWhite);
}

/// Re-scale the whole style from a pristine copy, so repeated changes cannot
/// compound. Only done when the value actually changes.
void apply_scale(float want) {
    static bool captured = false;
    static ImGuiStyle base;
    static float applied = -1.0f;
    if (!captured) {
        base = ImGui::GetStyle();
        captured = true;
    }
    if (want < 0.6f) want = 0.6f;
    if (want > 2.5f) want = 2.5f;
    if (std::fabs(applied - want) > 0.001f) {
        applied = want;
        ImGui::GetStyle() = base;
        ImGui::GetStyle().ScaleAllSizes(want);
        ImGui::GetIO().FontGlobalScale = want;
    }
    g_scale = want;
}

void settings_pane(state::Shared& s) {
    section("Appearance");
    ImGui::ColorEdit4("Accent", s.cfg.accent.data(),
                      ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_AlphaBar);
    ImGui::Dummy(ImVec2(0, px(4)));
    scale_slider(&s.cfg.ui_scale, s.cfg.accent);

    section("Keybinds");
    std::string label = "Open menu";
    bool listening = s.binding_active && s.binding.kind == state::BindKind::MenuToggle;
    if (ImGui::Button(listening ? "press a key..."
                                : ("Open menu  [" + key_name(s.cfg.menu_key) + "]").c_str())) {
        s.binding_active = true;
        s.binding = state::BindTarget{state::BindKind::MenuToggle, 0};
    }
    if (ImGui::Button("Panic — turn everything off")) s.panic_off();

    section("Config");
    if (ImGui::Button("Save")) {
        std::string where = config::save(s);
        log(where.empty() ? "config save failed" : "config saved");
    }
    ImGui::SameLine();
    if (ImGui::Button("Load")) {
        log(config::load(s) ? "config loaded" : "no config to load");
    }
}

}  // namespace

/// The always-on screen furniture: watermark, coordinates, and what is
/// currently switched on. This draws every frame whether or not the menu is
/// open, so it sets the scale itself rather than depending on the menu having
/// run first — otherwise the HUD sits at 1.0 until the menu is opened once.
inline void hud(const state::Shared& s) {
    const state::Visuals& v = s.cfg.visuals;
    if (!v.watermark && !v.hud_coords && !v.hud_modules) return;

    apply_scale(s.cfg.ui_scale);
    const state::Colour& accent = s.cfg.accent;
    ImDrawList* dl = ImGui::GetBackgroundDrawList();
    ImVec2 screen = ImGui::GetIO().DisplaySize;

    // A dark copy underneath each string: the HUD sits over whatever the world
    // happens to be, and pale text on snow or sand is otherwise unreadable.
    auto text = [&](ImVec2 at, ImU32 c, const char* str) {
        dl->AddText(ImVec2(at.x + 1.0f, at.y + 1.0f), IM_COL32(0, 0, 0, 160), str);
        dl->AddText(at, c, str);
    };

    if (v.watermark) {
        text(ImVec2(px(7), px(5)), col(accent), "Lodestone");
        // Name the key that is actually bound. The menu key is rebindable, so a
        // fixed "[insert]" hint goes wrong the moment anyone changes it.
        if (!s.menu_open) {
            std::string hint = "[" + key_name(s.cfg.menu_key) + "]";
            text(ImVec2(px(7), px(21)), kDim, hint.c_str());
        }
    }

    if (v.hud_coords && s.game.in_world) {
        char buf[64];
        std::snprintf(buf, sizeof(buf), "%.0f %.0f %.0f", s.game.pos.x, s.game.pos.y, s.game.pos.z);
        text(ImVec2(px(7), screen.y - px(18)), kText, buf);
    }

    if (v.hud_modules) {
        // Read straight off the module table, so a module added there shows up
        // here too instead of needing a second list kept in step by hand.
        state::Config cfg = s.cfg;  // the table walkers want a mutable config
        float y = px(5);
        for (const auto& m : state::modules()) {
            if (!m.flag(cfg)) continue;
            float w = ImGui::CalcTextSize(m.label).x;
            text(ImVec2(screen.x - w - px(7), y), col(accent), m.label);
            y += px(15);
        }
    }
}

/// Draw the whole menu. The caller holds the state lock for the duration, which
/// is what the render thread does anyway.
inline void draw(state::Shared& s) {
    static size_t tab = 0;
    apply_scale(s.cfg.ui_scale);
    const state::Colour& accent = s.cfg.accent;

    ImGui::SetNextWindowSize(ImVec2(px(900), px(470)), ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowPos(ImVec2(60, 56), ImGuiCond_FirstUseEver);
    ImGui::SetNextWindowSizeConstraints(ImVec2(px(620), px(320)), ImVec2(3000, 2000));
    if (!ImGui::Begin("##lodestone", nullptr, ImGuiWindowFlags_NoTitleBar)) {
        ImGui::End();
        return;
    }

    const auto& cats = state::categories();
    const float rail = px(180), side = px(250);

    // ---- navigation ----
    ImGui::BeginChild("##nav", ImVec2(rail, 0));
    {
        ImVec2 p = ImGui::GetCursorScreenPos();
        float w = ImGui::GetContentRegionAvail().x;
        ImDrawList* dl = ImGui::GetWindowDrawList();
        float tw = ImGui::CalcTextSize("LODESTONE").x;
        dl->AddText(ImVec2(p.x + (w - tw) * 0.5f, p.y + px(10)), kText, "LODESTONE");
        dl->AddLine(ImVec2(p.x, p.y + px(36)), ImVec2(p.x + w, p.y + px(36)), col(accent), px(1.5f));
        ImGui::Dummy(ImVec2(0, px(48)));
        for (size_t i = 0; i < cats.size(); ++i) {
            if (nav_item(cats[i], i == tab, accent)) tab = i;
        }
    }
    ImGui::EndChild();
    ImGui::SameLine();

    // ---- content ----
    float middle = ImGui::GetContentRegionAvail().x - side - px(16);
    if (middle < px(220)) middle = px(220);
    ImGui::BeginChild("##content", ImVec2(middle, 0));
    {
        const char* current = cats[tab];
        ImVec2 p = ImGui::GetCursorScreenPos();
        float w = ImGui::GetContentRegionAvail().x;
        ImDrawList* dl = ImGui::GetWindowDrawList();
        dl->AddCircleFilled(ImVec2(p.x + px(5), p.y + px(8)), px(4), col(accent));
        dl->AddText(ImVec2(p.x + px(18), p.y), col(accent), current);
        dl->AddLine(ImVec2(p.x, p.y + px(26)), ImVec2(p.x + w, p.y + px(26)), col(accent));
        ImGui::Dummy(ImVec2(0, px(34)));

        if (std::string(current) == "Settings") {
            settings_pane(s);
        } else {
            const auto& table = state::modules();
            for (size_t i = 0; i < table.size(); ++i) {
                if (std::string(table[i].category) != current) continue;
                bool on = table[i].flag(s.cfg);
                ImVec2 row = ImGui::GetCursorScreenPos();
                float avail = ImGui::GetContentRegionAvail().x;
                std::string id = std::string("##m") + table[i].key;
                if (check(id.c_str(), table[i].label, &on, accent)) table[i].flag(s.cfg) = on;
                // Right-click a row to bind it.
                if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
                    s.binding_active = true;
                    s.binding = state::BindTarget{state::BindKind::Module, i};
                }
                // The key, faint and right-aligned.
                std::string chip;
                bool listening = s.binding_active && s.binding.kind == state::BindKind::Module &&
                                 s.binding.module == i;
                if (listening) {
                    chip = "...";
                } else {
                    for (const auto& b : s.binds) {
                        if (b.target.kind == state::BindKind::Module && b.target.module == i) {
                            chip = "[" + key_name(b.key) + "]";
                            break;
                        }
                    }
                }
                if (!chip.empty()) {
                    float cw = ImGui::CalcTextSize(chip.c_str()).x;
                    ImGui::GetWindowDrawList()->AddText(
                        ImVec2(row.x + avail - cw, row.y + px(5)),
                        listening ? col(accent) : kDim, chip.c_str());
                }
            }
        }
    }
    ImGui::EndChild();
    ImGui::SameLine();

    // ---- status ----
    ImGui::BeginChild("##status", ImVec2(0, 0));
    {
        ImVec2 p = ImGui::GetCursorScreenPos();
        float w = ImGui::GetContentRegionAvail().x;
        ImDrawList* dl = ImGui::GetWindowDrawList();
        dl->AddCircleFilled(ImVec2(p.x + px(5), p.y + px(8)), px(4), col(accent));
        dl->AddText(ImVec2(p.x + px(18), p.y), kText, "Status");
        dl->AddLine(ImVec2(p.x, p.y + px(26)), ImVec2(p.x + w, p.y + px(26)), col(accent));
        ImGui::Dummy(ImVec2(0, px(34)));

        const auto& g = s.game;
        stat_row("state", g.in_world ? "in world" : "menu");
        stat_row("fps", fmt("%.0f", ImGui::GetIO().Framerate));
        stat_row("health", fmt("%.0f", g.health));
        stat_row("position", fmt("%.0f %.0f %.0f", g.pos.x, g.pos.y, g.pos.z));
        stat_row("entities", fmt("%.0f", static_cast<double>(g.targets.size())));

        int players = 0;
        for (const auto& t : g.targets) {
            if (t.kind == state::TargetKind::Player) ++players;
        }
        stat_row("players", fmt("%.0f", static_cast<double>(players)));

        int active = 0;
        for (const auto& m : state::modules()) {
            if (m.flag(s.cfg)) ++active;
        }
        stat_row("active", fmt("%.0f", static_cast<double>(active)));
    }
    ImGui::EndChild();

    ImGui::End();
}

}  // namespace lodestone::menu