working on campaign

This commit is contained in:
Vladislav Khorev committed 2026-10-23 22:47:55 +06:00
1 parent 9fa120d4ad
commit d38a75ea72
24 files changed
+929 -410

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+30
View File
@@ -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
}
]
}
@@ -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]
}
@@ -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
}
@@ -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
}
}
@@ -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]
}
+4
View File
@@ -0,0 +1,4 @@
{
"npcs": [
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"quests": [
]
}
@@ -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
}
@@ -0,0 +1,4 @@
{
"dialogues": [
]
}
+15
View File
@@ -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
)
+90
View File
@@ -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
}
]
}
}
+90
View File
@@ -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
}
]
}
}
+112
View File
@@ -0,0 +1,112 @@
#include "Campaign.h"
#include "utils/Utils.h"
#include "external/nlohmann/json.hpp"
#include <iostream>
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<float>(), j[1].get<float>(), j[2].get<float>());
}
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<std::string>{});
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<std::string, std::string>{});
c.locationConfigPaths = entry.value("locations", std::vector<std::string>{});
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
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "Localization.h"
#include <Eigen/Dense>
#include <map>
#include <optional>
#include <string>
#include <vector>
namespace FRG {
// One location of a campaign, loaded from resources/config/locations/<name>.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<std::string> 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<Eigen::Vector3f> waypoints;
Eigen::Vector3f initialPosition = Eigen::Vector3f::Zero();
float initialRotation = 0.f;
float waypointReachRadius = 5.f;
};
std::optional<Taxi> 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<std::string, std::string> titles; // language code -> title
std::vector<std::string> 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<CampaignConfig>& all() const { return campaigns_; }
// Returns nullptr if not found.
const CampaignConfig* find(const std::string& id) const;
private:
CampaignRegistry() = default;
std::vector<CampaignConfig> campaigns_;
};
} // namespace FRG
+166 -337
View File
@@ -4,6 +4,7 @@
#include "utils/Utils.h"
#include <fstream>
#include "items/ItemRegistry.h"
#include "Campaign.h"
#include "render/OpenGlExtensions.h"
#include <iostream>
#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<char> 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<Location> 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<Location>(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<Location>(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<Location>(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<Location>(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;
+25 -10
View File
@@ -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<std::string, LocationMap> campaignLocations;
// Builds one location from its config and wires the callbacks every location gets.
std::shared_ptr<Location> 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();
+3
View File
@@ -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);
+5 -1
View File
@@ -32,9 +32,13 @@ struct StoredChatMessage {
bool incoming;
};
using LocationMap = std::unordered_map<std::string, std::shared_ptr<Location>>;
struct GameState : public ISaveable {
// --- World ---
std::unordered_map<std::string, std::shared_ptr<Location>> 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;
+61 -61
View File
@@ -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 <iostream>
@@ -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<UiNode> 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<int>(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;
+8 -1
View File
@@ -72,7 +72,8 @@ namespace FRG {
void spendMoney(int amount);
int getMoney() const { return gameState_.money; }
std::function<void()> startGameFunc;
// Starts a new game of the given campaign (see CampaignRegistry).
std::function<void(const std::string& campaignId)> startGameFunc;
std::function<void(const std::string&)> startDialogueFunc;
std::function<void()> startDarklandsTransitionFunc;
std::function<void()> 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<UiNode> 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<UiNode> settingsGraphicsScreenRoot;
std::shared_ptr<UiNode> languageScreenRoot;
std::shared_ptr<UiNode> loadSavedGameScreenRoot;
std::shared_ptr<UiNode> campaignScreenRoot;
std::shared_ptr<UiNode> saveGameScreenRoot;