diff --git a/MODDING.md b/MODDING.md index a99f2ef..726ece3 100644 --- a/MODDING.md +++ b/MODDING.md @@ -32,7 +32,7 @@ There's two folders inside resources; "characters" and "font". "font" is just a font containing the font used in the selection screen. That's literally it. -"common" holds files characters share (sounds, particles, the cursor and so on). Whenever a character names a file, the game looks in the character's folder first, then in "common", so a character can use anything there as is, or replace it by having its own file at the same path. +"common" holds files characters share (sounds, particles, the cursor and so on). Whenever a character names a file, the game looks in the character's folder first, then in "common", so a character can use anything there as is, or replace it by having its own file at the same path. This includes the taskbar's icons, `gfx/ui/icons.anm2`: one region per icon, named after its button (`talk`, `mood`, `spawn`, `autofeed`, `home`, `stats`, `catalog`, `settings`, `help`, plus whatever `icon`s the interaction types name). A character with its own `gfx/ui/icons.anm2` gets its own icons. EVERYTHING ELSE is stored inside character folders in the "characters" folder; one folder per character, named however you like (the folder name is also the name of its save file). This has all data associated with characters. Think of characters more as tailored game experiences rather than literally being just the characters. Not only is there the character graphics, but backgrounds, items, parameters, etc. These are intensely customizable to suit whatever experience you'd like (within the confines of the engine, of course). @@ -149,14 +149,14 @@ The areas which will expand based on how full the character is and their progres | `scaleMax` | The scale (in percent) that will be added at maximum. Know that a scale of 100 is the normal scale of an animation. | ### interactTypes, interactAreas -`interactTypes` is an object of interaction types by name (each gets a button in the tools bar), giving each type's defaults. `interactAreas` is a list of the concrete areas on a character which can be interacted with; usually for belly rubs, kisses, etc. Each area names its `type` and takes all of that type's settings, changing whichever it gives itself. +`interactTypes` is an object of interaction types by name (each gets a button on the taskbar), giving each type's defaults. `interactAreas` is a list of the concrete areas on a character which can be interacted with; usually for belly rubs, kisses, etc. Each area names its `type` and takes all of that type's settings, changing whichever it gives itself. | Key | Type | Meaning | |---|---|---| | `type` | string | (Areas only) The interaction type to use. | | `null` | string | (Areas) The null in which the interact area can be triggered. | | `layer` | string | The layer that the scale effect (e.g. smacks) applies to. | -| `glyph` | string | The icon-font glyph used for this type in the tools bar. | +| `icon` | string | The region of `gfx/ui/icons.anm2` shown on this type's taskbar button. | | `isHold` | bool | If true, the interaction is held rather than clicked once. | | `cursorHover` | string | The animation the cursor will play when hovering over the interact area. | | `cursorActive` | string | The animation the cursor will play when interacting. | @@ -205,10 +205,8 @@ Determines menu and general UI appearance and behavior. | Key | Meaning | |---|---| | `font` | The font the menu will use. | -| `iconsFont` | The font holding the tools bar's glyphs. | | `rounding` | The rounding of corners the UI will use. | -| `homeGlyph` | The glyph of the tools bar's Home button. | -| `sounds` | `open` and `close` (a sliding panel), `hover` and `select` (a widget), and `cheats` (after entering a special code to activate cheats). | +| `sounds` | `open` and `close` (the taskbar or one of its windows), `hover` and `select` (a widget), and `cheats` (after entering a special code to activate cheats). | ### particles An object of particles by name, each with the `anm2` and `animation` it plays once where spawned. diff --git a/README.md b/README.md index ce8284e..9ae9ff4 100644 --- a/README.md +++ b/README.md @@ -62,4 +62,4 @@ See [MODDING.md](MODDING.md). ## License -See [LICENSE](LICENSE). +See [LICENSE](LICENSE). The taskbar icons are from [Tabler Icons](https://tabler.io/icons) (MIT), see `resources/common/gfx/ui/LICENSE.txt`. diff --git a/resources/characters/braixen/character.json b/resources/characters/braixen/character.json index b9946e5..ceb2896 100644 --- a/resources/characters/braixen/character.json +++ b/resources/characters/braixen/character.json @@ -44,7 +44,7 @@ "sounds": {"digest": "sfx/digest*.ogg", "gurgle": "sfx/gurgle*.ogg"}, "interactTypes": { "Rub": { - "glyph": "2", + "icon": "rub", "isHold": true, "cursorHover": "Hover", "cursorActive": "Rub", @@ -52,7 +52,7 @@ "particles": ["Circle"] }, "Smack": { - "glyph": "3", + "icon": "smack", "cursorHover": "SmackHover", "cursorActive": "Smack", "dialogueCooldown": 5, @@ -63,14 +63,14 @@ "particles": ["Circle"] }, "Kiss": { - "glyph": "4", + "icon": "kiss", "cursorHover": "KissHover", "cursorActive": "Kiss", "sounds": "sfx/kiss*.ogg", "particles": ["Circle"] }, "Turn Around": { - "glyph": "5", + "icon": "turn", "sounds": ["sfx/turn.ogg"], "particles": ["Circle"] } @@ -108,9 +108,7 @@ }, "menu": { "font": "font/font.ttf", - "iconsFont": "font/icons.ttf", "rounding": 10.0, - "homeGlyph": "1", "sounds": { "open": ["sfx/open.ogg"], "close": ["sfx/close.ogg"], diff --git a/resources/characters/mini/character.json b/resources/characters/mini/character.json index 57586aa..e8a2b83 100644 --- a/resources/characters/mini/character.json +++ b/resources/characters/mini/character.json @@ -39,7 +39,7 @@ "sounds": {"digest": "sfx/digest*.ogg", "gurgle": "sfx/gurgle*.ogg"}, "interactTypes": { "Rub": { - "glyph": "2", + "icon": "rub", "isHold": true, "cursorHover": "Hover", "cursorActive": "Rub", @@ -47,7 +47,7 @@ "particles": ["Circle"] }, "Smack": { - "glyph": "3", + "icon": "smack", "cursorHover": "SmackHover", "cursorActive": "Smack", "dialogueCooldown": 0.6667, @@ -58,7 +58,7 @@ "particles": ["Circle"] }, "Kiss": { - "glyph": "4", + "icon": "kiss", "cursorHover": "KissHover", "cursorActive": "Kiss", "sounds": "sfx/kiss*.ogg", @@ -94,9 +94,7 @@ }, "menu": { "font": "font/font.ttf", - "iconsFont": "font/icons.ttf", "rounding": 10.0, - "homeGlyph": "1", "sounds": { "open": ["sfx/open.ogg"], "close": ["sfx/close.ogg"], diff --git a/resources/characters/snivy/character.json b/resources/characters/snivy/character.json index b3e90a9..ee2ea46 100644 --- a/resources/characters/snivy/character.json +++ b/resources/characters/snivy/character.json @@ -46,7 +46,7 @@ "sounds": {"digest": "sfx/digest*.ogg", "gurgle": "sfx/gurgle*.ogg"}, "interactTypes": { "Rub": { - "glyph": "2", + "icon": "rub", "isHold": true, "cursorHover": "Hover", "cursorActive": "Rub", @@ -54,7 +54,7 @@ "particles": ["Circle"] }, "Smack": { - "glyph": "3", + "icon": "smack", "cursorHover": "SmackHover", "cursorActive": "Smack", "dialogueCooldown": 0.6667, @@ -64,14 +64,14 @@ "particles": ["Circle"] }, "Kiss": { - "glyph": "4", + "icon": "kiss", "cursorHover": "KissHover", "cursorActive": "Kiss", "sounds": "sfx/kiss*.ogg", "particles": ["Circle"] }, "Turn Around": { - "glyph": "5", + "icon": "turn", "sounds": ["sfx/turn.ogg"], "particles": ["Circle"] } @@ -113,9 +113,7 @@ }, "menu": { "font": "font/font.ttf", - "iconsFont": "font/icons.ttf", "rounding": 10.0, - "homeGlyph": "1", "sounds": { "open": ["sfx/open.ogg"], "close": ["sfx/close.ogg"], diff --git a/resources/common/font/icons.ttf b/resources/common/font/icons.ttf deleted file mode 100644 index ed407fb..0000000 Binary files a/resources/common/font/icons.ttf and /dev/null differ diff --git a/resources/common/gfx/ui/LICENSE.txt b/resources/common/gfx/ui/LICENSE.txt new file mode 100644 index 0000000..1dbceab --- /dev/null +++ b/resources/common/gfx/ui/LICENSE.txt @@ -0,0 +1,16 @@ +Icons from Tabler Icons (https://tabler.io/icons), under the MIT License. + +Copyright (c) 2020-2024 Paweł Kuna + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/resources/common/gfx/ui/icons.anm2 b/resources/common/gfx/ui/icons.anm2 new file mode 100644 index 0000000..8988416 --- /dev/null +++ b/resources/common/gfx/ui/icons.anm2 @@ -0,0 +1,34 @@ +{ + "anm2ed": 1, + "spritesheets": [ + { + "path": "icons.png", + "regions": [ + {"name": "talk", "crop": [0, 0], "size": [48, 48], "origin": "center"}, + {"name": "mood", "crop": [49, 0], "size": [48, 48], "origin": "center"}, + {"name": "spawn", "crop": [98, 0], "size": [48, 48], "origin": "center"}, + {"name": "autofeed", "crop": [147, 0], "size": [48, 48], "origin": "center"}, + {"name": "home", "crop": [196, 0], "size": [48, 48], "origin": "center"}, + {"name": "rub", "crop": [245, 0], "size": [48, 48], "origin": "center"}, + {"name": "smack", "crop": [294, 0], "size": [48, 48], "origin": "center"}, + {"name": "kiss", "crop": [343, 0], "size": [48, 48], "origin": "center"}, + {"name": "turn", "crop": [0, 49], "size": [48, 48], "origin": "center"}, + {"name": "stats", "crop": [49, 49], "size": [48, 48], "origin": "center"}, + {"name": "catalog", "crop": [98, 49], "size": [48, 48], "origin": "center"}, + {"name": "settings", "crop": [147, 49], "size": [48, 48], "origin": "center"}, + {"name": "help", "crop": [196, 49], "size": [48, 48], "origin": "center"}, + {"name": "close", "crop": [245, 49], "size": [48, 48], "origin": "center"} + ] + } + ], + "layers": [ + {"name": "Icon"} + ], + "animations": { + "Icon": { + "layers": [ + {"layer": 0, "frames": [{"region": 0}]} + ] + } + } +} diff --git a/resources/common/gfx/ui/icons.png b/resources/common/gfx/ui/icons.png new file mode 100644 index 0000000..1e84258 Binary files /dev/null and b/resources/common/gfx/ui/icons.png differ diff --git a/src/character.cpp b/src/character.cpp index b73a524..5fe5d78 100644 --- a/src/character.cpp +++ b/src/character.cpp @@ -4,6 +4,7 @@ constexpr float FONT_SIZE = 20; constexpr const char* COMMON_FOLDER = "common/"; +constexpr const char* ICONS_PATH = "gfx/ui/icons.anm2"; constexpr const char* TAG_NAMES[] = {"animation", "big", "color", "delay", "screenshake", "shake", "silent", "small", "sound", "wave"}; constexpr const char* USE_NAMES[] = {"", "edible", "tool"}; @@ -110,7 +111,7 @@ Anm2* Character::anm2_get(const std::string& path, Renderer& renderer, Audio& au static void interact_parse(object source, InteractArea& area, Character& character, Audio& audio) { json_keys_check(source, - {"type", "null", "layer", "glyph", "isHold", "animation", "idleOnEnd", "cursorHover", "cursorActive", + {"type", "null", "layer", "icon", "isHold", "animation", "idleOnEnd", "cursorHover", "cursorActive", "dialoguePool", "dialoguePoolFull", "dialogueCooldown", "digestionClick", "digestionHover", "scaleAmplitude", "scaleCycles", "scaleTime", "sounds", "particles"}, "an interact area"); @@ -119,7 +120,7 @@ static void interact_parse(object source, InteractArea& area, Character& charact area.cursorHover = json_string(source, "cursorHover", area.cursorHover); area.dialoguePool = json_string(source, "dialoguePool", area.dialoguePool); area.dialoguePoolFull = json_string(source, "dialoguePoolFull", area.dialoguePoolFull); - area.glyph = json_string(source, "glyph", area.glyph); + area.icon = json_string(source, "icon", area.icon); area.idleOnEnd = json_string(source, "idleOnEnd", area.idleOnEnd); area.digestionClick = json_float(source, "digestionClick", area.digestionClick); area.digestionHover = json_float(source, "digestionHover", area.digestionHover * TICK_RATE) / TICK_RATE; @@ -286,15 +287,15 @@ static void character_parse(object root, Character& character, Renderer& rendere character.anm2_get(json_string(cursor, "anm2"), renderer, audio)}; object menu = json_object(root, "menu"); - json_keys_check(menu, {"font", "iconsFont", "rounding", "homeGlyph", "sounds"}, "menu"); + json_keys_check(menu, {"font", "rounding", "sounds"}, "menu"); auto font_get = [&](const char* key) { std::string path = json_string(menu, key); return path.empty() ? nullptr : ImGui::GetIO().Fonts->AddFontFromFileTTF(file_find(character.roots, path).c_str(), FONT_SIZE); }; - character.menu = {json_string(menu, "homeGlyph"), font_get("font"), font_get("iconsFont"), - json_float(menu, "rounding")}; + character.menu = {font_get("font"), json_float(menu, "rounding")}; + character.icons = character.anm2_get(ICONS_PATH, renderer, audio); for (auto [name, value] : json_object(root, "particles")) { diff --git a/src/character.h b/src/character.h index a14bb57..6706285 100644 --- a/src/character.h +++ b/src/character.h @@ -81,7 +81,7 @@ struct InteractArea std::string cursorHover; std::string dialoguePool; std::string dialoguePoolFull; - std::string glyph; + std::string icon; std::string idleOnEnd; std::string name; std::vector particles; @@ -297,9 +297,7 @@ struct Cursor struct Menu { - std::string homeGlyph; ImFont* font = nullptr; - ImFont* iconsFont = nullptr; float rounding = 0; }; @@ -351,6 +349,7 @@ public: Swap talk; Traits traits; Anm2* anm2 = nullptr; + Anm2* icons = nullptr; Texture* portrait = nullptr; Texture* render = nullptr; Color color = {0.5f, 0.5f, 0.5f, 1}; diff --git a/src/play.cpp b/src/play.cpp index 84f4e96..9ae4f77 100644 --- a/src/play.cpp +++ b/src/play.cpp @@ -13,9 +13,7 @@ constexpr const char* KEY_DISCOVERED = "discovered"; constexpr const char* KEY_ITEM = "item"; constexpr float AUTOSAVE_TIME = 30; constexpr float CHEAT_TIME = 5; -constexpr float FOCUS_MENU_WIDTH = 0.3f; constexpr float FOCUS_PADDING = 100; -constexpr float FOCUS_TOOLS_WIDTH = 0.1f; constexpr float SHAKE_DECAY = 0.97f; constexpr float SHAKE_EPSILON = 0.1f; constexpr float SHAKE_LERP = 0.72f; @@ -133,16 +131,9 @@ Actor& Play::pointer_actor_get() return autofeed.isActive ? autoCursor : cursor; } -Focus Play::focus_get() -{ - if (text.isScene) - return FOCUS_CENTER; - return isMenuOpen && isToolsOpen ? FOCUS_BOTH : isMenuOpen ? FOCUS_MENU : isToolsOpen ? FOCUS_TOOLS : FOCUS_CENTER; -} - void Play::focus(Vec2 screenSize) { - camera.focus(body.actor.bounds_get(), screenSize, focus_get()); + camera.focus(body.actor.bounds_get(), screenSize, text.isScene ? 0 : inset); } void Play::particles_spawn(const std::vector& names, Vec2 position) @@ -195,7 +186,7 @@ void Play::events_handle(Vec2 screenSize) if (isPostgame) continue; text.set(*this, dialogue.hook_get(HOOK_END), TEXT_UNINTERRUPTIBLE); - isMenuOpen = isToolsOpen = false; + isTaskbarOpen = false; body.stats.calories = 0; body.stats.digestionProgress = 0; items.drop(); @@ -215,7 +206,7 @@ void Play::update(Vec2 screenSize) text.update(*this); if (isPostgamePending && (!text.entry || (text.entry->isTerminal && text.isFinished)) && !text.isScene) { - isMenuOpen = isPostgame = isCheats = true; + isTaskbarOpen = isPostgame = isCheats = true; isPostgamePending = false; focus(screenSize); } @@ -225,7 +216,7 @@ void Play::update(Vec2 screenSize) Pointer pointer; pointer.position = camera.world_get({io.MousePos.x, io.MousePos.y}); pointer.type = cursorType; - pointer.isCaptured = io.WantCaptureMouse; + pointer.isCaptured = io.WantCaptureMouse || isBlocked; pointer.isClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); pointer.isDown = ImGui::IsMouseDown(ImGuiMouseButton_Left); pointer.isReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left); @@ -308,20 +299,17 @@ void Camera::shake_add(float magnitude, int time) shake = {random_get(-magnitude, magnitude), random_get(-magnitude, magnitude)}; } -// Zooms and pans so a target fills the view, leaving room for the open panels. -void Camera::focus(Rect target, Vec2 screenSize, Focus panels) +// Zooms and pans so a target fills the view above the inset at the screen's bottom. +void Camera::focus(Rect target, Vec2 screenSize, float inset) { if (!is_rect_valid(target)) return; target = rect_center(target, FOCUS_PADDING); - float factor = std::min(screenSize.x / target.w, screenSize.y / target.h); + Vec2 view = {screenSize.x, std::max(1.0f, screenSize.y - inset)}; + float factor = std::min(view.x / target.w, view.y / target.h); zoom = ZOOM_LADDER[zoom_index_get(std::clamp(factor * PERCENT, ZOOM_LADDER[0], std::end(ZOOM_LADDER)[-1]))]; factor = zoom / PERCENT; - pan = rect_middle(target) - screenSize / factor / 2; - if (panels == FOCUS_MENU || panels == FOCUS_BOTH) - pan.x += screenSize.x * FOCUS_MENU_WIDTH / factor / 2; - if (panels == FOCUS_TOOLS || panels == FOCUS_BOTH) - pan.x -= screenSize.x * FOCUS_TOOLS_WIDTH / factor / 2; + pan = rect_middle(target) - view / factor / 2; } // Pans with the middle mouse (or control and the left), zooms through the ladder with the wheel toward the mouse, and @@ -330,7 +318,7 @@ void Camera::update(Play& play, Vec2 screenSize) { ImGuiIO& io = ImGui::GetIO(); Cursor& cursor = play.character->cursor; - if (!ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) + if (!play.isBlocked && !ImGui::IsWindowHovered(ImGuiHoveredFlags_AnyWindow) && !ImGui::IsAnyItemActive()) { if (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || (ImGui::IsMouseDown(ImGuiMouseButton_Left) && ImGui::IsKeyDown(ImGuiMod_Ctrl))) diff --git a/src/play.h b/src/play.h index 51e2ade..6b3d81a 100644 --- a/src/play.h +++ b/src/play.h @@ -22,15 +22,6 @@ enum CursorState CURSOR_ACTION }; -// Which side panels are open, which shifts where the camera centers the character. -enum Focus -{ - FOCUS_CENTER, - FOCUS_MENU, - FOCUS_TOOLS, - FOCUS_BOTH -}; - enum Hold { HOLD_RELEASE, @@ -191,14 +182,14 @@ public: bool isFocusPending = false; void update(Play& play, Vec2 screenSize); - void focus(Rect target, Vec2 screenSize, Focus panels); + void focus(Rect target, Vec2 screenSize, float inset); void shake_add(float magnitude, int time); Vec2 world_get(Vec2 screen); Affine view_get(Vec2 screenSize); }; // A character being played, sequencing its parts each tick: the body, items, interactions, autofeed, camera and -// dialogue. +// dialogue. Inset is how much of the screen's bottom the UI covers, and blocked means a window has the mouse. class Play { public: @@ -218,15 +209,16 @@ public: std::vector toasts; Rect bounds; float autosaveTime = 0; + float inset = 0; float cheatTime = 0; int cheatIndex = 0; int cursorType = -1; CursorState cursorState = CURSOR_DEFAULT; + bool isBlocked = false; bool isCheats = false; - bool isMenuOpen = true; bool isPostgame = false; bool isPostgamePending = false; - bool isToolsOpen = false; + bool isTaskbarOpen = true; void set(Character& played, Game game, Renderer& renderer, Audio& sound); void save(); @@ -236,7 +228,6 @@ public: void toast_add(const std::string& message); void particles_spawn(const std::vector& names, Vec2 position); Actor& pointer_actor_get(); - Focus focus_get(); template void visit(Visitor&& visitor) { diff --git a/src/ui.cpp b/src/ui.cpp index 4a24ddc..3b0487f 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -24,10 +24,27 @@ constexpr float INDICATOR_BOB = 0.12f; constexpr float INDICATOR_SIZE = 0.35f; constexpr float INDICATOR_SPEED = 2; constexpr float ITEM_SIZE = 96; +constexpr float BAR_WIDTH_MIN = 120; +constexpr float BUTTON_SIZE = 64; +constexpr float CATALOG_DIVIDER = 2; +constexpr float CATALOG_GRID = 0.55f; +constexpr float COOLDOWN_ALPHA = 0.6f; +constexpr float COOLDOWN_RADIUS = 0.42f; +constexpr float ICON_SIZE = 48; +constexpr float TOGGLE_FADE_FAR = 220; +constexpr float TOGGLE_FADE_NEAR = 60; +constexpr float TOGGLE_HEIGHT = 28; +constexpr float WINDOW_FIT = 0.9f; +constexpr float WINDOW_HEIGHT = 680; +constexpr float WINDOW_WIDTH = 1000; +constexpr int BUTTONS_FIXED = 9; +constexpr float INFO_GAP = 4; +constexpr int COOLDOWN_SEGMENTS = 48; +constexpr const char* PANEL_ICONS[] = {"", "stats", "catalog", "settings"}; +constexpr const char* PANEL_NAMES[] = {"", "Stats", "Catalog", "Settings"}; constexpr float KG_TO_G = 1000; constexpr float KG_TO_LB = 2.20462f; constexpr float LB_TO_OZ = 16; -constexpr float MENU_WIDTH = 0.3f; constexpr float OSCILLATION_AMPLITUDE = 2; constexpr float OSCILLATION_FREQUENCY = 1; constexpr float OSCILLATION_SPREAD = 0.16f; @@ -37,11 +54,9 @@ constexpr float SHAKE_FREQUENCY = 30; constexpr float SLIDE_EASE = 2; constexpr float SLIDE_TIME = 0.125f; constexpr float TEXT_BIG = 1.25f; -constexpr float TEXT_FIT_MIN = 1; -constexpr float TEXT_HEIGHT_LINES = 6; +constexpr float TEXT_HEIGHT_LINES = 5; constexpr float TEXT_SMALL = 0.75f; -constexpr float TEXT_WIDTH = 0.3f; -constexpr float TOOLS_WIDTH = 0.05f; +constexpr float TEXT_WIDTH = 0.4f; constexpr float TOOLTIP_WRAP = 24; constexpr float TRIANGLE_SIZE = 0.22f; constexpr float UNDISCOVERED_ALPHA = 0.5f; @@ -94,8 +109,9 @@ static Palette palette_get(Color color) void style_set(Color color, float rounding) { ImGuiStyle& style = ImGui::GetStyle(); - style.WindowRounding = style.FrameRounding = style.GrabRounding = rounding; + style.WindowRounding = style.FrameRounding = style.GrabRounding = style.PopupRounding = rounding; style.ScrollbarSize = SCROLLBAR_SIZE; + style.FontSizeBase = FONT_NORMAL; for (auto [id, value] : palette_get(color)) style.Colors[id] = value; style.Colors[ImGuiCol_PopupBg] = style.Colors[ImGuiCol_WindowBg]; @@ -138,11 +154,11 @@ static void tooltip_set(const char* text) static void triangle_draw(ImVec2 position, ImVec2 size, Direction direction) { ImVec2 center = {position.x + size.x / 2, position.y + size.y / 2}; - float half = size.x * TRIANGLE_SIZE; - float sign = direction == DIRECTION_LEFT ? -1 : 1; - ImGui::GetWindowDrawList()->AddTriangleFilled({center.x + half * sign, center.y}, - {center.x - half * sign, center.y - half}, - {center.x - half * sign, center.y + half}, + float half = size.y * TRIANGLE_SIZE; + float sign = direction == DIRECTION_UP ? -1 : 1; + ImGui::GetWindowDrawList()->AddTriangleFilled({center.x, center.y + half * sign}, + {center.x - half * 2, center.y - half * sign}, + {center.x + half * 2, center.y - half * sign}, ImGui::GetColorU32(ImGuiCol_Text)); } @@ -181,21 +197,6 @@ static std::string playtime_format(float seconds) total % SECONDS_PER_MINUTE); } -// Draws text into bounds, shrinking its font until it fits. -static void text_fit_draw(const std::string& text, ImVec2 bounds, float size) -{ - ImFont* font = ImGui::GetFont(); - ImVec2 measured = font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()); - if (measured.x > 0 && measured.y > 0) - size = std::clamp(size * std::min({1.0f, bounds.x / measured.x, bounds.y / measured.y}), TEXT_FIT_MIN, size); - measured = font->CalcTextSizeA(size, FLT_MAX, 0, text.c_str()); - ImVec2 position = ImGui::GetCursorScreenPos(); - ImGui::Dummy(bounds); - ImVec4 clip = {position.x, position.y, position.x + bounds.x, position.y + bounds.y}; - ImGui::GetWindowDrawList()->AddText(font, size, {position.x, position.y + std::max(0.0f, (bounds.y - measured.y) / 2)}, - ImGui::GetColorU32(ImGuiCol_Text), text.c_str(), nullptr, 0, &clip); -} - // Renders an actor onto a canvas, scaled to fit within size, and gives the size it takes up. static ImVec2 canvas_draw(Renderer& renderer, Canvas& canvas, Actor& actor, ImVec2 size) { @@ -387,34 +388,236 @@ void Ui::command_add(CommandType type, const std::string& key) commands->push_back({key, type}); } -// A side panel's bar, which opens and closes it. Hovering the bar opens it too. -void Ui::bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction, - const char* openTooltip, const char* closeTooltip) +// An icon from the character's icon sheet (see Character::icons), fit into a square and tinted. +void Ui::icon_draw(const char* name, ImVec2 position, float size, ImU32 tint) { - ImGui::SetNextWindowSize(size); - ImGui::SetNextWindowPos(position); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2()); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2()); - ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, size); - if (ImGui::Begin(direction == DIRECTION_LEFT ? "##Menu Bar" : "##Tools Bar", nullptr, BAR_FLAGS)) + Anm2* icons = character ? character->icons : nullptr; + const Anm2Region* region = icons ? icons->region_get(0, name) : nullptr; + Texture* sheet = region && !icons->sheets.empty() ? icons->sheets[0] : nullptr; + if (!sheet) + return; + float scale = size / std::max(region->size.x, region->size.y); + ImVec2 extent = {region->size.x * scale, region->size.y * scale}; + ImVec2 min = {position.x + (size - extent.x) / 2, position.y + (size - extent.y) / 2}; + ImGui::GetWindowDrawList()->AddImage( + texture_id_get(*sheet), min, {min.x + extent.x, min.y + extent.y}, + {region->crop.x / sheet->width, region->crop.y / sheet->height}, + {(region->crop.x + region->size.x) / sheet->width, (region->crop.y + region->size.y) / sheet->height}, tint); +} + +// A square button showing an icon. Active ones stay lit; disabled ones are dimmed and ignore clicks. +bool Ui::icon_button(const char* icon, const char* tooltip, ButtonState state) +{ + ImGui::PushID(icon); + if (state == BUTTON_ACTIVE) + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); + ImGui::BeginDisabled(state == BUTTON_DISABLED); + bool isPressed = is_fx(ImGui::Button("##Icon", {BUTTON_SIZE, BUTTON_SIZE})); + ImVec2 min = ImGui::GetItemRectMin(); + float inset = (BUTTON_SIZE - ICON_SIZE) / 2; + icon_draw(icon, {min.x + inset, min.y + inset}, ICON_SIZE, ImGui::GetColorU32(ImGuiCol_Text)); + ImGui::EndDisabled(); + if (state == BUTTON_ACTIVE) + ImGui::PopStyleColor(); + ImGui::SetItemTooltip("%s", tooltip); + ImGui::PopID(); + return isPressed; +} + +// A clockwise wedge over the last item, filled as far as progress (0 to 1) has come. +static void cooldown_draw(float progress) +{ + ImVec2 min = ImGui::GetItemRectMin(); + ImVec2 max = ImGui::GetItemRectMax(); + ImVec2 center = {(min.x + max.x) / 2, (min.y + max.y) / 2}; + float radius = (max.x - min.x) * COOLDOWN_RADIUS; + ImU32 color = ImGui::GetColorU32(ImGuiCol_PlotHistogram, COOLDOWN_ALPHA); + int segments = (int)std::ceil(COOLDOWN_SEGMENTS * progress); + for (int i = 0; i < segments; i++) { - ImVec2 buttonSize = ImGui::GetContentRegionAvail(); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - bool isPressed = is_fx(ImGui::Button("##Toggle", buttonSize)); - if (slide <= 0 || slide >= 1) - { - tooltip_set(isOpen ? closeTooltip : openTooltip); - if (isPressed || (!isOpen && slide <= 0 && ImGui::IsItemHovered())) - { - isOpen = !isOpen; - play.audio->play(play.character->sounds[isOpen ? SOUND_OPEN : SOUND_CLOSE]); - } - } - triangle_draw(cursor, buttonSize, (direction == DIRECTION_LEFT) != isOpen ? DIRECTION_LEFT : DIRECTION_RIGHT); + float from = -PI / 2 + 2 * PI * progress * i / segments; + float to = -PI / 2 + 2 * PI * progress * (i + 1) / segments; + ImGui::GetWindowDrawList()->AddTriangleFilled( + center, {center.x + std::cos(from) * radius, center.y + std::sin(from) * radius}, + {center.x + std::cos(to) * radius, center.y + std::sin(to) * radius}, color); } +} + +void Ui::window_open(Play& play, Panel next) +{ + panel = next; + play.audio->play(play.character->sounds[SOUND_OPEN]); +} + +// The bar along the bottom: the character's name and body at a glance, then a button for everything. A thin bar above +// it, showing as the mouse nears, slides it away and back. +void Ui::taskbar_draw(Play& play, ImVec2 screen) +{ + Character& data = *play.character; + Body& body = play.body; + Stats& stats = body.stats; + Items& items = play.items; + Dialogue& dialogue = data.dialogue; + ImGuiStyle& style = ImGui::GetStyle(); + std::string moodPool = body.stage <= 0 + ? data.dialoguePool + : data.stages[std::min(body.stage, (int)data.stages.size()) - 1].dialoguePool; + + const char* labels[] = {"Weight", "Capacity", "Digestion"}; + bool isMax = body.stage >= (int)data.stages.size(); + float digestion = stats.isDigesting ? (float)stats.digestionTimer / std::max(1, data.traits.digestionTime) + : stats.digestionProgress / DIGESTION_MAX; + std::string weight = weight_format(stats.weight, *measurement); + std::string next = weight_format(body.stage_threshold_get(body.stage + 1), *measurement); + std::string unit = next.substr(next.find(' ')); + if (weight.ends_with(unit)) + weight.resize(weight.size() - unit.size()); + std::string bars[] = {isMax ? std::string("MAX") : weight + " / " + next, + text_format("%.0f / %.0f kcal", stats.calories, stats.capacity), + stats.isDigesting ? std::string("Digesting...") : text_format("%.0f%%", digestion * PERCENT)}; + float fills[] = {body.stage_progress_get(), stats.calories / std::max(1.0f, body.capacity_max_get()), digestion}; + float overstuffed = std::clamp((stats.calories - stats.capacity) / (body.capacity_max_get() - stats.capacity), 0.0f, 1.0f); + + float widths[std::size(labels)]; + float used = 0; + for (int i = 0; i < (int)std::size(labels); i++) + { + widths[i] = std::max(BAR_WIDTH_MIN, ImGui::CalcTextSize(bars[i].c_str()).x + style.FramePadding.x * 4); + used += ImGui::CalcTextSize(labels[i]).x + style.ItemSpacing.x + widths[i]; + } + int count = BUTTONS_FIXED + (int)data.interactTypes.size(); + float nameWidth = ImGui::CalcTextSize(data.name.c_str()).x; + float rowWidth = count * BUTTON_SIZE + (count - 1) * style.ItemSpacing.x; + float infoWidth = nameWidth + used + style.ItemSpacing.x * INFO_GAP * std::size(labels); + float width = std::max(rowWidth, infoWidth) + style.WindowPadding.x * 2; + float height = style.WindowPadding.y * 2 + ImGui::GetFrameHeight() + style.ItemSpacing.y * 2 + BUTTON_SIZE; + slide_update(taskbarSlide, play.isTaskbarOpen && !play.text.isScene); + float shown = std::pow(taskbarSlide, SLIDE_EASE) * height; + float x = (screen.x - width) / 2; + float y = screen.y - shown; + play.inset = shown; + + float mouseY = ImGui::GetIO().MousePos.y; + float distance = std::abs(mouseY - (y - TOGGLE_HEIGHT / 2)); + float alpha = std::clamp((TOGGLE_FADE_FAR - distance) / (TOGGLE_FADE_FAR - TOGGLE_FADE_NEAR), 0.0f, 1.0f); + if (!play.text.isScene && alpha > 0) + { + ImGui::SetNextWindowPos({x, y - TOGGLE_HEIGHT}); + ImGui::SetNextWindowSize({width, TOGGLE_HEIGHT}); + ImGui::SetNextWindowBgAlpha(0); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2()); + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2()); + if (ImGui::Begin("##Taskbar Toggle", nullptr, BAR_FLAGS)) + { + ImVec2 position = ImGui::GetCursorScreenPos(); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg)); + bool isPressed = is_fx(ImGui::Button("##Toggle", {width, TOGGLE_HEIGHT})); + ImGui::PopStyleColor(); + if (isPressed) + { + play.isTaskbarOpen = !play.isTaskbarOpen; + play.audio->play(data.sounds[play.isTaskbarOpen ? SOUND_OPEN : SOUND_CLOSE]); + } + triangle_draw({position.x + width / 2 - TOGGLE_HEIGHT / 2, position.y}, {TOGGLE_HEIGHT, TOGGLE_HEIGHT}, + play.isTaskbarOpen ? DIRECTION_DOWN : DIRECTION_UP); + } + ImGui::End(); + ImGui::PopStyleVar(3); + } + if (taskbarSlide <= 0) + return; + + ImGui::SetNextWindowPos({x, y}); + ImGui::SetNextWindowSize({width, height}); + if (!ImGui::Begin("##Taskbar", nullptr, BAR_FLAGS)) + return ImGui::End(); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(data.name.c_str()); + float gap = std::max(style.ItemSpacing.x * INFO_GAP, + (ImGui::GetContentRegionAvail().x - nameWidth - used) / std::size(labels)); + for (int i = 0; i < (int)std::size(labels); i++) + { + ImGui::SameLine(0, gap / 2); + if (i > 0) + { + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddLine(cursor, {cursor.x, cursor.y + ImGui::GetFrameHeight()}, + ImGui::GetColorU32(ImGuiCol_Separator)); + } + ImGui::SameLine(0, gap / 2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(labels[i]); + ImGui::SameLine(); + if (i == 1 && overstuffed > 0) + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(1, 1 - overstuffed, 1 - overstuffed, 1)); + ImGui::ProgressBar(fills[i], {widths[i], 0}, bars[i].c_str()); + if (i == 1 && overstuffed > 0) + ImGui::PopStyleColor(); + if (i == 0 && ImGui::BeginItemTooltip()) + { + ImGui::Text("Stage: %i/%i (%0.1f%%)", body.stage + 1, (int)data.stages.size() + 1, fills[0] * PERCENT); + ImGui::EndTooltip(); + } + if (i == 2 && ImGui::BeginItemTooltip()) + { + ImGui::TextUnformatted(stats.isDigesting ? "Digestion in progress..." + : digestion <= 0 ? "Give food to start digesting!" + : "Digestion"); + ImGui::TextColored(imvec4_get(GRAY), "Rate: %0.2f%% / sec", stats.digestionRate * TICK_RATE); + ImGui::EndTooltip(); + } + } + ImGui::Separator(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (width - style.WindowPadding.x * 2 - rowWidth) / 2); + + auto state_get = [](bool isEnabled, bool isActive = false) { + return !isEnabled ? BUTTON_DISABLED : isActive ? BUTTON_ACTIVE : BUTTON_NORMAL; + }; + if (icon_button("talk", "Talk", state_get(dialogue.is_hook(HOOK_RANDOM)))) + play.text.set(play, dialogue.hook_get(HOOK_RANDOM)); + ImGui::SameLine(); + if (icon_button("mood", "Mood", state_get(dialogue.pools.contains(moodPool)))) + play.text.set(play, dialogue.pool_get(moodPool)); + ImGui::SameLine(); + std::string spawnTooltip = + items.spawnDelay > 0 ? text_format("Next item in %0.2fs", items.spawnDelay * TICK) : std::string("Spawn Item"); + if (icon_button("spawn", spawnTooltip.c_str(), state_get(items.spawnDelay <= 0 && items.is_spawn_possible(play)))) + items.spawn_queue_add(play); + if (items.spawnDelay > 0) + cooldown_draw(1 - std::clamp((float)items.spawnDelay / items.spawn_delay_get(play), 0.0f, 1.0f)); + ImGui::SameLine(); + Autofeed& autofeed = play.autofeed; + if (icon_button("autofeed", + autofeed.isEnabled ? "Return to manual interaction." + : "Serve the character food and assist in digestion automatically.", + state_get(autofeed.is_available(play), autofeed.isEnabled))) + autofeed.isEnabled = !autofeed.isEnabled; + ImGui::SameLine(); + if (icon_button("home", "Reset camera view.\n(Shortcut: Home)", BUTTON_NORMAL)) + play.focus({screen.x, screen.y}); + for (int i = 0; i < (int)data.interactTypes.size(); i++) + { + InteractArea& type = data.interactTypes[i]; + ImGui::SameLine(); + ImGui::PushID(i); + const char* tooltip = autofeed.isEnabled ? "Disabled while Autofeed is active!" : type.name.c_str(); + if (icon_button(type.icon.c_str(), tooltip, state_get(!autofeed.isEnabled, play.cursorType == i))) + play.cursorType = i; + ImGui::PopID(); + } + Panel panels[] = {PANEL_STATS, PANEL_CATALOG, PANEL_SETTINGS}; + for (Panel each : panels) + { + ImGui::SameLine(); + if (icon_button(PANEL_ICONS[each], PANEL_NAMES[each], BUTTON_NORMAL)) + window_open(play, each); + } + ImGui::SameLine(); + if (icon_button("help", "Help", state_get(dialogue.is_hook(HOOK_HELP)))) + play.text.set(play, dialogue.hook_get(HOOK_HELP)); ImGui::End(); - ImGui::PopStyleVar(4); } void Ui::text_draw(Play& play, ImVec2 screen) @@ -426,14 +629,15 @@ void Ui::text_draw(Play& play, ImVec2 screen) if (!entry || (line->text.empty() && !isChoosing)) return; ImGuiStyle& style = ImGui::GetStyle(); - ImVec2 size = {screen.x * TEXT_WIDTH - style.WindowPadding.x * 2, - ImGui::GetTextLineHeightWithSpacing() * TEXT_HEIGHT_LINES}; + ImGui::PushFont(nullptr, FONT_DIALOGUE); + ImVec2 size = {screen.x * TEXT_WIDTH, ImGui::GetTextLineHeightWithSpacing() * TEXT_HEIGHT_LINES}; ImGui::SetNextWindowSize(size); - ImGui::SetNextWindowPos({screen.x / 2 - size.x / 2, screen.y - size.y - style.WindowPadding.y}); + ImGui::SetNextWindowPos( + {screen.x / 2 - size.x / 2, screen.y - play.inset - TOGGLE_HEIGHT - size.y - style.WindowPadding.y}); if (ImGui::Begin("##Text", nullptr, PANEL_FLAGS)) { bool isHovered = ImGui::IsWindowHovered(); - bool isSpace = ImGui::IsKeyReleased(ImGuiKey_Space); + bool isSpace = !play.isBlocked && ImGui::IsKeyReleased(ImGuiKey_Space); bool isAdvance = (isHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) || isSpace; ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, {style.ItemSpacing.x, 0}); if (ImGui::BeginTabBar("##Name")) @@ -442,7 +646,6 @@ void Ui::text_draw(Play& play, ImVec2 screen) ImGui::EndTabItem(); ImGui::EndTabBar(); } - ImGui::PushFont(nullptr, FONT_NORMAL); letters_draw(*line, text.index, (float)ImGui::GetTime()); ImVec2 position = ImGui::GetWindowPos(); @@ -490,10 +693,10 @@ void Ui::text_draw(Play& play, ImVec2 screen) if (isAdvance) text.advance(play); } - ImGui::PopFont(); ImGui::PopStyleVar(); } ImGui::End(); + ImGui::PopFont(); } void Ui::toasts_draw(Play& play) @@ -502,6 +705,7 @@ void Ui::toasts_draw(Play& play) ImGuiViewport* viewport = ImGui::GetMainViewport(); ImDrawList* drawList = ImGui::GetForegroundDrawList(); float height = ImGui::GetTextLineHeightWithSpacing() + style.WindowPadding.y * 2; + float bottom = viewport->WorkPos.y + viewport->WorkSize.y - play.inset - TOGGLE_HEIGHT - style.WindowPadding.y; for (int i = 0; i < (int)play.toasts.size(); i++) { Toast& toast = play.toasts[i]; @@ -510,9 +714,7 @@ void Ui::toasts_draw(Play& play) color.w = (float)toast.life / TOAST_LIFE; return ImGui::GetColorU32(color); }; - ImVec2 position = {viewport->WorkPos.x + style.WindowPadding.x, - viewport->WorkPos.y + viewport->WorkSize.y - style.WindowPadding.y - - height * (play.toasts.size() - i)}; + ImVec2 position = {viewport->WorkPos.x + style.WindowPadding.x, bottom - height * (play.toasts.size() - i)}; ImVec2 end = {position.x + ImGui::CalcTextSize(toast.message.c_str()).x + style.WindowPadding.x * 2, position.y + height}; drawList->AddRectFilled(position, end, color_get(ImGuiCol_WindowBg), style.WindowRounding); @@ -579,90 +781,36 @@ void Ui::settings_draw(Settings& settings) ImGui::SetItemTooltip("Go back to the character selection screen.\nProgress will be saved."); } -void Ui::interact_tab_draw(Play& play, Settings& settings) +// The body in full (weight and stage, calories and digestion, totals), and the cheats when unlocked. +void Ui::stats_draw(Play& play) { Character& data = *play.character; Body& body = play.body; Stats& stats = body.stats; - Dialogue& dialogue = data.dialogue; - ImGuiStyle& style = ImGui::GetStyle(); - std::string feelingPool = body.stage <= 0 - ? data.dialoguePool - : data.stages[std::min(body.stage, (int)data.stages.size()) - 1].dialoguePool; - bool isRandom = dialogue.is_hook(HOOK_RANDOM); - bool isHelp = dialogue.is_hook(HOOK_HELP); - bool isFeeling = dialogue.pools.contains(feelingPool); + Traits& traits = data.traits; + bool isMax = body.stage >= (int)data.stages.size(); - ImGui::PushFont(nullptr, FONT_HUGE); - ImGui::SeparatorText(data.name.c_str()); + ImGui::PushFont(nullptr, FONT_HEADER_2); + ImGui::SeparatorText("Weight"); ImGui::PopFont(); + ImGui::Text("%s (Stage %i/%i)", weight_format(stats.weight, *measurement).c_str(), body.stage + 1, + (int)data.stages.size() + 1); + ImGui::ProgressBar(body.stage_progress_get(), {-FLT_MIN, 0}, isMax ? "MAX" : "To Next Stage"); + ImGui::PushStyleColor(ImGuiCol_Text, imvec4_get(GRAY)); + if (isMax) + ImGui::TextUnformatted("Maxed out!"); + else + ImGui::Text("Start: %s Next: %s", weight_format(body.stage_threshold_get(body.stage), *measurement).c_str(), + weight_format(body.stage_threshold_get(body.stage + 1), *measurement).c_str()); + ImGui::PopStyleColor(); - ImVec2 childSize = {(ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2, - FONT_HEADER_2 + ImGui::GetFrameHeightWithSpacing()}; - if (ImGui::BeginChild("##Weight", childSize)) - { - std::string weight = weight_format(stats.weight, settings.measurement); - text_fit_draw(weight, {ImGui::GetContentRegionAvail().x, FONT_HEADER_2}, FONT_HEADER_2); - ImGui::SetItemTooltip("%s", weight.c_str()); - bool isMax = body.stage >= (int)data.stages.size(); - ImGui::ProgressBar(body.stage_progress_get(), {ImGui::GetContentRegionAvail().x, 0}, - isMax ? "MAX" : "To Next Stage"); - if (ImGui::BeginItemTooltip()) - { - ImGui::Text("Stage: %i/%i (%0.1f%%)", body.stage + 1, (int)data.stages.size() + 1, - body.stage_progress_get() * PERCENT); - ImGui::Separator(); - ImGui::PushStyleColor(ImGuiCol_Text, imvec4_get(GRAY)); - if (isMax) - ImGui::TextUnformatted("Maxed out!"); - else - { - ImGui::Text("Start: %s", weight_format(body.stage_threshold_get(body.stage), settings.measurement).c_str()); - ImGui::Text("Current: %s", weight.c_str()); - ImGui::Text("Next: %s", - weight_format(body.stage_threshold_get(body.stage + 1), settings.measurement).c_str()); - } - ImGui::PopStyleColor(); - ImGui::EndTooltip(); - } - } - ImGui::EndChild(); - ImGui::SameLine(); - if (ImGui::BeginChild("##Calories", childSize)) - { - float capacityMax = body.capacity_max_get(); - float overstuffed = std::max(0.0f, (stats.calories - stats.capacity) / (capacityMax - stats.capacity)); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1 - overstuffed, 1 - overstuffed, 1)); - text_fit_draw(text_format("%.0f / %.0f kcal", stats.calories, stats.capacity), - {ImGui::GetContentRegionAvail().x, FONT_HEADER_2}, FONT_HEADER_2); - ImGui::PopStyleColor(); - if (ImGui::BeginItemTooltip()) - { - ImGui::Text("%.0f / %.0f ", stats.calories, stats.capacity); - ImGui::SameLine(0, 0); - ImGui::TextColored(imvec4_get(GRAY), "(%.0f)", capacityMax); - ImGui::SameLine(0, 0); - ImGui::TextUnformatted(" kcal"); - ImGui::EndTooltip(); - } - float digestion = stats.isDigesting ? (float)stats.digestionTimer / std::max(1, data.traits.digestionTime) - : stats.digestionProgress / DIGESTION_MAX; - ImGui::ProgressBar(digestion, {ImGui::GetContentRegionAvail().x, 0}, - stats.isDigesting ? "Digesting..." : "Digestion"); - if (ImGui::BeginItemTooltip()) - { - if (stats.isDigesting) - ImGui::TextUnformatted("Digestion in progress..."); - else if (digestion <= 0) - ImGui::TextUnformatted("Give food to start digesting!"); - else - ImGui::Text("%0.2f%%", digestion * PERCENT); - ImGui::Separator(); - ImGui::TextColored(imvec4_get(GRAY), "Rate: %0.2f%% / sec", stats.digestionRate * TICK_RATE); - ImGui::EndTooltip(); - } - } - ImGui::EndChild(); + ImGui::PushFont(nullptr, FONT_HEADER_2); + ImGui::SeparatorText("Digestion"); + ImGui::PopFont(); + ImGui::Text("%.0f / %.0f kcal", stats.calories, stats.capacity); + ImGui::SameLine(0, 0); + ImGui::TextColored(imvec4_get(GRAY), " (up to %.0f)", body.capacity_max_get()); + ImGui::Text("Rate: %0.2f%% / sec", stats.digestionRate * TICK_RATE); ImGui::PushFont(nullptr, FONT_HEADER_2); ImGui::SeparatorText("Stats"); @@ -671,67 +819,36 @@ void Ui::interact_tab_draw(Play& play, Settings& settings) ImGui::Text("Total Items Spawned: %i", stats.totalSpawned); ImGui::Text("Total Items Consumed: %i", stats.totalConsumed); ImGui::Text("Total Playtime: %s", playtime_format(stats.playtime).c_str()); + if (!play.isCheats) + return; - auto frame_height_get = [](float size) { - ImGui::PushFont(nullptr, size); - float height = ImGui::GetFrameHeightWithSpacing(); - ImGui::PopFont(); - return height; - }; - float buttonsHeight = frame_height_get(FONT_HUGE) + frame_height_get(FONT_HEADER_2); - buttonsHeight += isRandom || isHelp || isFeeling ? style.ItemSpacing.y + 1 : 0; - buttonsHeight += isRandom ? frame_height_get(FONT_HEADER_1) : 0; - buttonsHeight += (isHelp + isFeeling) * frame_height_get(FONT_HEADER_2); - if (float filler = ImGui::GetContentRegionAvail().y - buttonsHeight; filler > 0) - ImGui::Dummy({ImGui::GetContentRegionAvail().x, filler}); - - float width = ImGui::GetContentRegionAvail().x; - Items& items = play.items; - ImGui::PushFont(nullptr, FONT_HUGE); - if (items.spawnDelay <= 0) - { - ImGui::BeginDisabled(!items.is_spawn_possible(play)); - if (is_fx(ImGui::Button("Spawn Item", {width, 0}))) - items.spawn_queue_add(play); - ImGui::EndDisabled(); - } - else - { - ImGui::BeginDisabled(); - ImGui::ProgressBar(1 - std::clamp((float)items.spawnDelay / items.spawn_delay_get(play), 0.0f, 1.0f), - {width, ImGui::GetFrameHeight()}, "Spawn Item"); - ImGui::EndDisabled(); - tooltip_set(text_format("Next item in %0.2fs", items.spawnDelay * TICK).c_str()); - } - ImGui::PopFont(); - - Autofeed& autofeed = play.autofeed; ImGui::PushFont(nullptr, FONT_HEADER_2); - ImGui::BeginDisabled(!autofeed.is_available(play)); - if (is_fx(ImGui::Button(autofeed.isEnabled ? "Disable Autofeed" : "Enable Autofeed", {width, 0}))) - autofeed.isEnabled = !autofeed.isEnabled; - ImGui::EndDisabled(); - tooltip_set(autofeed.isEnabled ? "Return to manual interaction." - : "Serve the character food and assist in digestion automatically."); + ImGui::SeparatorText("Cheats"); ImGui::PopFont(); - - if (isRandom || isHelp || isFeeling) - ImGui::Separator(); - auto dialogue_button = [&](bool isShown, float size, const char* label, Entry* entry) { - if (!isShown) - return; - ImGui::PushFont(nullptr, size); - if (is_fx(ImGui::Button(label, {width, 0}))) - play.text.set(play, entry); - ImGui::PopFont(); - }; - dialogue_button(isRandom, FONT_HEADER_1, "Let's chat!", dialogue.hook_get(HOOK_RANDOM)); - dialogue_button(isHelp, FONT_HEADER_2, "Help", dialogue.hook_get(HOOK_HELP)); - dialogue_button(isFeeling, FONT_HEADER_2, "How are you feeling?", dialogue.pool_get(feelingPool)); + is_fx(ImGui::SliderFloat("Calories", &stats.calories, 0, body.capacity_max_get(), "%0.0f kcal")); + is_fx(ImGui::SliderFloat("Capacity", &stats.capacity, traits.capacityMin, traits.capacityMax, "%0.0f kcal")); + if (is_fx(ImGui::SliderFloat("Weight", &stats.weight, traits.weight, traits.weightMax, "%0.2f kg"))) + { + body.stage_set(body.stage_get(stats.weight)); + body.idle_play(); + } + int stage = body.stage + 1; + if (is_fx(ImGui::SliderInt("Stage", &stage, 1, (int)data.stages.size() + 1))) + { + body.stage_set(stage - 1); + stats.weight = body.stage_threshold_get(body.stage); + body.idle_play(); + } + float rate = stats.digestionRate * TICK_RATE; + if (is_fx(ImGui::SliderFloat("Digestion Rate", &rate, traits.digestionRateMin * TICK_RATE, + traits.digestionRateMax * TICK_RATE, "%0.2f%% / sec"))) + stats.digestionRate = rate / TICK_RATE; + if (is_fx(ImGui::Button("Digest"))) + stats.digestionProgress = DIGESTION_MAX; } -// The items as a grid of buttons, with the selected one's details below. Icons are drawn once. -void Ui::catalog_tab_draw(Play& play) +// The items as a grid of buttons, beside the selected one's details. Icons are drawn once per character. +void Ui::catalog_draw(Play& play) { Character& data = *play.character; std::vector& items = data.catalog.items; @@ -747,19 +864,19 @@ void Ui::catalog_tab_draw(Play& play) itemSizes[i] = canvas_draw(*renderer, itemCanvases[i], actor, {ITEM_SIZE, ITEM_SIZE}); } } - bool isSelected = selectedItem >= 0 && selectedItem < (int)items.size(); - bool isDiscovered = isSelected && play.items.discovered[selectedItem]; + ImGuiStyle& style = ImGui::GetStyle(); ImVec2 available = ImGui::GetContentRegionAvail(); - float infoHeight = isSelected || items.empty() ? available.y / 2 : 0; - - if (ImGui::BeginChild("##Items", {available.x, available.y - infoHeight - ImGui::GetStyle().ItemSpacing.y})) + float cell = ITEM_SIZE + style.FramePadding.x * 2; + int columns = std::max(1, (int)((available.x * CATALOG_GRID + style.ItemSpacing.x) / (cell + style.ItemSpacing.x))); + float gridWidth = columns * (cell + style.ItemSpacing.x) - style.ItemSpacing.x + style.ScrollbarSize + + style.WindowPadding.x; + if (ImGui::BeginChild("##Items", {gridWidth, available.y})) { - bool isAnyHovered = false; - float right = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x; for (int i = 0; i < (int)items.size(); i++) { ImGui::PushID(i); - bool isColored = play.items.discovered[i] && items[i].color; + bool isFound = play.items.discovered[i]; + bool isColored = isFound && items[i].color; if (isColored) style_push(*items[i].color); bool isCurrent = i == selectedItem; @@ -767,210 +884,111 @@ void Ui::catalog_tab_draw(Play& play) for (ImGuiCol id : {ImGuiCol_Button, ImGuiCol_ButtonHovered, ImGuiCol_ButtonActive}) ImGui::PushStyleColor(id, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); ImVec2 image = itemSizes[i]; - ImVec4 tint = play.items.discovered[i] ? ImVec4(1, 1, 1, 1) : ImVec4(0, 0, 0, UNDISCOVERED_ALPHA); - ImVec2 padding = ImGui::GetStyle().FramePadding; + ImVec2 padding = style.FramePadding; ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, {padding.x + (ITEM_SIZE - image.x) / 2, padding.y + (ITEM_SIZE - image.y) / 2}); + ImVec4 tint = isFound ? ImVec4(1, 1, 1, 1) : ImVec4(0, 0, 0, UNDISCOVERED_ALPHA); if (is_fx(ImGui::ImageButton("##Item", texture_id_get(itemCanvases[i].texture), image, {0, 0}, {1, 1}, {}, tint))) selectedItem = i; ImGui::PopStyleVar(); if (isCurrent) ImGui::PopStyleColor(3); - isAnyHovered |= ImGui::IsItemHovered(); - if (!isCurrent && ImGui::BeginItemTooltip()) - { - item_details_draw(data, play.items.discovered[i] ? &items[i] : nullptr); - ImGui::EndTooltip(); - } if (isColored) style_pop(); - float next = ImGui::GetItemRectMax().x + ImGui::GetStyle().ItemSpacing.x + ITEM_SIZE; - if (i + 1 < (int)items.size() && next < right) + if ((i + 1) % columns != 0 && i + 1 < (int)items.size()) ImGui::SameLine(); ImGui::PopID(); } - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !isAnyHovered) - selectedItem = -1; } ImGui::EndChild(); + ImGui::SameLine(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddLine(cursor, {cursor.x, cursor.y + available.y}, + ImGui::GetColorU32(ImGuiCol_Separator), CATALOG_DIVIDER); + ImGui::Dummy({CATALOG_DIVIDER, available.y}); + ImGui::SameLine(); - if (infoHeight <= 0) - return; - bool isColored = isDiscovered && items[selectedItem].color; + bool isSelected = selectedItem >= 0 && selectedItem < (int)items.size(); + bool isFound = isSelected && play.items.discovered[selectedItem]; + bool isColored = isFound && items[selectedItem].color; if (isColored) style_push(*items[selectedItem].color); - if (ImGui::BeginChild("##Info", {available.x, infoHeight})) + if (ImGui::BeginChild("##Details", {0, available.y})) { - ImGui::Separator(); - ImGui::PushFont(nullptr, FONT_HEADER_2); - ImGui::TextWrapped("%s", !isSelected ? "No items." : isDiscovered ? items[selectedItem].name.c_str() : UNKNOWN); - ImGui::PopFont(); - if (isSelected) - ImGui::Separator(); - if (isDiscovered) - { - float half = (ImGui::GetContentRegionAvail().y - ImGui::GetStyle().ItemSpacing.y) / 2; - if (ImGui::BeginChild("##Stats", {0, half})) - item_summary_draw(data, items[selectedItem]); - ImGui::EndChild(); - ImGui::Separator(); - if (ImGui::BeginChild("##Description", {0, half})) - ImGui::TextWrapped("%s", items[selectedItem].description.c_str()); - ImGui::EndChild(); - } + if (items.empty()) + ImGui::TextWrapped("No items."); else if (isSelected) - ImGui::TextWrapped("%s", UNKNOWN); + { + ImGui::PushFont(nullptr, FONT_HEADER_2); + ImGui::TextWrapped("%s", isFound ? items[selectedItem].name.c_str() : UNKNOWN); + ImGui::PopFont(); + ImGui::Separator(); + if (isFound) + { + item_summary_draw(data, items[selectedItem]); + ImGui::Separator(); + } + ImGui::TextWrapped("%s", isFound ? items[selectedItem].description.c_str() : UNKNOWN); + } } ImGui::EndChild(); if (isColored) style_pop(); } -void Ui::cheats_tab_draw(Play& play) +// The open window (stats, catalog or settings): one at a time, fixed in size above the taskbar, closed by its X or +// escape, and holding the mouse while open. +void Ui::window_draw(Play& play, Settings& settings, ImVec2 screen) { - Traits& traits = play.character->traits; - Body& body = play.body; - Stats& stats = body.stats; - if (ImGui::BeginChild("##Cheats")) - { - is_fx(ImGui::SliderFloat("Calories", &stats.calories, 0, body.capacity_max_get(), "%0.0f kcal")); - is_fx(ImGui::SliderFloat("Capacity", &stats.capacity, traits.capacityMin, traits.capacityMax, "%0.0f kcal")); - if (is_fx(ImGui::SliderFloat("Weight", &stats.weight, traits.weight, traits.weightMax, "%0.2f kg"))) - { - body.stage_set(body.stage_get(stats.weight)); - body.idle_play(); - } - int stage = body.stage + 1; - if (is_fx(ImGui::SliderInt("Stage", &stage, 1, (int)play.character->stages.size() + 1))) - { - body.stage_set(stage - 1); - stats.weight = body.stage_threshold_get(body.stage); - body.idle_play(); - } - float rate = stats.digestionRate * TICK_RATE; - if (is_fx(ImGui::SliderFloat("Digestion Rate", &rate, traits.digestionRateMin * TICK_RATE, - traits.digestionRateMax * TICK_RATE, "%0.2f%% / sec"))) - stats.digestionRate = rate / TICK_RATE; - if (is_fx(ImGui::Button("Digest"))) - stats.digestionProgress = DIGESTION_MAX; - } - ImGui::EndChild(); -} - -void Ui::tools_draw(Play& play, ImVec2 position, ImVec2 size) -{ - Character& data = *play.character; + if (play.text.isScene) + panel = PANEL_NONE; + play.isBlocked = panel != PANEL_NONE; + if (panel == PANEL_NONE) + return; + const char* title = PANEL_NAMES[panel]; + float above = screen.y - play.inset - TOGGLE_HEIGHT; + ImVec2 size = {std::min(WINDOW_WIDTH, screen.x * WINDOW_FIT), std::min(WINDOW_HEIGHT, above * WINDOW_FIT)}; ImGui::SetNextWindowSize(size); - ImGui::SetNextWindowPos(position); - if (ImGui::Begin("##Tools", nullptr, PANEL_FLAGS)) + ImGui::SetNextWindowPos({screen.x / 2, above / 2}, ImGuiCond_Always, {0.5f, 0.5f}); + if (!ImGui::IsPopupOpen(title)) + ImGui::OpenPopup(title); + bool isOpen = true; + if (ImGui::BeginPopupModal(title, &isOpen, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { - float side = ImGui::GetContentRegionAvail().x; - ImFont* icons = data.menu.iconsFont; - auto tool_button = [&](const char* id, const std::string& glyph, const char* tooltip, bool isActive, - bool isDisabled) { - bool isIcon = icons && !glyph.empty(); - ImGui::PushID(id); - if (isActive) - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonHovered)); - ImGui::BeginDisabled(isDisabled); - bool isPressed = is_fx(ImGui::Button(isIcon ? "##Icon" : id, {side, side})); - if (isIcon) - { - ImVec2 min = ImGui::GetItemRectMin(); - ImVec2 itemSize = ImGui::GetItemRectSize(); - ImVec2 textSize = icons->CalcTextSizeA(itemSize.y, FLT_MAX, 0, glyph.c_str()); - ImGui::GetWindowDrawList()->AddText( - icons, itemSize.y, {min.x + (itemSize.x - textSize.x) / 2, min.y + (itemSize.y - textSize.y) / 2}, - ImGui::GetColorU32(ImGuiCol_Text), glyph.c_str()); - } - ImGui::EndDisabled(); - if (isActive) - ImGui::PopStyleColor(); - if (*tooltip) - ImGui::SetItemTooltip("%s", tooltip); - ImGui::PopID(); - return isPressed; - }; - if (tool_button("Home", data.menu.homeGlyph, "Reset camera view.\n(Shortcut: Home)", false, false)) - play.focus({ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y}); - for (int i = 0; i < (int)data.interactTypes.size(); i++) + if (panel == PANEL_STATS) + stats_draw(play); + else if (panel == PANEL_CATALOG) + catalog_draw(play); + else + settings_draw(settings); + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { - InteractArea& type = data.interactTypes[i]; - const char* tooltip = play.autofeed.isEnabled ? "Disabled while Autofeed is active!" - : icons && !type.glyph.empty() ? type.name.c_str() - : ""; - if (tool_button(type.name.c_str(), type.glyph, tooltip, play.cursorType == i, play.autofeed.isEnabled)) - play.cursorType = i; + isOpen = false; + ImGui::CloseCurrentPopup(); } + ImGui::EndPopup(); } - ImGui::End(); + if (isOpen) + return; + panel = PANEL_NONE; + play.audio->play(play.character->sounds[SOUND_CLOSE]); } void Ui::play_update(Play& play, Settings& settings) { character = play.character; - ImGuiStyle& style = ImGui::GetStyle(); + measurement = &settings.measurement; ImVec2 screen = ImGui::GetIO().DisplaySize; - float barWidth = std::max(ImGui::GetTextLineHeightWithSpacing(), style.WindowMinSize.x); - float height = screen.y - style.WindowPadding.y * 2; - - if (play.items.tooltip >= 0 && ImGui::BeginTooltip()) + if (play.items.tooltip >= 0 && !play.isBlocked && ImGui::BeginTooltip()) { item_details_draw(*character, &character->catalog.items[play.items.tooltip]); ImGui::EndTooltip(); } + taskbar_draw(play, screen); toasts_draw(play); text_draw(play, screen); - if (play.text.isScene) - return; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, style.FrameRounding); - slide_update(toolsSlide, play.isToolsOpen); - float toolsWidth = screen.x * TOOLS_WIDTH; - float toolsX = -toolsWidth + toolsWidth * std::pow(toolsSlide, SLIDE_EASE); - if (toolsSlide > 0) - tools_draw(play, {toolsX, style.WindowPadding.y}, {toolsWidth, height}); - bar_draw(play, play.isToolsOpen, toolsSlide, {toolsX + toolsWidth, style.WindowPadding.y}, {barWidth, height}, - DIRECTION_RIGHT, "Open Tools", "Close Tools"); - - slide_update(menuSlide, play.isMenuOpen); - float menuWidth = screen.x * MENU_WIDTH; - float menuX = screen.x - menuWidth * std::pow(menuSlide, SLIDE_EASE); - if (menuSlide > 0) - { - ImGui::SetNextWindowSize({menuWidth, height}); - ImGui::SetNextWindowPos({menuX, style.WindowPadding.y}); - if (ImGui::Begin("##Menu", nullptr, PANEL_FLAGS) && ImGui::BeginTabBar("##Tabs")) - { - if (is_fx(ImGui::BeginTabItem("Interact"))) - { - interact_tab_draw(play, settings); - ImGui::EndTabItem(); - } - if (is_fx(ImGui::BeginTabItem("Catalog"))) - { - catalog_tab_draw(play); - ImGui::EndTabItem(); - } - if (is_fx(ImGui::BeginTabItem("Settings"))) - { - settings_draw(settings); - ImGui::EndTabItem(); - } - if (play.isCheats && is_fx(ImGui::BeginTabItem("Cheats"))) - { - cheats_tab_draw(play); - ImGui::EndTabItem(); - } - ImGui::EndTabBar(); - } - ImGui::End(); - } - bar_draw(play, play.isMenuOpen, menuSlide, {menuX - barWidth, style.WindowPadding.y}, {barWidth, height}, - DIRECTION_LEFT, "Open Main Menu", "Close Main Menu"); - ImGui::PopStyleVar(2); + window_draw(play, settings, screen); } void Ui::select_update(std::vector& characters, Settings& settings) @@ -1125,7 +1143,7 @@ void Ui::select_update(std::vector& characters, Settings& settings) } float side = ImGui::GetContentRegionAvail().x / 4; float right = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x; - for (int i = 0; i < (int)characters.size(); i++) + for (int i = 0; i < (int)characters.size(); i++) { Character& entry = characters[i]; ImGui::PushID(i); diff --git a/src/ui.h b/src/ui.h index 088c446..802f3c2 100644 --- a/src/ui.h +++ b/src/ui.h @@ -4,18 +4,32 @@ #include "save.h" #include -constexpr float FONT_NORMAL = 20; -constexpr float FONT_HEADER_3 = 24; -constexpr float FONT_HEADER_2 = 30; -constexpr float FONT_HEADER_1 = 40; -constexpr float FONT_HUGE = 80; +constexpr float FONT_NORMAL = 26; +constexpr float FONT_HEADER_3 = 30; +constexpr float FONT_HEADER_2 = 34; +constexpr float FONT_DIALOGUE = 32; class Play; enum Direction { - DIRECTION_LEFT, - DIRECTION_RIGHT + DIRECTION_UP, + DIRECTION_DOWN +}; + +enum Panel +{ + PANEL_NONE, + PANEL_STATS, + PANEL_CATALOG, + PANEL_SETTINGS +}; + +enum ButtonState +{ + BUTTON_NORMAL, + BUTTON_ACTIVE, + BUTTON_DISABLED }; enum CommandType @@ -39,7 +53,7 @@ using Commands = std::vector; void style_set(Color color, float rounding = 0); -// The menus, tools, dialogue box, toasts and select screen. +// The taskbar, its windows, the dialogue box, toasts and the select screen. class Ui { public: @@ -52,12 +66,13 @@ public: Character* catalogCharacter = nullptr; Character* character = nullptr; Commands* commands = nullptr; + Measurement* measurement = nullptr; Renderer* renderer = nullptr; - float menuSlide = 1; - float toolsSlide = 0; + float taskbarSlide = 1; int characterIndex = 0; int selectedIndex = -1; int selectedItem = -1; + Panel panel = PANEL_NONE; bool isFullscreen = false; bool isNewGameWarning = false; bool isSelectedSaved = false; @@ -67,14 +82,15 @@ public: private: bool is_fx(bool result); + bool icon_button(const char* icon, const char* tooltip, ButtonState state); + void icon_draw(const char* name, ImVec2 position, float size, ImU32 tint); void command_add(CommandType type, const std::string& key = ""); + void window_open(Play& play, Panel next); + void taskbar_draw(Play& play, ImVec2 screen); + void window_draw(Play& play, Settings& settings, ImVec2 screen); + void stats_draw(Play& play); + void catalog_draw(Play& play); void settings_draw(Settings& settings); - void interact_tab_draw(Play& play, Settings& settings); - void catalog_tab_draw(Play& play); - void cheats_tab_draw(Play& play); - void tools_draw(Play& play, ImVec2 position, ImVec2 size); - void bar_draw(Play& play, bool& isOpen, float slide, ImVec2 position, ImVec2 size, Direction direction, - const char* openTooltip, const char* closeTooltip); void text_draw(Play& play, ImVec2 screen); void toasts_draw(Play& play); };