diff --git a/UI.md b/UI.md index df585af..0583530 100644 --- a/UI.md +++ b/UI.md @@ -26,6 +26,7 @@ These properties are available on every node type. | `height` | float \| `"match_parent"` | `0` | Height in virtual pixels | | `horizontal_gravity` | `"left"` \| `"center"` \| `"right"` | `"left"` | Positions the node horizontally inside a **FrameLayout** parent | | `vertical_gravity` | `"bottom"` \| `"center"` \| `"top"` | `"bottom"` | Positions the node vertically inside a **FrameLayout** parent | +| `visible` | bool | `true` | Whether the node (and all its children) are rendered and interactive. Can be toggled at runtime via `setNodeVisible` | --- @@ -343,6 +344,12 @@ A non-interactive image. Supports optional fade-in and pulse-scale animations. | `pulse.maxScale` | float | `1.1` | Maximum scale during the pulse cycle | | `pulse.periodMs` | float | `1000` | Duration of one full pulse cycle in milliseconds | +**C++ pop-in animation** (scales the node from 0 → 1, ease-out quad): +```cpp +uiManager.startPopIn("background", 300.0f); // duration in milliseconds +``` +Typically called immediately after making a node visible. The node is automatically removed from the animation list when the scale reaches 1. + --- ## Animations @@ -433,9 +440,58 @@ uiManager.setNodeVisible("hint5", false); bool visible = uiManager.getNodeVisible("hint5"); ``` +### Pop-in animation + +Scales a node from 0 to 1 using an ease-out curve. Useful for chat bubble reveals and similar "appear" effects. + +```cpp +uiManager.startPopIn("messageBubble", 300.0f); // node name, duration ms +``` + +Set the node's `scaleX`/`scaleY` to `0` and call `setNodeVisible` before calling `startPopIn` to avoid a one-frame flash at full size. + +### Dynamic node repositioning + +`node->localY` (and `localX`) can be modified directly on a node pointer, then a layout recalculation applied: + +```cpp +auto node = uiManager.findNode("messageBubble"); +node->localY = 350.0f; // new bottom-Y (for vertical_gravity: bottom nodes) +uiManager.updateAllLayouts(); // recomputes screenRect and rebuilds meshes +``` + +This is how the phone chat manager repositions bubbles as new messages arrive. + ### Per-frame update ```cpp uiManager.update(deltaMs); // advance animations and fade-ins uiManager.draw(renderer); // render everything ``` + +--- + +## Dialogue → UI integration (phone chat bubbles) + +Dialogue nodes in JSON can carry a `"bubbleSlot"` field naming a `StaticImage` UI node. When the dialogue runtime presents that line, it fires the `onBubbleSlotReady` callback with the slot name, which the game uses to reveal the corresponding bubble image. + +```json +{ + "id": "line_1", + "type": "Line", + "speaker": "Айпери", + "text": "...", + "next": "line_2", + "bubbleSlot": "message01in" +} +``` + +Lines without `"bubbleSlot"` (or with an empty value) do not trigger any UI change — useful for internal monologue lines that have no corresponding chat image. + +**C++ wiring:** +```cpp +dialogueSystem.setOnBubbleSlotReady([](const std::string& slotName) { + // slotName == "message01in" etc. + menuManager.revealPhoneChatBubble(slotName); +}); +``` diff --git a/resources/dialogue/dorm_dialogues.json b/resources/dialogue/dorm_dialogues.json index b9be6b8..10890f4 100644 --- a/resources/dialogue/dorm_dialogues.json +++ b/resources/dialogue/dorm_dialogues.json @@ -35,6 +35,143 @@ "type": "End" } ] + }, + { + "id": "dialog_chat_parents001", + "start": "line_1", + "nodes": [ + { + "id": "line_1", + "type": "Line", + "speaker": "Отец", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Бекзат, мы тебе отправили немного денег, постарайся прожить на эти деньги до конца недели!", + "next": "line_2", + "bubbleSlot": "message01in" + }, + { + "id": "line_2", + "type": "Line", + "speaker": "Бекзат", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Спасибо!", + "next": "end_1", + "bubbleSlot": "message02out" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "dialog_chat_aiperi001", + "start": "line_1", + "nodes": [ + { + "id": "line_1", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Бекзат, помнишь мы скидывались на торт для Аиды Джаныбековой? Я тогда еще приносила скатерть, тарелки и нож для торта. И я до сих пор не получила назад ничего.", + "next": "line_2", + "bubbleSlot": "message01in" + }, + { + "id": "line_2", + "type": "Line", + "speaker": "Бекзат", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Скатерть и тарелки вроде бы лежат в студзоне.", + "next": "line_3", + "bubbleSlot": "message02out" + }, + { + "id": "line_3", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "А нож?", + "next": "line_4", + "bubbleSlot": "message03in" + }, + { + "id": "line_4", + "type": "Line", + "speaker": "Бекзат", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Нож, наверное, так и остался в учительской.", + "next": "line_5", + "bubbleSlot": "message04out" + }, + { + "id": "line_5", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "А давай не \"наверное\"?", + "next": "line_6", + "bubbleSlot": "message05in" + }, + { + "id": "line_6", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "А давай ты приедешь в универ, зайдешь в учительскую, заберешь нож и отдашь мне?", + "next": "line_7", + "bubbleSlot": "message06in" + }, + { + "id": "line_7", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "У вас сегодня как раз Аида ведет лекцию. После лекции попросишь у нее ключи от учительской и заберешь нож.", + "next": "line_8", + "bubbleSlot": "message07in" + }, + { + "id": "line_8", + "type": "Line", + "speaker": "Бекзат", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Почему ты сама не можешь забрать?", + "next": "line_9", + "bubbleSlot": "message08out" + }, + { + "id": "line_9", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Ты же знаешь, если я встречу Аиду, она 100% даст мне какое-нибудь сложное задание.", + "next": "line_10", + "bubbleSlot": "message09in" + }, + { + "id": "line_10", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "И потом, это ты у меня брал нож, с чего я должна ходить искать его по всему универу?", + "next": "line_11", + "bubbleSlot": "message10in" + }, + { + "id": "line_11", + "type": "Line", + "speaker": "Айпери", + "portrait": "resources/w/gg/gg2_s_podsvetkoy5.png", + "text": "Так что жду тебя в универе! Не вздумай прогулять!", + "next": "end_1", + "bubbleSlot": "message11in" + }, + { + "id": "end_1", + "type": "End" + } + ] }, { "id": "dialog_no_sleep001", diff --git a/resources/w/ui/img/phone/chat02_09in.png b/resources/w/ui/img/phone/chat02_09in.png index ef29c27..2ee25dc 100644 --- a/resources/w/ui/img/phone/chat02_09in.png +++ b/resources/w/ui/img/phone/chat02_09in.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b01459f5924237a9b70c730bb05248ff440bef1067e593572543aa33c7f7d12 -size 64043 +oid sha256:0832e761c79e4337ecbc2aad36998e1b3b58b18db20206fe392c0847e3fdbfe0 +size 68508 diff --git a/resources/w/ui/img/phone/chat02_10in.png b/resources/w/ui/img/phone/chat02_10in.png index 8ba1bf2..ef29c27 100644 --- a/resources/w/ui/img/phone/chat02_10in.png +++ b/resources/w/ui/img/phone/chat02_10in.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfc591430404513130c076680031a16105bc93b4dea84cccbc6b085162a15926 -size 42336 +oid sha256:9b01459f5924237a9b70c730bb05248ff440bef1067e593572543aa33c7f7d12 +size 64043 diff --git a/resources/w/ui/img/phone/chat02_11in.png b/resources/w/ui/img/phone/chat02_11in.png new file mode 100644 index 0000000..8ba1bf2 --- /dev/null +++ b/resources/w/ui/img/phone/chat02_11in.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfc591430404513130c076680031a16105bc93b4dea84cccbc6b085162a15926 +size 42336 diff --git a/resources/w/ui/screen_phone_chat1.json b/resources/w/ui/screen_phone_chat1.json index 30c0f30..cdbedd1 100644 --- a/resources/w/ui/screen_phone_chat1.json +++ b/resources/w/ui/screen_phone_chat1.json @@ -69,9 +69,10 @@ "width": 320.6, "height": 103.6, "x" : 430, - "y" : 100, + "y" : 506.4, "horizontal_gravity": "left", - "vertical_gravity": "top", + "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat01_01in.png" }, { @@ -80,9 +81,10 @@ "width": 116.2, "height": 43.4, "x" : 430, - "y" : 203.6, + "y" : 453, "horizontal_gravity": "right", - "vertical_gravity": "top", + "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat01_02out.png" } ] diff --git a/resources/w/ui/screen_phone_chat2 — копия.json b/resources/w/ui/screen_phone_chat2 — копия.json new file mode 100644 index 0000000..e1c3c0b --- /dev/null +++ b/resources/w/ui/screen_phone_chat2 — копия.json @@ -0,0 +1,189 @@ +{ + "root": { + "type": "FrameLayout", + "name": "hud_root", + "width": "match_parent", + "height": "match_parent", + "vertical_align": "center", + "horizontal_align": "center", + "children": [ + { + "type": "Button", + "name": "phoneExitButton", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 0, + "width": "match_parent", + "height": "match_parent", + "textures": { + "normal": "resources/transparent.png", + "hover": "resources/transparent.png", + "pressed": "resources/transparent.png" + } + }, + { + "type": "Button", + "name": "phoneMain", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": -60, + "width": 617.4, + "height": 991.2, + "textures": { + "normal": "resources/w/ui/img/phone/PhoneChat001.png", + "hover": "resources/w/ui/img/phone/PhoneChat001.png", + "pressed": "resources/w/ui/img/phone/PhoneChat001.png" + } + }, + { + "type": "StaticImage", + "name": "message01in", + "width": 320.6, + "height": 148.4, + "x" : 430, + "y" : 1097, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_01in.png" + }, + { + "type": "StaticImage", + "name": "message02out", + "width": 320.6, + "height": 64.4, + "x" : 430, + "y" : 1022.6, + "horizontal_gravity": "right", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_02out.png" + }, + { + "type": "StaticImage", + "name": "message03in", + "width": 103.6, + "height": 43.4, + "x" : 430, + "y" : 969.2, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_03in.png" + }, + { + "type": "StaticImage", + "name": "message04out", + "width": 320.6, + "height": 64.4, + "x" : 430, + "y" : 894.8, + "horizontal_gravity": "right", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_04out.png" + }, + { + "type": "StaticImage", + "name": "message05in", + "width": 243.6, + "height": 43.4, + "x" : 430, + "y" : 841.4, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_05in.png" + }, + { + "type": "StaticImage", + "name": "message06in", + "width": 320.6, + "height": 85.4, + "x" : 430, + "y" : 746, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_06in.png" + }, + { + "type": "StaticImage", + "name": "message07in", + "width": 320.6, + "height": 106.4, + "x" : 430, + "y" : 629.6, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_07in.png" + }, + { + "type": "StaticImage", + "name": "message08out", + "width": 320.6, + "height": 64.4, + "x" : 430, + "y" : 555.2, + "horizontal_gravity": "right", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_08out.png" + }, + { + "type": "StaticImage", + "name": "message09in", + "width": 320.6, + "height": 85.4, + "x" : 430, + "y" : 459.8, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_09in.png" + }, + { + "type": "StaticImage", + "name": "message10in", + "width": 320.6, + "height": 85.4, + "x" : 430, + "y" : 364.4, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_10in.png" + }, + { + "type": "StaticImage", + "name": "message11in", + "width": 320.6, + "height": 64.4, + "x" : 430, + "y" : 290, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "texture": "resources/w/ui/img/phone/chat02_11in.png" + }, + { + "type": "TextButton", + "name": "chatTitleButton", + "horizontal_gravity": "center", + "x": 0.0, + "y": 20.0, + "width": 446.25, + "height": 78.4, + "text": "Айпери", + "textPaddingY": 16.0, + "textPaddingX": 140.0, + "fontSize": 32, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "topAligned": true, + "wrap": true, + "color": [ + 1.0, + 1.0, + 1.0, + 1.0 + ], + "textures": { + "normal": "resources/w/ui/img/phone/CharHeader001.png", + "hover": "resources/w/ui/img/phone/CharHeader001.png", + "pressed": "resources/w/ui/img/phone/CharHeader001.png" + } + } + ] + } +} \ No newline at end of file diff --git a/resources/w/ui/screen_phone_chat2.json b/resources/w/ui/screen_phone_chat2.json index 209ad37..4486306 100644 --- a/resources/w/ui/screen_phone_chat2.json +++ b/resources/w/ui/screen_phone_chat2.json @@ -41,9 +41,10 @@ "width": 320.6, "height": 148.4, "x" : 430, - "y" : 991.6, + "y" : 1097, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_01in.png" }, { @@ -52,9 +53,10 @@ "width": 320.6, "height": 64.4, "x" : 430, - "y" : 917.2, + "y" : 1022.6, "horizontal_gravity": "right", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_02out.png" }, { @@ -63,9 +65,10 @@ "width": 103.6, "height": 43.4, "x" : 430, - "y" : 863.8, + "y" : 969.2, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_03in.png" }, { @@ -74,9 +77,10 @@ "width": 320.6, "height": 64.4, "x" : 430, - "y" : 789.4, + "y" : 894.8, "horizontal_gravity": "right", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_04out.png" }, { @@ -85,9 +89,10 @@ "width": 243.6, "height": 43.4, "x" : 430, - "y" : 746, + "y" : 841.4, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_05in.png" }, { @@ -96,9 +101,10 @@ "width": 320.6, "height": 85.4, "x" : 430, - "y" : 650.6, + "y" : 746, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_06in.png" }, { @@ -107,9 +113,10 @@ "width": 320.6, "height": 106.4, "x" : 430, - "y" : 534.2, + "y" : 629.6, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_07in.png" }, { @@ -118,9 +125,10 @@ "width": 320.6, "height": 64.4, "x" : 430, - "y" : 459.8, + "y" : 555.2, "horizontal_gravity": "right", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_08out.png" }, { @@ -129,21 +137,35 @@ "width": 320.6, "height": 85.4, "x" : 430, - "y" : 364.4, + "y" : 459.8, "horizontal_gravity": "left", "vertical_gravity": "bottom", + "visible": false, "texture": "resources/w/ui/img/phone/chat02_09in.png" }, { "type": "StaticImage", "name": "message10in", "width": 320.6, + "height": 85.4, + "x" : 430, + "y" : 364.4, + "horizontal_gravity": "left", + "vertical_gravity": "bottom", + "visible": false, + "texture": "resources/w/ui/img/phone/chat02_10in.png" + }, + { + "type": "StaticImage", + "name": "message11in", + "width": 320.6, "height": 64.4, "x" : 430, "y" : 290, "horizontal_gravity": "left", "vertical_gravity": "bottom", - "texture": "resources/w/ui/img/phone/chat02_10in.png" + "visible": false, + "texture": "resources/w/ui/img/phone/chat02_11in.png" }, { "type": "TextButton", diff --git a/src/Game.cpp b/src/Game.cpp index 6a68a6a..4de4811 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -387,6 +387,18 @@ namespace ZL menuManager.onItemPickedUp(itemId); }; + // Wire phone dialogue start function so MenuManager can trigger dialogues. + menuManager.startDialogueFunc = [this](const std::string& id) { + if (currentLocation) currentLocation->dialogueSystem.startDialogue(id); + }; + + // Wire bubble-slot callback so chat bubbles appear as dialogue lines are shown. + for (auto& [name, loc] : locations) { + loc->dialogueSystem.setOnBubbleSlotReady([this](const std::string& bubbleSlot) { + menuManager.revealPhoneChatBubble(bubbleSlot); + }); + } + currentLocation = locations["location_dorm"]; currentLocation->scriptEngine.callLocationEnterCallback(); @@ -705,6 +717,7 @@ namespace ZL break; case SDLK_f: currentLocation->dialogueSystem.startDialogue("dialog_start001"); + break; case SDLK_e: currentLocation->dialogueSystem.startCutscene("test_cutscene_01"); //.startDialogue("test_cutscene_pan_dialogue"); @@ -940,8 +953,11 @@ namespace ZL const int uiX = mx; const int uiY = Environment::projectionHeight - my; - menuManager.uiManager.onTouchDown(fingerId, uiX, uiY); - st.capturedByUi = menuManager.uiManager.isUiInteractionForFinger(fingerId); + const bool dialogueActive = currentLocation && currentLocation->dialogueSystem.isActive(); + if (!dialogueActive) { + menuManager.uiManager.onTouchDown(fingerId, uiX, uiY); + } + st.capturedByUi = !dialogueActive && menuManager.uiManager.isUiInteractionForFinger(fingerId); activePointers[fingerId] = st; @@ -972,7 +988,10 @@ namespace ZL { const int uiX = mx; const int uiY = Environment::projectionHeight - my; - menuManager.uiManager.onTouchUp(fingerId, uiX, uiY); + const bool dialogueActive = currentLocation && currentLocation->dialogueSystem.isActive(); + if (!dialogueActive) { + menuManager.uiManager.onTouchUp(fingerId, uiX, uiY); + } auto it = activePointers.find(fingerId); if (it == activePointers.end()) return; @@ -1013,7 +1032,10 @@ namespace ZL { const int uiX = mx; const int uiY = Environment::projectionHeight - my; - menuManager.uiManager.onTouchMove(fingerId, uiX, uiY); + const bool dialogueActive = currentLocation && currentLocation->dialogueSystem.isActive(); + if (!dialogueActive) { + menuManager.uiManager.onTouchMove(fingerId, uiX, uiY); + } auto it = activePointers.find(fingerId); if (it != activePointers.end()) { diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index e6c5f8c..94a98d6 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -83,6 +83,7 @@ namespace ZL { hudStep5abRoot = loadUiFromFile("resources/w/ui/hud_step5ab.json", renderer, zipFile); //phoneScreenRoot = loadUiFromFile("resources/w/ui/screen_phone_chat_list.json", renderer, zipFile); phoneScreenRoot = loadUiFromFile("resources/w/ui/screen_phone_chat2.json", renderer, zipFile); + //phoneScreenRoot = loadUiFromFile("resources/w/ui/screen_phone_chat1.json", renderer, zipFile); newInventoryRoot = loadUiFromFile("resources/w/ui/screen_inventory.json", renderer, zipFile); questJournalRoot = loadUiFromFile("resources/w/ui/screen_journal.json", renderer, zipFile); @@ -202,17 +203,19 @@ namespace ZL { uiManager.setButtonCallback("phoneExitButton", [this](const std::string&) { closePhoneScreen(); }); - uiManager.setButtonCallback("phoneMain", [this](const std::string&) { - //Keep the callback - }); - uiManager.setTextButtonCallback("chat1button", [this](const std::string&) { - std::cout << "Hello test " << std::endl; + uiManager.setButtonCallback("phoneMain", [this](const std::string&) {}); - }); + // Reset chat state and start the dialogue + phoneChatVisibleBubbles_.clear(); + resetPhoneChatNodes(); + if (startDialogueFunc) { + startDialogueFunc("dialog_chat_aiperi001"); + } } void MenuManager::closePhoneScreen() { state = GameState::Gameplay; + phoneChatVisibleBubbles_.clear(); uiManager.popMenu(); } @@ -412,4 +415,54 @@ namespace ZL { refreshQuestJournalUi(); } + void MenuManager::resetPhoneChatNodes() { + static const char* kChatNodes[] = { + "message01in", "message02out", "message03in", "message04out", + "message05in", "message06in", "message07in", "message08out", + "message09in", "message10in", "message11in", nullptr + }; + for (int i = 0; kChatNodes[i]; ++i) { + uiManager.setNodeVisible(kChatNodes[i], false); + auto n = uiManager.findNode(kChatNodes[i]); + if (n) { n->scaleX = 1.0f; n->scaleY = 1.0f; } + } + } + + void MenuManager::recomputePhoneChatPositions() { + float totalHeight = 0.0f; + for (size_t i = 0; i < phoneChatVisibleBubbles_.size(); ++i) { + totalHeight += phoneChatVisibleBubbles_[i].height; + if (i > 0) totalHeight += CHAT_SPACING; + } + + const float available = CHAT_TOP_Y - CHAT_BOTTOM_Y; + const float topY = (totalHeight <= available) + ? CHAT_TOP_Y + : CHAT_BOTTOM_Y + totalHeight; + + float cursor = topY; + for (auto& bubble : phoneChatVisibleBubbles_) { + auto node = uiManager.findNode(bubble.nodeName); + if (!node) continue; + node->localY = cursor - bubble.height; + cursor -= bubble.height + CHAT_SPACING; + } + uiManager.updateAllLayouts(); + } + + void MenuManager::revealPhoneChatBubble(const std::string& slotName) { + if (state != GameState::PhoneScreen) return; + auto node = uiManager.findNode(slotName); + if (!node) return; + + // Zero scale before making visible to avoid a one-frame flash at full size + node->scaleX = 0.0f; + node->scaleY = 0.0f; + + phoneChatVisibleBubbles_.push_back({slotName, node->height}); + uiManager.setNodeVisible(slotName, true); + recomputePhoneChatPositions(); + uiManager.startPopIn(slotName, 300.0f); + } + } // namespace ZL diff --git a/src/MenuManager.h b/src/MenuManager.h index abe1735..6b8ac01 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -50,6 +50,10 @@ namespace ZL { void openPhoneScreen(); void closePhoneScreen(); + void revealPhoneChatBubble(const std::string& slotName); + bool isPhoneScreenOpen() const { return state == GameState::PhoneScreen; } + + std::function startDialogueFunc; void advanceTutorialStep(); void onItemPickedUp(const std::string& itemId); @@ -66,6 +70,8 @@ namespace ZL { void selectInventoryItem(int index); void refreshItemPickupHud(); void setupStep5Callbacks(); + void resetPhoneChatNodes(); + void recomputePhoneChatPositions(); GameState state = GameState::Gameplay; Inventory* inventory = nullptr; @@ -96,6 +102,17 @@ namespace ZL { int selectedQuestIndex = -1; std::vector visibleQuestIds; + + // Phone chat state + struct PhoneChatBubbleInfo { + std::string nodeName; + float height; + }; + std::vector phoneChatVisibleBubbles_; + + static constexpr float CHAT_TOP_Y = 610.0f; + static constexpr float CHAT_BOTTOM_Y = 290.0f; + static constexpr float CHAT_SPACING = 10.0f; }; } // namespace ZL diff --git a/src/UiManager.cpp b/src/UiManager.cpp index 707104e..6e9309c 100644 --- a/src/UiManager.cpp +++ b/src/UiManager.cpp @@ -429,6 +429,7 @@ namespace ZL { } if (j.contains("name")) node->name = j["name"].get(); + if (j.contains("visible")) node->visible = j["visible"].get(); // 2. Читаем размеры во временные "локальные" поля // Это критически важно: мы не пишем сразу в screenRect, @@ -825,6 +826,7 @@ namespace ZL { textFields.clear(); staticImages.clear(); pulsingNodes.clear(); + popInNodes.clear(); collectButtonsAndSliders(root); nodeActiveAnims.clear(); @@ -1050,6 +1052,17 @@ namespace ZL { ); } + void UiManager::startPopIn(const std::string& nodeName, float durationMs) { + auto node = findNode(nodeName); + if (!node) return; + node->scaleX = 0.0f; + node->scaleY = 0.0f; + node->popInActive = true; + node->popInProgress = 0.0f; + node->popInDurationMs = durationMs; + popInNodes.push_back(node); + } + void UiManager::collectButtonsAndSliders(const std::shared_ptr& node) { if (node->button) { buttons.push_back(node->button); @@ -1188,6 +1201,7 @@ namespace ZL { prev.pressedSliders = pressedSliders; prev.focusedTextField = focusedTextField; prev.path = ""; + prev.popInNodes = popInNodes; prev.animCallbacks = animCallbacks; @@ -1246,6 +1260,7 @@ namespace ZL { textFields = s.textFields; staticImages = s.staticImages; pulsingNodes = s.pulsingNodes; + popInNodes = s.popInNodes; pressedButtons = s.pressedButtons; pressedTextButtons = s.pressedTextButtons; pressedSliders = s.pressedSliders; @@ -1381,6 +1396,26 @@ namespace ZL { node->scaleY = s; } + // Pop-in scale animations (scale 0 → 1 on bubble reveal) + for (auto& node : popInNodes) { + if (!node || !node->popInActive) continue; + node->popInProgress += deltaMs / node->popInDurationMs; + if (node->popInProgress >= 1.0f) { + node->popInProgress = 1.0f; + node->popInActive = false; + node->scaleX = 1.0f; + node->scaleY = 1.0f; + } else { + // ease-out quad + const float t = node->popInProgress; + const float s = 1.0f - (1.0f - t) * (1.0f - t); + node->scaleX = s; + node->scaleY = s; + } + } + popInNodes.erase(std::remove_if(popInNodes.begin(), popInNodes.end(), + [](const auto& n) { return !n || !n->popInActive; }), popInNodes.end()); + std::vector, size_t>> animationsToRemove; std::vector> pendingCallbacks; diff --git a/src/UiManager.h b/src/UiManager.h index 0f033d1..a505d94 100644 --- a/src/UiManager.h +++ b/src/UiManager.h @@ -272,6 +272,11 @@ namespace ZL { float pulsePeriodMs = 1000.0f; float pulseElapsedMs = 0.0f; // runtime, not persisted in JSON + // Pop-in scale animation (scale 0→1 on first reveal) + bool popInActive = false; + float popInProgress = 0.0f; // 0..1 + float popInDurationMs = 300.0f; + // Иерархия std::vector> children; @@ -406,6 +411,7 @@ namespace ZL { bool stopAnimationOnNode(const std::string& nodeName, const std::string& animName); bool setAnimationCallback(const std::string& nodeName, const std::string& animName, std::function cb); void updateAllLayouts(); + void startPopIn(const std::string& nodeName, float durationMs = 300.0f); std::shared_ptr findNode(const std::string& name); @@ -448,6 +454,7 @@ namespace ZL { std::vector> textFields; std::vector> staticImages; std::vector> pulsingNodes; + std::vector> popInNodes; std::map, std::vector> nodeActiveAnims; std::map, std::function> animCallbacks; // key: (nodeName, animName) @@ -467,6 +474,7 @@ namespace ZL { std::vector> textFields; std::vector> staticImages; std::vector> pulsingNodes; + std::vector> popInNodes; std::map> pressedButtons; std::map> pressedTextButtons; std::map> pressedSliders; diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index b8ee914..0e658ef 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -105,6 +105,7 @@ Node DialogueDatabase::parseNode(const json& j) { node.falseNext = j.value("falseNext", ""); node.cutsceneId = j.value("cutsceneId", ""); node.luaCallback = j.value("luaCallback", ""); + node.bubbleSlot = j.value("bubbleSlot", ""); if (j.contains("conditions") && j["conditions"].is_array()) { for (const auto& item : j["conditions"]) { diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index 8347271..678548d 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -81,6 +81,10 @@ void DialogueRuntime::setOnCutsceneFadeInComplete(std::function cb) { + onBubbleSlotReady = std::move(cb); +} + void DialogueRuntime::stop() { activeDialogue = nullptr; activeCutscene = nullptr; @@ -431,6 +435,9 @@ void DialogueRuntime::presentLine(const Node& node) { if (!node.luaCallback.empty() && onDialogueLineStarted) { onDialogueLineStarted(node.luaCallback); } + if (!node.bubbleSlot.empty() && onBubbleSlotReady) { + onBubbleSlotReady(node.bubbleSlot); + } } void DialogueRuntime::presentChoices(const Node& node) { diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index b5e28e9..de154de 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -22,6 +22,7 @@ public: void setOnDialogueLineStarted(std::function cb); void setOnCutsceneLineStarted(std::function cb); void setOnCutsceneFadeInComplete(std::function cb); + void setOnBubbleSlotReady(std::function cb); void stop(); void update(int deltaMs); @@ -56,6 +57,7 @@ private: std::function onDialogueLineStarted; std::function onCutsceneLineStarted; std::function onCutsceneFadeInComplete; + std::function onBubbleSlotReady; std::string activeCutsceneId; bool fadeInCallbackFired = false; diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp index 435da0b..3b9c6d9 100644 --- a/src/dialogue/DialogueSystem.cpp +++ b/src/dialogue/DialogueSystem.cpp @@ -140,6 +140,10 @@ void DialogueSystem::setOnCutsceneFadeInComplete(std::function cb) { + runtime.setOnBubbleSlotReady(std::move(cb)); +} + void DialogueSystem::stopDialogue() { runtime.stop(); } diff --git a/src/dialogue/DialogueSystem.h b/src/dialogue/DialogueSystem.h index 0021d71..b869ddb 100644 --- a/src/dialogue/DialogueSystem.h +++ b/src/dialogue/DialogueSystem.h @@ -27,6 +27,7 @@ public: void setOnDialogueLineStarted(std::function cb); void setOnCutsceneLineStarted(std::function cb); void setOnCutsceneFadeInComplete(std::function cb); + void setOnBubbleSlotReady(std::function cb); void setOnDialogueAdvanced(std::function cb); void stopDialogue(); diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index e64dd19..69d723b 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -96,6 +96,9 @@ struct Node { // For CutsceneStart std::string cutsceneId; + + // Name of the UI node (StaticImage) to reveal in the phone chat when this line is shown + std::string bubbleSlot; }; struct DialogueDefinition { diff --git a/src/items/Item.cpp b/src/items/Item.cpp index 07d1943..4c95718 100644 --- a/src/items/Item.cpp +++ b/src/items/Item.cpp @@ -24,4 +24,5 @@ namespace ZL { [&itemId](const Item& item) { return item.id == itemId; }) != items.end(); } + } // namespace ZL \ No newline at end of file