#define IMGUI_DEFINE_MATH_OPERATORS // before the first imgui.h include #include "hd2d/UI.h" #include "hd2d/Log.h" #include #include #include #include #include #include #include #include namespace hd2d::ui { namespace { Fonts g_fonts; // ---- colour helpers --------------------------------------------------------- float alphaOf(ImU32 c) { return float((c >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f; } ImU32 withAlpha(ImU32 c, float a) { int na = int(std::clamp(alphaOf(c) * a, 0.0f, 1.0f) * 255.0f + 0.5f); return (c & ~IM_COL32_A_MASK) | (ImU32(na) << IM_COL32_A_SHIFT); } ImU32 rgba(int r, int g, int b, float a) { return IM_COL32(r, g, b, int(std::clamp(a, 0.0f, 1.0f) * 255.0f + 0.5f)); } // Component-wise multiply (rgb and alpha). ImU32 modulate(ImU32 c, ImU32 t) { ImVec4 a = ImGui::ColorConvertU32ToFloat4(c), b = ImGui::ColorConvertU32ToFloat4(t); return ImGui::ColorConvertFloat4ToU32(ImVec4(a.x * b.x, a.y * b.y, a.z * b.z, a.w * b.w)); } ImU32 mix(ImU32 c0, ImU32 c1, float t) { ImVec4 a = ImGui::ColorConvertU32ToFloat4(c0), b = ImGui::ColorConvertU32ToFloat4(c1); return ImGui::ColorConvertFloat4ToU32( ImVec4(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t, a.w + (b.w - a.w) * t)); } ImVec2 snap(ImVec2 p) { return {std::floor(p.x + 0.5f), std::floor(p.y + 0.5f)}; } ImFont* orDefault(ImFont* f) { return f ? f : ImGui::GetFont(); } float displayScale() { return scaleFor(ImGui::GetIO().DisplaySize); } // Fills whatever `draw` adds with a vertical gradient (vertex alpha is kept). template void verticalGradient(ImDrawList* dl, float y0, float y1, ImU32 top, ImU32 bottom, F&& draw) { int v0 = dl->VtxBuffer.Size; draw(); ImGui::ShadeVertsLinearColorGradientKeepAlpha(dl, v0, dl->VtxBuffer.Size, ImVec2(0, y0), ImVec2(0, y1), top, bottom); } // Horizontal line fading out towards both ends (or only towards the right). void fadeLine(ImDrawList* dl, ImVec2 a, ImVec2 b, float thickness, ImU32 col, bool bothEnds = true) { ImU32 clear = withAlpha(col, 0.0f); float y0 = a.y - thickness * 0.5f, y1 = a.y + thickness * 0.5f; if (bothEnds) { float mid = (a.x + b.x) * 0.5f; dl->AddRectFilledMultiColor(ImVec2(a.x, y0), ImVec2(mid, y1), clear, col, col, clear); dl->AddRectFilledMultiColor(ImVec2(mid, y0), ImVec2(b.x, y1), col, clear, clear, col); } else { dl->AddRectFilledMultiColor(ImVec2(a.x, y0), ImVec2(b.x, y1), col, clear, clear, col); } } // Diamond gem: gold body, lighter upper-left facet, dark outline. void gem(ImDrawList* dl, ImVec2 c, float r, ImU32 body, ImU32 light, float alpha) { ImVec2 t(c.x, c.y - r), rt(c.x + r, c.y), b(c.x, c.y + r), l(c.x - r, c.y); dl->AddQuadFilled(t, rt, b, l, withAlpha(IM_COL32(10, 10, 16, 255), 0.75f * alpha)); float ri = std::max(1.0f, r - 1.2f); ImVec2 ti(c.x, c.y - ri), rti(c.x + ri, c.y), bi(c.x, c.y + ri), li(c.x - ri, c.y); dl->AddQuadFilled(ti, rti, bi, li, withAlpha(body, alpha)); dl->AddTriangleFilled(li, ti, c, withAlpha(light, alpha)); } // ---- text layout --------------------------------------------------------------- struct Span { const char* b; const char* e; }; int utf8Len(unsigned char c) { return c < 0x80 ? 1 : (c >> 5) == 0x6 ? 2 : (c >> 4) == 0xE ? 3 : (c >> 3) == 0x1E ? 4 : 1; } // Splits `[b, e)` into lines: hard breaks on '\n', word wrap at `wrap` px (<= 0: none). void layoutLines(ImFont* font, float size, const char* b, const char* e, float wrap, std::vector& out) { out.clear(); const float scale = size / font->FontSize; const char* p = b; for (;;) { const char* nl = static_cast(std::memchr(p, '\n', size_t(e - p))); const char* pe = nl ? nl : e; const char* s = p; if (s == pe) out.push_back({s, s}); while (s < pe) { const char* eol = wrap > 0.0f ? font->CalcWordWrapPositionA(scale, s, pe, wrap) : pe; if (eol <= s) eol = std::min(pe, s + utf8Len(static_cast(*s))); const char* le = eol; while (le > s && (le[-1] == ' ' || le[-1] == '\t')) --le; out.push_back({s, le}); s = eol; while (s < pe && (*s == ' ' || *s == '\t')) ++s; } if (!nl) break; p = nl + 1; } } float lineAdvance(float size) { return std::round(size * 1.06f); } void drawSpan(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, const char* b, const char* e, bool shadow) { if (b >= e) return; pos = snap(pos); if (shadow) { float o = std::max(1.0f, std::round(size / 36.0f)); float a = alphaOf(color); // Soft halo for contrast on busy backgrounds plus the crisp 1px offset shadow. dl->AddText(font, size, ImVec2(pos.x + o, pos.y + o * 2.0f), rgba(0, 0, 0, 0.30f * a), b, e); dl->AddText(font, size, ImVec2(pos.x + o, pos.y + o), rgba(4, 4, 10, 0.85f * a), b, e); } dl->AddText(font, size, pos, color, b, e); } // ---- option list (menu + dialogue choices) ---------------------------------------- struct ListMetrics { float s, size, titleSize, rowH, padX, padY, cursorW, titleH; ImVec2 panel; }; ListMetrics listMetrics(const std::vector& items, std::string_view title, float scale) { ListMetrics m; m.s = scale; m.size = std::round(kBodySize * 0.9f * scale); m.titleSize = std::round(kNameSize * scale); m.rowH = std::round(m.size * 1.3f); m.padX = std::round(26.0f * scale); m.padY = std::round(18.0f * scale); m.cursorW = std::round(30.0f * scale); m.titleH = title.empty() ? 0.0f : std::round(m.titleSize + 22.0f * scale); ImFont* body = orDefault(g_fonts.body); float w = 0.0f; for (const std::string& it : items) w = std::max(w, measure(body, m.size, it).x); if (!title.empty()) w = std::max(w, measure(orDefault(g_fonts.name), m.titleSize, title).x - m.cursorW); m.panel.x = std::round(std::max(w + m.cursorW + m.padX * 2.0f + 8.0f * scale, 200.0f * scale)); m.panel.y = std::round(m.padY * 2.0f + m.titleH + m.rowH * float(items.size())); return m; } void drawList(ImDrawList* dl, ImVec2 min, const ListMetrics& m, const std::vector& items, int selected, std::string_view title) { const float s = m.s; ImVec2 max = min + m.panel; PanelStyle ps; ps.scale = s; panel(dl, min, max, ps); float y = min.y + m.padY; if (!title.empty()) { textCentered(dl, g_fonts.name, m.titleSize, ImVec2((min.x + max.x) * 0.5f, y + m.titleSize * 0.5f), kGold, title); float ly = std::round(y + m.titleSize + 10.0f * s) + 0.5f; fadeLine(dl, ImVec2(min.x + 14.0f * s, ly), ImVec2(max.x - 14.0f * s, ly), std::max(1.0f, s), kGoldDim); y += m.titleH; } const float t = float(ImGui::GetTime()); ImFont* body = orDefault(g_fonts.body); for (int i = 0; i < int(items.size()); ++i) { ImVec2 r0(min.x + 8.0f * s, y), r1(max.x - 8.0f * s, y + m.rowH); bool sel = i == selected; float textY = y + (m.rowH - m.size) * 0.5f; if (sel) { ImU32 hi = IM_COL32(222, 189, 120, 70), clear = IM_COL32(222, 189, 120, 0); dl->AddRectFilledMultiColor(r0, r1, hi, clear, clear, hi); fadeLine(dl, ImVec2(r0.x, r0.y + 0.5f), ImVec2(r1.x, r0.y + 0.5f), 1.0f, IM_COL32(222, 189, 120, 110), false); fadeLine(dl, ImVec2(r0.x, r1.y - 0.5f), ImVec2(r1.x, r1.y - 0.5f), 1.0f, IM_COL32(222, 189, 120, 110), false); // Right-pointing gold cursor that bobs gently. float bob = std::sin(t * 6.0f) * 2.5f * s; float cx = min.x + m.padX + 4.0f * s + bob, cy = y + m.rowH * 0.5f, h = 8.0f * s; ImVec2 a(cx - h * 0.6f, cy - h), b(cx + h * 0.9f, cy), c(cx - h * 0.6f, cy + h); dl->AddTriangleFilled(a + ImVec2(1, 1.5f) * s, b + ImVec2(1, 1.5f) * s, c + ImVec2(1, 1.5f) * s, IM_COL32(0, 0, 0, 150)); dl->AddTriangleFilled(a, b, c, kGold); dl->AddTriangleFilled(a, b, ImVec2(cx - h * 0.6f, cy), IM_COL32(255, 238, 190, 255)); } drawSpan(dl, body, m.size, ImVec2(min.x + m.padX + m.cursorW, textY), sel ? IM_COL32(255, 248, 230, 255) : kTextMuted, items[size_t(i)].data(), items[size_t(i)].data() + items[size_t(i)].size(), true); y += m.rowH; } } bool isSentenceEnd(const char* p, size_t len) { if (len == 1) return *p == '.' || *p == '!' || *p == '?'; return len == 3 && !std::memcmp(p, "\xE2\x80\xA6", 3); // ellipsis } bool isClauseEnd(const char* p, size_t len) { if (len == 1) return *p == ',' || *p == ';' || *p == ':'; return len == 3 && (!std::memcmp(p, "\xE2\x80\x94", 3) || !std::memcmp(p, "\xE2\x80\x93", 3)); // em/en dash } } // namespace // ---- fonts / theme ------------------------------------------------------------------- bool loadFonts(const std::string& fontDir, float pixelScale) { static const ImWchar kRanges[] = { 0x0020, 0x00FF, // Basic Latin + Latin-1 0x0100, 0x017F, // Latin Extended-A 0x2010, 0x2027, // dashes, quotes, bullets, ellipsis 0x2030, 0x203A, // per mille, guillemets 0x20AC, 0x20AC, // euro 0x2122, 0x2122, // trade mark 0, }; ImGuiIO& io = ImGui::GetIO(); ImFontAtlas* atlas = io.Fonts; if (io.BackendRendererUserData) ImGui_ImplOpenGL3_DestroyFontsTexture(); atlas->Clear(); atlas->AddFontDefault(); // stays io.FontDefault (first font) for debug panels io.FontDefault = nullptr; g_fonts = {}; pixelScale = std::max(pixelScale, 0.25f); bool ok = true; auto add = [&](const char* file, float size, float multiply) -> ImFont* { std::string path = fontDir + "/" + file; if (!std::filesystem::exists(path)) { HD2D_ERR("ui: missing font %s", path.c_str()); ok = false; return nullptr; } ImFontConfig cfg; cfg.OversampleH = 2; cfg.OversampleV = 1; cfg.RasterizerMultiply = multiply; std::snprintf(cfg.Name, sizeof cfg.Name, "%s %.0fpx", file, size * pixelScale); return atlas->AddFontFromFileTTF(path.c_str(), std::round(size * pixelScale), &cfg, kRanges); }; g_fonts.body = add("EBGaramond.ttf", kBodySize, 1.15f); g_fonts.small = add("EBGaramond.ttf", kSmallSize, 1.2f); g_fonts.name = add("Cinzel.ttf", kNameSize, 1.1f); g_fonts.title = add("CinzelDecorative-Bold.ttf", kTitleSize, 1.0f); // The backend re-uploads the atlas on the next NewFrame(). return ok; } const Fonts& fonts() { return g_fonts; } float scaleFor(ImVec2 viewport) { return viewport.y > 0.0f ? viewport.y / 1080.0f : 1.0f; } void applyTheme() { ImGuiStyle& st = ImGui::GetStyle(); st.WindowRounding = 6.0f; st.ChildRounding = 4.0f; st.FrameRounding = 4.0f; st.PopupRounding = 4.0f; st.GrabRounding = 3.0f; st.TabRounding = 4.0f; st.ScrollbarRounding = 6.0f; st.WindowBorderSize = 1.0f; st.FrameBorderSize = 1.0f; st.PopupBorderSize = 1.0f; st.WindowPadding = ImVec2(12, 10); st.FramePadding = ImVec2(8, 4); st.ItemSpacing = ImVec2(8, 6); st.WindowTitleAlign = ImVec2(0.5f, 0.5f); auto c = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); }; ImVec4* k = st.Colors; const ImVec4 gold = c(222, 189, 120), goldDim = c(150, 122, 74); k[ImGuiCol_Text] = c(246, 240, 226); k[ImGuiCol_TextDisabled] = c(140, 134, 120); k[ImGuiCol_WindowBg] = c(14, 17, 30, 0.92f); k[ImGuiCol_ChildBg] = c(0, 0, 0, 0.0f); k[ImGuiCol_PopupBg] = c(12, 14, 26, 0.96f); k[ImGuiCol_Border] = c(150, 122, 74, 0.70f); k[ImGuiCol_BorderShadow] = c(0, 0, 0, 0.0f); k[ImGuiCol_FrameBg] = c(28, 32, 52, 0.85f); k[ImGuiCol_FrameBgHovered] = c(44, 48, 72, 0.90f); k[ImGuiCol_FrameBgActive] = c(58, 56, 70, 1.0f); k[ImGuiCol_TitleBg] = c(20, 22, 36, 0.95f); k[ImGuiCol_TitleBgActive] = c(46, 38, 26, 0.98f); k[ImGuiCol_TitleBgCollapsed] = c(14, 17, 30, 0.75f); k[ImGuiCol_MenuBarBg] = c(20, 22, 36, 1.0f); k[ImGuiCol_ScrollbarBg] = c(8, 10, 18, 0.6f); k[ImGuiCol_ScrollbarGrab] = c(110, 92, 60, 0.8f); k[ImGuiCol_ScrollbarGrabHovered] = goldDim; k[ImGuiCol_ScrollbarGrabActive] = gold; k[ImGuiCol_CheckMark] = gold; k[ImGuiCol_SliderGrab] = goldDim; k[ImGuiCol_SliderGrabActive] = gold; k[ImGuiCol_Button] = c(40, 42, 62, 0.90f); k[ImGuiCol_ButtonHovered] = c(96, 78, 46, 0.95f); k[ImGuiCol_ButtonActive] = c(150, 122, 74, 1.0f); k[ImGuiCol_Header] = c(96, 78, 46, 0.55f); k[ImGuiCol_HeaderHovered] = c(120, 98, 58, 0.75f); k[ImGuiCol_HeaderActive] = c(150, 122, 74, 0.90f); k[ImGuiCol_Separator] = c(150, 122, 74, 0.55f); k[ImGuiCol_SeparatorHovered] = goldDim; k[ImGuiCol_SeparatorActive] = gold; k[ImGuiCol_ResizeGrip] = c(150, 122, 74, 0.25f); k[ImGuiCol_ResizeGripHovered] = c(150, 122, 74, 0.65f); k[ImGuiCol_ResizeGripActive] = gold; k[ImGuiCol_Tab] = c(30, 32, 50, 0.90f); k[ImGuiCol_TabHovered] = c(120, 98, 58, 0.85f); k[ImGuiCol_TabSelected] = c(86, 70, 42, 1.0f); k[ImGuiCol_TabSelectedOverline] = gold; k[ImGuiCol_TabDimmed] = c(20, 22, 36, 0.90f); k[ImGuiCol_TabDimmedSelected] = c(52, 44, 32, 1.0f); k[ImGuiCol_PlotLines] = goldDim; k[ImGuiCol_PlotLinesHovered] = gold; k[ImGuiCol_PlotHistogram] = goldDim; k[ImGuiCol_PlotHistogramHovered] = gold; k[ImGuiCol_TableHeaderBg] = c(30, 32, 50, 1.0f); k[ImGuiCol_TableBorderStrong] = c(150, 122, 74, 0.6f); k[ImGuiCol_TableBorderLight] = c(150, 122, 74, 0.3f); k[ImGuiCol_TableRowBg] = c(0, 0, 0, 0.0f); k[ImGuiCol_TableRowBgAlt] = c(255, 255, 255, 0.03f); k[ImGuiCol_TextSelectedBg] = c(150, 122, 74, 0.45f); k[ImGuiCol_DragDropTarget] = gold; k[ImGuiCol_NavCursor] = gold; k[ImGuiCol_NavWindowingHighlight] = c(255, 255, 255, 0.7f); k[ImGuiCol_NavWindowingDimBg] = c(0, 0, 0, 0.4f); k[ImGuiCol_ModalWindowDimBg] = c(0, 0, 0, 0.55f); } // ---- primitives ------------------------------------------------------------------------ void panel(ImDrawList* dl, ImVec2 min, ImVec2 max, const PanelStyle& style) { const float s = style.scale > 0.0f ? style.scale : displayScale(); const float fade = alphaOf(style.tint); if (fade <= 0.0f) return; min = snap(min); max = snap(max); const float r = style.rounding * s; // Soft drop shadow: stacked, expanding translucent rounded rects. const ImVec2 off(0.0f, std::round(5.0f * s)); const float spread = 14.0f * s; for (int i = 8; i >= 1; --i) { float e = spread * float(i) / 8.0f; dl->AddRectFilled(min + off - ImVec2(e, e), max + off + ImVec2(e, e), rgba(0, 0, 0, 0.045f * fade), r + e); } // Navy -> near-black vertical gradient body. verticalGradient(dl, min.y, max.y, IM_COL32(32, 39, 66, 255), IM_COL32(8, 10, 21, 255), [&] { dl->AddRectFilled(min, max, rgba(255, 255, 255, style.alpha * fade), r); }); // Faint top sheen. fadeLine(dl, ImVec2(min.x + r, min.y + 2.0f * s), ImVec2(max.x - r, min.y + 2.0f * s), std::max(1.0f, s), rgba(255, 255, 255, 0.10f * fade)); // Thin warm-gold double border. const ImU32 gold = withAlpha(modulate(kGold, style.tint | IM_COL32_A_MASK), fade); const ImU32 goldDim = withAlpha(modulate(kGoldDim, style.tint | IM_COL32_A_MASK), 0.85f * fade); const float outer = std::max(1.0f, std::round(1.5f * s)); const float inset = std::round(5.0f * s); const ImVec2 h(0.5f, 0.5f); dl->AddRect(min + h, max - h, gold, r, 0, outer); dl->AddRect(min + ImVec2(inset, inset) + h, max - ImVec2(inset, inset) - h, goldDim, std::max(0.0f, r - inset), 0, std::max(1.0f, s)); if (!style.ornate) return; // Corner gems sitting across both border lines, bright L-brackets along the // inner rule, plus a small gem centred on the top and bottom rules. const float g = 6.5f * s, arm = std::round(24.0f * s), c = std::round(inset * 0.5f) + 0.5f; const ImU32 light = withAlpha(IM_COL32(255, 240, 200, 255), fade); const ImVec2 corners[4] = {{min.x + c, min.y + c}, {max.x - c, min.y + c}, {max.x - c, max.y - c}, {min.x + c, max.y - c}}; const ImVec2 dirs[4] = {{1, 1}, {-1, 1}, {-1, -1}, {1, -1}}; const float lt = std::max(1.0f, std::round(2.0f * s)); for (int i = 0; i < 4; ++i) { ImVec2 p = corners[i], d = dirs[i]; ImVec2 q(p.x + d.x * (inset - c + 1.0f), p.y + d.y * (inset - c + 1.0f)); // on the inner rule dl->AddLine(ImVec2(q.x + d.x * g * 0.6f, q.y), ImVec2(q.x + d.x * arm, q.y), gold, lt); dl->AddLine(ImVec2(q.x, q.y + d.y * g * 0.6f), ImVec2(q.x, q.y + d.y * arm), gold, lt); dl->AddCircleFilled(ImVec2(q.x + d.x * (arm + 3.0f * s), q.y), 1.6f * s, gold); dl->AddCircleFilled(ImVec2(q.x, q.y + d.y * (arm + 3.0f * s)), 1.6f * s, gold); gem(dl, p, g, gold, light, 1.0f); } if (max.x - min.x > 240.0f * s) { float cx = std::round((min.x + max.x) * 0.5f); gem(dl, ImVec2(cx, min.y + 0.5f), 3.5f * s, gold, light, 1.0f); gem(dl, ImVec2(cx, max.y - 0.5f), 3.5f * s, gold, light, 1.0f); } } void text(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s, bool shadow) { drawSpan(dl, orDefault(font), size, pos, color, s.data(), s.data() + s.size(), shadow); } void textWrapped(ImDrawList* dl, ImFont* font, float size, ImVec2 pos, ImU32 color, std::string_view s, float wrapWidth, bool shadow) { font = orDefault(font); static thread_local std::vector lines; layoutLines(font, size, s.data(), s.data() + s.size(), wrapWidth, lines); for (const Span& l : lines) { drawSpan(dl, font, size, pos, color, l.b, l.e, shadow); pos.y += lineAdvance(size); } } ImVec2 measure(ImFont* font, float size, std::string_view s, float wrapWidth) { font = orDefault(font); if (wrapWidth <= 0.0f) return font->CalcTextSizeA(size, FLT_MAX, 0.0f, s.data(), s.data() + s.size()); static thread_local std::vector lines; layoutLines(font, size, s.data(), s.data() + s.size(), wrapWidth, lines); float w = 0.0f; for (const Span& l : lines) w = std::max(w, font->CalcTextSizeA(size, FLT_MAX, 0.0f, l.b, l.e).x); return ImVec2(w, lineAdvance(size) * float(lines.size() - 1) + size); } void textCentered(ImDrawList* dl, ImFont* font, float size, ImVec2 center, ImU32 color, std::string_view s, bool shadow) { ImVec2 sz = measure(font, size, s); text(dl, font, size, ImVec2(center.x - sz.x * 0.5f, center.y - sz.y * 0.5f), color, s, shadow); } void titleText(ImDrawList* dl, ImVec2 center, std::string_view s, float size) { ImFont* font = orDefault(g_fonts.title); const char* b = s.data(); const char* e = b + s.size(); ImVec2 sz = font->CalcTextSizeA(size, FLT_MAX, 0.0f, b, e); ImVec2 pos = snap(ImVec2(center.x - sz.x * 0.5f, center.y - sz.y * 0.5f)); const float u = size / 96.0f; // Soft dark halo (legible on bright scenery), then a warm glow around the glyphs. for (int ring = 0; ring < 3; ++ring) { float rad = (ring == 0 ? 10.0f : ring == 1 ? 6.0f : 3.5f) * u; ImU32 col = ring == 0 ? IM_COL32(0, 0, 0, 22) : ring == 1 ? IM_COL32(10, 6, 0, 34) : IM_COL32(255, 200, 110, 46); for (int i = 0; i < 12; ++i) { float a = float(i) * 3.14159265f / 6.0f; dl->AddText(font, size, pos + ImVec2(std::cos(a), std::sin(a)) * rad, col, b, e); } } // Dark outline + drop shadow keep the letterforms crisp against the glow. for (int i = 0; i < 8; ++i) { float a = float(i) * 3.14159265f / 4.0f; dl->AddText(font, size, pos + ImVec2(std::cos(a), std::sin(a)) * (2.0f * u), IM_COL32(20, 12, 4, 200), b, e); } dl->AddText(font, size, pos + ImVec2(0.0f, 4.0f * u), IM_COL32(0, 0, 0, 150), b, e); // Gold face: pale highlight at the top to deep gold at the bottom. verticalGradient(dl, pos.y + sz.y * 0.2f, pos.y + sz.y * 0.85f, IM_COL32(255, 246, 214, 255), IM_COL32(200, 142, 52, 255), [&] { dl->AddText(font, size, pos, IM_COL32_WHITE, b, e); }); // Flourish rule underneath: fading gold lines with a centre gem, on a dark underlay. float y = std::round(pos.y + sz.y * 0.95f) + 0.5f; float half = sz.x * 0.5f + 40.0f * u; float cx = center.x; const float lt = std::max(1.0f, std::round(2.0f * u)); for (int pass = 0; pass < 2; ++pass) { ImU32 col = pass ? kGold : IM_COL32(0, 0, 0, 110); ImU32 clear = withAlpha(col, 0.0f); float t = pass ? lt : lt + 2.0f * std::max(1.0f, u); dl->AddRectFilledMultiColor(ImVec2(cx - half, y - t * 0.5f), ImVec2(cx - 12.0f * u, y + t * 0.5f), clear, col, col, clear); fadeLine(dl, ImVec2(cx + 12.0f * u, y), ImVec2(cx + half, y), t, col, false); } gem(dl, ImVec2(cx, y), 8.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f); gem(dl, ImVec2(cx - 24.0f * u, y), 4.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f); gem(dl, ImVec2(cx + 24.0f * u, y), 4.0f * u, kGold, IM_COL32(255, 240, 200, 255), 1.0f); } void nameplate(ImDrawList* dl, ImVec2 anchor, std::string_view s, ImU32 accent, float scale) { ImFont* font = orDefault(g_fonts.name); const float size = std::round(kNameSize * 0.82f * scale); ImVec2 ts = measure(font, size, s); const float padX = std::round(16.0f * scale), padY = std::round(6.0f * scale), tip = std::round(8.0f * scale); ImVec2 sz(std::round(ts.x + padX * 2.0f), std::round(size + padY * 2.0f)); ImVec2 min = snap(ImVec2(anchor.x - sz.x * 0.5f, anchor.y - tip - sz.y)); ImVec2 max = min + sz; const float r = sz.y * 0.5f; dl->AddRectFilled(min + ImVec2(0, 3.0f * scale) - ImVec2(2, 2) * scale, max + ImVec2(0, 3.0f * scale) + ImVec2(2, 2) * scale, IM_COL32(0, 0, 0, 70), r + 2.0f * scale); // Pointer below the pill. ImVec2 t0(anchor.x - tip, max.y - 1.0f), t1(anchor.x + tip, max.y - 1.0f), t2(anchor.x, anchor.y); dl->AddTriangleFilled(t0 - ImVec2(1.5f, 0) * scale, t1 + ImVec2(1.5f, 0) * scale, t2 + ImVec2(0, 2.0f) * scale, accent); dl->AddTriangleFilled(t0 + ImVec2(1.5f, 0) * scale, t1 - ImVec2(1.5f, 0) * scale, t2 - ImVec2(0, 1.0f) * scale, IM_COL32(10, 12, 24, 235)); verticalGradient(dl, min.y, max.y, IM_COL32(34, 40, 66, 255), IM_COL32(10, 12, 24, 255), [&] { dl->AddRectFilled(min, max, IM_COL32(255, 255, 255, 230), r); }); dl->AddRect(min + ImVec2(0.5f, 0.5f), max - ImVec2(0.5f, 0.5f), accent, r, 0, std::max(1.0f, std::round(1.5f * scale))); text(dl, font, size, ImVec2(min.x + padX, min.y + padY - 1.0f * scale), kTextColor, s); } ImVec2 menuSize(const std::vector& items, std::string_view title, float scale) { return listMetrics(items, title, scale).panel; } void menuAt(ImDrawList* dl, ImVec2 pos, ImVec2 pivot, const std::vector& items, int selected, std::string_view title, float scale) { ListMetrics m = listMetrics(items, title, scale); drawList(dl, ImVec2(pos.x - m.panel.x * pivot.x, pos.y - m.panel.y * pivot.y), m, items, selected, title); } void menu(ImDrawList* dl, ImVec2 center, const std::vector& items, int selected, std::string_view title, float scale) { menuAt(dl, center, ImVec2(0.5f, 0.5f), items, selected, title, scale); } void progressBar(ImDrawList* dl, ImVec2 min, ImVec2 max, float t, ImU32 color) { min = snap(min); max = snap(max); const float h = max.y - min.y, r = h * 0.5f; dl->AddRectFilled(min, max, IM_COL32(6, 8, 14, 225), r); const float in = std::max(1.0f, std::round(h * 0.18f)); ImVec2 f0(min.x + in, min.y + in), f1(max.x - in, max.y - in); float w = (f1.x - f0.x) * std::clamp(t, 0.0f, 1.0f); if (w > 0.5f) { f1.x = f0.x + w; float fr = std::min(r - in, w * 0.5f); verticalGradient(dl, f0.y, f1.y, mix(color, IM_COL32_WHITE, 0.45f), mix(color, IM_COL32_BLACK, 0.25f), [&] { dl->AddRectFilled(f0, f1, IM_COL32(255, 255, 255, int(alphaOf(color) * 255.0f)), fr); }); float hy = f0.y + std::max(1.0f, (f1.y - f0.y) * 0.25f); if (w > 2.0f * fr) dl->AddLine(ImVec2(f0.x + fr, hy), ImVec2(f1.x - fr, hy), IM_COL32(255, 255, 255, 70), 1.0f); } dl->AddRect(min + ImVec2(0.5f, 0.5f), max - ImVec2(0.5f, 0.5f), withAlpha(kGoldDim, 0.9f), r, 0, 1.0f); } void advanceIndicator(ImDrawList* dl, ImVec2 c, float scale, float time) { const float bob = std::sin(time * 4.5f) * 2.5f * scale; const float a = 0.6f + 0.4f * std::cos(time * 4.5f); const float w = 8.0f * scale, h = 10.0f * scale; c.y += bob; ImVec2 l(c.x - w, c.y - h * 0.4f), r(c.x + w, c.y - h * 0.4f), b(c.x, c.y + h * 0.6f), top(c.x, c.y - h * 0.9f); ImVec2 sh(1.0f * scale, 2.0f * scale); dl->AddQuadFilled(top + sh, r + sh, b + sh, l + sh, rgba(0, 0, 0, 0.55f * a)); dl->AddQuadFilled(top, r, b, l, withAlpha(kGold, a)); dl->AddTriangleFilled(l, top, ImVec2(c.x, c.y - h * 0.4f), withAlpha(IM_COL32(255, 244, 210, 255), a)); dl->AddTriangleFilled(ImVec2(c.x, c.y - h * 0.4f), r, b, withAlpha(IM_COL32(170, 128, 60, 255), a)); } // ---- DialogueBox --------------------------------------------------------------------------- void DialogueBox::start(std::vector lines, std::vector choices) { lines_ = std::move(lines); if (lines_.empty()) lines_.emplace_back(); choices_ = std::move(choices); line_ = 0; revealed_ = 0; budget_ = 0.0f; selected_ = 0; chosen_ = -1; open_ = true; } bool DialogueBox::lineRevealed() const { return !open_ || revealed_ >= lines_[line_].text.size(); } bool DialogueBox::choicesVisible() const { return open_ && !choices_.empty() && line_ + 1 == lines_.size() && lineRevealed(); } void DialogueBox::stepChar() { const std::string& t = lines_[line_].text; const char* p = t.data() + revealed_; size_t len = std::min(size_t(utf8Len(static_cast(*p))), t.size() - revealed_); // Swallow malformed continuation bytes so we always land on a boundary. while (revealed_ + len < t.size() && (static_cast(t[revealed_ + len]) & 0xC0) == 0x80) ++len; revealed_ += len; bool atBreak = revealed_ >= t.size() || t[revealed_] == ' ' || t[revealed_] == '\n' || t[revealed_] == '"' || t[revealed_] == '\'' || t[revealed_] == ')'; if (atBreak && revealed_ < t.size()) { if (isSentenceEnd(p, len)) budget_ -= sentencePause; else if (isClauseEnd(p, len)) budget_ -= clausePause; } } void DialogueBox::update(float dt) { if (!open_) return; time_ += dt; if (lineRevealed()) { budget_ = 0.0f; return; } budget_ += dt; const float cost = 1.0f / std::max(1.0f, charsPerSecond); while (!lineRevealed() && budget_ >= cost) { budget_ -= cost; stepChar(); } } bool DialogueBox::advance() { if (!open_) return false; if (!lineRevealed()) { revealed_ = lines_[line_].text.size(); return false; } if (line_ + 1 < lines_.size()) { ++line_; revealed_ = 0; budget_ = 0.0f; return false; } chosen_ = choices_.empty() ? -1 : selected_; open_ = false; return true; } void DialogueBox::moveChoice(int delta) { if (!choicesVisible()) return; int n = int(choices_.size()); selected_ = ((selected_ + delta) % n + n) % n; } void DialogueBox::close() { open_ = false; chosen_ = -1; } void DialogueBox::draw(ImVec2 viewport) { if (!open_) return; ImDrawList* dl = ImGui::GetForegroundDrawList(); const DialogueLine& line = lines_[line_]; const float s = scaleFor(viewport); ImFont* body = orDefault(g_fonts.body); const float size = std::round(kBodySize * s); const float lh = lineAdvance(size); const float padX = std::round(48.0f * s), padY = std::round(30.0f * s); const float boxW = std::round(std::min(viewport.x * 0.70f, viewport.y * 1.6f)); float textX = padX, textW = boxW - padX * 2.0f; const float minTextH = lh * 2.0f + size; // room for three lines float portrait = 0.0f; if (line.portrait) { portrait = std::round(minTextH + padY * 2.0f - 28.0f * s); textX = std::round(20.0f * s + portrait + 30.0f * s); textW = boxW - textX - padX; } static thread_local std::vector spans; const char* tb = line.text.data(); const char* te = tb + line.text.size(); layoutLines(body, size, tb, te, textW, spans); const float textH = std::max(minTextH, lh * float(spans.size() - 1) + size); const float boxH = std::round(textH + padY * 2.0f); const ImVec2 min = snap(ImVec2((viewport.x - boxW) * 0.5f, viewport.y - std::round(40.0f * s) - boxH)); const ImVec2 max = min + ImVec2(boxW, boxH); PanelStyle ps; ps.scale = s; panel(dl, min, max, ps); if (line.portrait) { ImVec2 p0 = snap(ImVec2(min.x + 20.0f * s, min.y + (boxH - portrait) * 0.5f)), p1 = p0 + ImVec2(portrait, portrait); dl->AddRectFilled(p0, p1, IM_COL32(4, 6, 12, 200), 3.0f * s); dl->AddImage(line.portrait, p0, p1, line.uv0, line.uv1); dl->AddRect(p0 - ImVec2(0.5f, 0.5f), p1 + ImVec2(0.5f, 0.5f), withAlpha(kGoldDim, 0.9f), 3.0f * s, 0, std::max(1.0f, s)); } // Typewriter: every line was wrapped from the full text, so words never jump. const char* shown = tb + revealed_; ImVec2 pos(min.x + textX, min.y + padY); for (const Span& sp : spans) { if (sp.b >= shown) break; drawSpan(dl, body, size, pos, kTextColor, sp.b, std::min(sp.e, shown), true); pos.y += lh; } // Gold speaker name plate straddling the top-left edge. if (!line.speaker.empty()) { ImFont* nf = orDefault(g_fonts.name); const float ns = std::round(kNameSize * s); ImVec2 ts = measure(nf, ns, line.speaker); ImVec2 sz(std::round(ts.x + 44.0f * s), std::round(ns + 16.0f * s)); ImVec2 n0 = snap(ImVec2(min.x + 46.0f * s, min.y - sz.y * 0.62f)), n1 = n0 + sz; const float r = 4.0f * s; for (int i = 4; i >= 1; --i) { float e = 2.0f * s * float(i); dl->AddRectFilled(n0 - ImVec2(e, e) + ImVec2(0, 3.0f * s), n1 + ImVec2(e, e) + ImVec2(0, 3.0f * s), IM_COL32(0, 0, 0, 22), r + e); } verticalGradient(dl, n0.y, n1.y, IM_COL32(246, 222, 160, 255), IM_COL32(170, 124, 56, 255), [&] { dl->AddRectFilled(n0, n1, IM_COL32_WHITE, r); }); dl->AddRect(n0 + ImVec2(0.5f, 0.5f), n1 - ImVec2(0.5f, 0.5f), IM_COL32(92, 64, 26, 255), r, 0, std::max(1.0f, s)); float in = std::round(3.0f * s); dl->AddRect(n0 + ImVec2(in, in) + ImVec2(0.5f, 0.5f), n1 - ImVec2(in, in) - ImVec2(0.5f, 0.5f), IM_COL32(255, 244, 210, 150), std::max(0.0f, r - in), 0, 1.0f); ImVec2 tp = snap(ImVec2(n0.x + (sz.x - ts.x) * 0.5f, n0.y + (sz.y - ts.y) * 0.5f)); const char* sb = line.speaker.data(); const char* se = sb + line.speaker.size(); dl->AddText(nf, ns, tp + ImVec2(0, 1.0f), IM_COL32(255, 248, 225, 140), sb, se); // engraved highlight dl->AddText(nf, ns, tp, IM_COL32(40, 26, 10, 255), sb, se); } if (choicesVisible()) { menuAt(dl, ImVec2(max.x - 28.0f * s, min.y - 14.0f * s), ImVec2(1.0f, 1.0f), choices_, selected_, {}, s * 0.9f); } else if (lineRevealed()) { advanceIndicator(dl, ImVec2(max.x - 34.0f * s, max.y - 26.0f * s), s, time_); } } // ---- Toasts ------------------------------------------------------------------------------- void Toasts::push(std::string text, ImU32 accent, float seconds) { items_.push_back({std::move(text), accent, std::max(seconds, 0.6f)}); if (items_.size() > 6) items_.erase(items_.begin()); } void Toasts::update(float dt) { for (Item& it : items_) it.age += dt; std::erase_if(items_, [](const Item& it) { return it.age >= it.seconds; }); } void Toasts::draw(ImVec2 viewport) { if (items_.empty()) return; ImDrawList* dl = ImGui::GetForegroundDrawList(); const float s = scaleFor(viewport); ImFont* font = orDefault(g_fonts.small); const float size = std::round(kSmallSize * s); const float padX = std::round(28.0f * s), padY = std::round(12.0f * s), gemW = std::round(22.0f * s); float y = std::round(64.0f * s); for (const Item& it : items_) { float in = std::clamp(it.age / 0.25f, 0.0f, 1.0f), out = std::clamp((it.seconds - it.age) / 0.5f, 0.0f, 1.0f); float a = std::min(in, out); in = 1.0f - (1.0f - in) * (1.0f - in); ImVec2 ts = measure(font, size, it.text); ImVec2 sz(std::round(ts.x + padX * 2.0f + gemW), std::round(size + padY * 2.0f)); ImVec2 min = snap(ImVec2((viewport.x - sz.x) * 0.5f, y - (1.0f - in) * 14.0f * s)), max = min + sz; PanelStyle ps; ps.scale = s; ps.ornate = false; ps.rounding = 4.0f; ps.alpha = 0.85f; ps.tint = withAlpha(IM_COL32_WHITE, a); panel(dl, min, max, ps); gem(dl, ImVec2(min.x + padX + 4.0f * s, (min.y + max.y) * 0.5f), 6.0f * s, it.accent, mix(it.accent, IM_COL32_WHITE, 0.55f), a); fadeLine(dl, ImVec2(min.x + 10.0f * s, max.y - 7.0f * s), ImVec2(max.x - 10.0f * s, max.y - 7.0f * s), std::max(1.0f, s), withAlpha(it.accent, 0.7f * a)); text(dl, font, size, ImVec2(min.x + padX + gemW, min.y + padY - 1.0f * s), withAlpha(kTextColor, a), it.text); y += std::round((sz.y + 10.0f * s) * std::max(in, 0.0f)); } } } // namespace hd2d::ui