diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index ef08cc7..3515b76 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -122,6 +122,9 @@ set(SOURCES ../src/dialogue/DialogueOverlay.cpp ../src/dialogue/DialogueSystem.h ../src/dialogue/DialogueSystem.cpp + ../src/quest/QuestTypes.h + ../src/quest/QuestJournal.h + ../src/quest/QuestJournal.cpp ) add_executable(bishkek-witcher ${SOURCES}) diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index fdb6289..8b0a15c 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -79,6 +79,9 @@ add_executable(space-game001 ../src/dialogue/DialogueOverlay.cpp ../src/dialogue/DialogueSystem.h ../src/dialogue/DialogueSystem.cpp + ../src/quest/QuestTypes.h + ../src/quest/QuestJournal.h + ../src/quest/QuestJournal.cpp ) # Установка проекта по умолчанию для Visual Studio diff --git a/resources/config2/ui_inventory.json b/resources/config2/ui_inventory.json index ab996ef..65b4df6 100644 --- a/resources/config2/ui_inventory.json +++ b/resources/config2/ui_inventory.json @@ -28,47 +28,72 @@ "name": "inventory_items_panel", "x": 50.0, "y": 150.0, - "width": 250.0, - "height": 300.0, + "width": 320.0, + "height": 420.0, "children": [ { "type": "StaticImage", "name": "panel_background", - "width": 200, - "height": 400, + "x": 0.0, + "y": 0.0, + "width": 320.0, + "height": 420.0, "texture": "resources/w/red.png" }, + { + "type": "TextView", + "name": "inventory_title_text", + "x": 20.0, + "y": 18.0, + "width": 230.0, + "height": 34.0, + "text": "Inventory", + "fontSize": 24, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 0.0, + "paddingY": 0.0, + "color": [1.0, 1.0, 1.0, 1.0] + }, { "type": "TextView", "name": "inventory_items_text", - "x": -100.0, - "y": -100.0, - "width": 250.0, - "height": 300.0, + "x": 20.0, + "y": 70.0, + "width": 280.0, + "height": 320.0, "text": "Inventory (Empty)", "fontSize": 18, "fontPath": "resources/fonts/DroidSans.ttf", "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 0.0, + "paddingY": 0.0, + "maxLines": 14, "color": [1.0, 1.0, 1.0, 1.0] }, { "type": "TextButton", - "name": "close_inventory_button", - "x": 165.0, - "y": 0.0, - "width": 40.0, - "height": 40.0, - "text": "X", - "fontSize": 20, - "fontPath": "resources/fonts/DroidSans.ttf", - "textCentered": true, - "color": [1.0, 1.0, 1.0, 1.0], - "textures": { - "normal": "resources/w/blue.png" - } + "name": "close_inventory_button", + "x": 266.0, + "y": 16.0, + "width": 40.0, + "height": 40.0, + "text": "X", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/blue.png", + "hover": "resources/w/blue.png", + "pressed": "resources/w/blue.png" + } } ] } ] } -} \ No newline at end of file +} diff --git a/resources/config2/ui_quest_journal.json b/resources/config2/ui_quest_journal.json new file mode 100644 index 0000000..5b15444 --- /dev/null +++ b/resources/config2/ui_quest_journal.json @@ -0,0 +1,353 @@ +{ + "root": { + "type": "FrameLayout", + "name": "quest_journal_root", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "TextButton", + "name": "quest_journal_button", + "x": 220.0, + "y": 50.0, + "width": 170.0, + "height": 60.0, + "text": "Quests", + "fontSize": 24, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/red.png", + "hover": "resources/w/red.png", + "pressed": "resources/w/red.png" + } + }, + { + "type": "FrameLayout", + "name": "quest_journal_panel", + "x": 55.0, + "y": 85.0, + "width": 1170.0, + "height": 590.0, + "children": [ + { + "type": "StaticImage", + "name": "quest_panel_background", + "x": 0.0, + "y": 0.0, + "width": 1170.0, + "height": 590.0, + "texture": "resources/black.png" + }, + { + "type": "TextView", + "name": "quest_journal_title_text", + "x": 0.0, + "y": 18.0, + "width": 1170.0, + "height": 44.0, + "text": "QUESTS", + "fontSize": 32, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": true, + "topAligned": true, + "paddingY": 2.0, + "color": [1.0, 1.0, 1.0, 1.0] + }, + { + "type": "TextButton", + "name": "quest_close_button", + "x": 1110.0, + "y": 20.0, + "width": 38.0, + "height": 38.0, + "text": "X", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": true, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { + "normal": "resources/w/blue.png", + "hover": "resources/w/blue.png", + "pressed": "resources/w/blue.png" + } + }, + { + "type": "StaticImage", + "name": "quest_separator_left", + "x": 390.0, + "y": 88.0, + "width": 2.0, + "height": 470.0, + "texture": "resources/w/red.png" + }, + { + "type": "StaticImage", + "name": "quest_separator_right", + "x": 770.0, + "y": 88.0, + "width": 2.0, + "height": 470.0, + "texture": "resources/w/red.png" + }, + { + "type": "TextView", + "name": "quest_list_header_text", + "x": 35.0, + "y": 90.0, + "width": 330.0, + "height": 32.0, + "text": "ЗАДАНИЯ", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 4.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextButton", + "name": "quest_slot_0", + "x": 35.0, + "y": 130.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_1", + "x": 35.0, + "y": 178.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_2", + "x": 35.0, + "y": 226.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_3", + "x": 35.0, + "y": 274.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_4", + "x": 35.0, + "y": 322.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_5", + "x": 35.0, + "y": 370.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_6", + "x": 35.0, + "y": 418.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_7", + "x": 35.0, + "y": 466.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextButton", + "name": "quest_slot_8", + "x": 35.0, + "y": 514.0, + "width": 330.0, + "height": 42.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "textCentered": false, + "textPaddingX": 12.0, + "color": [1.0, 1.0, 1.0, 1.0], + "textures": { "normal": "resources/transparent.png", "hover": "resources/w/blue.png", "pressed": "resources/w/blue.png" } + }, + { + "type": "TextView", + "name": "quest_middle_title_text", + "x": 415.0, + "y": 94.0, + "width": 330.0, + "height": 72.0, + "text": "Выберите задание", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 4.0, + "maxLines": 2, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_meta_text", + "x": 415.0, + "y": 168.0, + "width": 330.0, + "height": 48.0, + "text": "", + "fontSize": 16, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 2.0, + "maxLines": 2, + "color": [0.72, 0.72, 0.72, 1.0] + }, + { + "type": "TextView", + "name": "quest_objectives_header_text", + "x": 415.0, + "y": 232.0, + "width": 330.0, + "height": 32.0, + "text": "ЦЕЛИ", + "fontSize": 20, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 8.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_objectives_text", + "x": 415.0, + "y": 272.0, + "width": 330.0, + "height": 285.0, + "text": "", + "fontSize": 17, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 4.0, + "maxLines": 10, + "color": [0.88, 0.88, 0.88, 1.0] + }, + { + "type": "TextView", + "name": "quest_lore_title_text", + "x": 795.0, + "y": 94.0, + "width": 330.0, + "height": 38.0, + "text": "Описание задания", + "fontSize": 22, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "paddingX": 8.0, + "paddingY": 0.0, + "color": [1.0, 0.88, 0.45, 1.0] + }, + { + "type": "TextView", + "name": "quest_description_text", + "x": 795.0, + "y": 145.0, + "width": 330.0, + "height": 410.0, + "text": "", + "fontSize": 18, + "fontPath": "resources/fonts/DroidSans.ttf", + "centered": false, + "topAligned": true, + "wrap": true, + "paddingX": 8.0, + "paddingY": 6.0, + "maxLines": 15, + "color": [0.92, 0.92, 0.92, 1.0] + } + ] + } + ] + } +} diff --git a/resources/dialogue/cutscene_image_tests.json b/resources/dialogue/cutscene_image_tests.json new file mode 100644 index 0000000..cb8c04d --- /dev/null +++ b/resources/dialogue/cutscene_image_tests.json @@ -0,0 +1,290 @@ +{ + "dialogues": [ + { + "id": "test_cutscene_skip_hold_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_skip_hold_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_hardcut_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_hardcut_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_crossfade_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_crossfade_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + }, + { + "id": "test_cutscene_images_silent_dialogue", + "start": "cutscene_start", + "nodes": [ + { + "id": "cutscene_start", + "type": "CutsceneStart", + "cutsceneId": "test_cutscene_images_silent_01", + "next": "end_1" + }, + { + "id": "end_1", + "type": "End" + } + ] + } + ], + "cutscenes": [ + { + "id": "test_cutscene_skip_hold_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 12000, + "cameraTrack": [ + { + "durationMs": 3000, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopRight", "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.65, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 3000, + "from": { "anchor": "BottomRight", "zoom": 1.65, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "This cutscene is long enough to test hold-to-skip.", + "durationMs": 2600 + }, + { + "speaker": "Narrator", + "portrait": "", + "text": "A normal click must not skip it.", + "durationMs": 2600 + }, + { + "speaker": "Ghost", + "portrait": "resources/ghost_avatar.png", + "text": "Only the skip button with hold should work.", + "durationMs": 2600 + } + ] + }, + { + "id": "test_cutscene_images_hardcut_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 9000, + "cameraTrack": [ + { + "durationMs": 4500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 4500, + "from": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 4500, + "fadeInMs": 0, + "fadeOutMs": 0 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 4500, + "endMs": 9000, + "fadeInMs": 0, + "fadeOutMs": 0 + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "First image should switch sharply to the second one.", + "durationMs": 2800 + }, + { + "speaker": "Narrator", + "portrait": "", + "text": "No fade should be visible here.", + "durationMs": 2800 + } + ] + }, + { + "id": "test_cutscene_images_crossfade_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 10000, + "cameraTrack": [ + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "Custom", "centerX": 0.35, "centerY": 0.30, "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutQuad" + }, + { + "durationMs": 2500, + "from": { "anchor": "Custom", "centerX": 0.35, "centerY": 0.30, "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "Custom", "centerX": 0.70, "centerY": 0.32, "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseOutCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "Custom", "centerX": 0.70, "centerY": 0.32, "zoom": 1.45, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.70, "rotationDeg": 0.0 }, + "easing": "EaseInCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "BottomRight", "zoom": 1.70, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 6000, + "fadeInMs": 0, + "fadeOutMs": 0 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 4500, + "endMs": 10000, + "fadeInMs": 1500, + "fadeOutMs": 0 + } + ], + "lines": [ + { + "speaker": "Narrator", + "portrait": "", + "text": "The second image should fade over the first one.", + "durationMs": 2600 + }, + { + "speaker": "Ghost", + "portrait": "resources/ghost_avatar.png", + "text": "This test checks overlap and alpha blending.", + "durationMs": 2600 + } + ] + }, + { + "id": "test_cutscene_images_silent_01", + "background": "resources/first_cutscene.png", + "skippable": true, + "durationMs": 11000, + "cameraTrack": [ + { + "durationMs": 2500, + "from": { "anchor": "Center", "zoom": 1.0, "rotationDeg": 0.0 }, + "to": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopLeft", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "TopRight", "zoom": 1.35, "rotationDeg": 0.0 }, + "easing": "EaseInOutSine" + }, + { + "durationMs": 3000, + "from": { "anchor": "TopRight", "zoom": 1.35, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "easing": "EaseOutCubic" + }, + { + "durationMs": 2500, + "from": { "anchor": "BottomRight", "zoom": 1.55, "rotationDeg": 0.0 }, + "to": { "anchor": "BottomLeft", "zoom": 1.45, "rotationDeg": 0.0 }, + "easing": "EaseInOutQuad" + } + ], + "images": [ + { + "path": "resources/first_cutscene.png", + "startMs": 0, + "endMs": 3500, + "fadeInMs": 0, + "fadeOutMs": 800 + }, + { + "path": "resources/second_cutscene.png", + "startMs": 3000, + "endMs": 7500, + "fadeInMs": 800, + "fadeOutMs": 1000 + }, + { + "path": "resources/loading.png", + "startMs": 7000, + "endMs": 11000, + "fadeInMs": 1000, + "fadeOutMs": 0 + } + ], + "lines": [] + } + ] +} diff --git a/resources/quests/quests.json b/resources/quests/quests.json new file mode 100644 index 0000000..f54a982 --- /dev/null +++ b/resources/quests/quests.json @@ -0,0 +1,90 @@ +{ + "quests": [ + { + "id": "tutorial_open_journal", + "title": "Журнал заданий", + "category": "Tutorial", + "status": "Completed", + "recommendedLevel": 0, + "description": "Теперь у героя есть журнал заданий. В нём можно просматривать текущие поручения, цели и подробные записи о событиях, которые уже произошли в мире игры.", + "objectives": [ + { "id": "open_journal", "text": "Открыть журнал заданий", "completed": true } + ] + }, + { + "id": "side_lost_bag", + "title": "Потерянная сумка", + "category": "Side", + "status": "Completed", + "recommendedLevel": 1, + "description": "Путник потерял сумку недалеко от лесной тропы. Внутри оказались вещи, важные для его дальнейшего пути. Возвращение сумки укрепило доверие местных жителей к герою.", + "objectives": [ + { "id": "find_bag", "text": "Найти сумку у лесной тропы", "completed": true }, + { "id": "bring_bag", "text": "Вернуть сумку путнику", "completed": true } + ] + }, + { + "id": "failed_missing_courier", + "title": "След курьера", + "category": "Side", + "status": "Failed", + "recommendedLevel": 2, + "description": "Курьер исчез до того, как герой смог выйти на его след. Местные говорят, что на дороге остались только следы копыт и обрывок ремня. Возможность узнать, кто забрал посылку, была упущена.", + "objectives": [ + { "id": "ask_innkeeper", "text": "Расспросить трактирщика", "completed": true }, + { "id": "find_courier", "text": "Найти курьера до наступления ночи", "completed": false } + ] + }, + { + "id": "contract_old_well", + "title": "Старый колодец", + "category": "Contract", + "status": "Active", + "recommendedLevel": 2, + "description": "На окраине деревни стоит старый колодец. Ночью из него слышны голоса, а утром вокруг находят мокрые следы. Местные боятся подходить к нему, но староста считает, что причина происходящего связана с давним проклятием.", + "objectives": [ + { "id": "inspect_well", "text": "Осмотреть старый колодец", "completed": false }, + { "id": "search_tracks", "text": "Найти следы возле каменной ограды", "completed": false }, + { "id": "report_elder", "text": "Вернуться к старосте", "completed": false } + ] + }, + { + "id": "main_find_ghost", + "title": "Следы призрака", + "category": "Main", + "status": "Active", + "recommendedLevel": 1, + "description": "В заброшенной части деревни появился странный призрак. Местные жители говорят, что он появляется только ночью и исчезает возле старого колодца. Похоже, дух пытается привести кого-то к месту, где много лет назад случилась трагедия.", + "objectives": [ + { "id": "talk_to_ghost", "text": "Поговорить с призраком возле старого дома", "completed": false }, + { "id": "inspect_well", "text": "Осмотреть старый колодец", "completed": false }, + { "id": "return_to_elder", "text": "Вернуться к старосте", "completed": false } + ] + }, + { + "id": "main_black_letter", + "title": "Письмо с чёрной печатью", + "category": "Main", + "status": "Active", + "recommendedLevel": 3, + "description": "В руки героя попало письмо с чёрной восковой печатью. Имя отправителя стёрто, но на бумаге остался запах дыма и дорогих трав. Письмо упоминает встречу у северных ворот и человека, который знает правду о призраке.", + "objectives": [ + { "id": "read_letter", "text": "Прочитать письмо", "completed": true }, + { "id": "go_north_gate", "text": "Добраться до северных ворот", "completed": false }, + { "id": "find_contact", "text": "Найти человека с серебряным кольцом", "completed": false } + ] + }, + { + "id": "side_herbalist_help", + "title": "Травы для лекаря", + "category": "Side", + "status": "Available", + "recommendedLevel": 1, + "description": "Лекарю нужны редкие болотные травы, чтобы приготовить настой для больных детей. Он предупредил, что растение раскрывается только на рассвете, а рядом часто появляются дикие звери.", + "objectives": [ + { "id": "collect_herbs", "text": "Собрать болотные травы на рассвете", "completed": false }, + { "id": "bring_herbs", "text": "Отнести травы лекарю", "completed": false } + ] + } + ] +} diff --git a/resources/second_cutscene.png b/resources/second_cutscene.png new file mode 100644 index 0000000..e77f337 --- /dev/null +++ b/resources/second_cutscene.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bbc3563a65a71ed2cd6c708217da7c787c8561a5bbabf8d5ee4f14f91e06423 +size 2132238 diff --git a/resources/shaders/cutscene_fade_desktop.fragment b/resources/shaders/cutscene_fade_desktop.fragment new file mode 100644 index 0000000..6ceb9b8 --- /dev/null +++ b/resources/shaders/cutscene_fade_desktop.fragment @@ -0,0 +1,11 @@ +//precision mediump float; +uniform sampler2D Texture; +uniform float uAlpha; +varying vec2 texCoord; + +void main() +{ + vec4 color = texture2D(Texture, texCoord).rgba; + color.a *= uAlpha; + gl_FragColor = color; +} diff --git a/resources/shaders/cutscene_fade_web.fragment b/resources/shaders/cutscene_fade_web.fragment new file mode 100644 index 0000000..eff9cf5 --- /dev/null +++ b/resources/shaders/cutscene_fade_web.fragment @@ -0,0 +1,11 @@ +precision mediump float; +uniform sampler2D Texture; +uniform float uAlpha; +varying vec2 texCoord; + +void main() +{ + vec4 color = texture2D(Texture, texCoord).rgba; + color.a *= uAlpha; + gl_FragColor = color; +} diff --git a/resources/shaders/default_shadow.vertex b/resources/shaders/default_shadow.vertex index 7f3b9c5..a0b1034 100644 --- a/resources/shaders/default_shadow.vertex +++ b/resources/shaders/default_shadow.vertex @@ -1,3 +1,5 @@ +#version 120 + attribute vec3 vPosition; attribute vec2 vTexCoord; attribute vec3 vNormal; diff --git a/resources/shaders/skinning_shadow.vertex b/resources/shaders/skinning_shadow.vertex index 4aa14cb..ba8ae2f 100644 --- a/resources/shaders/skinning_shadow.vertex +++ b/resources/shaders/skinning_shadow.vertex @@ -1,3 +1,5 @@ +#version 120 + attribute vec3 vPosition; attribute vec2 vTexCoord; attribute vec3 vNormal; diff --git a/src/Game.cpp b/src/Game.cpp index 06f301a..174d56b 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -53,7 +53,7 @@ namespace ZL if (s_instance) { s_instance->mainThreadHandler.EnqueueMainThreadTask([&]() { s_instance->setupPart2(); - }); + }); } } @@ -86,7 +86,7 @@ namespace ZL ZL::BindOpenGlFunctions(); ZL::CheckGlError(__FILE__, __LINE__); renderer.InitOpenGL(); - + #ifdef EMSCRIPTEN // These shaders and loading.png are preloaded separately (not from zip), // so they are available immediately without waiting for resources.zip. @@ -111,9 +111,9 @@ namespace ZL minDimension = width; } - loadingMesh.data = CreateRect2D({ 0.0f, 0.0f }, { minDimension*0.5f, minDimension*0.5f }, 3); + loadingMesh.data = CreateRect2D({ 0.0f, 0.0f }, { minDimension * 0.5f, minDimension * 0.5f }, 3); loadingMesh.RefreshVBO(); - + #ifdef EMSCRIPTEN // Asynchronously download resources.zip; setupPart2() is called on completion. // The loading screen stays visible until the download finishes. @@ -125,7 +125,7 @@ namespace ZL std::cout << "Load resurces step 2" << std::endl; this->setupPart2(); std::cout << "Load resurces step 3" << std::endl; - }); + }); #endif } @@ -201,7 +201,7 @@ namespace ZL currentLocation->player->setTarget(currentLocation->teleportPosition); } currentLocation->playerInTeleportZone = true; - }; + }; location2->onTeleport = [this]() { std::cout << "[TELEPORT] location 2 -> location 1" << std::endl; currentLocation = location1; @@ -210,12 +210,12 @@ namespace ZL currentLocation->player->setTarget(currentLocation->teleportPosition); } currentLocation->playerInTeleportZone = true; - }; + }; currentLocation = location2; std::cout << "Load resurces step 5" << std::endl; - + std::cout << "Load resurces step 12" << std::endl; // Shadow mapping shaders @@ -240,6 +240,10 @@ namespace ZL // Load UI with inventory button try { menuManager.uiManager.loadFromFile("resources/config2/ui_inventory.json", renderer, CONST_ZIP_FILE); + menuManager.uiManager.appendFromFile("resources/config2/ui_quest_journal.json", renderer, CONST_ZIP_FILE); + + questJournal.loadFromFile("resources/quests/quests.json", CONST_ZIP_FILE); + setupQuestJournalUi(); std::cout << "UI loaded successfully" << std::endl; menuManager.uiManager.setNodeVisible("inventory_items_panel", false); @@ -247,6 +251,9 @@ namespace ZL menuManager.uiManager.setTextButtonCallback("inventory_button", [this](const std::string& name) { std::cout << "[UI] Inventory button clicked" << std::endl; + if (this->questJournalOpen) { + this->toggleQuestJournal(); + } this->menuManager.uiManager.setNodeVisible("inventory_items_panel", true); this->menuManager.uiManager.setNodeVisible("close_inventory_button", true); this->inventoryOpen = true; @@ -266,7 +273,7 @@ namespace ZL } this->menuManager.uiManager.setText("inventory_items_text", itemText); - }); + }); menuManager.uiManager.setTextButtonCallback("close_inventory_button", [this](const std::string& name) { @@ -274,7 +281,7 @@ namespace ZL menuManager.uiManager.setNodeVisible("inventory_items_panel", false); menuManager.uiManager.setNodeVisible("close_inventory_button", false); inventoryOpen = false; - }); + }); } catch (const std::exception& e) { std::cerr << "Failed to load UI: " << e.what() << std::endl; @@ -292,7 +299,7 @@ namespace ZL } } - + void Game::drawUI() { glClear(GL_DEPTH_BUFFER_BIT); @@ -318,7 +325,150 @@ namespace ZL CheckGlError(__FILE__, __LINE__); } - + + + + static int questStatusPriority(Quest::QuestStatus status) { + switch (status) { + case Quest::QuestStatus::Active: return 0; + case Quest::QuestStatus::Available: return 1; + case Quest::QuestStatus::Completed: return 2; + case Quest::QuestStatus::Failed: return 3; + default: return 4; + } + } + + static std::array questStatusColor(Quest::QuestStatus status) { + switch (status) { + case Quest::QuestStatus::Completed: return { 0.25f, 0.95f, 0.35f, 1.0f }; + case Quest::QuestStatus::Failed: return { 1.0f, 0.25f, 0.25f, 1.0f }; + case Quest::QuestStatus::Active: return { 1.0f, 1.0f, 1.0f, 1.0f }; + case Quest::QuestStatus::Available: return { 0.86f, 0.86f, 0.86f, 1.0f }; + default: return { 0.45f, 0.45f, 0.45f, 1.0f }; + } + } + + void Game::setupQuestJournalUi() { + questJournalOpen = false; + selectedQuestIndex = -1; + visibleQuestIds.clear(); + + menuManager.uiManager.setNodeVisible("quest_journal_panel", false); + menuManager.uiManager.setNodeVisible("quest_close_button", false); + + menuManager.uiManager.setTextButtonCallback("quest_journal_button", [this](const std::string&) { + toggleQuestJournal(); + }); + + menuManager.uiManager.setTextButtonCallback("quest_close_button", [this](const std::string&) { + if (questJournalOpen) { + toggleQuestJournal(); + } + }); + + for (int i = 0; i < 9; ++i) { + const std::string slotName = "quest_slot_" + std::to_string(i); + menuManager.uiManager.setTextButtonCallback(slotName, [this, i](const std::string&) { + selectQuestByIndex(i); + }); + menuManager.uiManager.setNodeVisible(slotName, false); + } + } + + void Game::toggleQuestJournal() { + questJournalOpen = !questJournalOpen; + std::cout << "[quest] toggleQuestJournal: " << (questJournalOpen ? "open" : "closed") << std::endl; + + if (questJournalOpen) { + if (inventoryOpen) { + menuManager.uiManager.setNodeVisible("inventory_items_panel", false); + menuManager.uiManager.setNodeVisible("close_inventory_button", false); + inventoryOpen = false; + } + } + + menuManager.uiManager.setNodeVisible("quest_journal_panel", questJournalOpen); + menuManager.uiManager.setNodeVisible("quest_close_button", questJournalOpen); + + if (questJournalOpen) { + refreshQuestJournalUi(); + if (!visibleQuestIds.empty()) { + selectQuestByIndex(0); + } + } + } + + void Game::refreshQuestJournalUi() { + visibleQuestIds.clear(); + auto quests = questJournal.getVisibleQuests(); + + std::sort(quests.begin(), quests.end(), [](const Quest::QuestState* a, const Quest::QuestState* b) { + const int pa = questStatusPriority(a->status); + const int pb = questStatusPriority(b->status); + if (pa != pb) return pa < pb; + // Newer quests are shown above older quests inside the same status bucket. + return a->orderIndex > b->orderIndex; + }); + + for (int i = 0; i < 9; ++i) { + const std::string slotName = "quest_slot_" + std::to_string(i); + + if (i < static_cast(quests.size())) { + const auto* quest = quests[i]; + visibleQuestIds.push_back(quest->definition.id); + + const bool selected = (i == selectedQuestIndex); + const std::string prefix = selected ? "> " : " "; + menuManager.uiManager.setTextButtonText(slotName, prefix + quest->definition.title); + menuManager.uiManager.setTextButtonColor(slotName, questStatusColor(quest->status)); + menuManager.uiManager.setNodeVisible(slotName, true); + } + else { + menuManager.uiManager.setTextButtonText(slotName, ""); + menuManager.uiManager.setNodeVisible(slotName, false); + } + } + } + + void Game::selectQuestByIndex(int index) { + if (index < 0 || index >= static_cast(visibleQuestIds.size())) { + return; + } + + selectedQuestIndex = index; + Quest::QuestState* quest = questJournal.findQuest(visibleQuestIds[index]); + if (!quest) { + return; + } + + const auto& def = quest->definition; + + menuManager.uiManager.setText("quest_middle_title_text", def.title); + menuManager.uiManager.setTextColor("quest_middle_title_text", questStatusColor(quest->status)); + + const std::string meta = std::string("Category: ") + Quest::toString(def.category) + + " | Status: " + Quest::toString(quest->status) + + " | Level: " + std::to_string(def.recommendedLevel); + menuManager.uiManager.setText("quest_meta_text", meta); + + std::string objectivesText; + for (size_t i = 0; i < def.objectives.size(); ++i) { + const auto& obj = def.objectives[i]; + const bool isActive = static_cast(i) == quest->activeObjectiveIndex; + const std::string mark = obj.completed ? "[x] " : (isActive ? "> [ ] " : "[ ] "); + objectivesText += mark + obj.text; + if (i + 1 < def.objectives.size()) { + objectivesText += "\n"; + } + } + menuManager.uiManager.setText("quest_objectives_text", objectivesText); + + menuManager.uiManager.setText("quest_lore_title_text", "Описание задания"); + menuManager.uiManager.setText("quest_description_text", def.description); + + refreshQuestJournalUi(); + } + void Game::drawScene() { glViewport(0, 0, Environment::width, Environment::height); if (!loadingCompleted) { @@ -360,7 +510,7 @@ namespace ZL float height = Environment::projectionHeight; renderer.PushProjectionMatrix( - -width * 0.5f, width*0.5f, + -width * 0.5f, width * 0.5f, -height * 0.5f, height * 0.5f, -10, 10); @@ -376,13 +526,13 @@ namespace ZL CheckGlError(__FILE__, __LINE__); } - + int64_t Game::getSyncTimeMs() { int64_t localNow = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); return localNow; } - + void Game::processTickCount() { @@ -431,7 +581,7 @@ namespace ZL Environment::exitGameLoop = true; } - + if (event.type == SDL_WINDOWEVENT && event.window.event == SDL_WINDOWEVENT_RESIZED) { // Обновляем размеры и сбрасываем кеш текстов, т.к. меши хранятся в пикселях Environment::width = event.window.data1; @@ -552,6 +702,10 @@ namespace ZL activateSlowMoEffect(); break; + case SDLK_j: + toggleQuestJournal(); + break; + case SDLK_RETURN: default: @@ -584,7 +738,7 @@ namespace ZL } } render(); - + mainThreadHandler.processMainThreadTasks(); } @@ -801,4 +955,4 @@ namespace ZL } } -} // namespace ZL +} // namespace ZL \ No newline at end of file diff --git a/src/Game.h b/src/Game.h index 6554125..a35fb96 100644 --- a/src/Game.h +++ b/src/Game.h @@ -20,7 +20,8 @@ #include #include #include "Location.h" -#include "AudioPlayerAsync.h" +#include "AudioPlayerAsync.h" +#include "quest/QuestJournal.h" namespace ZL { @@ -53,6 +54,11 @@ namespace ZL { bool inventoryOpen = false; + ZL::Quest::QuestJournal questJournal; + bool questJournalOpen = false; + int selectedQuestIndex = -1; + std::vector visibleQuestIds; + MenuManager menuManager; void activateSlowMoEffect(); @@ -101,6 +107,14 @@ namespace ZL { void updatePinchZoom(); void endPinch(); int countNonUiPointers() const; + void handleDown(int64_t fingerId, int mx, int my); + void handleUp(int64_t fingerId, int mx, int my); + void handleMotion(int64_t fingerId, int mx, int my); + + void setupQuestJournalUi(); + void toggleQuestJournal(); + void refreshQuestJournalUi(); + void selectQuestByIndex(int index); #ifdef EMSCRIPTEN static Game* s_instance; diff --git a/src/Location.cpp b/src/Location.cpp index b8a5691..cae8dfc 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -166,6 +166,7 @@ namespace ZL std::cerr << "Failed to init NPC name TextRenderer" << std::endl; npcNameText.reset(); } + dialogueSystem.loadDatabase("resources/dialogue/sample_dialogues.json"); /*dialogueSystem.addTriggerZone({ "ghost_room_trigger", "test_line_dialogue", @@ -840,6 +841,10 @@ namespace ZL } void Location::handleUp(int64_t fingerId, int mx, int my) { + if (dialogueSystem.blocksGameplayInput()) { + dialogueSystem.handlePointerReleased(static_cast(mx), Environment::projectionHeight - static_cast(my)); + return; + } } void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my) diff --git a/src/UiManager.cpp b/src/UiManager.cpp index d20487c..c5f4126 100644 --- a/src/UiManager.cpp +++ b/src/UiManager.cpp @@ -4,12 +4,90 @@ #include #include #include +#include #include "GameConstants.h" namespace ZL { using json = nlohmann::json; + + static int countWrappedLines(const std::string& text) { + if (text.empty()) return 0; + int lines = 1; + for (char c : text) { + if (c == '\n') ++lines; + } + return lines; + } + + static std::string limitLines(const std::string& text, int maxLines) { + if (maxLines <= 0) return text; + std::string out; + int lines = 1; + for (char c : text) { + if (c == '\n') { + if (lines >= maxLines) { + out += "..."; + return out; + } + ++lines; + } + out.push_back(c); + } + return out; + } + + static std::string wrapTextByPixels(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale, int maxLines = 0) { + if (input.empty() || maxWidthPx <= 1.0f) return input; + + std::string output; + std::string currentLine; + std::string currentWord; + auto flushLine = [&]() { + if (!currentLine.empty()) { + if (!output.empty()) output.push_back('\n'); + output += currentLine; + currentLine.clear(); + } + }; + auto pushWord = [&](const std::string& word) { + if (word.empty()) return; + if (currentLine.empty()) { + currentLine = word; + return; + } + const std::string candidate = currentLine + " " + word; + if (textRenderer.measureTextWidth(candidate, scale) <= maxWidthPx) { + currentLine = candidate; + } else { + flushLine(); + currentLine = word; + } + }; + + for (size_t i = 0; i < input.size(); ++i) { + const char ch = input[i]; + if (ch == '\n') { + pushWord(currentWord); + currentWord.clear(); + flushLine(); + continue; + } + if (ch == ' ' || ch == '\t' || ch == '\r') { + pushWord(currentWord); + currentWord.clear(); + continue; + } + currentWord.push_back(ch); + } + + pushWord(currentWord); + flushLine(); + + return limitLines(output, maxLines); + } + static float applyEasing(const std::string& easing, float t) { if (easing == "easein") { return t * t; @@ -130,14 +208,60 @@ namespace ZL { // Draw text on top (uses absolute coords, add anim offset manually) + // use left padding, which is required for inventory/quest lists. if (textRenderer && !text.empty()) { - float cx = rect.x + rect.w / 2.0f + animOffsetX; - float cy = rect.y + rect.h / 2.0f + animOffsetY; - textRenderer->drawText(text, cx, cy, 1.0f, textCentered, color); + float tx = rect.x + rect.w / 2.0f + animOffsetX; + if (!textCentered) { + tx = rect.x + textPaddingX + animOffsetX; + } + const float ty = rect.y + rect.h * 0.5f + textPaddingY + animOffsetY; + textRenderer->drawText(text, tx, ty, 1.0f, textCentered, color); } glEnable(GL_DEPTH_TEST); } + void UiTextView::draw(Renderer& renderer) const { + (void)renderer; + if (!textRenderer || text.empty()) { + return; + } + + const float scale = 1.0f; + + // Backward compatibility: + // Old UI files, including the original inventory panel, positioned TextView + // around the rect center. If a TextView does not explicitly request wrapping, + // top alignment, padding or line limiting, keep that old behavior. + const bool usesModernRectText = wrap || topAligned || paddingX != 0.0f || paddingY != 0.0f || maxLines > 0; + if (!usesModernRectText) { + textRenderer->drawText( + text, + rect.x + rect.w * 0.5f, + rect.y + rect.h * 0.5f, + scale, + centered, + color + ); + return; + } + + const float availableWidth = max(1.0f, rect.w - paddingX * 2.0f); + const std::string finalText = wrap + ? wrapTextByPixels(text, *textRenderer, availableWidth, scale, maxLines) + : limitLines(text, maxLines); + + float tx = centered ? rect.x + rect.w * 0.5f : rect.x + paddingX; + float ty = rect.y + rect.h * 0.5f; + + if (topAligned) { + // TextRenderer expects a baseline position. This offset places the first + // visible line close to the top inside the TextView rectangle. + ty = rect.y + rect.h - paddingY - static_cast(fontSize); + } + + textRenderer->drawText(finalText, tx, ty, scale, centered, color); + } + void UiSlider::buildTrackMesh() { trackMesh.data.PositionData.clear(); trackMesh.data.TexCoordData.clear(); @@ -490,6 +614,8 @@ namespace ZL { if (j.contains("fontPath")) tb->fontPath = j["fontPath"].get(); if (j.contains("fontSize")) tb->fontSize = j["fontSize"].get(); if (j.contains("textCentered")) tb->textCentered = j["textCentered"].get(); + if (j.contains("textPaddingX")) tb->textPaddingX = j["textPaddingX"].get(); + if (j.contains("textPaddingY")) tb->textPaddingY = j["textPaddingY"].get(); if (j.contains("color") && j["color"].is_array() && j["color"].size() == 4) { for (int i = 0; i < 4; ++i) tb->color[i] = j["color"][i].get(); } @@ -573,6 +699,11 @@ namespace ZL { } } if (j.contains("centered")) tv->centered = j["centered"].get(); + if (j.contains("wrap")) tv->wrap = j["wrap"].get(); + if (j.contains("topAligned")) tv->topAligned = j["topAligned"].get(); + if (j.contains("paddingX")) tv->paddingX = j["paddingX"].get(); + if (j.contains("paddingY")) tv->paddingY = j["paddingY"].get(); + if (j.contains("maxLines")) tv->maxLines = j["maxLines"].get(); tv->textRenderer = std::make_unique(); if (!tv->textRenderer->init(renderer, tv->fontPath, tv->fontSize, zipFile)) { @@ -675,6 +806,25 @@ namespace ZL { replaceRoot(newRoot); } + void UiManager::appendFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile) { + std::shared_ptr extraRoot = loadUiFromFile(path, renderer, zipFile); + if (!extraRoot) { + std::cerr << "UiManager: appendFromFile failed: " << path << std::endl; + return; + } + + if (!root) { + replaceRoot(extraRoot); + return; + } + + for (auto& child : extraRoot->children) { + root->children.push_back(child); + } + + replaceRoot(root); + } + void UiManager::layoutNode(const std::shared_ptr& node, float parentX, float parentY, float parentW, float parentH, float finalLocalX, float finalLocalY) { @@ -1640,6 +1790,15 @@ namespace ZL { return true; } + bool UiManager::setTextColor(const std::string& name, const std::array& color) { + auto tv = findTextView(name); + if (!tv) { + return false; + } + tv->color = color; + return true; + } + std::shared_ptr UiManager::findTextButton(const std::string& name) { for (auto& tb : textButtons) if (tb->name == name) return tb; return nullptr; @@ -1672,6 +1831,13 @@ namespace ZL { return true; } + bool UiManager::setTextButtonColor(const std::string& name, const std::array& color) { + auto tb = findTextButton(name); + if (!tb) return false; + tb->color = color; + return true; + } + std::shared_ptr UiManager::findNode(const std::string& name) { if (!root) return nullptr; return findNodeByName(root, name); diff --git a/src/UiManager.h b/src/UiManager.h index ce14d3b..1246249 100644 --- a/src/UiManager.h +++ b/src/UiManager.h @@ -145,6 +145,8 @@ namespace ZL { int fontSize = 32; std::array color = { 1.f, 1.f, 1.f, 1.f }; bool textCentered = true; + float textPaddingX = 12.0f; + float textPaddingY = 0.0f; std::unique_ptr textRenderer; @@ -169,14 +171,15 @@ namespace ZL { int fontSize = 32; std::array color = { 1.f, 1.f, 1.f, 1.f }; // rgba bool centered = true; + bool wrap = false; + bool topAligned = true; + float paddingX = 0.0f; + float paddingY = 0.0f; + int maxLines = 0; // 0 = no line limit std::unique_ptr textRenderer; - void draw(Renderer& renderer) const { - if (textRenderer) { - textRenderer->drawText(text, rect.x + rect.w / 2, rect.y + rect.h / 2, 1.0f, centered, color); - } - } + void draw(Renderer& renderer) const; }; struct UiTextField { @@ -273,6 +276,7 @@ namespace ZL { void replaceRoot(std::shared_ptr newRoot); void loadFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile = ""); + void appendFromFile(const std::string& path, Renderer& renderer, const std::string& zipFile = ""); void draw(Renderer& renderer); @@ -329,6 +333,7 @@ namespace ZL { bool setTextButtonCallback(const std::string& name, std::function cb); bool setTextButtonPressCallback(const std::string& name, std::function cb); bool setTextButtonText(const std::string& name, const std::string& newText); + bool setTextButtonColor(const std::string& name, const std::array& color); bool addSlider(const std::string& name, const UiRect& rect, Renderer& renderer, const std::string& zipFile, const std::string& trackPath, const std::string& knobPath, float initialValue = 0.0f, bool vertical = true); @@ -339,6 +344,7 @@ namespace ZL { std::shared_ptr findTextView(const std::string& name); bool setText(const std::string& name, const std::string& newText); + bool setTextColor(const std::string& name, const std::array& color); std::shared_ptr findTextField(const std::string& name); bool setTextFieldCallback(const std::string& name, std::function cb); diff --git a/src/dialogue/DialogueDatabase.cpp b/src/dialogue/DialogueDatabase.cpp index bc5db6d..88841d2 100644 --- a/src/dialogue/DialogueDatabase.cpp +++ b/src/dialogue/DialogueDatabase.cpp @@ -180,6 +180,16 @@ CutsceneCameraSegment DialogueDatabase::parseCutsceneCameraSegment(const json& j return segment; } +CutsceneImageCue DialogueDatabase::parseCutsceneImageCue(const json& j) { + CutsceneImageCue cue; + cue.path = j.value("path", ""); + cue.startMs = j.value("startMs", 0); + cue.endMs = j.value("endMs", 0); + cue.fadeInMs = j.value("fadeInMs", 0); + cue.fadeOutMs = j.value("fadeOutMs", 0); + return cue; +} + StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { StaticCutsceneDefinition cutscene; cutscene.id = j.value("id", ""); @@ -194,6 +204,12 @@ StaticCutsceneDefinition DialogueDatabase::parseCutscene(const json& j) { } } + if (j.contains("images") && j["images"].is_array()) { + for (const auto& item : j["images"]) { + cutscene.images.push_back(parseCutsceneImageCue(item)); + } + } + if (j.contains("lines") && j["lines"].is_array()) { for (const auto& item : j["lines"]) { cutscene.lines.push_back(parseCutsceneLine(item)); diff --git a/src/dialogue/DialogueDatabase.h b/src/dialogue/DialogueDatabase.h index 01732c1..50946d2 100644 --- a/src/dialogue/DialogueDatabase.h +++ b/src/dialogue/DialogueDatabase.h @@ -34,6 +34,7 @@ private: static CutsceneLine parseCutsceneLine(const json& j); static CutsceneCameraPose parseCutsceneCameraPose(const json& j); static CutsceneCameraSegment parseCutsceneCameraSegment(const json& j); + static CutsceneImageCue parseCutsceneImageCue(const json& j); static StaticCutsceneDefinition parseCutscene(const json& j); }; diff --git a/src/dialogue/DialogueOverlay.cpp b/src/dialogue/DialogueOverlay.cpp index e5c2b02..0d8f517 100644 --- a/src/dialogue/DialogueOverlay.cpp +++ b/src/dialogue/DialogueOverlay.cpp @@ -85,17 +85,67 @@ bool DialogueOverlay::init(Renderer& renderer, const std::string& zipFile) { } void DialogueOverlay::update(const PresentationModel& model, int deltaMs) { + if (model.mode == PresentationMode::Hidden) { + hoveredChoiceIndex = -1; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + lastChoiceRects.clear(); + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = {}; + return; + } + if (model.mode != PresentationMode::Choice) { hoveredChoiceIndex = -1; } + + if (model.mode != PresentationMode::Cutscene || !model.cutsceneSkippable) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + return; + } + + const int safeDeltaMs = max(deltaMs, 0); + + if (cutsceneSkipHintVisible) { + cutsceneSkipHintRemainingMs -= safeDeltaMs; + if (cutsceneSkipHintRemainingMs <= 0) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + } + } + + if (cutsceneSkipHolding && cutsceneSkipArmed) { + cutsceneSkipHoldElapsedMs += safeDeltaMs; + if (cutsceneSkipHoldElapsedMs >= CutsceneSkipHoldDurationMs) { + cutsceneSkipTriggered = true; + cutsceneSkipHolding = false; + cutsceneSkipHoldElapsedMs = CutsceneSkipHoldDurationMs; + } + } } void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) { if (model.mode == PresentationMode::Hidden) { lastChoiceRects.clear(); - lastDialogueAdvanceRect = {}; - lastCutsceneAdvanceRect = {}; - cutsceneAdvanceEnabled = false; + lastDialogueAdvanceRect = {}; + lastCutsceneAdvanceRect = {}; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; return; } @@ -116,7 +166,11 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& lastDialogueAdvanceRect = { portraitRect.x, portraitRect.y, textboxRect.x + textboxRect.w - portraitRect.x, textboxRect.h }; lastCutsceneAdvanceRect = {}; - cutsceneAdvanceEnabled = false; + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; if (!portraitQuad.initialized || portraitQuad.rect.w != portraitRect.w || portraitQuad.rect.h != portraitRect.h || portraitQuad.rect.x != portraitRect.x || portraitQuad.rect.y != portraitRect.y) { @@ -151,8 +205,16 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& nameRenderer->drawText(model.speaker, nameX, nameY, 1.0f, false, { 1.0f, 0.88f, 0.45f, 1.0f }); } - const std::string wrappedBody = wrapText(model.visibleText, 90); - bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); + // const std::string wrappedBody = wrapText(model.visibleText, 90); + // bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); + // const std::string wrappedBody = wrapText(model.visibleText, 56); + // bodyRenderer->drawText(wrappedBody, bodyX, bodyY, 1.0f, false, { 1.0f, 1.0f, 1.0f, 1.0f }); + + const float bodyTextScale = 1.0f; + const float bodyMaxWidthPx = textboxRect.w - 48.0f; + + const std::string wrappedBody = wrapTextToWidth(model.visibleText, *bodyRenderer, bodyMaxWidthPx, bodyTextScale); + bodyRenderer->drawText(wrappedBody, bodyX, bodyY, bodyTextScale, false, { 1.0f, 1.0f, 1.0f, 1.0f }); lastChoiceRects.clear(); if (model.mode == PresentationMode::Choice) { @@ -196,11 +258,21 @@ void DialogueOverlay::drawDialogue(Renderer& renderer, const PresentationModel& ? std::array{0.82f, 0.82f, 0.82f, 1.0f} : std::array{ 1.0f, 0.93f, 0.65f, 1.0f }; + const float choiceTextScale = 1.0f; + const float choiceMaxWidthPx = rect.w - 28.0f; + + const std::string wrappedChoiceText = wrapTextToWidth( + model.choices[i].text, + *choiceRenderer, + choiceMaxWidthPx, + choiceTextScale + ); + choiceRenderer->drawText( - wrapText(model.choices[i].text, 52), + wrappedChoiceText, rect.x + 14.0f, rect.y + 9.0f, - 1.0f, + choiceTextScale, false, isHighlighted ? std::array{1.0f, 1.0f, 1.0f, 1.0f} : color ); @@ -318,48 +390,57 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& lastDialogueAdvanceRect = {}; lastCutsceneAdvanceRect = subtitleRect; - cutsceneAdvanceEnabled = model.showCutsceneSubtitle; - - std::shared_ptr bgTexture = model.backgroundPath.empty() ? nullptr : loadTextureCached(model.backgroundPath); glEnable(GL_BLEND); - renderer.shaderManager.PushShader(defaultShaderName); + + renderer.shaderManager.PushShader("cutsceneFade"); renderer.RenderUniform1i(textureUniformName, 0); renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); renderer.PushMatrix(); renderer.LoadIdentity(); - if (bgTexture) { - const float texW = static_cast(bgTexture->getWidth()); - const float texH = static_cast(bgTexture->getHeight()); + const UiRect screenRect{ 0.0f, 0.0f, W, H }; - ResolvedViewport currentViewport{}; + std::vector imageLayers = model.cutsceneImages; + if (imageLayers.empty() && !model.backgroundPath.empty()) { + imageLayers.push_back({ model.backgroundPath, 1.0f }); + } + + for (const PresentedCutsceneImage& layer : imageLayers) { + const auto texture = loadTextureCached(layer.path); + if (!texture) { + continue; + } + + const float texW = static_cast(texture->getWidth()); + const float texH = static_cast(texture->getHeight()); + + ResolvedViewport layerViewport{}; if (model.cutsceneCamera.active) { const ResolvedViewport fromViewport = resolveViewportPose(model.cutsceneCamera.from, texW, texH, W, H); const ResolvedViewport toViewport = resolveViewportPose(model.cutsceneCamera.to, texW, texH, W, H); - - currentViewport = blendViewport( + layerViewport = blendViewport( fromViewport, toViewport, std::clamp(model.cutsceneCamera.t, 0.0f, 1.0f) ); } else { - currentViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H); + layerViewport = resolveViewportPose(CutsceneCameraPose{}, texW, texH, W, H); } - const float halfW = currentViewport.widthPx * 0.5f; - const float halfH = currentViewport.heightPx * 0.5f; - const float rotationRad = currentViewport.rotationDeg * 3.14159265358979323846f / 180.0f; + const float halfW = layerViewport.widthPx * 0.5f; + const float halfH = layerViewport.heightPx * 0.5f; + const float rotationRad = layerViewport.rotationDeg * 3.14159265358979323846f / 180.0f; const float c = std::cos(rotationRad); const float s = std::sin(rotationRad); auto rotatePoint = [&](float x, float y) -> Eigen::Vector2f { return { - currentViewport.centerXPx + x * c - y * s, - currentViewport.centerYPx + x * s + y * c + layerViewport.centerXPx + x * c - y * s, + layerViewport.centerYPx + x * s + y * c }; }; @@ -376,7 +457,6 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& }; }; - const UiRect screenRect{ 0.0f, 0.0f, W, H }; backgroundQuad.rebuildWithUV( screenRect, toUV(srcBL), @@ -385,14 +465,47 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& toUV(srcBR) ); - drawQuad(renderer, backgroundQuad, bgTexture); + renderer.RenderUniform1f("uAlpha", std::clamp(layer.alpha, 0.0f, 1.0f)); + drawQuad(renderer, backgroundQuad, texture); } + renderer.PopMatrix(); + renderer.PopProjectionMatrix(); + renderer.shaderManager.PopShader(); + + // UI quads over the image: subtitle panel and skip progress hint background. + renderer.shaderManager.PushShader(defaultShaderName); + renderer.RenderUniform1i(textureUniformName, 0); + renderer.PushProjectionMatrix(0.0f, W, 0.0f, H, -10.0f, 10.0f); + renderer.PushMatrix(); + renderer.LoadIdentity(); + if (model.showCutsceneSubtitle) { subtitleQuad.rebuild(subtitleRect); drawQuad(renderer, subtitleQuad, cutsceneSubtitleTexture); } + if (model.cutsceneSkippable && cutsceneSkipHintVisible) { + const UiRect hintBg{ W - 250.0f, H - 62.0f, 226.0f, 42.0f }; + skipHintBgQuad.rebuild(hintBg); + drawQuad(renderer, skipHintBgQuad, choiceOptionalTexture); + + const UiRect progressBg{ W - 232.0f, H - 34.0f, 190.0f, 7.0f }; + skipProgressBgQuad.rebuild(progressBg); + drawQuad(renderer, skipProgressBgQuad, choiceOptionalTexture); + + if (cutsceneSkipHolding) { + const float progress = std::clamp( + static_cast(cutsceneSkipHoldElapsedMs) / static_cast(CutsceneSkipHoldDurationMs), + 0.0f, + 1.0f + ); + const UiRect progressFill{ progressBg.x, progressBg.y, progressBg.w * progress, progressBg.h }; + skipProgressFillQuad.rebuild(progressFill); + drawQuad(renderer, skipProgressFillQuad, choiceMainTexture); + } + } + renderer.PopMatrix(); renderer.PopProjectionMatrix(); renderer.shaderManager.PopShader(); @@ -408,24 +521,85 @@ void DialogueOverlay::drawCutscene(Renderer& renderer, const PresentationModel& { 1.0f, 0.88f, 0.45f, 1.0f } ); } + const float subtitleTextScale = 1.0f; + const float subtitleMaxWidthPx = subtitleRect.w - 48.0f; + + const std::string wrappedSubtitle = wrapTextToWidth( + model.visibleText, + *cutsceneRenderer, + subtitleMaxWidthPx, + subtitleTextScale + ); + cutsceneRenderer->drawText( - wrapText(model.visibleText, 62), + wrappedSubtitle, subtitleRect.x + 24.0f, subtitleRect.y + 30.0f, - 1.0f, + subtitleTextScale, false, { 1.0f, 1.0f, 1.0f, 1.0f } ); } + if (model.cutsceneSkippable && cutsceneSkipHintVisible) { + choiceRenderer->drawText( + cutsceneSkipHolding ? "Hold to skip..." : "Hold LMB to skip", + W - 232.0f, + H - 50.0f, + 0.85f, + false, + { 1.0f, 1.0f, 1.0f, 0.95f } + ); + } + glDisable(GL_BLEND); } +bool DialogueOverlay::consumeSkipRequested() { + const bool result = cutsceneSkipTriggered; + cutsceneSkipTriggered = false; + + if (result) { + cutsceneSkipHintVisible = false; + cutsceneSkipArmed = false; + cutsceneSkipHolding = false; + cutsceneSkipHintRemainingMs = 0; + cutsceneSkipHoldElapsedMs = 0; + } + + return result; +} + void DialogueOverlay::handlePointerDown(float x, float y, const PresentationModel& model) { + (void)x; + (void)y; + if (model.mode == PresentationMode::Choice) { handlePointerMoved(x, y, model); return; } + + if (model.mode == PresentationMode::Cutscene && model.cutsceneSkippable) { + // First click/tap only arms skip and shows the hint for a short time. + // It does not immediately start skipping, to avoid accidental skip. + if (!cutsceneSkipArmed) { + cutsceneSkipHintVisible = true; + cutsceneSkipArmed = true; + cutsceneSkipHolding = false; + cutsceneSkipTriggered = false; + cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; + cutsceneSkipHoldElapsedMs = 0; + return; + } + + // Once armed, holding anywhere on the screen starts skip progress. + cutsceneSkipHintVisible = true; + cutsceneSkipHintRemainingMs = CutsceneSkipHintDurationMs; + cutsceneSkipHolding = true; + cutsceneSkipTriggered = false; + cutsceneSkipHoldElapsedMs = 0; + return; + } } void DialogueOverlay::handlePointerMoved(float x, float y, const PresentationModel& model) { @@ -458,16 +632,17 @@ bool DialogueOverlay::handlePointerReleased(float x, float y, const Presentation } if (model.mode == PresentationMode::Dialogue) { - if (lastDialogueAdvanceRect.contains(x, y)) { - outAdvanceDialogue = true; - return true; - } - return false; + outAdvanceDialogue = rectContains(lastDialogueAdvanceRect, x, y); + return outAdvanceDialogue; } if (model.mode == PresentationMode::Cutscene) { - return cutsceneAdvanceEnabled && lastCutsceneAdvanceRect.contains(x, y); - } + if (cutsceneSkipHolding && cutsceneSkipHoldElapsedMs < CutsceneSkipHoldDurationMs) { + cutsceneSkipHolding = false; + cutsceneSkipHoldElapsedMs = 0; + } + return true; + } return false; } @@ -525,6 +700,71 @@ std::string DialogueOverlay::wrapText(const std::string& input, size_t maxLineLe return output; } +std::string DialogueOverlay::wrapTextToWidth(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale) +{ + if (input.empty() || maxWidthPx <= 1.0f) { + return input; + } + + std::string output; + std::string currentLine; + std::string currentWord; + + auto flushLine = [&]() { + if (!currentLine.empty()) { + if (!output.empty()) { + output.push_back('\n'); + } + output += currentLine; + currentLine.clear(); + } + }; + + auto pushWord = [&](const std::string& word) { + if (word.empty()) { + return; + } + + if (currentLine.empty()) { + currentLine = word; + return; + } + + const std::string candidate = currentLine + " " + word; + if (textRenderer.measureTextWidth(candidate, scale) <= maxWidthPx) { + currentLine = candidate; + } + else { + flushLine(); + currentLine = word; + } + }; + + for (size_t i = 0; i < input.size(); ++i) { + const char ch = input[i]; + + if (ch == '\n') { + pushWord(currentWord); + currentWord.clear(); + flushLine(); + continue; + } + + if (ch == ' ' || ch == '\t' || ch == '\r') { + pushWord(currentWord); + currentWord.clear(); + continue; + } + + currentWord.push_back(ch); + } + + pushWord(currentWord); + flushLine(); + + return output; +} + bool DialogueOverlay::rectContains(const UiRect& rect, float x, float y) { return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h; } diff --git a/src/dialogue/DialogueOverlay.h b/src/dialogue/DialogueOverlay.h index 66fc822..aae4a0b 100644 --- a/src/dialogue/DialogueOverlay.h +++ b/src/dialogue/DialogueOverlay.h @@ -21,6 +21,7 @@ public: void handlePointerDown(float x, float y, const PresentationModel& model); void handlePointerMoved(float x, float y, const PresentationModel& model); bool handlePointerReleased(float x, float y, const PresentationModel& model, int& outChoiceIndex, bool& outAdvanceDialogue); + bool consumeSkipRequested(); private: struct TexturedQuad { @@ -59,10 +60,22 @@ private: mutable std::vector lastChoiceRects; mutable UiRect lastDialogueAdvanceRect{}; mutable UiRect lastCutsceneAdvanceRect{}; - mutable bool cutsceneAdvanceEnabled = false; + mutable UiRect lastCutsceneSkipRect{}; int hoveredChoiceIndex = -1; + // Cutscene skip UX: + // First LMB/tap anywhere arms skip and shows a hint for 5 seconds. + // While armed, holding LMB/touch anywhere for 3.5 seconds requests skip. + bool cutsceneSkipHintVisible = false; + bool cutsceneSkipArmed = false; + bool cutsceneSkipHolding = false; + bool cutsceneSkipTriggered = false; + int cutsceneSkipHintRemainingMs = 0; + int cutsceneSkipHoldElapsedMs = 0; + static constexpr int CutsceneSkipHintDurationMs = 5000; + static constexpr int CutsceneSkipHoldDurationMs = 3500; + std::unique_ptr nameRenderer; std::unique_ptr bodyRenderer; std::unique_ptr choiceRenderer; @@ -72,6 +85,9 @@ private: TexturedQuad textboxQuad; TexturedQuad subtitleQuad; TexturedQuad backgroundQuad; + TexturedQuad skipHintBgQuad; + TexturedQuad skipProgressBgQuad; + TexturedQuad skipProgressFillQuad; mutable std::vector choiceQuads; std::unordered_map> textureCache; @@ -83,6 +99,7 @@ private: void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr& texture) const; static std::string wrapText(const std::string& input, size_t maxLineLength); + static std::string wrapTextToWidth(const std::string& input, const TextRenderer& textRenderer, float maxWidthPx, float scale); static bool rectContains(const UiRect& rect, float x, float y); static float lerpFloat(float a, float b, float t); diff --git a/src/dialogue/DialogueRuntime.cpp b/src/dialogue/DialogueRuntime.cpp index 5f0fcbd..436ac48 100644 --- a/src/dialogue/DialogueRuntime.cpp +++ b/src/dialogue/DialogueRuntime.cpp @@ -27,12 +27,13 @@ bool DialogueRuntime::startDialogue(const std::string& dialogueId) { currentNodeId.clear(); pendingNodeAfterCutscene.clear(); visibleChoices.clear(); - selectedChoice = 0; + selectedChoice = -1; revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); presentation = {}; presentation.dialogueId = dialogue->id; @@ -45,12 +46,13 @@ void DialogueRuntime::stop() { currentNodeId.clear(); pendingNodeAfterCutscene.clear(); visibleChoices.clear(); - selectedChoice = 0; + selectedChoice = -1; revealCharacters = 0.0f; currentCutsceneLine = -1; cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); mode = Mode::Inactive; presentation = {}; } @@ -155,11 +157,11 @@ void DialogueRuntime::confirmAdvance() { } if (mode == Mode::WaitingForChoice) { - if (visibleChoices.empty()) { + if (visibleChoices.empty() || selectedChoice < 0 || selectedChoice >= static_cast(visibleChoices.size())) { return; } - const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1)]; + const Choice& choice = visibleChoices[selectedChoice]; if (choice.consumeOnce && !choice.id.empty()) { consumedChoices.insert(choice.id); } @@ -169,13 +171,7 @@ void DialogueRuntime::confirmAdvance() { } if (mode == Mode::PlayingCutscene) { - if (!activeCutscene || activeCutscene->lines.empty()) { - return; - } - - if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(activeCutscene->lines.size())) { - advanceCutsceneLine(); - } + return; } } @@ -208,6 +204,43 @@ void DialogueRuntime::selectChoice(int index) { presentation.selectedChoice = selectedChoice; } +bool DialogueRuntime::canSkipCurrentCutscene() const { + return mode == Mode::PlayingCutscene && activeCutscene && activeCutscene->skippable; +} + +void DialogueRuntime::skipCurrentCutscene() { + if (!canSkipCurrentCutscene()) { + return; + } + + // Multi-image cutscenes skip to the next image cue first. + // This matches the desired behavior: skip advances the visual chapter, + // not necessarily the whole cutscene immediately. + if (!activeCutscene->images.empty()) { + int nextImageStartMs = -1; + + for (const CutsceneImageCue& cue : activeCutscene->images) { + if (cue.path.empty()) { + continue; + } + if (cue.startMs > cutsceneElapsedMs) { + if (nextImageStartMs < 0 || cue.startMs < nextImageStartMs) { + nextImageStartMs = cue.startMs; + } + } + } + + if (nextImageStartMs >= 0) { + cutsceneElapsedMs = nextImageStartMs; + syncCutsceneLineToElapsedTime(); + refreshCutscenePresentation(); + return; + } + } + + finishCutscene(); +} + void DialogueRuntime::setFlag(const std::string& name, int value) { flags[name] = value; } @@ -325,10 +358,12 @@ void DialogueRuntime::presentLine(const Node& node) { presentation.portraitPath = node.portrait; presentation.backgroundPath.clear(); presentation.choices.clear(); - presentation.selectedChoice = 0; + presentation.selectedChoice = -1; presentation.revealCompleted = node.text.empty(); presentation.showCutsceneSubtitle = false; + presentation.cutsceneSkippable = false; presentation.cutsceneCamera = {}; + presentation.cutsceneImages.clear(); if (presentation.revealCompleted) { presentation.visibleText = node.text; @@ -372,7 +407,9 @@ void DialogueRuntime::presentChoices(const Node& node) { presentation.selectedChoice = -1; presentation.revealCompleted = true; presentation.showCutsceneSubtitle = false; + presentation.cutsceneSkippable = false; presentation.cutsceneCamera = {}; + presentation.cutsceneImages.clear(); } void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene) { @@ -400,7 +437,21 @@ void DialogueRuntime::startCutscene(const std::string& cutsceneId, const std::st cutsceneElapsedMs = 0; cutsceneTimerMs = 0; currentCutsceneLine = activeCutscene->lines.empty() ? -1 : 0; - cutsceneTotalDurationMs = std::max(activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene)); + int imageTrackDurationMs = 0; + for (size_t i = 0; i < activeCutscene->images.size(); ++i) { + const CutsceneImageCue& cue = activeCutscene->images[i]; + int cueEnd = cue.endMs; + if (cueEnd <= cue.startMs) { + if (i + 1 < activeCutscene->images.size()) { + cueEnd = std::max(activeCutscene->images[i + 1].startMs, cue.startMs); + } + else { + cueEnd = cue.startMs + std::max(cue.fadeInMs, 0) + std::max(cue.fadeOutMs, 0) + 1000; + } + } + imageTrackDurationMs = std::max(imageTrackDurationMs, cueEnd); + } + cutsceneTotalDurationMs = std::max({ activeCutscene->durationMs, computeCameraTrackDurationMs(*activeCutscene), imageTrackDurationMs }); if (cutsceneTotalDurationMs <= 0 && activeCutscene->lines.empty()) { cutsceneTotalDurationMs = 3000; } @@ -420,6 +471,7 @@ void DialogueRuntime::finishCutscene() { cutsceneTimerMs = 0; cutsceneElapsedMs = 0; cutsceneTotalDurationMs = 0; + currentCutsceneBackground.clear(); if (!pendingNodeAfterCutscene.empty()) { const std::string nextNode = pendingNodeAfterCutscene; pendingNodeAfterCutscene.clear(); @@ -430,6 +482,35 @@ void DialogueRuntime::finishCutscene() { } } +void DialogueRuntime::syncCutsceneLineToElapsedTime() { + if (!activeCutscene || activeCutscene->lines.empty()) { + currentCutsceneLine = -1; + cutsceneTimerMs = 0; + return; + } + + int elapsed = std::max(cutsceneElapsedMs, 0); + int accumulatedMs = 0; + + for (size_t i = 0; i < activeCutscene->lines.size(); ++i) { + const CutsceneLine& line = activeCutscene->lines[i]; + const int durationMs = (line.durationMs > 0) + ? line.durationMs + : computeFallbackCutsceneDurationMs(line.text); + + if (elapsed < accumulatedMs + durationMs) { + currentCutsceneLine = static_cast(i); + cutsceneTimerMs = std::max(0, elapsed - accumulatedMs); + return; + } + + accumulatedMs += durationMs; + } + + currentCutsceneLine = -1; + cutsceneTimerMs = 0; +} + void DialogueRuntime::advanceCutsceneLine() { if (!activeCutscene) { stop(); @@ -491,6 +572,78 @@ CutsceneCameraBlendState DialogueRuntime::evaluateCutsceneCameraBlend() const { return result; } +std::vector DialogueRuntime::evaluateCutsceneImages() const { + std::vector result; + if (!activeCutscene) { + return result; + } + + const std::string& fallbackPath = !currentCutsceneBackground.empty() + ? currentCutsceneBackground + : activeCutscene->background; + + if (activeCutscene->images.empty()) { + if (!fallbackPath.empty()) { + result.push_back({ fallbackPath, 1.0f }); + } + return result; + } + + const int effectiveTotalDuration = (cutsceneTotalDurationMs > 0) ? cutsceneTotalDurationMs : std::max(activeCutscene->durationMs, 1); + const int now = std::max(cutsceneElapsedMs, 0); + + for (size_t i = 0; i < activeCutscene->images.size(); ++i) { + const CutsceneImageCue& cue = activeCutscene->images[i]; + if (cue.path.empty()) { + continue; + } + + const int startMs = std::max(cue.startMs, 0); + int endMs = cue.endMs; + if (endMs <= startMs) { + if (i + 1 < activeCutscene->images.size()) { + endMs = std::max(activeCutscene->images[i + 1].startMs, startMs + 1); + } + else { + endMs = effectiveTotalDuration; + } + } + if (endMs <= startMs) { + endMs = startMs + 1; + } + + if (now < startMs || now > endMs) { + continue; + } + + float alpha = 1.0f; + if (cue.fadeInMs > 0 && now < startMs + cue.fadeInMs) { + alpha = std::clamp( + static_cast(now - startMs) / static_cast(cue.fadeInMs), + 0.0f, + 1.0f + ); + } + + if (alpha > 0.0f) { + result.push_back({ cue.path, alpha }); + } + } + + // Safety fallback: never leave the cutscene without an opaque image layer. + if (result.empty() && !fallbackPath.empty()) { + result.push_back({ fallbackPath, 1.0f }); + } + + // If the first active layer is still fading in, put an opaque fallback/base below it. + // This prevents the world from becoming visible behind the cutscene. + if (!result.empty() && result.front().alpha < 0.999f && !fallbackPath.empty() && result.front().path != fallbackPath) { + result.insert(result.begin(), { fallbackPath, 1.0f }); + } + + return result; +} + void DialogueRuntime::refreshCutscenePresentation() { if (!activeCutscene) { return; @@ -499,9 +652,11 @@ void DialogueRuntime::refreshCutscenePresentation() { presentation.mode = PresentationMode::Cutscene; presentation.backgroundPath = activeCutscene->background; presentation.cutsceneCamera = evaluateCutsceneCameraBlend(); + presentation.cutsceneImages = evaluateCutsceneImages(); + presentation.cutsceneSkippable = activeCutscene->skippable; presentation.choices.clear(); - presentation.selectedChoice = 0; + presentation.selectedChoice = -1; presentation.revealCompleted = true; const bool hasSubtitle = currentCutsceneLine >= 0 && currentCutsceneLine < static_cast(activeCutscene->lines.size()); @@ -516,26 +671,24 @@ void DialogueRuntime::refreshCutscenePresentation() { } const CutsceneLine& line = activeCutscene->lines[currentCutsceneLine]; -/*<<<<<<< HEAD + if (!line.background.empty()) { currentCutsceneBackground = line.background; } presentation.mode = PresentationMode::Cutscene; -======= ->>>>>>> witcher001-cutscene*/ presentation.speaker = line.speaker; presentation.fullText = line.text; presentation.visibleText = line.text; presentation.portraitPath = line.portrait; -/*<<<<<<< HEAD + //presentation.backgroundPath = activeCutscene->background; presentation.backgroundPath = currentCutsceneBackground; presentation.choices.clear(); presentation.selectedChoice = 0; presentation.revealCompleted = true; -=======*/ + std::cout << "[CUTSCENE] lines=" << activeCutscene->lines.size() << " current=" << currentCutsceneLine @@ -569,7 +722,6 @@ float DialogueRuntime::applyEasing(EasingType easing, float t) { default: return t; } -//>>>>>>> witcher001-cutscene } int DialogueRuntime::computeFallbackCutsceneDurationMs(const std::string& text) { @@ -630,13 +782,15 @@ bool DialogueRuntime::restoreSaveState(const json& state) { const std::string nodeId = state.value("currentNodeId", ""); pendingNodeAfterCutscene = state.value("pendingNodeAfterCutscene", ""); - selectedChoice = state.value("selectedChoice", 0); + selectedChoice = state.value("selectedChoice", -1); currentCutsceneLine = state.value("currentCutsceneLine", -1); cutsceneTimerMs = state.value("cutsceneTimerMs", 0); const bool ok = nodeId.empty() ? true : enterNode(nodeId); if (mode == Mode::WaitingForChoice && !visibleChoices.empty()) { - selectedChoice = std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1); + if (selectedChoice >= 0) { + selectedChoice = std::clamp(selectedChoice, 0, static_cast(visibleChoices.size()) - 1); + } presentation.selectedChoice = selectedChoice; } return ok; diff --git a/src/dialogue/DialogueRuntime.h b/src/dialogue/DialogueRuntime.h index 6021df8..e4eb135 100644 --- a/src/dialogue/DialogueRuntime.h +++ b/src/dialogue/DialogueRuntime.h @@ -27,6 +27,8 @@ public: void confirmAdvance(); void moveSelection(int delta); void selectChoice(int index); + bool canSkipCurrentCutscene() const; + void skipCurrentCutscene(); const PresentationModel& getPresentation() const { return presentation; } @@ -58,7 +60,7 @@ private: PresentationModel presentation; Mode mode = Mode::Inactive; - int selectedChoice = 0; + int selectedChoice = -1; float revealCharacters = 0.0f; float revealSpeedCharsPerSecond = 52.0f; @@ -77,10 +79,12 @@ private: void presentChoices(const Node& node); void startCutscene(const std::string& cutsceneId, const std::string& nextNodeAfterCutscene); void finishCutscene(); + void syncCutsceneLineToElapsedTime(); void advanceCutsceneLine(); void refreshCutscenePresentation(); CutsceneCameraBlendState evaluateCutsceneCameraBlend() const; + std::vector evaluateCutsceneImages() const; static float applyEasing(EasingType easing, float t); static int computeFallbackCutsceneDurationMs(const std::string& text); diff --git a/src/dialogue/DialogueSystem.cpp b/src/dialogue/DialogueSystem.cpp index 38269b8..6b0f67a 100644 --- a/src/dialogue/DialogueSystem.cpp +++ b/src/dialogue/DialogueSystem.cpp @@ -29,6 +29,10 @@ void DialogueSystem::update(int deltaMs, const Eigen::Vector3f& playerPosition) } runtime.update(deltaMs); + overlay.update(runtime.getPresentation(), deltaMs); + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + } } void DialogueSystem::draw(Renderer& renderer) { @@ -40,6 +44,18 @@ bool DialogueSystem::handleKeyDown(SDL_Keycode key) { return false; } + if (runtime.isPlayingCutscene()) { + switch (key) { + case SDLK_RETURN: + case SDLK_SPACE: + case SDLK_e: + case SDLK_ESCAPE: + return true; + default: + return false; + } + } + switch (key) { case SDLK_RETURN: case SDLK_SPACE: @@ -89,7 +105,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) { bool advanceDialogue = false; const PresentationModel& model = runtime.getPresentation(); if (!overlay.handlePointerReleased(x, y, model, choiceIndex, advanceDialogue)) { - return false; + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + return true; + } + return runtime.isPlayingCutscene(); } if (choiceIndex >= 0) { @@ -103,6 +123,11 @@ bool DialogueSystem::handlePointerReleased(float x, float y) { return true; } + if (overlay.consumeSkipRequested()) { + runtime.skipCurrentCutscene(); + return true; + } + return true; } diff --git a/src/dialogue/DialogueTypes.h b/src/dialogue/DialogueTypes.h index dd73dd2..bb8f31f 100644 --- a/src/dialogue/DialogueTypes.h +++ b/src/dialogue/DialogueTypes.h @@ -135,6 +135,14 @@ struct CutsceneCameraSegment { EasingType easing = EasingType::EaseInOutSine; }; +struct CutsceneImageCue { + std::string path; + int startMs = 0; + int endMs = 0; + int fadeInMs = 0; + int fadeOutMs = 0; +}; + struct StaticCutsceneDefinition { std::string id; std::string background; @@ -142,6 +150,7 @@ struct StaticCutsceneDefinition { bool skippable = true; int durationMs = 0; std::vector cameraTrack; + std::vector images; std::vector lines; }; @@ -151,6 +160,11 @@ struct PresentedChoice { ChoiceKind kind = ChoiceKind::Main; }; +struct PresentedCutsceneImage { + std::string path; + float alpha = 1.0f; +}; + enum class PresentationMode { Hidden, Dialogue, @@ -177,17 +191,19 @@ struct PresentationModel { int selectedChoice = -1; bool revealCompleted = true; bool showCutsceneSubtitle = false; + bool cutsceneSkippable = false; CutsceneCameraBlendState cutsceneCamera; + std::vector cutsceneImages; }; struct SaveState { std::string dialogueId; std::string currentNodeId; std::string pendingNodeAfterCutscene; - std::unordered_set flags; + std::unordered_map flags; std::unordered_set consumedChoices; - int selectedChoice = 0; + int selectedChoice = -1; int currentCutsceneLine = -1; int cutsceneTimerMs = 0; bool active = false; diff --git a/src/quest/QuestJournal.cpp b/src/quest/QuestJournal.cpp new file mode 100644 index 0000000..5e9ae3a --- /dev/null +++ b/src/quest/QuestJournal.cpp @@ -0,0 +1,211 @@ +#include "quest/QuestJournal.h" +#include "external/nlohmann/json.hpp" +#include "utils/Utils.h" +#include +#include + +namespace ZL::Quest { + +using json = nlohmann::json; + +const char* toString(QuestStatus status) { + switch (status) { + case QuestStatus::Hidden: return "Hidden"; + case QuestStatus::Available: return "Available"; + case QuestStatus::Active: return "Active"; + case QuestStatus::Completed: return "Completed"; + case QuestStatus::Failed: return "Failed"; + default: return "Unknown"; + } +} + +const char* toString(QuestCategory category) { + switch (category) { + case QuestCategory::Main: return "Main"; + case QuestCategory::Side: return "Side"; + case QuestCategory::Contract: return "Contract"; + case QuestCategory::Tutorial: return "Tutorial"; + default: return "Unknown"; + } +} + +static QuestStatus parseQuestStatus(const std::string& value) { + if (value == "Available") return QuestStatus::Available; + if (value == "Active") return QuestStatus::Active; + if (value == "Completed") return QuestStatus::Completed; + if (value == "Failed") return QuestStatus::Failed; + return QuestStatus::Hidden; +} + +static QuestCategory parseQuestCategory(const std::string& value) { + if (value == "Main") return QuestCategory::Main; + if (value == "Contract") return QuestCategory::Contract; + if (value == "Tutorial") return QuestCategory::Tutorial; + return QuestCategory::Side; +} + +bool QuestJournal::loadFromFile(const std::string& path, const std::string& zipFile) { + quests.clear(); + questOrder.clear(); + + std::string content; + try { + if (zipFile.empty()) { + content = ZL::readTextFile(path); + } + else { + auto data = ZL::readFileFromZIP(path, zipFile); + content.assign(data.begin(), data.end()); + } + } + catch (const std::exception& e) { + std::cerr << "[quest] Failed to read " << path << ": " << e.what() << std::endl; + return false; + } + + if (content.empty()) { + std::cerr << "[quest] Empty quest file: " << path << std::endl; + return false; + } + + json root; + try { + root = json::parse(content); + } + catch (const std::exception& e) { + std::cerr << "[quest] JSON parse error in " << path << ": " << e.what() << std::endl; + return false; + } + + if (!root.contains("quests") || !root["quests"].is_array()) { + std::cerr << "[quest] Missing quests array in " << path << std::endl; + return false; + } + + for (const auto& item : root["quests"]) { + QuestDefinition def; + def.id = item.value("id", ""); + def.title = item.value("title", def.id); + def.description = item.value("description", ""); + def.category = parseQuestCategory(item.value("category", "Side")); + def.initialStatus = parseQuestStatus(item.value("status", "Hidden")); + def.recommendedLevel = item.value("recommendedLevel", 0); + + if (item.contains("objectives") && item["objectives"].is_array()) { + for (const auto& obj : item["objectives"]) { + QuestObjective objective; + objective.id = obj.value("id", ""); + objective.text = obj.value("text", ""); + objective.completed = obj.value("completed", false); + if (!objective.id.empty()) { + def.objectives.push_back(objective); + } + } + } + + if (def.id.empty()) { + continue; + } + + QuestState state; + state.definition = def; + state.status = def.initialStatus; + state.activeObjectiveIndex = 0; + state.orderIndex = static_cast(questOrder.size()); + + quests[def.id] = std::move(state); + questOrder.push_back(def.id); + } + + return true; +} + +bool QuestJournal::setStatus(const std::string& questId, QuestStatus status) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + quest->status = status; + return true; +} + +bool QuestJournal::unlockQuest(const std::string& questId) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + if (quest->status == QuestStatus::Hidden) { + quest->status = QuestStatus::Available; + } + return true; +} + +bool QuestJournal::startQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Active); +} + +bool QuestJournal::completeQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Completed); +} + +bool QuestJournal::failQuest(const std::string& questId) { + return setStatus(questId, QuestStatus::Failed); +} + +bool QuestJournal::setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + + for (auto& objective : quest->definition.objectives) { + if (objective.id == objectiveId) { + objective.completed = completed; + return true; + } + } + + return false; +} + +bool QuestJournal::setActiveObjective(const std::string& questId, int objectiveIndex) { + QuestState* quest = findQuest(questId); + if (!quest) return false; + + if (objectiveIndex < 0 || objectiveIndex >= static_cast(quest->definition.objectives.size())) { + return false; + } + + quest->activeObjectiveIndex = objectiveIndex; + return true; +} + +QuestState* QuestJournal::findQuest(const std::string& questId) { + auto it = quests.find(questId); + return (it != quests.end()) ? &it->second : nullptr; +} + +const QuestState* QuestJournal::findQuest(const std::string& questId) const { + auto it = quests.find(questId); + return (it != quests.end()) ? &it->second : nullptr; +} + +std::vector QuestJournal::getVisibleQuests() { + std::vector result; + for (const std::string& id : questOrder) { + QuestState* quest = findQuest(id); + if (!quest) continue; + if (quest->status != QuestStatus::Hidden) { + result.push_back(quest); + } + } + return result; +} + +std::vector QuestJournal::getVisibleQuests() const { + std::vector result; + for (const std::string& id : questOrder) { + const QuestState* quest = findQuest(id); + if (!quest) continue; + if (quest->status != QuestStatus::Hidden) { + result.push_back(quest); + } + } + return result; +} + +} // namespace ZL::Quest diff --git a/src/quest/QuestJournal.h b/src/quest/QuestJournal.h new file mode 100644 index 0000000..12eec2d --- /dev/null +++ b/src/quest/QuestJournal.h @@ -0,0 +1,35 @@ +#pragma once + +#include "quest/QuestTypes.h" +#include +#include +#include + +namespace ZL::Quest { + +class QuestJournal { +public: + bool loadFromFile(const std::string& path, const std::string& zipFile = ""); + + bool unlockQuest(const std::string& questId); + bool startQuest(const std::string& questId); + bool completeQuest(const std::string& questId); + bool failQuest(const std::string& questId); + + bool setObjectiveCompleted(const std::string& questId, const std::string& objectiveId, bool completed = true); + bool setActiveObjective(const std::string& questId, int objectiveIndex); + + QuestState* findQuest(const std::string& questId); + const QuestState* findQuest(const std::string& questId) const; + + std::vector getVisibleQuests(); + std::vector getVisibleQuests() const; + +private: + std::unordered_map quests; + std::vector questOrder; + + bool setStatus(const std::string& questId, QuestStatus status); +}; + +} // namespace ZL::Quest diff --git a/src/quest/QuestTypes.h b/src/quest/QuestTypes.h new file mode 100644 index 0000000..a9ca481 --- /dev/null +++ b/src/quest/QuestTypes.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace ZL::Quest { + +enum class QuestStatus { + Hidden, + Available, + Active, + Completed, + Failed +}; + +enum class QuestCategory { + Main, + Side, + Contract, + Tutorial +}; + +struct QuestObjective { + std::string id; + std::string text; + bool completed = false; +}; + +struct QuestDefinition { + std::string id; + std::string title; + std::string description; + QuestCategory category = QuestCategory::Side; + QuestStatus initialStatus = QuestStatus::Hidden; + int recommendedLevel = 0; + std::vector objectives; +}; + +struct QuestState { + QuestDefinition definition; + QuestStatus status = QuestStatus::Hidden; + int activeObjectiveIndex = 0; + int orderIndex = 0; // acquisition/load order; larger value means newer quest +}; + +const char* toString(QuestStatus status); +const char* toString(QuestCategory category); + +} // namespace ZL::Quest diff --git a/src/render/TextRenderer.cpp b/src/render/TextRenderer.cpp index 39efa63..b3abb1b 100644 --- a/src/render/TextRenderer.cpp +++ b/src/render/TextRenderer.cpp @@ -347,6 +347,38 @@ bool TextRenderer::loadGlyphs(const std::string& ttfPath, int pixelSize, const s return true; } +float TextRenderer::measureTextWidth(const std::string& text, float scale) const +{ + if (text.empty()) { + return 0.0f; + } + + float penX = 0.0f; + float maxLineWidth = 0.0f; + + size_t textPos = 0; + while (textPos < text.size()) { + uint32_t cp = nextUtf8Codepoint(text, textPos); + + if (cp == '\n') { + maxLineWidth = max(maxLineWidth, penX); + penX = 0.0f; + continue; + } + + auto it = glyphs.find(cp); + if (it == glyphs.end()) { + continue; + } + + const GlyphInfo& g = it->second; + penX += static_cast(g.advance >> 6) * scale; + } + + maxLineWidth = max(maxLineWidth, penX); + return maxLineWidth; +} + void TextRenderer::drawText(const std::string& text, float x, float y, float scale, bool centered, std::array color) { if (!r || text.empty() || !atlasTexture) return; diff --git a/src/render/TextRenderer.h b/src/render/TextRenderer.h index ae77229..0dd3075 100644 --- a/src/render/TextRenderer.h +++ b/src/render/TextRenderer.h @@ -29,6 +29,9 @@ public: bool init(Renderer& renderer, const std::string& ttfPath, int pixelSize, const std::string& zipfilename); void drawText(const std::string& text, float x, float y, float scale, bool centered, std::array color = { 1.f,1.f,1.f,1.f }); + float measureTextWidth(const std::string& text, float scale = 1.0f) const; + float getLineHeight(float scale = 1.0f) const { return lineHeight * scale; } + // Clear cached meshes (call on window resize / DPI change) void ClearCache();