From d38a75ea72ffe169e5f045075e4a27b3ff2b790a Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Fri, 23 Oct 2026 22:47:55 +0600 Subject: [PATCH] working on campaign --- proj-android/app/jni/src/CMakeLists.txt | 1 + proj-linux/CMakeLists.txt | 1 + proj-web/CMakeLists.txt | 1 + proj-windows/CMakeLists.txt | 1 + resources/config/campaigns.json | 30 ++ .../locations/battle_zone_uni_exterior.json | 11 + resources/config/locations/location_dorm.json | 32 ++ resources/config/locations/uni_exterior.json | 27 + resources/config/locations/uni_interior.json | 31 ++ resources/config/npcs_battle_zone.json | 4 + resources/config/quests_battle_zone.json | 4 + resources/config/start_state_battle_zone.json | 131 +++++ resources/dialogue/battle_zone_dialogues.json | 4 + resources/start_battle_zone.lua | 15 + resources/w/ui/screen_campaign.json | 90 ++++ resources/w/ui_en/screen_campaign.json | 90 ++++ src/Campaign.cpp | 112 ++++ src/Campaign.h | 76 +++ src/Game.cpp | 503 ++++++------------ src/Game.h | 35 +- src/GameState.cpp | 3 + src/GameState.h | 6 +- src/MenuManager.cpp | 122 ++--- src/MenuManager.h | 9 +- 24 files changed, 929 insertions(+), 410 deletions(-) create mode 100644 resources/config/campaigns.json create mode 100644 resources/config/locations/battle_zone_uni_exterior.json create mode 100644 resources/config/locations/location_dorm.json create mode 100644 resources/config/locations/uni_exterior.json create mode 100644 resources/config/locations/uni_interior.json create mode 100644 resources/config/npcs_battle_zone.json create mode 100644 resources/config/quests_battle_zone.json create mode 100644 resources/config/start_state_battle_zone.json create mode 100644 resources/dialogue/battle_zone_dialogues.json create mode 100644 resources/start_battle_zone.lua create mode 100644 resources/w/ui/screen_campaign.json create mode 100644 resources/w/ui_en/screen_campaign.json create mode 100644 src/Campaign.cpp create mode 100644 src/Campaign.h diff --git a/proj-android/app/jni/src/CMakeLists.txt b/proj-android/app/jni/src/CMakeLists.txt index 4d66f49..5e757b3 100644 --- a/proj-android/app/jni/src/CMakeLists.txt +++ b/proj-android/app/jni/src/CMakeLists.txt @@ -112,6 +112,7 @@ add_library(main SHARED ../../../../src/items/Item.cpp ../../../../src/items/ItemRegistry.h ../../../../src/items/ItemRegistry.cpp + ../../../../src/Campaign.cpp ../../../../src/items/InteractiveObject.h ../../../../src/items/InteractiveObject.cpp ../../../../src/items/InteractiveObjectState.h diff --git a/proj-linux/CMakeLists.txt b/proj-linux/CMakeLists.txt index 61bca5c..ab7902c 100644 --- a/proj-linux/CMakeLists.txt +++ b/proj-linux/CMakeLists.txt @@ -137,6 +137,7 @@ add_executable(ShadowOverBishkekDemo ../src/items/Item.cpp ../src/items/ItemRegistry.h ../src/items/ItemRegistry.cpp + ../src/Campaign.cpp ../src/items/InteractiveObject.h ../src/items/InteractiveObject.cpp ../src/items/InteractiveObjectState.h diff --git a/proj-web/CMakeLists.txt b/proj-web/CMakeLists.txt index 14284cb..fa92489 100644 --- a/proj-web/CMakeLists.txt +++ b/proj-web/CMakeLists.txt @@ -131,6 +131,7 @@ set(SOURCES ../src/items/Item.cpp ../src/items/ItemRegistry.h ../src/items/ItemRegistry.cpp + ../src/Campaign.cpp ../src/items/InteractiveObject.h ../src/items/InteractiveObject.cpp ../src/items/InteractiveObjectState.h diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index d3c9678..40ae8bb 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -79,6 +79,7 @@ add_executable(ShadowOverBishkekDemo WIN32 ../src/items/Item.cpp ../src/items/ItemRegistry.h ../src/items/ItemRegistry.cpp + ../src/Campaign.cpp ../src/items/InteractiveObject.h ../src/items/InteractiveObject.cpp ../src/items/InteractiveObjectState.h diff --git a/resources/config/campaigns.json b/resources/config/campaigns.json new file mode 100644 index 0000000..7669b1a --- /dev/null +++ b/resources/config/campaigns.json @@ -0,0 +1,30 @@ +{ + "campaigns": [ + { + "id": "main", + "title": { "ru": "Основная игра", "en": "Main Game" }, + "locations": [ + "resources/config/locations/location_dorm.json", + "resources/config/locations/uni_exterior.json", + "resources/config/locations/uni_interior.json" + ], + "startLocation": "location_dorm", + "startMusic": "music/obshaga.ogg", + "startState": "resources/config/start_state.json", + "quests": "resources/config/quests.json", + "tutorial": true + }, + { + "id": "battle_zone", + "title": { "ru": "Зона боя", "en": "Battle Zone" }, + "locations": [ + "resources/config/locations/battle_zone_uni_exterior.json" + ], + "startLocation": "uni_exterior", + "startMusic": "music/bishkek univer day.ogg", + "startState": "resources/config/start_state_battle_zone.json", + "quests": "resources/config/quests_battle_zone.json", + "tutorial": false + } + ] +} diff --git a/resources/config/locations/battle_zone_uni_exterior.json b/resources/config/locations/battle_zone_uni_exterior.json new file mode 100644 index 0000000..707856b --- /dev/null +++ b/resources/config/locations/battle_zone_uni_exterior.json @@ -0,0 +1,11 @@ +{ + "id": "uni_exterior", + "gameObjects": "resources/config/gameobjects_uni_exterior_x.json", + "npcs": "resources/config/npcs_battle_zone.json", + "dialogues": "resources/dialogue/battle_zone_dialogues.json", + "navigation": [ + "resources/navigation/uni_exterior3_trees_car.txt" + ], + "script": "resources/start_battle_zone.lua", + "playerPosition": [5, 0, -18.4] +} diff --git a/resources/config/locations/location_dorm.json b/resources/config/locations/location_dorm.json new file mode 100644 index 0000000..1fb2bf1 --- /dev/null +++ b/resources/config/locations/location_dorm.json @@ -0,0 +1,32 @@ +{ + "id": "location_dorm", + "gameObjects": "resources/config/gameobjects_dorm_new_x.json", + "interactiveObjects": "resources/config/interactive_objects_dorm_x.json", + "npcs": "resources/config/npcs_dorm.json", + "dialogues": "resources/dialogue/dorm_dialogues004.json", + "navigation": [ + "resources/navigation/dorm3_bca.txt", + "resources/navigation/dorm3_ca.txt", + "resources/navigation/dorm3_ba.txt", + "resources/navigation/dorm3_a.txt", + "resources/navigation/dorm3_b.txt", + "resources/navigation/dorm3_all_open.txt" + ], + "script": "resources/start_dorm.lua", + "teleports": "resources/config/teleports_dorm.json", + "triggerZones": "resources/config/trigger_zones_dorm.json", + "lights": "resources/config/lights_dorm.json", + "playerPosition": [6.76345, 0, -14.6022], + "taxi": { + "triggerPosition": [-19.0, 0, 0], + "waypoints": [ + [-20.85, 0, 30.0], + [-19.35, 0, 24.0], + [-19.35, 0, 14.0] + ], + "initialPosition": [-20.85, 0, 64], + "initialRotation": 0, + "waypointReachRadius": 5 + }, + "lockInteractiveObjectsDuringTutorial": true +} diff --git a/resources/config/locations/uni_exterior.json b/resources/config/locations/uni_exterior.json new file mode 100644 index 0000000..3b6f983 --- /dev/null +++ b/resources/config/locations/uni_exterior.json @@ -0,0 +1,27 @@ +{ + "id": "uni_exterior", + "gameObjects": "resources/config/gameobjects_uni_exterior_x.json", + "interactiveObjects": "resources/config/interactive_objects_uni_exterior_x.json", + "npcs": "resources/config/npcs_uni_exterior.json", + "dialogues": "resources/dialogue/uni_exterior_dialogues003.json", + "navigation": [ + "resources/navigation/uni_exterior3_trees_car.txt", + "resources/navigation/uni_exterior3_trees.txt" + ], + "script": "resources/start_uni_exterior.lua", + "teleports": "resources/config/teleports_uni_exterior.json", + "triggerZones": "resources/config/trigger_zones_uni_exterior.json", + "lights": "resources/config/lights_uni_exterior.json", + "playerPosition": [5, 0, -18.4], + "taxi": { + "triggerPosition": [-17.6, 0.05, -45], + "waypoints": [ + [-17.6, 0.05, -90.0], + [-17.6, 0.05, -75.0], + [-17.6, 0.05, -55.0] + ], + "initialPosition": [-17.6, 0.05, -100], + "initialRotation": 180, + "waypointReachRadius": 5 + } +} diff --git a/resources/config/locations/uni_interior.json b/resources/config/locations/uni_interior.json new file mode 100644 index 0000000..beea079 --- /dev/null +++ b/resources/config/locations/uni_interior.json @@ -0,0 +1,31 @@ +{ + "id": "uni_interior", + "gameObjects": "resources/config/gameobjects_uni_interior_x.json", + "interactiveObjects": "resources/config/interactive_objects_uni_interior_x.json", + "npcs": "resources/config/npcs_uni_interior.json", + "dialogues": "resources/dialogue/uni_interior_dialogues_010.json", + "navigation": [ + "resources/navigation/uni_interior4_unlocked_n2.txt", + "resources/navigation/uni_interior3_n2_hall.txt", + "resources/navigation/uni_interior4_n2_lr_hall.txt", + "resources/navigation/uni_interior4_n2_lr_tr_hall.txt", + "resources/navigation/uni_interior3_n2_lr_tr_hall_aiperi.txt", + "resources/navigation/uni_interior3_darklands_all_open.txt", + "resources/navigation/uni_interior4_unlocked_hall.txt", + "resources/navigation/uni_interior4_unlocked_n3.txt", + "resources/navigation/uni_interior4_unlocked_s1.txt", + "resources/navigation/uni_interior4_unlocked_s2.txt", + "resources/navigation/uni_interior4_unlocked_s3.txt", + "resources/navigation/uni_interior4_unlocked_s3_s1.txt", + "resources/navigation/uni_interior4_unlocked_s3_hall.txt", + "resources/navigation/uni_interior4_unlocked_s3_n3.txt", + "resources/navigation/uni_interior4_unlocked_s3_n2.txt", + "resources/navigation/uni_interior4_unlocked_s3_s2.txt", + "resources/navigation/uni_interior4_locked.txt" + ], + "script": "resources/start_uni_interior.lua", + "teleports": "resources/config/teleports_uni_interior.json", + "triggerZones": "resources/config/trigger_zones_uni_interior.json", + "lights": "resources/config/lights_uni_interior.json", + "playerPosition": [0.942694, 0, -9.63104] +} diff --git a/resources/config/npcs_battle_zone.json b/resources/config/npcs_battle_zone.json new file mode 100644 index 0000000..2532f81 --- /dev/null +++ b/resources/config/npcs_battle_zone.json @@ -0,0 +1,4 @@ +{ + "npcs": [ + ] +} diff --git a/resources/config/quests_battle_zone.json b/resources/config/quests_battle_zone.json new file mode 100644 index 0000000..c3dfd9f --- /dev/null +++ b/resources/config/quests_battle_zone.json @@ -0,0 +1,4 @@ +{ + "quests": [ + ] +} diff --git a/resources/config/start_state_battle_zone.json b/resources/config/start_state_battle_zone.json new file mode 100644 index 0000000..30908e8 --- /dev/null +++ b/resources/config/start_state_battle_zone.json @@ -0,0 +1,131 @@ +{ + "campaignId": "battle_zone", + "chatHistory": [ + [], + [], + [] + ], + "chatPreviewMsg": [ + "", + "", + "" + ], + "chatUnread": [ + true, + true, + true + ], + "currentLocationName": "uni_exterior", + "globalFloats": { + "player_hp": 200.0 + }, + "globalInts": {}, + "inventory": { + "items": [] + }, + "isDarklands": false, + "isDawn": false, + "isNight": false, + "locations": { + "uni_exterior": { + "dialogueState": { + "active": false + }, + "interactiveObjectStates": [], + "locationState": { + "activeNavigationIndex": 0, + "cameraAzimuth": -2.3499999046325684, + "cameraInclination": 1.103600025177002, + "isDarklands": false, + "isDawn": false, + "isNight": false, + "targetInteractNpcIndex": -1, + "targetInteractiveObjectIndex": -1, + "targetTeleportZoneIndex": -1, + "taxi": { + "taxiArrivedCallbackCalled": false, + "taxiCar": { + "currentWaypoint": 0, + "mode": 1, + "positionX": -100.0, + "positionY": -100.0, + "positionZ": -100.0, + "rotation": 0.0, + "steeringAngle": 0.0, + "velocity": 0.0, + "waypointReachRadius": 3.0, + "waypoints": [] + }, + "taxiCarInitialState": { + "currentWaypoint": 0, + "mode": 1, + "positionX": -100.0, + "positionY": -100.0, + "positionZ": -100.0, + "rotation": 0.0, + "steeringAngle": 0.0, + "velocity": 0.0, + "waypointReachRadius": 3.0, + "waypoints": [] + }, + "taxiCarTriggerPositionX": 0.0, + "taxiCarTriggerPositionY": 0.0, + "taxiCarTriggerPositionZ": 0.0, + "taxiDefaultWaypoints": [] + } + }, + "npcStates": [], + "playerState": { + "attackTargetIndex": -1, + "attack_cooldown": 0.0, + "battle_state": 0, + "currentState": 0, + "currentWaypointIndex": 0, + "enabled": true, + "faceTargetIndex": -1, + "facingAngle": 0.0, + "homeDriftCheckTimer": 5.0, + "homePosition": [ + 0.0, + 0.0, + 0.0 + ], + "hp": 200.0, + "pathWaypoints": [], + "position": [ + 5.0, + 0.0, + -18.399999618530273 + ], + "requestedWalkTarget": [ + 5.0, + 0.0, + -18.399999618530273 + ], + "showWeapon": true, + "targetFacingAngle": 0.0, + "visibleRange": 6.0, + "walkTarget": [ + 5.0, + 0.0, + -18.399999618530273 + ] + }, + "scriptGlobals": null, + "teleportZoneState": {}, + "triggerZoneState": {} + } + }, + "money": 5500, + "playerHp": 200.0, + "playerMaxHp": 200.0, + "questJournal": { + "quests": [] + }, + "savedAt": "2026-07-24 23:40", + "taxiIsCalled": false, + "tutorialJournalScreenOpened": false, + "tutorialNeedOpenTaxiScreen": false, + "tutorialPhoneChatScreenOpened": false, + "tutorialStep": 9 +} \ No newline at end of file diff --git a/resources/dialogue/battle_zone_dialogues.json b/resources/dialogue/battle_zone_dialogues.json new file mode 100644 index 0000000..937436a --- /dev/null +++ b/resources/dialogue/battle_zone_dialogues.json @@ -0,0 +1,4 @@ +{ + "dialogues": [ + ] +} diff --git a/resources/start_battle_zone.lua b/resources/start_battle_zone.lua new file mode 100644 index 0000000..77b8294 --- /dev/null +++ b/resources/start_battle_zone.lua @@ -0,0 +1,15 @@ +-- ============================================ +-- BATTLE ZONE (campaign "battle_zone") +-- uni_exterior level with static objects and navigation only. +-- ============================================ + +function callback_enter_location() +end + +function callback_exit_location() +end + +game_api.set_location_callbacks( + callback_enter_location, + callback_exit_location +) diff --git a/resources/w/ui/screen_campaign.json b/resources/w/ui/screen_campaign.json new file mode 100644 index 0000000..e0e2cc7 --- /dev/null +++ b/resources/w/ui/screen_campaign.json @@ -0,0 +1,90 @@ +{ + "root": { + "type": "FrameLayout", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "StaticImage", + "name": "campaignBkg", + "width": 2992, + "height": 1632, + "horizontal_gravity": "center", + "vertical_gravity": "center", + "texture": "resources/w/ui/img/main/MenuBkg001_v001.png" + }, + { + "type": "TextView", + "name": "campaignTitle", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 200, + "width": 300, + "height": 70, + "text": "Выберите кампанию:", + "fontSize": 48, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot1Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 130, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot2Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 60, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot3Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": -10, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot4Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": -80, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignBackButton", + "horizontal_gravity": "center", + "vertical_gravity": "bottom", + "y": 40, + "width": 200, + "height": 60, + "text": "Назад", + "fontSize": 40, + "textCentered": true + } + ] + } +} diff --git a/resources/w/ui_en/screen_campaign.json b/resources/w/ui_en/screen_campaign.json new file mode 100644 index 0000000..d2d0c2e --- /dev/null +++ b/resources/w/ui_en/screen_campaign.json @@ -0,0 +1,90 @@ +{ + "root": { + "type": "FrameLayout", + "width": "match_parent", + "height": "match_parent", + "children": [ + { + "type": "StaticImage", + "name": "campaignBkg", + "width": 2992, + "height": 1632, + "horizontal_gravity": "center", + "vertical_gravity": "center", + "texture": "resources/w/ui/img/main/MenuBkg001_v001.png" + }, + { + "type": "TextView", + "name": "campaignTitle", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 200, + "width": 300, + "height": 70, + "text": "Choose a campaign:", + "fontSize": 48, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot1Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 130, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot2Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": 60, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot3Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": -10, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignSlot4Button", + "horizontal_gravity": "center", + "vertical_gravity": "center", + "y": -80, + "width": 400, + "height": 70, + "text": "", + "fontSize": 32, + "textCentered": true + }, + { + "type": "TextButton", + "name": "campaignBackButton", + "horizontal_gravity": "center", + "vertical_gravity": "bottom", + "y": 40, + "width": 200, + "height": 60, + "text": "Back", + "fontSize": 40, + "textCentered": true + } + ] + } +} diff --git a/src/Campaign.cpp b/src/Campaign.cpp new file mode 100644 index 0000000..fb4f77a --- /dev/null +++ b/src/Campaign.cpp @@ -0,0 +1,112 @@ +#include "Campaign.h" +#include "utils/Utils.h" +#include "external/nlohmann/json.hpp" +#include + +namespace FRG { + + using json = nlohmann::json; + + static bool parseJsonFile(const std::string& path, const std::string& zipFile, json& out) { + const std::string content = readLocalizedConfigFile(path, zipFile); + if (content.empty()) { + std::cerr << "[Campaign] Failed to read " << path << "\n"; + return false; + } + try { + out = json::parse(content); + } catch (const std::exception& e) { + std::cerr << "[Campaign] JSON parse error in " << path << ": " << e.what() << "\n"; + return false; + } + return true; + } + + static Eigen::Vector3f readVec3(const json& j) { + if (!j.is_array() || j.size() != 3) return Eigen::Vector3f::Zero(); + return Eigen::Vector3f(j[0].get(), j[1].get(), j[2].get()); + } + + bool LocationConfig::fromFile(const std::string& path, const std::string& zipFile, LocationConfig& out) { + json j; + if (!parseJsonFile(path, zipFile, j)) return false; + + out = LocationConfig{}; + out.id = j.value("id", ""); + out.gameObjectsJsonPath = j.value("gameObjects", ""); + out.interactiveObjectsJsonPath = j.value("interactiveObjects", ""); + out.npcsJsonPath = j.value("npcs", ""); + out.dialoguesJsonPath = j.value("dialogues", ""); + out.navigationJsonPaths = j.value("navigation", std::vector{}); + out.scriptPath = j.value("script", ""); + out.teleportsJsonPath = j.value("teleports", ""); + out.triggerZonesJsonPath = j.value("triggerZones", ""); + out.lightsJsonPath = j.value("lights", ""); + if (j.contains("playerPosition")) out.playerPosition = readVec3(j["playerPosition"]); + out.lockInteractiveObjectsDuringTutorial = j.value("lockInteractiveObjectsDuringTutorial", false); + + if (j.contains("taxi") && j["taxi"].is_object()) { + const json& t = j["taxi"]; + Taxi taxi; + if (t.contains("triggerPosition")) taxi.triggerPosition = readVec3(t["triggerPosition"]); + if (t.contains("initialPosition")) taxi.initialPosition = readVec3(t["initialPosition"]); + if (t.contains("waypoints") && t["waypoints"].is_array()) { + for (const auto& w : t["waypoints"]) taxi.waypoints.push_back(readVec3(w)); + } + taxi.initialRotation = t.value("initialRotation", taxi.initialRotation); + taxi.waypointReachRadius = t.value("waypointReachRadius", taxi.waypointReachRadius); + out.taxi = taxi; + } + + if (out.id.empty()) { + std::cerr << "[Campaign] Location config without an id: " << path << "\n"; + return false; + } + return true; + } + + std::string CampaignConfig::title(Language lang) const { + auto it = titles.find(languageToCode(lang)); + if (it != titles.end()) return it->second; + return titles.empty() ? id : titles.begin()->second; + } + + CampaignRegistry& CampaignRegistry::instance() { + static CampaignRegistry reg; + return reg; + } + + void CampaignRegistry::loadFromJson(const std::string& jsonPath, const std::string& zipFile) { + json j; + if (!parseJsonFile(jsonPath, zipFile, j)) return; + if (!j.contains("campaigns") || !j["campaigns"].is_array()) { + std::cerr << "[Campaign] No 'campaigns' array in " << jsonPath << "\n"; + return; + } + + campaigns_.clear(); + for (const auto& entry : j["campaigns"]) { + CampaignConfig c; + c.id = entry.value("id", ""); + if (c.id.empty()) continue; + c.titles = entry.value("title", std::map{}); + c.locationConfigPaths = entry.value("locations", std::vector{}); + c.startLocation = entry.value("startLocation", ""); + c.startMusic = entry.value("startMusic", ""); + c.startStatePath = entry.value("startState", ""); + c.questsPath = entry.value("quests", ""); + c.tutorial = entry.value("tutorial", false); + campaigns_.push_back(std::move(c)); + } + + logger() << "[Campaign] Loaded " << campaigns_.size() << " campaigns from " << jsonPath << "\n"; + } + + const CampaignConfig* CampaignRegistry::find(const std::string& id) const { + for (const auto& c : campaigns_) { + if (c.id == id) return &c; + } + return nullptr; + } + +} // namespace FRG diff --git a/src/Campaign.h b/src/Campaign.h new file mode 100644 index 0000000..67ce177 --- /dev/null +++ b/src/Campaign.h @@ -0,0 +1,76 @@ +#pragma once +#include "Localization.h" +#include +#include +#include +#include +#include + +namespace FRG { + + // One location of a campaign, loaded from resources/config/locations/.json. + // Optional paths may be left empty; Location::setup skips them. + struct LocationConfig { + std::string id; + std::string gameObjectsJsonPath; + std::string interactiveObjectsJsonPath; + std::string npcsJsonPath; + std::string dialoguesJsonPath; + std::vector navigationJsonPaths; + std::string scriptPath; + std::string teleportsJsonPath; + std::string triggerZonesJsonPath; + std::string lightsJsonPath; + Eigen::Vector3f playerPosition = Eigen::Vector3f::Zero(); + + // Where the taxi drives in when the player calls it. No taxi when absent. + struct Taxi { + Eigen::Vector3f triggerPosition = Eigen::Vector3f::Zero(); + std::vector waypoints; + Eigen::Vector3f initialPosition = Eigen::Vector3f::Zero(); + float initialRotation = 0.f; + float waypointReachRadius = 5.f; + }; + std::optional taxi; + + // Interactive objects stay locked until the tutorial reaches "pick up an item". + bool lockInteractiveObjectsDuringTutorial = false; + + // Returns false (and logs) when the file can't be read or parsed. + static bool fromFile(const std::string& path, const std::string& zipFile, LocationConfig& out); + }; + + // A playable campaign: its own set of locations, quests and starting state. + struct CampaignConfig { + std::string id; + std::map titles; // language code -> title + std::vector locationConfigPaths; + std::string startLocation; + std::string startMusic; + std::string startStatePath; + std::string questsPath; + bool tutorial = false; + + std::string title(Language lang) const; + }; + + // Campaigns listed in resources/config/campaigns.json, in display order. + class CampaignRegistry { + public: + static constexpr const char* kDefaultCampaignId = "main"; + + static CampaignRegistry& instance(); + + void loadFromJson(const std::string& jsonPath, const std::string& zipFile = ""); + + const std::vector& all() const { return campaigns_; } + // Returns nullptr if not found. + const CampaignConfig* find(const std::string& id) const; + + private: + CampaignRegistry() = default; + + std::vector campaigns_; + }; + +} // namespace FRG diff --git a/src/Game.cpp b/src/Game.cpp index cf07c02..b791fac 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -4,6 +4,7 @@ #include "utils/Utils.h" #include #include "items/ItemRegistry.h" +#include "Campaign.h" #include "render/OpenGlExtensions.h" #include #include "render/TextureManager.h" @@ -326,24 +327,18 @@ namespace FRG Character::nameTranslations.loadFromFile("resources/config/translate_names.json"); }); - // createLocations() is also used by the full-reset path (see performFullReset()), - // so it stays a single synchronous function rather than being split into - // per-location queue steps; it's queued here as one coarse step. + // Every campaign's locations are built up front (one loading step per location), + // so starting or loading any campaign later only switches the active set. + CampaignRegistry::instance().loadFromJson("resources/config/campaigns.json", CONST_ZIP_FILE); loadSteps.push([this]() { - createLocationsStep1(); + gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load }); + for (const CampaignConfig& campaign : CampaignRegistry::instance().all()) { + queueCampaignBuildSteps(campaign); + } loadSteps.push([this]() { - createLocationsStep2(); - }); - loadSteps.push([this]() { - createLocationsStep3(); - }); - loadSteps.push([this]() { - createLocationsStep4(); - }); - loadSteps.push([this]() { - createLocationsStep5(); - }); + activateCampaign(CampaignRegistry::kDefaultCampaignId); + }); loadSteps.push([this]() { // Wire inventory callbacks: tutorial tracking + toast notifications. @@ -380,6 +375,12 @@ namespace FRG menuManager.skipCutsceneFunc = [this]() { if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene(); }; + + menuManager.callTaxiFunc = [this]() { + // The taxi hint has served its purpose once a taxi is on its way. + for (auto& [name, loc] : gameState.locations) loc->onPlayerTaxiRequired = nullptr; + if (currentLocation()) currentLocation()->onTaxiCalled(); + }; }); loadSteps.push([this]() { @@ -391,15 +392,17 @@ namespace FRG std::cerr << "Failed to load UI: " << e.what() << std::endl; } - menuManager.startGameFunc = [this]() { - mainThreadHandler.EnqueueMainThreadTask([this]() { + menuManager.startGameFunc = [this](const std::string& campaignId) { + mainThreadHandler.EnqueueMainThreadTask([this, campaignId]() { + activateCampaign(campaignId); performResetToInitialState(); applyTutorialSetting(); - gameState.currentLocationName = "location_dorm"; + const CampaignConfig& campaign = activeCampaign(); + gameState.currentLocationName = campaign.startLocation; menuManager.showGameplayHud(); // first HUD frame drawn from the fresh state - currentLocation()->scriptEngine.callLocationEnterCallback(); - this->audioPlayer->crossFadeMusicAsync("music/obshaga.ogg"); - menuManager.lastMusicTrack = "music/obshaga.ogg"; + if (currentLocation()) currentLocation()->scriptEngine.callLocationEnterCallback(); + audioPlayer->crossFadeMusicAsync(campaign.startMusic); + menuManager.lastMusicTrack = campaign.startMusic; }); }; @@ -466,13 +469,49 @@ namespace FRG loadStepsDone = 0; } - void Game::rearmDormOneShotCallbacks() { - auto it = gameState.locations.find("location_dorm"); - if (it == gameState.locations.end() || !it->second) return; + void Game::rearmOneShotCallbacks(LocationMap& locations) { + for (auto& [name, loc] : locations) { + loc->onPlayerTaxiRequired = [this]() { + menuManager.tutorialShowTaxiHint(); + }; + } + } - it->second->onPlayerTaxiRequired = [this]() { - menuManager.tutorialShowTaxiHint(); - }; + void Game::activateCampaign(const std::string& campaignId) { + auto it = campaignLocations.find(campaignId); + if (it == campaignLocations.end()) { + std::cerr << "[campaign] Unknown campaign '" << campaignId << "', using '" + << CampaignRegistry::kDefaultCampaignId << "'" << std::endl; + it = campaignLocations.find(CampaignRegistry::kDefaultCampaignId); + if (it == campaignLocations.end()) return; + } + gameState.campaignId = it->first; + gameState.locations = it->second; + gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE); + } + + const CampaignConfig& Game::activeCampaign() const { + const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState.campaignId); + if (!campaign) campaign = CampaignRegistry::instance().find(CampaignRegistry::kDefaultCampaignId); + if (!campaign) { + static const CampaignConfig empty; + return empty; + } + return *campaign; + } + + void Game::queueCampaignBuildSteps(const CampaignConfig& campaign) { + const std::string campaignId = campaign.id; + for (const std::string& configPath : campaign.locationConfigPaths) { + loadSteps.push([this, campaignId, configPath]() { + LocationConfig config; + if (LocationConfig::fromFile(configPath, CONST_ZIP_FILE, config)) + campaignLocations[campaignId][config.id] = createLocation(config); + }); + } + loadSteps.push([this, campaignId]() { + wireCampaignLocations(campaignLocations[campaignId]); + }); } void Game::performFullReset() { @@ -485,6 +524,7 @@ namespace FRG gameState.money = 5500; gameState.globalInts.clear(); gameState.globalFloats.clear(); + gameState.globalFloats["player_hp"] = 200; gameState.tutorialStep = TutorialStep::AdvanceDialogue; gameState.tutorialPhoneChatScreenOpened = false; gameState.tutorialJournalScreenOpened = false; @@ -495,21 +535,31 @@ namespace FRG gameState.chatHistory[i].clear(); } gameState.inventory.clear(); - gameState.questJournal.loadFromFile(localizedConfigPath("resources/config/quests.json", g_currentLanguage), CONST_ZIP_FILE); - createLocationsStep1(); - createLocationsStep2(); - createLocationsStep3(); - createLocationsStep4(); - createLocationsStep5(); - // Developer workflow: uncomment to (re)generate resources/config/start_state.json + const CampaignConfig& campaign = activeCampaign(); + gameState.questJournal.loadFromFile(campaign.questsPath, CONST_ZIP_FILE); + + LocationMap& locations = campaignLocations[campaign.id]; + locations.clear(); + for (const std::string& configPath : campaign.locationConfigPaths) { + LocationConfig config; + if (LocationConfig::fromFile(configPath, CONST_ZIP_FILE, config)) + locations[config.id] = createLocation(config); + } + wireCampaignLocations(locations); + gameState.locations = locations; + if (shadowMap) { + for (auto& [name, loc] : locations) loc->shadowMap = shadowMap; + } + + // Developer workflow: uncomment to (re)generate the campaign's start-state JSON // from this freshly-rebuilt state after changing gameplay/content JSON or Lua. // Re-comment before shipping. //saveInitialState(); } void Game::performResetToInitialState() { - const std::string path = "resources/config/start_state.json"; + const std::string path = activeCampaign().startStatePath; std::vector fileData; if (strlen(CONST_ZIP_FILE) > 0) @@ -528,6 +578,7 @@ namespace FRG if (!content.empty()) { try { nlohmann::json root = nlohmann::json::parse(content); + const std::string campaignId = gameState.campaignId; // GameState::load() merges into existing maps rather than replacing them, // so any Lua globals set during play that aren't in the pristine snapshot // must be cleared explicitly before loading it. @@ -537,11 +588,12 @@ namespace FRG // destruction, no re-parsing JSON/nav files, no new // sol2 state (GameState::load only touches map entries // that already exist). + gameState.campaignId = campaignId; // the snapshot may predate campaigns // Locations survive this reset, so any one-shot callbacks they consumed // during the previous playthrough (taxi request) must be - // re-armed here -- createLocations() normally does this, but doesn't run - // on this fast path. - rearmDormOneShotCallbacks(); + // re-armed here -- wireCampaignLocations() normally does this, but doesn't + // run on this fast path. + rearmOneShotCallbacks(gameState.locations); return; } catch (const std::exception& e) { logger() << "[reset] Failed to parse " << path << ": " << e.what() @@ -555,19 +607,21 @@ namespace FRG performFullReset(); } - // Runs after either reset path. start_state.json always holds the start of the - // tutorial; a disabled tutorial is simply one that is already completed. + // Runs after either reset path. Start states always hold the start of the tutorial + // (if the campaign has one); a disabled tutorial is simply one that is already completed. void Game::applyTutorialSetting() { - if (!menuManager.tutorialEnabledSetting) + if (!activeCampaign().tutorial || !menuManager.tutorialEnabledSetting) gameState.tutorialStep = TutorialStep::Completed; } void Game::saveInitialState() { nlohmann::json root; gameState.save(root); - const std::string path = "start_state.json"; + // Written next to the executable; copy it over the campaign's start-state file. + const std::string startStatePath = activeCampaign().startStatePath; + const std::string path = startStatePath.substr(startStatePath.find_last_of('/') + 1); logger() << "[reset] saveInitialState called, to " << path << std::endl; - + std::ofstream file(path); if (file.is_open()) { file << root.dump(2); @@ -578,309 +632,80 @@ namespace FRG saveJsonToFile(root, path); } - void Game::createLocationsStep1() + std::shared_ptr Game::createLocation(const LocationConfig& config) { - gameState.locations.clear(); + LocationSetup params; + params.gameObjectsJsonPath = config.gameObjectsJsonPath; + params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath; + params.npcsJsonPath = config.npcsJsonPath; + params.dialoguesJsonPath = config.dialoguesJsonPath; + params.navigationJsonPaths = config.navigationJsonPaths; + params.scriptPath = config.scriptPath; + params.teleportsJsonPath = config.teleportsJsonPath; + params.triggerZonesJsonPath = config.triggerZonesJsonPath; + params.lightsJsonPath = config.lightsJsonPath; + params.playerPosition = config.playerPosition; - gameState.globalFloats["player_hp"] = 200; - - } - - - void Game::createLocationsStep2() - { - LocationSetup uniInteriorParams; - uniInteriorParams.gameObjectsJsonPath = "resources/config/gameobjects_uni_interior_x.json"; - uniInteriorParams.npcsJsonPath = "resources/config/npcs_uni_interior.json"; - uniInteriorParams.dialoguesJsonPath = "resources/dialogue/uni_interior_dialogues_010.json"; - - uniInteriorParams.navigationJsonPaths = { - "resources/navigation/uni_interior4_unlocked_n2.txt", //0 - "resources/navigation/uni_interior3_n2_hall.txt", - "resources/navigation/uni_interior4_n2_lr_hall.txt", - "resources/navigation/uni_interior4_n2_lr_tr_hall.txt", - "resources/navigation/uni_interior3_n2_lr_tr_hall_aiperi.txt", - "resources/navigation/uni_interior3_darklands_all_open.txt", //5 - "resources/navigation/uni_interior4_unlocked_hall.txt", - "resources/navigation/uni_interior4_unlocked_n3.txt", - "resources/navigation/uni_interior4_unlocked_s1.txt", - "resources/navigation/uni_interior4_unlocked_s2.txt", - "resources/navigation/uni_interior4_unlocked_s3.txt", - "resources/navigation/uni_interior4_unlocked_s3_s1.txt", - "resources/navigation/uni_interior4_unlocked_s3_hall.txt", - "resources/navigation/uni_interior4_unlocked_s3_n3.txt", - "resources/navigation/uni_interior4_unlocked_s3_n2.txt", - "resources/navigation/uni_interior4_unlocked_s3_s2.txt", - "resources/navigation/uni_interior4_locked.txt" //16 - }; - - - - /* - uniInteriorParams.navigationJsonPaths = { - "resources/navigation/uni_interior4_unlocked_n2.json", //0 - "resources/navigation/uni_interior3_n2_hall.json", - "resources/navigation/uni_interior4_n2_lr_hall.json", - "resources/navigation/uni_interior4_n2_lr_tr_hall.json", - "resources/navigation/uni_interior3_n2_lr_tr_hall_aiperi.json", - "resources/navigation/uni_interior3_darklands_all_open.json", //5 - "resources/navigation/uni_interior4_unlocked_hall.json", - "resources/navigation/uni_interior4_unlocked_n3.json", - "resources/navigation/uni_interior4_unlocked_s1.json", - "resources/navigation/uni_interior4_unlocked_s2.json", - "resources/navigation/uni_interior4_unlocked_s3.json", - "resources/navigation/uni_interior4_unlocked_s3_s1.json", //11 - "resources/navigation/uni_interior4_unlocked_s3_hall.json", - "resources/navigation/uni_interior4_unlocked_s3_n3.json", - "resources/navigation/uni_interior4_unlocked_s3_n2.json", - "resources/navigation/uni_interior4_unlocked_s3_s2.json", - "resources/navigation/uni_interior4_locked.json" //16 - };*/ - - /* - uniInteriorParams.navigationJsonPaths = { - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", //6 - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json", - "resources/navigation/uni_interior3_darklands_all_open.json" - };*/ - - uniInteriorParams.teleportsJsonPath = "resources/config/teleports_uni_interior.json"; - uniInteriorParams.triggerZonesJsonPath = "resources/config/trigger_zones_uni_interior.json"; - uniInteriorParams.lightsJsonPath = "resources/config/lights_uni_interior.json"; - uniInteriorParams.scriptPath = "resources/start_uni_interior.lua"; - uniInteriorParams.interactiveObjectsJsonPath = "resources/config/interactive_objects_uni_interior_x.json"; - uniInteriorParams.playerPosition = Eigen::Vector3f(0.942694, 0, -9.63104); - - gameState.locations["uni_interior"] = std::make_shared(renderer, gameState.inventory); - gameState.locations["uni_interior"]->setup(uniInteriorParams, &gameState.questJournal, audioPlayer.get()); - gameState.locations["uni_interior"]->scriptEngine.setGlobalStore(&gameState.globalInts); - gameState.locations["uni_interior"]->scriptEngine.setGlobalFloatStore(&gameState.globalFloats); - gameState.locations["uni_interior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { gameState.isNight = isNight; gameState.isDawn = isDawn; }; - gameState.locations["uni_interior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; - gameState.locations["uni_interior"]->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; - gameState.locations["uni_interior"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; - gameState.locations["uni_interior"]->requestReturnToMainMenu = [this]() { + auto loc = std::make_shared(renderer, gameState.inventory); + loc->setup(params, &gameState.questJournal, audioPlayer.get()); + loc->scriptEngine.setGlobalStore(&gameState.globalInts); + loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats); + loc->requestNightDayTransition = [this](bool isNight, bool isDawn) { + gameState.isNight = isNight; + gameState.isDawn = isDawn; + }; + loc->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; + loc->requestAdvanceDarklandsHud = [this]() { menuManager.advanceUniIntDarklandsHud(); }; + loc->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; + loc->requestReturnToMainMenu = [this]() { gameState.currentLocationName.clear(); menuManager.showMenuAfterGameCompleted(); }; - gameState.locations["uni_interior"]->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { + loc->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { menuManager.setChatUnread(idx, unread, msg); }; - if (gameState.locations["uni_interior"]->player) - gameState.locations["uni_interior"]->player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); }; - for (auto& npc : gameState.locations["uni_interior"]->npcs) { + if (loc->player) + loc->player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); }; + for (auto& npc : loc->npcs) { if (npc && npc->state.canAttack) { npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); }; } } - - gameState.locations["uni_interior"]->requestDarklandsPlayBattleMusic = [this]() { + loc->requestDarklandsPlayBattleMusic = [this]() { audioPlayer->crossFadeMusicFromPositionAsync("music/bishkek fight.ogg"); menuManager.lastMusicTrack = "music/bishkek fight.ogg"; }; - gameState.locations["uni_interior"]->requestDarklandsPlayNormalMusic = [this]() { + loc->requestDarklandsPlayNormalMusic = [this]() { audioPlayer->crossFadeMusicFromPositionAsync("music/bishkek fight calm.ogg"); menuManager.lastMusicTrack = "music/bishkek fight calm.ogg"; }; + if (config.lockInteractiveObjectsDuringTutorial) { + // Objects can be picked up once the tutorial reaches "pick up an item". + loc->areInteractiveObjectsLocked = [this]() { + return gameState.tutorialStep < TutorialStep::PickUpItem; + }; + } + + if (config.taxi) { + const LocationConfig::Taxi& taxi = *config.taxi; + loc->state.taxiCarTriggerPosition = taxi.triggerPosition; + loc->state.taxiCarWaypoints = taxi.waypoints; + loc->state.taxiCarInitialState.position = taxi.initialPosition; + loc->state.taxiCarInitialState.rotation = taxi.initialRotation; + loc->state.taxiCarInitialState.mode = NpcCar::Mode::NONE; + loc->state.taxiCarInitialState.velocity = 0.f; + loc->state.taxiCarInitialState.steeringAngle = 0.f; + loc->state.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius; + loc->state.taxiCar = loc->state.taxiCarInitialState; + } + + return loc; } - - void Game::createLocationsStep3() + void Game::wireCampaignLocations(LocationMap& locations) { - LocationSetup uniInteriorParams; - - LocationSetup uniExteriorParams = uniInteriorParams; - uniExteriorParams.gameObjectsJsonPath = "resources/config/gameobjects_uni_exterior_x.json"; - uniExteriorParams.interactiveObjectsJsonPath = "resources/config/interactive_objects_uni_exterior_x.json"; - /* - uniExteriorParams.navigationJsonPaths = { - "resources/navigation/uni_exterior0.json", - "resources/navigation/uni_exterior0.json" - };*/ - //uni_exterior0 - /*uniExteriorParams.navigationJsonPaths = { - "resources/navigation/uni_exterior3_all.json", - "resources/navigation/uni_exterior3_all.json" - };*/ - /* - uniExteriorParams.navigationJsonPaths = { - "resources/navigation/uni_exterior3_trees_car.json", - "resources/navigation/uni_exterior3_trees.json", - };*/ - - uniExteriorParams.navigationJsonPaths = { - "resources/navigation/uni_exterior3_trees_car.txt", - "resources/navigation/uni_exterior3_trees.txt", - }; - - uniExteriorParams.teleportsJsonPath = "resources/config/teleports_uni_exterior.json"; - uniExteriorParams.triggerZonesJsonPath = "resources/config/trigger_zones_uni_exterior.json"; - uniExteriorParams.lightsJsonPath = "resources/config/lights_uni_exterior.json"; - uniExteriorParams.scriptPath = "resources/start_uni_exterior.lua"; - uniExteriorParams.playerPosition = Eigen::Vector3f(5, 0, -18.4); - uniExteriorParams.npcsJsonPath = "resources/config/npcs_uni_exterior.json"; - uniExteriorParams.dialoguesJsonPath = "resources/dialogue/uni_exterior_dialogues003.json"; - - - gameState.locations["uni_exterior"] = std::make_shared(renderer, gameState.inventory); - gameState.locations["uni_exterior"]->setup(uniExteriorParams, &gameState.questJournal, audioPlayer.get()); - gameState.locations["uni_exterior"]->scriptEngine.setGlobalStore(&gameState.globalInts); - gameState.locations["uni_exterior"]->scriptEngine.setGlobalFloatStore(&gameState.globalFloats); - gameState.locations["uni_exterior"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { - gameState.isNight = isNight; - gameState.isDawn = isDawn; - }; - gameState.locations["uni_exterior"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; - gameState.locations["uni_exterior"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; - //gameState.locations["uni_exterior"]->requestReturnToMainMenu = [this]() { menuManager.showMenuAfterGameCompleted(); }; - gameState.locations["uni_exterior"]->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { - menuManager.setChatUnread(idx, unread, msg); - }; - if (gameState.locations["uni_exterior"]->player) - gameState.locations["uni_exterior"]->player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); }; - - - //Exterior set up taxi: - - gameState.locations["uni_exterior"]->state.taxiCarTriggerPosition = Eigen::Vector3f(-17.6, 0.05, -45); - - gameState.locations["uni_exterior"]->state.taxiCarWaypoints = { - Vector3f(-17.6, 0.05, -90.0), - Vector3f(-17.6, 0.05, -75.0), - Vector3f(-17.6, 0.05, -55.0) - }; - - gameState.locations["uni_exterior"]->state.taxiCarInitialState.position = /*Vector3f(-17.6, 0.05, -40);*/ Vector3f(-17.6, 0.05, -100); - gameState.locations["uni_exterior"]->state.taxiCarInitialState.rotation = 180.f; - gameState.locations["uni_exterior"]->state.taxiCarInitialState.mode = NpcCar::Mode::NONE; - gameState.locations["uni_exterior"]->state.taxiCarInitialState.velocity = 0.f; - gameState.locations["uni_exterior"]->state.taxiCarInitialState.steeringAngle = 0.f; - gameState.locations["uni_exterior"]->state.taxiCarInitialState.waypointReachRadius = 5.f; - - gameState.locations["uni_exterior"]->state.taxiCar = gameState.locations["uni_exterior"]->state.taxiCarInitialState; - - //Exterior set up taxi end - - } - - - void Game::createLocationsStep4() - { - LocationSetup params_dorm; - //params_dorm.gameObjectsJsonPath = "resources/config/gameobjects_dorm.json"; - //params_dorm.gameObjectsJsonPath = "resources/config/gameobjects_dorm_trees001.json"; - params_dorm.gameObjectsJsonPath = "resources/config/gameobjects_dorm_new_x.json"; - - params_dorm.npcsJsonPath = "resources/config/npcs_dorm.json"; - params_dorm.dialoguesJsonPath = "resources/dialogue/dorm_dialogues004.json"; - - /* - params_dorm.navigationJsonPaths = { - "resources/navigation/dorm3_bca.json", - "resources/navigation/dorm3_ca.json", - "resources/navigation/dorm3_ba.json", - "resources/navigation/dorm3_a.json", - "resources/navigation/dorm3_b.json", - "resources/navigation/dorm3_all_open.json", - };*/ - - params_dorm.navigationJsonPaths = { - "resources/navigation/dorm3_bca.txt", //0 - "resources/navigation/dorm3_ca.txt", //1 - "resources/navigation/dorm3_ba.txt", - "resources/navigation/dorm3_a.txt", - "resources/navigation/dorm3_b.txt", - "resources/navigation/dorm3_all_open.txt", //5 - }; - - /* - params_dorm.navigationJsonPaths = { - "resources/navigation/dorm0_large.json", - "resources/navigation/dorm0_large.json", - "resources/navigation/dorm0_large.json", - "resources/navigation/dorm0_large.json", - "resources/navigation/dorm0_large.json", - "resources/navigation/dorm0_large.json", - };*/ - params_dorm.teleportsJsonPath = "resources/config/teleports_dorm.json"; - params_dorm.triggerZonesJsonPath = "resources/config/trigger_zones_dorm.json"; - params_dorm.lightsJsonPath = "resources/config/lights_dorm.json"; - params_dorm.scriptPath = "resources/start_dorm.lua"; - params_dorm.interactiveObjectsJsonPath = "resources/config/interactive_objects_dorm_x.json"; - params_dorm.playerPosition = Eigen::Vector3f(6.76345, 0, -14.6022); - - gameState.locations["location_dorm"] = std::make_shared(renderer, gameState.inventory); - gameState.locations["location_dorm"]->setup(params_dorm, &gameState.questJournal, audioPlayer.get()); - gameState.locations["location_dorm"]->scriptEngine.setGlobalStore(&gameState.globalInts); - gameState.locations["location_dorm"]->scriptEngine.setGlobalFloatStore(&gameState.globalFloats); - gameState.locations["location_dorm"]->requestNightDayTransition = [this](bool isNight, bool isDawn) { gameState.isNight = isNight; gameState.isDawn = isDawn; }; - gameState.locations["location_dorm"]->requestDarklandsTransition = [this]() { return startDarklandsTransition(); }; - gameState.locations["location_dorm"]->requestClosePhone = [this]() { menuManager.closePhoneEntirely(); }; - //gameState.locations["location_dorm"]->requestReturnToMainMenu = [this]() { menuManager.showMenuAfterGameCompleted(); }; - gameState.locations["location_dorm"]->requestSetChatUnread = [this](int idx, bool unread, const std::string& msg) { - menuManager.setChatUnread(idx, unread, msg); - }; - if (gameState.locations["location_dorm"]->player) - gameState.locations["location_dorm"]->player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); }; - - // Objects can be picked up once the tutorial reaches "pick up an item". - gameState.locations["location_dorm"]->areInteractiveObjectsLocked = [this]() { - return gameState.tutorialStep < TutorialStep::PickUpItem; - }; - - //Dorm set up taxi: - - gameState.locations["location_dorm"]->state.taxiCarTriggerPosition = Eigen::Vector3f(-19.0, 0, 0); - - gameState.locations["location_dorm"]->state.taxiCarWaypoints = { - Vector3f(-20.85, 0, 30.0), - Vector3f(-19.35, 0, 24.0), - Vector3f(-19.35, 0, 14.0) - }; - - gameState.locations["location_dorm"]->state.taxiCarInitialState.position = Vector3f(-20.85, 0, 64); - gameState.locations["location_dorm"]->state.taxiCarInitialState.rotation = 0.f; - gameState.locations["location_dorm"]->state.taxiCarInitialState.mode = NpcCar::Mode::NONE; - gameState.locations["location_dorm"]->state.taxiCarInitialState.velocity = 0.f; - gameState.locations["location_dorm"]->state.taxiCarInitialState.steeringAngle = 0.f; - gameState.locations["location_dorm"]->state.taxiCarInitialState.waypointReachRadius = 5.f; - - gameState.locations["location_dorm"]->state.taxiCar = gameState.locations["location_dorm"]->state.taxiCarInitialState; - - //Dorm set up taxi end - - - } - - void Game::createLocationsStep5() - { - - rearmDormOneShotCallbacks(); - - menuManager.callTaxiFunc = [this]() - { - if (gameState.locations["location_dorm"]) - { - gameState.locations["location_dorm"]->onPlayerTaxiRequired = nullptr; - } - - if (currentLocation()) - { - currentLocation()->onTaxiCalled(); - } - }; + rearmOneShotCallbacks(locations); // Teleport callbacks: destination name and position come from the teleport zone data. auto teleportCallback = [this](const std::string& destName, const Eigen::Vector3f& destPos, float destRotY) { @@ -918,19 +743,19 @@ namespace FRG menuManager.onLocationChanged(destName); }; - gameState.locations["uni_exterior"]->onTeleport = teleportCallback; - gameState.locations["uni_interior"]->onTeleport = teleportCallback; - gameState.locations["location_dorm"]->onTeleport = teleportCallback; + for (auto& [name, loc] : locations) { + loc->onTeleport = teleportCallback; + } // Share the global int store with all dialogue runtimes so flags set via // dialogue JSON and via Lua set_dialogue_flag all see the same state. - for (auto& [name, loc] : gameState.locations) { + for (auto& [name, loc] : locations) { loc->dialogueSystem.setGlobalFlagStore(&gameState.globalInts); } // Wire tutorial advance callbacks for all locations. // completeTutorialStep() only acts on the current step, so sharing is safe. - for (auto& [name, loc] : gameState.locations) { + for (auto& [name, loc] : locations) { loc->dialogueSystem.setOnDialogueAdvanced([this]() { gameState.completeTutorialStep(TutorialStep::AdvanceDialogue); }); @@ -941,14 +766,14 @@ namespace FRG } // Wire chat-bubble callback so dynamic bubbles appear as dialogue lines are shown. - for (auto& [name, loc] : gameState.locations) { + for (auto& [name, loc] : locations) { loc->dialogueSystem.setOnChatBubbleReady([this](const std::string& text, bool incoming) { menuManager.onChatBubbleReady(text, incoming); }); } // Wire cutscene HUD: show skip button when cutscene starts, restore HUD when it ends. - for (auto& [name, loc] : gameState.locations) { + for (auto& [name, loc] : locations) { loc->dialogueSystem.setOnCutsceneStarted([this]() { menuManager.onCutsceneStarted(); }); @@ -958,7 +783,7 @@ namespace FRG } // Wire HP-change callbacks so all player instances update the health bar HUD. - for (auto& [name, loc] : gameState.locations) { + for (auto& [name, loc] : locations) { if (loc->player) { loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) { menuManager.updateHealthBar(hp, maxHp); @@ -999,8 +824,8 @@ namespace FRG CheckGlError(__FILE__, __LINE__); logger() << "Shadow map initialized" << std::endl; - for (auto& [name, loc] : gameState.locations) { - loc->shadowMap = shadowMap; + for (auto& [campaignId, locations] : campaignLocations) { + for (auto& [name, loc] : locations) loc->shadowMap = shadowMap; } } @@ -1011,8 +836,8 @@ namespace FRG shadowMap.reset(); CheckGlError(__FILE__, __LINE__); - for (auto& [name, loc] : gameState.locations) { - loc->shadowMap.reset(); + for (auto& [campaignId, locations] : campaignLocations) { + for (auto& [name, loc] : locations) loc->shadowMap.reset(); } CheckGlError(__FILE__, __LINE__); @@ -1891,6 +1716,7 @@ namespace FRG nlohmann::json root = nlohmann::json::parse(content); SaveSlotInfo info; info.empty = false; + info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId)); info.locationName = root.value("currentLocationName", ""); info.savedAt = root.value("savedAt", ""); return info; @@ -1924,6 +1750,9 @@ namespace FRG } try { nlohmann::json root = nlohmann::json::parse(content); + // Every campaign is built at startup, so loading only switches to the save's + // campaign (its locations and quest definitions) before applying the state. + activateCampaign(root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId))); gameState.load(root); } catch (const std::exception& e) { std::cerr << "[save] Failed to parse save file " << path << ": " << e.what() << std::endl; diff --git a/src/Game.h b/src/Game.h index eb10016..0102efd 100644 --- a/src/Game.h +++ b/src/Game.h @@ -24,7 +24,11 @@ #include "GameState.h" namespace FRG { + struct LocationConfig; + struct CampaignConfig; + struct SaveSlotInfo { + std::string campaignId; std::string locationName; std::string savedAt; bool empty = true; @@ -123,29 +127,40 @@ namespace FRG { void saveGame(int slot); void loadGame(int slot); SaveSlotInfo readSlotInfo(int slot) const; - void createLocationsStep1(); - void createLocationsStep2(); - void createLocationsStep3(); - void createLocationsStep4(); - void createLocationsStep5(); + // Every campaign's locations, all built at startup (campaign id -> locations). + // gameState.locations is a copy of the active campaign's entry. + std::unordered_map campaignLocations; + + // Builds one location from its config and wires the callbacks every location gets. + std::shared_ptr createLocation(const LocationConfig& config); + // Wires the callbacks that span a campaign's locations (teleports, dialogue, HUD...). + void wireCampaignLocations(LocationMap& locations); + // Queues one loading step per location of `campaign`, plus its wiring step. + void queueCampaignBuildSteps(const CampaignConfig& campaign); + // Makes `campaignId` the played campaign: its locations and its quest definitions. + void activateCampaign(const std::string& campaignId); + const CampaignConfig& activeCampaign() const; + void createShadowMapAndProparateToLocations(); void destroyShadowMapAndProparateToLocations(); - // Reset entry point used by "Start New Game": loads resources/config/start_state.json - // into the existing Location objects if present, else falls back to performFullReset(). + // Reset entry point used by "Start New Game": loads the active campaign's start-state + // JSON into its existing Location objects, else falls back to performFullReset(). // Must only ever run from a deferred task (see mainThreadHandler.EnqueueMainThreadTask), // never synchronously from a Lua/cutscene call stack. void performResetToInitialState(); + // Rebuilds the active campaign's locations from scratch. void performFullReset(); - // Applies MenuManager::tutorialEnabledSetting to the freshly reset state (new games only). + // Applies the campaign's tutorial flag and MenuManager::tutorialEnabledSetting to + // the freshly reset state (new games only). void applyTutorialSetting(); void saveInitialState(); // (Re-)arms one-shot gameplay callbacks (taxi request) that - // createLocations() wires up but that get consumed (set to nullptr) once used + // wireCampaignLocations() sets up but that get consumed (set to nullptr) once used // during play. Must be called after gameState.load() on the fast reset path, // since that path keeps the existing Location objects instead of recreating them. - void rearmDormOneShotCallbacks(); + void rearmOneShotCallbacks(LocationMap& locations); int64_t getSyncTimeMs(); void processTickCount(); diff --git a/src/GameState.cpp b/src/GameState.cpp index 8abcbdd..6551a92 100644 --- a/src/GameState.cpp +++ b/src/GameState.cpp @@ -36,6 +36,7 @@ void GameState::save(nlohmann::json& out) const out["savedAt"] = ts.str(); // --- World --- + out["campaignId"] = campaignId; out["currentLocationName"] = currentLocationName; out["isDarklands"] = isDarklands; out["isNight"] = isNight; @@ -104,6 +105,8 @@ void GameState::load(const nlohmann::json& in) //logger() << "GameState::load step 1" << std::endl; // --- World --- + // Literal default: saves made before campaigns existed belong to the main game. + campaignId = in.value("campaignId", std::string("main")); currentLocationName = in.value("currentLocationName", currentLocationName); isDarklands = in.value("isDarklands", isDarklands); isNight = in.value("isNight", isNight); diff --git a/src/GameState.h b/src/GameState.h index 051ba6e..c2e7cd0 100644 --- a/src/GameState.h +++ b/src/GameState.h @@ -32,9 +32,13 @@ struct StoredChatMessage { bool incoming; }; +using LocationMap = std::unordered_map>; + struct GameState : public ISaveable { // --- World --- - std::unordered_map> locations; + // Campaign being played (see CampaignRegistry); `locations` holds that campaign's locations. + std::string campaignId = "main"; + LocationMap locations; std::string currentLocationName; bool isDarklands = false; bool isNight = false; diff --git a/src/MenuManager.cpp b/src/MenuManager.cpp index b600c5e..45cd0fb 100644 --- a/src/MenuManager.cpp +++ b/src/MenuManager.cpp @@ -2,6 +2,7 @@ #include "Game.h" #include "Localization.h" #include "items/ItemRegistry.h" +#include "Campaign.h" #include "render/TextRenderer.h" #include "utils/Utils.h" #include @@ -323,6 +324,7 @@ namespace FRG { settingsGraphicsScreenRoot = loadLocalizedUi("resources/w/ui/screen_settings_graphics.json", zipFile); languageScreenRoot = loadLocalizedUi("resources/w/ui/screen_language.json", zipFile); loadSavedGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_load.json", zipFile); + campaignScreenRoot = loadLocalizedUi("resources/w/ui/screen_campaign.json", zipFile); saveGameScreenRoot = loadLocalizedUi("resources/w/ui/screen_save.json", zipFile); } @@ -337,7 +339,7 @@ namespace FRG { texItemSelected_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/ButtonBkg001.png", zipFile, true); texItemTransparent_ = renderer.textureManager.LoadFromPng("resources/w/ui/img/journal/ButtonBkgTransparent001.png", zipFile, true); - gameState_.questJournal.loadFromFile("resources/config/quests.json", zipFile); + // Quest definitions are loaded per campaign by Game (see Game::activateCampaign). toastTextureQuestNew = loadLocalizedTexture("resources/w/ui/img/toast/quest_new001.png", zipFile); toastTextureQuestCompleted = loadLocalizedTexture("resources/w/ui/img/toast/quest_completed001.png", zipFile); @@ -376,18 +378,19 @@ namespace FRG { showMainMenu(); } + void MenuManager::startNewGame(const std::string& campaignId) { + // The reset runs on a later main-thread task, which then shows the HUD itself; + // the main menu stays up until then. Showing the HUD now would draw one frame + // from the previous game's state (e.g. its phone/journal buttons). + if (startGameFunc) startGameFunc(campaignId); + } + void MenuManager::enterGameplay(bool isFromLoad) { if (isFromLoad) { uiManager.clearMenuStack(); topUiManager.clearMenuStack(); } - // A new game resets the state on a later main-thread task, which then shows the - // HUD itself; the main menu stays up until then. Showing the HUD now would draw - // one frame from the previous game's state (e.g. its phone/journal buttons). - if (!isFromLoad && uiState_ == GameUiState::MainMenu && startGameFunc) - startGameFunc(); - else - showGameplayHud(); + showGameplayHud(); } std::shared_ptr MenuManager::gameplayHudRoot() const { @@ -472,8 +475,7 @@ namespace FRG { uiManager.setTextButtonCallback("menuStartButton", [this](const std::string&) { audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); - enterGameplay(); - + showCampaignScreen(); }); uiManager.setTextButtonCallback("menuAboutButton", [this](const std::string&) { showAboutScreen(); @@ -534,8 +536,7 @@ namespace FRG { uiManager.setTextButtonCallback("menuStartButton", [this](const std::string&) { audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); - enterGameplay(); - + showCampaignScreen(); }); uiManager.setTextButtonCallback("menuAboutButton", [this](const std::string&) { showAboutScreen(); @@ -1034,7 +1035,8 @@ namespace FRG { steps.push_back([this]() { ItemRegistry::instance().loadFromJson("resources/config/items.json", CONST_ZIP_FILE); - gameState_.questJournal.reloadDefinitionsPreservingState("resources/config/quests.json", CONST_ZIP_FILE); + if (const CampaignConfig* campaign = CampaignRegistry::instance().find(gameState_.campaignId)) + gameState_.questJournal.reloadDefinitionsPreservingState(campaign->questsPath, CONST_ZIP_FILE); }); steps.push_back([this]() { @@ -1074,22 +1076,7 @@ namespace FRG { for (int i = 0; i < 4; ++i) { int slot = i + 1; if (getSlotInfoFunc) { - SaveSlotInfo info = getSlotInfoFunc(slot); - - std::string emptyText; - if (FRG::g_currentLanguage == FRG::Language::Russian) - { - emptyText = EMPTY_LANGUAGE_RU; - } - else if (FRG::g_currentLanguage == FRG::Language::English) - { - emptyText = EMPTY_LANGUAGE_EN; - } - - std::string label = info.empty - ? emptyText - : localizedLocationName[info.locationName][FRG::g_currentLanguage] + " " + info.savedAt; - uiManager.setTextButtonText(kSlotButtons[i], label); + uiManager.setTextButtonText(kSlotButtons[i], slotLabel(getSlotInfoFunc(slot))); } uiManager.setTextButtonCallback(kSlotButtons[i], [this, slot](const std::string&) { audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); @@ -1099,6 +1086,47 @@ namespace FRG { } } + std::string MenuManager::slotLabel(const SaveSlotInfo& info) { + if (info.empty) + return FRG::g_currentLanguage == FRG::Language::English ? EMPTY_LANGUAGE_EN : EMPTY_LANGUAGE_RU; + + std::string label = localizedLocationName[info.locationName][FRG::g_currentLanguage] + " " + info.savedAt; + // Name the campaign unless it's the main game, so saves of different campaigns are told apart. + const CampaignConfig* campaign = CampaignRegistry::instance().find(info.campaignId); + if (campaign && campaign->id != CampaignRegistry::kDefaultCampaignId) + label = campaign->title(FRG::g_currentLanguage) + ": " + label; + return label; + } + + void MenuManager::showCampaignScreen() + { + uiManager.pushMenuFromSavedRoot(campaignScreenRoot); + uiManager.setTextButtonCallback("campaignBackButton", [this](const std::string&) { + uiManager.popMenu(); + uiManager.updateAllLayouts(); + audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); + }); + + // One button per campaign in campaigns.json order; spare buttons are hidden. + static const char* kCampaignButtons[4] = { + "campaignSlot1Button", "campaignSlot2Button", "campaignSlot3Button", "campaignSlot4Button" + }; + const auto& campaigns = CampaignRegistry::instance().all(); + for (int i = 0; i < 4; ++i) { + const bool used = i < static_cast(campaigns.size()); + uiManager.setNodeVisible(kCampaignButtons[i], used); + if (!used) continue; + + const std::string campaignId = campaigns[i].id; + uiManager.setTextButtonText(kCampaignButtons[i], campaigns[i].title(FRG::g_currentLanguage)); + uiManager.setTextButtonCallback(kCampaignButtons[i], [this, campaignId](const std::string&) { + audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); + uiManager.popMenu(); + startNewGame(campaignId); + }); + } + } + void MenuManager::showSaveGameScreen() { uiManager.pushMenuFromSavedRoot(saveGameScreenRoot); @@ -1115,42 +1143,12 @@ namespace FRG { int slot = i + 1; const char* buttonName = kSlotButtons[i]; if (getSlotInfoFunc) { - SaveSlotInfo info = getSlotInfoFunc(slot); - - std::string emptyText; - if (FRG::g_currentLanguage == FRG::Language::Russian) - { - emptyText = EMPTY_LANGUAGE_RU; - } - else if (FRG::g_currentLanguage == FRG::Language::English) - { - emptyText = EMPTY_LANGUAGE_EN; - } - - std::string label = info.empty - ? emptyText - : localizedLocationName[info.locationName][FRG::g_currentLanguage] + " " + info.savedAt; - uiManager.setTextButtonText(buttonName, label); + uiManager.setTextButtonText(buttonName, slotLabel(getSlotInfoFunc(slot))); } uiManager.setTextButtonCallback(buttonName, [this, slot, buttonName](const std::string&) { if (onSaveGame) onSaveGame(slot); if (getSlotInfoFunc) { - SaveSlotInfo info = getSlotInfoFunc(slot); - - std::string emptyText; - if (FRG::g_currentLanguage == FRG::Language::Russian) - { - emptyText = EMPTY_LANGUAGE_RU; - } - else if (FRG::g_currentLanguage == FRG::Language::English) - { - emptyText = EMPTY_LANGUAGE_EN; - } - - std::string label = info.empty - ? emptyText - : localizedLocationName[info.locationName][FRG::g_currentLanguage] + " " + info.savedAt; - uiManager.setTextButtonText(buttonName, label); + uiManager.setTextButtonText(buttonName, slotLabel(getSlotInfoFunc(slot))); audioPlayer_.playSoundAsync("resources/audio/751089__smallconfusion__mechanical-plastic-click-11.ogg"); } }); @@ -2142,6 +2140,8 @@ namespace FRG { void MenuManager::onEnemyKilledInUniInterior() { + // Every location's enemies report here; only the university interior drives the tutorial. + if (gameState_.currentLocationName != "uni_interior") return; // The last tutorial step; like the attack hint, also ends the earlier darklands hints. if (gameState_.tutorialStep >= TutorialStep::DarklandsHealth) gameState_.tutorialStep = TutorialStep::Completed; diff --git a/src/MenuManager.h b/src/MenuManager.h index 1fc2f62..8515f67 100644 --- a/src/MenuManager.h +++ b/src/MenuManager.h @@ -72,7 +72,8 @@ namespace FRG { void spendMoney(int amount); int getMoney() const { return gameState_.money; } - std::function startGameFunc; + // Starts a new game of the given campaign (see CampaignRegistry). + std::function startGameFunc; std::function startDialogueFunc; std::function startDarklandsTransitionFunc; std::function startNightTransitionFunc; @@ -164,6 +165,7 @@ namespace FRG { GameState& gameState_; AudioPlayerAsync& audioPlayer_; + void startNewGame(const std::string& campaignId); void enterGameplay(bool isFromLoad = false); // HUD layout for the current location and darklands mode. std::shared_ptr gameplayHudRoot() const; @@ -212,6 +214,10 @@ namespace FRG { void showLanguageScreen(); void showLoadGameScreen(); void showSaveGameScreen(); + // Campaign list opened by "Start game". + void showCampaignScreen(); + // Text of a load/save slot button. + std::string slotLabel(const SaveSlotInfo& info); // Toast internals struct ToastEntry { @@ -292,6 +298,7 @@ namespace FRG { std::shared_ptr settingsGraphicsScreenRoot; std::shared_ptr languageScreenRoot; std::shared_ptr loadSavedGameScreenRoot; + std::shared_ptr campaignScreenRoot; std::shared_ptr saveGameScreenRoot;