Major refactoring - internal server state machine implemented
This commit is contained in:
1 parent
71c654e5b6
commit
66e93d46f4
48 files changed
+3948
-3696
No files matched your search
+149
-84
@@ -77,15 +77,18 @@ namespace FRG
|
||||
|
||||
Location* Game::currentLocation() const {
|
||||
if (gameState.currentLocationName.empty()) return nullptr;
|
||||
auto it = gameState.locations.find(gameState.currentLocationName);
|
||||
return (it != gameState.locations.end()) ? it->second.get() : nullptr;
|
||||
const auto& locations = internalGameServer.runtimeLocations();
|
||||
auto it = locations.find(gameState.currentLocationName);
|
||||
return (it != locations.end()) ? it->second.get() : nullptr;
|
||||
}
|
||||
|
||||
Game::Game()
|
||||
: newTickCount(0)
|
||||
: audioPlayer(std::make_unique<AudioPlayerAsync>())
|
||||
, internalGameServer()
|
||||
, gameState(internalGameServer.hostState())
|
||||
, menuManager(renderer, internalGameServer, *audioPlayer)
|
||||
, newTickCount(0)
|
||||
, lastTickCount(0)
|
||||
, audioPlayer(std::make_unique<AudioPlayerAsync>())
|
||||
, menuManager(renderer, gameState, *audioPlayer)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -391,15 +394,23 @@ namespace FRG
|
||||
menuManager.skipCutsceneFunc = [this]() {
|
||||
if (currentLocation()) currentLocation()->dialogueSystem.skipCutscene();
|
||||
};
|
||||
|
||||
menuManager.callTaxiFunc = [this]() {
|
||||
if (currentLocation()) currentLocation()->onTaxiCalled();
|
||||
};
|
||||
});
|
||||
|
||||
loadSteps.push([this]() {
|
||||
try {
|
||||
menuManager.setup(gameState.inventory, CONST_ZIP_FILE);
|
||||
menuManager.setup(CONST_ZIP_FILE);
|
||||
gameState.questJournal.onQuestUnlocked = [this](const std::string& id) {
|
||||
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
|
||||
if (quest) menuManager.showToast(menuManager.toastTextureQuestNew, quest->definition.title);
|
||||
};
|
||||
gameState.questJournal.onQuestCompleted = [this](const std::string& id) {
|
||||
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
|
||||
if (quest) menuManager.showToast(menuManager.toastTextureQuestCompleted, quest->definition.title);
|
||||
};
|
||||
gameState.questJournal.onQuestFailed = [this](const std::string& id) {
|
||||
const Quest::QuestState* quest = gameState.questJournal.findQuest(id);
|
||||
if (quest) menuManager.showToast(menuManager.toastTextureQuestFailed, quest->definition.title);
|
||||
};
|
||||
logger() << "UI loaded successfully" << std::endl;
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
@@ -515,7 +526,8 @@ namespace FRG
|
||||
}
|
||||
|
||||
bool Game::isLocationLoaded(const std::string& campaignId, const std::string& locationId) const {
|
||||
return player && loadedCampaignId == campaignId && gameState.locations.count(locationId) > 0;
|
||||
return player && loadedCampaignId == campaignId
|
||||
&& internalGameServer.runtimeLocations().count(locationId) > 0;
|
||||
}
|
||||
|
||||
void Game::enterLocationThen(const std::string& requestedCampaignId, const std::string& requestedLocationId,
|
||||
@@ -556,20 +568,24 @@ namespace FRG
|
||||
void Game::loadCampaign(const std::string& campaignId) {
|
||||
loadedCampaignId = campaignId;
|
||||
gameState.campaignId = campaignId;
|
||||
gameState.storedLocationStates.clear();
|
||||
gameState.locations.clear();
|
||||
gameState.players.clear();
|
||||
gameState.questJournal.loadFromFile(activeCampaign().questsPath, CONST_ZIP_FILE);
|
||||
gameState.globalFloats["player_hp"] = 200; // read by location scripts as they load
|
||||
// The player stays loaded for the whole campaign; locations come and go around him.
|
||||
player = Location::createPlayer(renderer, audioPlayer.get());
|
||||
auto& playerState = gameState.players["player"];
|
||||
if (!playerState) playerState = std::make_shared<CharacterState>();
|
||||
player = Location::createPlayer(renderer, audioPlayer.get(), *playerState, internalGameServer);
|
||||
if (player)
|
||||
player->state.onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
||||
player->onDeathAnimComplete = [this]() { startDarklandsTransition(); };
|
||||
}
|
||||
|
||||
void Game::unloadCampaign() {
|
||||
unloadLocation(false);
|
||||
player.reset();
|
||||
gameState.players.clear();
|
||||
loadedCampaignId.clear();
|
||||
gameState.storedLocationStates.clear();
|
||||
gameState.locations.clear();
|
||||
renderer.textureManager.UnloadUnused(); // the player's textures
|
||||
}
|
||||
|
||||
@@ -580,23 +596,24 @@ namespace FRG
|
||||
<< locationId << "'" << std::endl;
|
||||
return;
|
||||
}
|
||||
const bool initializeLocation =
|
||||
!gameState.locations[locationId].hasPersistentState;
|
||||
std::shared_ptr<Location> loc = createLocation(*config);
|
||||
wireLocation(loc.get());
|
||||
if (shadowMap) loc->shadowMap = shadowMap;
|
||||
if (initializeLocation)
|
||||
loc->scriptEngine.callLocationInitCallback();
|
||||
// Back to how the player left it (or to the start state's snapshot of it).
|
||||
auto stored = gameState.storedLocationStates.find(locationId);
|
||||
if (stored != gameState.storedLocationStates.end()) loc->load(stored->second);
|
||||
gameState.locations[locationId] = loc;
|
||||
internalGameServer.bindRuntimeLocation(locationId, loc);
|
||||
}
|
||||
|
||||
void Game::unloadLocation(bool storeState) {
|
||||
for (auto& [name, loc] : gameState.locations) {
|
||||
if (!storeState) continue;
|
||||
nlohmann::json locJson;
|
||||
loc->save(locJson);
|
||||
gameState.storedLocationStates[name] = std::move(locJson);
|
||||
if (storeState) internalGameServer.synchronizeRuntimeAuxiliaryState();
|
||||
else {
|
||||
for (const auto& [name, loc] : internalGameServer.runtimeLocations())
|
||||
gameState.locations.erase(name);
|
||||
}
|
||||
gameState.locations.clear();
|
||||
internalGameServer.runtimeLocations().clear();
|
||||
// Textures only the location's models and characters used are freed now.
|
||||
renderer.textureManager.UnloadUnused();
|
||||
}
|
||||
@@ -619,11 +636,12 @@ namespace FRG
|
||||
Location* loc = currentLocation();
|
||||
if (!loc) return;
|
||||
if (loc->player) {
|
||||
loc->player->state.position = destPos;
|
||||
loc->player->setTarget(destPos);
|
||||
loc->player->stopInPlace();
|
||||
loc->player->state.facingAngle = destRotY;
|
||||
loc->player->state.targetFacingAngle = destRotY;
|
||||
loc->updateCharacterState(*loc->player, [&](CharacterState& workingState) {
|
||||
workingState.position = destPos;
|
||||
workingState.facingAngle = destRotY;
|
||||
workingState.targetFacingAngle = destRotY;
|
||||
});
|
||||
loc->stopCharacter(*loc->player);
|
||||
}
|
||||
loc->state.cameraAzimuth = destRotY;
|
||||
loc->state.isDarklands = gameState.isDarklands;
|
||||
@@ -632,14 +650,16 @@ namespace FRG
|
||||
loc->scriptEngine.callLocationEnterCallback();
|
||||
|
||||
//Reset taxi
|
||||
loc->state.taxiCar = loc->state.taxiCarInitialState;
|
||||
loc->state.taxiArrivedCallbackCalled = false;
|
||||
auto locationState = gameState.locations.find(destName);
|
||||
if (locationState != gameState.locations.end())
|
||||
locationState->second.resetTaxi();
|
||||
gameState.taxiIsCalled = false;
|
||||
|
||||
menuManager.onLocationChanged(destName);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void Game::performFullReset() {
|
||||
gameState.currentLocationName.clear();
|
||||
gameState.isDarklands = false;
|
||||
@@ -666,9 +686,9 @@ namespace FRG
|
||||
|
||||
// Rebuild the loaded location from its config; the others have no stored state
|
||||
// any more, so they are built fresh when the player first goes there.
|
||||
gameState.storedLocationStates.clear();
|
||||
gameState.locations.clear();
|
||||
std::vector<std::string> loadedIds;
|
||||
for (auto& [name, loc] : gameState.locations) loadedIds.push_back(name);
|
||||
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loadedIds.push_back(name);
|
||||
unloadLocation(false);
|
||||
for (const std::string& id : loadedIds) loadLocation(id);
|
||||
|
||||
@@ -698,15 +718,8 @@ 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.
|
||||
gameState.globalInts.clear();
|
||||
gameState.globalFloats.clear();
|
||||
gameState.load(root); // applies to the loaded location in place and stores
|
||||
internalGameServer.load(root); // applies to the loaded location in place and stores
|
||||
// the other locations' states for when they load.
|
||||
gameState.campaignId = campaignId; // the snapshot may predate campaigns
|
||||
return;
|
||||
} catch (const std::exception& e) {
|
||||
logger() << "[reset] Failed to parse " << path << ": " << e.what()
|
||||
@@ -729,7 +742,7 @@ namespace FRG
|
||||
|
||||
void Game::saveInitialState() {
|
||||
nlohmann::json root;
|
||||
gameState.save(root);
|
||||
internalGameServer.save(root);
|
||||
// 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);
|
||||
@@ -747,20 +760,28 @@ namespace FRG
|
||||
|
||||
std::shared_ptr<Location> Game::createLocation(const LocationConfig& config)
|
||||
{
|
||||
LocationGameState& authoritative = gameState.locations[config.id];
|
||||
if (!authoritative.location) authoritative.location = std::make_shared<LocationState>();
|
||||
|
||||
if (authoritative.location->navigationMaps.empty())
|
||||
authoritative.location->initNavigation(config.navigationJsonPaths);
|
||||
|
||||
LocationSetup params;
|
||||
params.gameObjectsJsonPath = config.gameObjectsJsonPath;
|
||||
params.interactiveObjectsJsonPath = config.interactiveObjectsJsonPath;
|
||||
params.npcsJsonPath = config.npcsJsonPath;
|
||||
params.dialoguesJsonPath = config.dialoguesJsonPath;
|
||||
params.navigationJsonPaths = config.navigationJsonPaths;
|
||||
//params.navigationJsonPaths = config.navigationJsonPaths;
|
||||
params.scriptPath = config.scriptPath;
|
||||
params.teleportsJsonPath = config.teleportsJsonPath;
|
||||
params.triggerZonesJsonPath = config.triggerZonesJsonPath;
|
||||
params.lightsJsonPath = config.lightsJsonPath;
|
||||
params.playerPosition = config.playerPosition;
|
||||
params.player = player;
|
||||
params.gameState = &authoritative;
|
||||
|
||||
auto loc = std::make_shared<Location>(renderer, gameState.inventory);
|
||||
auto loc = std::make_shared<Location>(
|
||||
renderer, gameState.inventory, *authoritative.location, internalGameServer, config.id);
|
||||
loc->setup(params, &gameState.questJournal, audioPlayer.get());
|
||||
loc->scriptEngine.setGlobalStore(&gameState.globalInts);
|
||||
loc->scriptEngine.setGlobalFloatStore(&gameState.globalFloats);
|
||||
@@ -779,8 +800,8 @@ namespace FRG
|
||||
menuManager.setChatUnread(idx, unread, msg);
|
||||
};
|
||||
for (auto& npc : loc->npcs) {
|
||||
if (npc && npc->state.canAttack) {
|
||||
npc->state.onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
|
||||
if (npc && npc->characterState.canAttack) {
|
||||
npc->onDeathAnimComplete = [this]() { menuManager.onEnemyKilledInUniInterior(); };
|
||||
}
|
||||
}
|
||||
loc->requestDarklandsPlayBattleMusic = [this]() {
|
||||
@@ -792,24 +813,23 @@ namespace FRG
|
||||
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;
|
||||
};
|
||||
}
|
||||
authoritative.lockInteractiveObjectsDuringTutorial =
|
||||
config.lockInteractiveObjectsDuringTutorial;
|
||||
|
||||
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;
|
||||
LocationState& locationState = *authoritative.location;
|
||||
locationState.taxiCarTriggerPosition = taxi.triggerPosition;
|
||||
locationState.taxiCarWaypoints = taxi.waypoints;
|
||||
locationState.taxiCarInitialState.position = taxi.initialPosition;
|
||||
locationState.taxiCarInitialState.rotation = taxi.initialRotation;
|
||||
locationState.taxiCarInitialState.mode = NpcCar::Mode::NONE;
|
||||
locationState.taxiCarInitialState.velocity = 0.f;
|
||||
locationState.taxiCarInitialState.steeringAngle = 0.f;
|
||||
locationState.taxiCarInitialState.waypointReachRadius = taxi.waypointReachRadius;
|
||||
locationState.taxiCar.waypointReachRadius = taxi.waypointReachRadius;
|
||||
if (!authoritative.hasPersistentState)
|
||||
authoritative.resetTaxi();
|
||||
}
|
||||
|
||||
return loc;
|
||||
@@ -866,24 +886,30 @@ namespace FRG
|
||||
if (loc->player) {
|
||||
// Raw pointer: the player belongs to the location (and outlives it), so a
|
||||
// shared_ptr here would keep unloaded locations alive. Rewired on every load.
|
||||
loc->player->state.onHpChanged = [this, loc](float hp, float maxHp, int attackerIndex) {
|
||||
loc->player->onHpChanged = [this, loc](float hp, float maxHp, const std::string& attackerId) {
|
||||
menuManager.updateHealthBar(hp, maxHp);
|
||||
|
||||
if (attackerIndex != CharacterState::kNoTarget) {
|
||||
if (!attackerId.empty()) {
|
||||
//Someone attacked, cancel dialogue and agro the player
|
||||
|
||||
if (loc->dialogueSystem.isDialogueActive()) {
|
||||
loc->dialogueSystem.stopDialogue();
|
||||
}
|
||||
|
||||
if ((loc->player->state.attackTargetIndex == CharacterState::kNoTarget ||
|
||||
((loc->player->state.attackTargetIndex >= 0) &&
|
||||
(loc->npcs[loc->player->state.attackTargetIndex]->state.hp <= 0)))
|
||||
bool currentTargetDead = false;
|
||||
for (const auto& npc : loc->npcs) {
|
||||
if (npc && npc->entityId() == loc->player->characterState.attackTargetId) {
|
||||
currentTargetDead = npc->characterState.hp <= 0.f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((loc->player->characterState.attackTargetId.empty() || currentTargetDead)
|
||||
&&
|
||||
loc->player->isMoving() == false
|
||||
&&
|
||||
loc->player->getHp() > 0) {
|
||||
loc->player->state.attackTargetIndex = attackerIndex;
|
||||
internalGameServer.submit(SetCharacterTargetCommand{
|
||||
loc->locationId(), "player", attackerId});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -905,7 +931,7 @@ namespace FRG
|
||||
CheckGlError(__FILE__, __LINE__);
|
||||
logger() << "Shadow map initialized" << std::endl;
|
||||
|
||||
for (auto& [name, loc] : gameState.locations) loc->shadowMap = shadowMap;
|
||||
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap = shadowMap;
|
||||
|
||||
}
|
||||
void Game::destroyShadowMapAndProparateToLocations()
|
||||
@@ -915,7 +941,7 @@ namespace FRG
|
||||
shadowMap.reset();
|
||||
CheckGlError(__FILE__, __LINE__);
|
||||
|
||||
for (auto& [name, loc] : gameState.locations) loc->shadowMap.reset();
|
||||
for (auto& [name, loc] : internalGameServer.runtimeLocations()) loc->shadowMap.reset();
|
||||
CheckGlError(__FILE__, __LINE__);
|
||||
|
||||
}
|
||||
@@ -1177,7 +1203,9 @@ namespace FRG
|
||||
|
||||
if (loadingCompleted && currentLocation())
|
||||
{
|
||||
currentLocation()->update(delta);
|
||||
// The authoritative implementation advances simulation; a future
|
||||
// network client can use the same call to poll/apply server updates.
|
||||
internalGameServer.update(delta);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1228,6 +1256,10 @@ namespace FRG
|
||||
}
|
||||
|
||||
void Game::update() {
|
||||
// Host systems (locations, scripts and loading) still execute in-process.
|
||||
// Publish any changes they made since the previous frame as a new immutable
|
||||
// revision before presentation clients query the server.
|
||||
internalGameServer.markHostMutation();
|
||||
SDL_Event event;
|
||||
const bool mobileIsPortrait = loadingCompleted && (Environment::width < Environment::height);
|
||||
while (SDL_PollEvent(&event)) {
|
||||
@@ -1332,8 +1364,9 @@ namespace FRG
|
||||
}
|
||||
else if (event.button.button == SDL_BUTTON_RIGHT
|
||||
&& event.type == SDL_MOUSEBUTTONUP
|
||||
&& editorMode == EditorMode::Navigation
|
||||
&& currentLocation()) {
|
||||
&& currentLocation()
|
||||
&& currentLocation()->editorMode == EditorMode::Navigation
|
||||
) {
|
||||
currentLocation()->editor.handleRightClick();
|
||||
}
|
||||
}
|
||||
@@ -1378,15 +1411,32 @@ namespace FRG
|
||||
}
|
||||
if (event.key.keysym.sym == SDLK_1)
|
||||
{
|
||||
startNightTransition();
|
||||
currentLocation()->switchNavigation(0);
|
||||
//startNightTransition();
|
||||
}
|
||||
if (event.key.keysym.sym == SDLK_2)
|
||||
{
|
||||
startDarklandsTransition();
|
||||
currentLocation()->switchNavigation(1);
|
||||
//startDarklandsTransition();
|
||||
}
|
||||
if (event.key.keysym.sym == SDLK_3)
|
||||
{
|
||||
logger() << "Player position: " << currentLocation()->player->state.position.transpose() << std::endl;
|
||||
currentLocation()->switchNavigation(2);
|
||||
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
|
||||
}
|
||||
if (event.key.keysym.sym == SDLK_n)
|
||||
{
|
||||
|
||||
if (currentLocation()->editorMode == EditorMode::None)
|
||||
{
|
||||
currentLocation()->editorMode = EditorMode::Navigation;
|
||||
}
|
||||
else
|
||||
{
|
||||
currentLocation()->editorMode = EditorMode::None;
|
||||
}
|
||||
|
||||
//logger() << "Player position: " << currentLocation()->player->characterState.position.transpose() << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1438,12 +1488,18 @@ namespace FRG
|
||||
if (!currentLocation()) return;
|
||||
|
||||
if (currentLocation()->player) {
|
||||
currentLocation()->player->state.slowMoTimeRemaining = currentLocation()->player->state.slowMoActiveTime;
|
||||
currentLocation()->updateCharacterState(
|
||||
*currentLocation()->player, [](CharacterState& workingState) {
|
||||
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
|
||||
});
|
||||
}
|
||||
|
||||
for (auto& npc : currentLocation()->npcs) {
|
||||
if (npc) {
|
||||
npc->state.slowMoTimeRemaining = npc->state.slowMoActiveTime;
|
||||
currentLocation()->updateCharacterState(
|
||||
*npc, [](CharacterState& workingState) {
|
||||
workingState.slowMoTimeRemaining = workingState.slowMoActiveTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1816,10 +1872,11 @@ namespace FRG
|
||||
if (content.empty()) return {};
|
||||
try {
|
||||
nlohmann::json root = nlohmann::json::parse(content);
|
||||
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) return {};
|
||||
SaveSlotInfo info;
|
||||
info.empty = false;
|
||||
info.campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
|
||||
info.locationName = root.value("currentLocationName", "");
|
||||
info.campaignId = root.at("campaignId").get<std::string>();
|
||||
info.locationName = root.at("currentLocationId").get<std::string>();
|
||||
info.savedAt = root.value("savedAt", "");
|
||||
return info;
|
||||
} catch (...) {
|
||||
@@ -1830,7 +1887,7 @@ namespace FRG
|
||||
void Game::saveGame(int slot)
|
||||
{
|
||||
nlohmann::json root;
|
||||
gameState.save(root);
|
||||
internalGameServer.save(root);
|
||||
std::string path = "save_slot" + std::to_string(slot) + ".json";
|
||||
|
||||
/*std::ofstream file(path);
|
||||
@@ -1858,11 +1915,19 @@ namespace FRG
|
||||
return;
|
||||
}
|
||||
|
||||
// The save's campaign and location may not be loaded yet; the state is applied once they are.
|
||||
const std::string campaignId = root.value("campaignId", std::string(CampaignRegistry::kDefaultCampaignId));
|
||||
const std::string locationId = root.value("currentLocationName", std::string());
|
||||
if (root.value("schemaVersion", -1) != GameState::kSchemaVersion) {
|
||||
std::cerr << "[save] Unsupported save schema in " << path << std::endl;
|
||||
return;
|
||||
}
|
||||
const std::string campaignId = root.value("campaignId", std::string());
|
||||
const std::string locationId = root.value("currentLocationId", std::string());
|
||||
const CampaignConfig* campaign = CampaignRegistry::instance().find(campaignId);
|
||||
if (!campaign || !campaign->findLocation(locationId)) {
|
||||
std::cerr << "[save] Unknown campaign/location in " << path << std::endl;
|
||||
return;
|
||||
}
|
||||
enterLocationThen(campaignId, locationId, [this, root]() {
|
||||
gameState.load(root);
|
||||
internalGameServer.load(root);
|
||||
playMusicForLoadedGame();
|
||||
menuManager.enterGameplay(true);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user