This commit is contained in:
Vlad 2026-04-30 15:15:11 +06:00
commit 4de8f78eca
29 changed files with 2052 additions and 111 deletions

View File

@ -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})

View File

@ -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

View File

@ -28,34 +28,57 @@
"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,
"x": 266.0,
"y": 16.0,
"width": 40.0,
"height": 40.0,
"text": "X",
@ -64,7 +87,9 @@
"textCentered": true,
"color": [1.0, 1.0, 1.0, 1.0],
"textures": {
"normal": "resources/w/blue.png"
"normal": "resources/w/blue.png",
"hover": "resources/w/blue.png",
"pressed": "resources/w/blue.png"
}
}
]

View File

@ -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]
}
]
}
]
}
}

View File

@ -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": []
}
]
}

View File

@ -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 }
]
}
]
}

BIN
resources/second_cutscene.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,3 +1,5 @@
#version 120
attribute vec3 vPosition;
attribute vec2 vTexCoord;
attribute vec3 vNormal;

View File

@ -1,3 +1,5 @@
#version 120
attribute vec3 vPosition;
attribute vec2 vTexCoord;
attribute vec3 vNormal;

View File

@ -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;
@ -319,6 +326,149 @@ 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<float, 4> 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<int>(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<int>(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<int>(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) {
@ -552,6 +702,10 @@ namespace ZL
activateSlowMoEffect();
break;
case SDLK_j:
toggleQuestJournal();
break;
case SDLK_RETURN:
default:

View File

@ -21,6 +21,7 @@
#include <unordered_set>
#include "Location.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<std::string> 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;

View File

@ -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<float>(mx), Environment::projectionHeight - static_cast<float>(my));
return;
}
}
void Location::handleMotion(int64_t fingerId, int eventX, int eventY, int mx, int my)

View File

@ -4,12 +4,90 @@
#include <fstream>
#include <iostream>
#include <algorithm>
#include <sstream>
#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<float>(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<std::string>();
if (j.contains("fontSize")) tb->fontSize = j["fontSize"].get<int>();
if (j.contains("textCentered")) tb->textCentered = j["textCentered"].get<bool>();
if (j.contains("textPaddingX")) tb->textPaddingX = j["textPaddingX"].get<float>();
if (j.contains("textPaddingY")) tb->textPaddingY = j["textPaddingY"].get<float>();
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<float>();
}
@ -573,6 +699,11 @@ namespace ZL {
}
}
if (j.contains("centered")) tv->centered = j["centered"].get<bool>();
if (j.contains("wrap")) tv->wrap = j["wrap"].get<bool>();
if (j.contains("topAligned")) tv->topAligned = j["topAligned"].get<bool>();
if (j.contains("paddingX")) tv->paddingX = j["paddingX"].get<float>();
if (j.contains("paddingY")) tv->paddingY = j["paddingY"].get<float>();
if (j.contains("maxLines")) tv->maxLines = j["maxLines"].get<int>();
tv->textRenderer = std::make_unique<TextRenderer>();
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<UiNode> 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<UiNode>& 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<float, 4>& color) {
auto tv = findTextView(name);
if (!tv) {
return false;
}
tv->color = color;
return true;
}
std::shared_ptr<UiTextButton> 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<float, 4>& color) {
auto tb = findTextButton(name);
if (!tb) return false;
tb->color = color;
return true;
}
std::shared_ptr<UiNode> UiManager::findNode(const std::string& name) {
if (!root) return nullptr;
return findNodeByName(root, name);

View File

@ -145,6 +145,8 @@ namespace ZL {
int fontSize = 32;
std::array<float, 4> color = { 1.f, 1.f, 1.f, 1.f };
bool textCentered = true;
float textPaddingX = 12.0f;
float textPaddingY = 0.0f;
std::unique_ptr<TextRenderer> textRenderer;
@ -169,14 +171,15 @@ namespace ZL {
int fontSize = 32;
std::array<float, 4> 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> 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<UiNode> 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<void(const std::string&)> cb);
bool setTextButtonPressCallback(const std::string& name, std::function<void(const std::string&)> cb);
bool setTextButtonText(const std::string& name, const std::string& newText);
bool setTextButtonColor(const std::string& name, const std::array<float, 4>& 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<UiTextView> findTextView(const std::string& name);
bool setText(const std::string& name, const std::string& newText);
bool setTextColor(const std::string& name, const std::array<float, 4>& color);
std::shared_ptr<UiTextField> findTextField(const std::string& name);
bool setTextFieldCallback(const std::string& name, std::function<void(const std::string&, const std::string&)> cb);

View File

@ -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));

View File

@ -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);
};

View File

@ -85,9 +85,55 @@ 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) {
@ -95,7 +141,11 @@ void DialogueOverlay::draw(Renderer& renderer, const PresentationModel& model) {
lastChoiceRects.clear();
lastDialogueAdvanceRect = {};
lastCutsceneAdvanceRect = {};
cutsceneAdvanceEnabled = false;
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<float, 4>{0.82f, 0.82f, 0.82f, 1.0f}
: std::array<float, 4>{ 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<float, 4>{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<Texture> 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<float>(bgTexture->getWidth());
const float texH = static_cast<float>(bgTexture->getHeight());
const UiRect screenRect{ 0.0f, 0.0f, W, H };
ResolvedViewport currentViewport{};
std::vector<PresentedCutsceneImage> 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<float>(texture->getWidth());
const float texH = static_cast<float>(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<float>(cutsceneSkipHoldElapsedMs) / static_cast<float>(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,15 +632,16 @@ 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;
}

View File

@ -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<UiRect> 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<TextRenderer> nameRenderer;
std::unique_ptr<TextRenderer> bodyRenderer;
std::unique_ptr<TextRenderer> choiceRenderer;
@ -72,6 +85,9 @@ private:
TexturedQuad textboxQuad;
TexturedQuad subtitleQuad;
TexturedQuad backgroundQuad;
TexturedQuad skipHintBgQuad;
TexturedQuad skipProgressBgQuad;
TexturedQuad skipProgressFillQuad;
mutable std::vector<TexturedQuad> choiceQuads;
std::unordered_map<std::string, std::shared_ptr<Texture>> textureCache;
@ -83,6 +99,7 @@ private:
void drawQuad(Renderer& renderer, const TexturedQuad& quad, const std::shared_ptr<Texture>& 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);

View File

@ -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<int>(visibleChoices.size())) {
return;
}
const Choice& choice = visibleChoices[std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1)];
const Choice& choice = visibleChoices[selectedChoice];
if (choice.consumeOnce && !choice.id.empty()) {
consumedChoices.insert(choice.id);
}
@ -169,14 +171,8 @@ void DialogueRuntime::confirmAdvance() {
}
if (mode == Mode::PlayingCutscene) {
if (!activeCutscene || activeCutscene->lines.empty()) {
return;
}
if (currentCutsceneLine >= 0 && currentCutsceneLine < static_cast<int>(activeCutscene->lines.size())) {
advanceCutsceneLine();
}
}
}
void DialogueRuntime::moveSelection(int delta) {
@ -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<int>(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<PresentedCutsceneImage> DialogueRuntime::evaluateCutsceneImages() const {
std::vector<PresentedCutsceneImage> 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<float>(now - startMs) / static_cast<float>(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<int>(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()) {
if (selectedChoice >= 0) {
selectedChoice = std::clamp(selectedChoice, 0, static_cast<int>(visibleChoices.size()) - 1);
}
presentation.selectedChoice = selectedChoice;
}
return ok;

View File

@ -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<PresentedCutsceneImage> evaluateCutsceneImages() const;
static float applyEasing(EasingType easing, float t);
static int computeFallbackCutsceneDurationMs(const std::string& text);

View File

@ -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;
}

View File

@ -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<CutsceneCameraSegment> cameraTrack;
std::vector<CutsceneImageCue> images;
std::vector<CutsceneLine> 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<PresentedCutsceneImage> cutsceneImages;
};
struct SaveState {
std::string dialogueId;
std::string currentNodeId;
std::string pendingNodeAfterCutscene;
std::unordered_set<std::string, int> flags;
std::unordered_map<std::string, int> flags;
std::unordered_set<std::string> consumedChoices;
int selectedChoice = 0;
int selectedChoice = -1;
int currentCutsceneLine = -1;
int cutsceneTimerMs = 0;
bool active = false;

211
src/quest/QuestJournal.cpp Normal file
View File

@ -0,0 +1,211 @@
#include "quest/QuestJournal.h"
#include "external/nlohmann/json.hpp"
#include "utils/Utils.h"
#include <algorithm>
#include <iostream>
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<int>(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<int>(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<QuestState*> QuestJournal::getVisibleQuests() {
std::vector<QuestState*> 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<const QuestState*> QuestJournal::getVisibleQuests() const {
std::vector<const QuestState*> 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

35
src/quest/QuestJournal.h Normal file
View File

@ -0,0 +1,35 @@
#pragma once
#include "quest/QuestTypes.h"
#include <string>
#include <unordered_map>
#include <vector>
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<QuestState*> getVisibleQuests();
std::vector<const QuestState*> getVisibleQuests() const;
private:
std::unordered_map<std::string, QuestState> quests;
std::vector<std::string> questOrder;
bool setStatus(const std::string& questId, QuestStatus status);
};
} // namespace ZL::Quest

49
src/quest/QuestTypes.h Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <string>
#include <vector>
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<QuestObjective> 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

View File

@ -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<float>(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<float, 4> color)
{
if (!r || text.empty() || !atlasTexture) return;

View File

@ -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<float, 4> 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();