diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL b/soh/assets/custom/objects/object_gacha/gGachaTokenDL new file mode 100644 index 00000000000..f41466ff373 --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0 new file mode 100644 index 00000000000..85ff7c127fc --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_0 @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1 new file mode 100644 index 00000000000..fd28ee32bc7 --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_tri_1 @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0 new file mode 100644 index 00000000000..18e0d84e209 --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_0 @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1 b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1 new file mode 100644 index 00000000000..db2f3412b35 --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/gGachaTokenDL_layer_Opaque_vtx_1 @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque new file mode 100644 index 00000000000..efb6a26d31c --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltex_layerOpaque @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque new file mode 100644 index 00000000000..efb6a26d31c --- /dev/null +++ b/soh/assets/custom/objects/object_gacha/mat_gGachaTokenDL_seashelltexdark_layerOpaque @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/soh/assets/soh_assets.h b/soh/assets/soh_assets.h index 4799b53c01f..bc33c871839 100644 --- a/soh/assets/soh_assets.h +++ b/soh/assets/soh_assets.h @@ -532,3 +532,6 @@ static const ALIGN_ASSET(2) char gShipLogoDL[] = dgShipLogoDL; #define dnintendo_rogo_static_Tex_LUS_000000 "__OTR__textures/nintendo_rogo_static/nintendo_rogo_static_Tex_LUS_000000" static const ALIGN_ASSET(2) char nintendo_rogo_static_Tex_LUS_000000[] = dnintendo_rogo_static_Tex_LUS_000000; + +#define dgGachaTokenDL "__OTR__objects/object_gacha/gGachaTokenDL" +static const ALIGN_ASSET(2) char gGachaTokenDL[] = dgGachaTokenDL; diff --git a/soh/include/z64save.h b/soh/include/z64save.h index 35f30c26398..de702cba25c 100644 --- a/soh/include/z64save.h +++ b/soh/include/z64save.h @@ -202,9 +202,16 @@ typedef struct { #pragma region SoH +#define GACHA_MAX_ITEMS RC_MAX + typedef struct ShipRandomizerSaveContextData { u8 triforcePiecesCollected; u8 bombchuUpgradeLevel; + u32 gachaTokens; + u32 gachaListIndex; + u32 gachaItemCount; + u32 gachaItems[GACHA_MAX_ITEMS]; + u32 gachaChecks[GACHA_MAX_ITEMS]; } ShipRandomizerSaveContextData; typedef struct ShipBossRushSaveContextData { diff --git a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp index a02a658877d..484a1c27ff4 100644 --- a/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp +++ b/soh/soh/Enhancements/cosmetics/CosmeticsEditor.cpp @@ -433,6 +433,7 @@ static std::map cosmeticOptions = { COSMETIC_OPTION("World.GossipStone", "Gossip Stone", COSMETICS_GROUP_WORLD, ColorRGBA8(200, 200, 200, 255), false, true, true), COSMETIC_OPTION("World.RedIce", "Red Ice", COSMETICS_GROUP_WORLD, ColorRGBA8(255, 0, 0, 255), false, true, false), COSMETIC_OPTION("World.MysteryItem", "Mystery Item", COSMETICS_GROUP_WORLD, ColorRGBA8( 0, 60, 100, 255), false, true, false), + COSMETIC_OPTION("World.GachaToken", "Gacha Token", COSMETICS_GROUP_WORLD, ColorRGBA8( 0, 100, 200, 255), false, true, true), COSMETIC_OPTION("Navi.IdlePrimary", "Idle Primary", COSMETICS_GROUP_NAVI, ColorRGBA8(255, 255, 255, 255), false, true, false), COSMETIC_OPTION("Navi.IdleSecondary", "Idle Secondary", COSMETICS_GROUP_NAVI, ColorRGBA8( 0, 0, 255, 0), false, true, true), diff --git a/soh/soh/Enhancements/randomizer/3drando/fill.cpp b/soh/soh/Enhancements/randomizer/3drando/fill.cpp index 799b9a34461..416f7ea0c28 100644 --- a/soh/soh/Enhancements/randomizer/3drando/fill.cpp +++ b/soh/soh/Enhancements/randomizer/3drando/fill.cpp @@ -10,6 +10,7 @@ #include "pool_functions.hpp" #include "soh/Enhancements/randomizer/static_data.h" #include "soh/Enhancements/debugger/performanceTimer.h" +#include "gacha_fill.hpp" #include #include @@ -1464,6 +1465,10 @@ int Fill() { CreateAllHints(); CreateWarpSongTexts(); StopPerformanceTimer(PT_HINTS); + + if (ctx->GetOption(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) { + BuildGachaList(); + } SPDLOG_DEBUG("Number of retries {}", retries); return 1; } diff --git a/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp new file mode 100644 index 00000000000..83132e65f85 --- /dev/null +++ b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.cpp @@ -0,0 +1,184 @@ +#include "gacha_fill.hpp" + +#include "../SeedContext.h" +#include "../static_data.h" +#include "random.hpp" + +#include +#include +#include +#include + +using namespace Rando; + +// Returns true if this location should remain as-is (vanilla placement or excluded from the +// randomized pool) rather than being replaced by a gacha token. +static bool IsVanillaPlaced(RandomizerCheck rc) { + auto ctx = Context::GetInstance(); + auto loc = StaticData::GetLocation(rc); + auto itemLoc = ctx->GetItemLocation(rc); + + if (itemLoc->GetPlacedRandomizerGet() == RG_NONE) { + return true; // start-with or excluded — not in the location table + } + + // Link's Pocket: only replace with a token when the setting is "Anything". + // All other settings (Dungeon Reward, Advancement, specific reward type) must keep their item. + if (rc == RC_LINKS_POCKET) { + return !ctx->GetOption(RSK_LINKS_POCKET).Is(RO_LINKS_POCKET_ANYTHING); + } + + // Skip Child Zelda: these locations are given directly at save init, not from the field. + if (ctx->GetOption(RSK_SKIP_CHILD_ZELDA).Is(RO_GENERIC_ON)) { + if (rc == RC_SONG_FROM_IMPA || rc == RC_HC_MALON_EGG || rc == RC_HC_ZELDAS_LETTER) { + return true; + } + } + + // Master Sword shuffle with adult start: given at save init, not from the field. + if (ctx->GetOption(RSK_SHUFFLE_MASTER_SWORD).Is(RO_GENERIC_ON) && + ctx->GetOption(RSK_SELECTED_STARTING_AGE).Is(RO_AGE_ADULT)) { + if (rc == RC_TOT_MASTER_SWORD) { + return true; + } + } + + switch (loc->GetRCType()) { + case RCTYPE_MAP: + case RCTYPE_COMPASS: + return ctx->GetOption(RSK_SHUFFLE_MAPANDCOMPASS).Is(RO_DUNGEON_ITEM_LOC_VANILLA); + case RCTYPE_SMALL_KEY: + return ctx->GetOption(RSK_KEYSANITY).Is(RO_DUNGEON_ITEM_LOC_VANILLA); + case RCTYPE_GF_KEY: + return ctx->GetOption(RSK_GERUDO_KEYS).Is(RO_GERUDO_KEYS_VANILLA); + case RCTYPE_BOSS_KEY: + // Ganon's boss key uses its own setting + if (ctx->GetOption(RSK_GANONS_BOSS_KEY).Is(RO_GANON_BOSS_KEY_VANILLA)) { + return true; + } + return ctx->GetOption(RSK_BOSS_KEYSANITY).Is(RO_DUNGEON_ITEM_LOC_VANILLA); + case RCTYPE_GOSSIP_STONE: + case RCTYPE_STATIC_HINT: + return true; // stones never hold items + default: + return false; + } +} + +void BuildGachaList() { + auto ctx = Context::GetInstance(); + + bool noLogic = ctx->GetOption(RSK_LOGIC_RULES).Is(RO_LOGIC_NO_LOGIC); + + // --- Collect (RC, RG) pairs --- + // Advancement pairs are grouped by sphere; pairs within each sphere are shuffled together + // so the RC<->RG binding is preserved throughout the zone-placement algorithm. + using Pair = std::pair; + + std::vector> sphereAdvPairs; + std::vector advancementLocs; // for dedup in other loop + + if (!noLogic) { + for (auto& sphere : ctx->playthroughLocations) { + std::vector spherePairs; + for (RandomizerCheck rc : sphere) { + if (IsVanillaPlaced(rc)) continue; + RandomizerGet item = ctx->GetItemLocation(rc)->GetPlacedRandomizerGet(); + if (item == RG_NONE || item == RG_GACHA_TOKEN) continue; + spherePairs.push_back({rc, item}); + advancementLocs.push_back(rc); + } + Shuffle(spherePairs); + sphereAdvPairs.push_back(std::move(spherePairs)); + } + } + + std::vector otherPairs; + for (RandomizerCheck rc : ctx->allLocations) { + if (IsVanillaPlaced(rc)) continue; + if (!noLogic && + std::find(advancementLocs.begin(), advancementLocs.end(), rc) != advancementLocs.end()) continue; + RandomizerGet item = ctx->GetItemLocation(rc)->GetPlacedRandomizerGet(); + if (item == RG_NONE || item == RG_GACHA_TOKEN) continue; + otherPairs.push_back({rc, item}); + } + Shuffle(otherPairs); + + size_t totalAdv = 0; + for (auto& v : sphereAdvPairs) totalAdv += v.size(); + size_t totalLocations = totalAdv + otherPairs.size(); + + // --- Build the final lists --- + std::vector gachaList; + std::vector gachaCheckList; + + auto appendPairs = [&](const std::vector& pairs) { + for (auto& [rc, rg] : pairs) { + gachaList.push_back(rg); + gachaCheckList.push_back(rc); + } + }; + + if (noLogic || totalAdv == 0) { + // No logic or nothing to sequence: fully random order. + appendPairs(otherPairs); + } else if (otherPairs.empty()) { + for (auto& sphere : sphereAdvPairs) + appendPairs(sphere); + } else { + // Zone-based placement with logic guarantee. + // + // For each sphere S the player's token budget is proportional to how many + // advancement locations they've unlocked relative to the total: + // tokenLimit[S] = round(unlockedAdv[S] / totalAdv * totalLocations) + // + // Sphere S advancement items are placed anywhere within 0..tokenLimit[S]-1. + // Each zone is filled with the sphere's advancement pairs plus enough junk + // to reach the limit, then the zone is shuffled — random placement within + // the constraint, no forced even junk distribution. + gachaList.reserve(totalLocations); + gachaCheckList.reserve(totalLocations); + size_t unlockedAdv = 0; + size_t otherIdx = 0; + + for (auto& spherePairs : sphereAdvPairs) { + if (spherePairs.empty()) continue; + unlockedAdv += spherePairs.size(); + + size_t tokenLimit = (size_t)round((double)unlockedAdv / totalAdv * totalLocations); + size_t currentSize = gachaList.size(); + + size_t junkForZone = 0; + if (tokenLimit > currentSize + spherePairs.size()) { + size_t available = otherPairs.size() - otherIdx; + junkForZone = std::min(tokenLimit - currentSize - spherePairs.size(), available); + } + + std::vector zone(spherePairs.begin(), spherePairs.end()); + for (size_t j = 0; j < junkForZone; j++) { + zone.push_back(otherPairs[otherIdx++]); + } + Shuffle(zone); + appendPairs(zone); + } + + // Remaining junk after all sphere zones. + while (otherIdx < otherPairs.size()) { + auto& [rc, rg] = otherPairs[otherIdx++]; + gachaList.push_back(rg); + gachaCheckList.push_back(rc); + } + } + + SPDLOG_INFO("GachaFill: built list of {} items ({} advancement, {} other)", + gachaList.size(), totalAdv, otherPairs.size()); + + ctx->SetGachaList(gachaList); + ctx->SetGachaCheckList(gachaCheckList); + + // --- Replace all non-vanilla randomized locations with RG_GACHA_TOKEN --- + for (RandomizerCheck rc : ctx->allLocations) { + if (IsVanillaPlaced(rc)) continue; + ctx->PlaceItemInLocation(rc, RG_GACHA_TOKEN); + } +} diff --git a/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp new file mode 100644 index 00000000000..688c474811d --- /dev/null +++ b/soh/soh/Enhancements/randomizer/3drando/gacha_fill.hpp @@ -0,0 +1,5 @@ +#pragma once + +// Builds the ordered gacha item list from the completed Fill() placement and stores it in +// the Context. Must be called after GeneratePlaythrough() and PareDownPlaythrough() succeed. +void BuildGachaList(); diff --git a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp index 5e1b84ec22a..bbb37c6a82f 100644 --- a/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp +++ b/soh/soh/Enhancements/randomizer/3drando/spoiler_log.cpp @@ -361,6 +361,18 @@ void SpoilerLog_Write() { WriteShuffledEntrances(); WriteAllLocations(); + if (RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) { + const auto& gachaList = ctx->GetGachaList(); + nlohmann::ordered_json gachaJson = nlohmann::ordered_json::array(); + for (size_t i = 0; i < gachaList.size(); i++) { + gachaJson.push_back({ + { "index", i }, + { "item", Rando::StaticData::RetrieveItem(gachaList[i]).GetName().GetEnglish() }, + }); + } + jsonData["gachaList"] = gachaJson; + } + if (!std::filesystem::exists(Ship::Context::GetPathRelativeToAppDirectory("Randomizer"))) { std::filesystem::create_directory(Ship::Context::GetPathRelativeToAppDirectory("Randomizer")); } diff --git a/soh/soh/Enhancements/randomizer/GachaMachine.cpp b/soh/soh/Enhancements/randomizer/GachaMachine.cpp new file mode 100644 index 00000000000..b5b4827f483 --- /dev/null +++ b/soh/soh/Enhancements/randomizer/GachaMachine.cpp @@ -0,0 +1,166 @@ +#include "GachaMachine.h" + +#include + +extern "C" { +#include +#include +#include +extern PlayState* gPlayState; +} + +#include "SeedContext.h" +#include "static_data.h" +#include "soh/Enhancements/custom-message/CustomMessageManager.h" + +#include +#include + +using namespace Rando; +using namespace std::string_literals; + +static std::queue sGachaPendingRCs; + +// --------------------------------------------------------------------------- +// Scene-based category check +// --------------------------------------------------------------------------- + +static bool IsDungeonScene(int scene) { + return (scene >= SCENE_DEKU_TREE && scene <= SCENE_INSIDE_GANONS_CASTLE) || + scene == SCENE_INSIDE_GANONS_CASTLE_COLLAPSE; +} + +static bool IsCityVillageScene(int scene) { + switch (scene) { + case SCENE_KOKIRI_FOREST: + case SCENE_KAKARIKO_VILLAGE: + case SCENE_GORON_CITY: + case SCENE_ZORAS_DOMAIN: + case SCENE_MARKET_DAY: + case SCENE_MARKET_NIGHT: + case SCENE_MARKET_RUINS: + case SCENE_TEMPLE_OF_TIME: + case SCENE_HYRULE_CASTLE: + case SCENE_BACK_ALLEY_DAY: + case SCENE_BACK_ALLEY_NIGHT: + return true; + default: + return false; + } +} + +bool GachaMachine_IsStoneActive() { + int scene = gPlayState->sceneNum; + if (IsDungeonScene(scene)) + return RAND_GET_OPTION(RSK_GACHA_STONES_DUNGEON).Is(RO_GENERIC_ON); + if (IsCityVillageScene(scene)) + return RAND_GET_OPTION(RSK_GACHA_STONES_CITY).Is(RO_GENERIC_ON); + return RAND_GET_OPTION(RSK_GACHA_STONES_OVERWORLD).Is(RO_GENERIC_ON); +} + +// --------------------------------------------------------------------------- +// Helper: build a CustomMessage from a raw string and load it properly. +// --------------------------------------------------------------------------- + +static void GachaLoadMessage(const std::string& text) { + CustomMessage raw(text); + CustomMessage formatted(raw.GetEnglish(MF_FORMATTED), + raw.GetGerman(MF_FORMATTED), + raw.GetFrench(MF_FORMATTED)); + formatted.LoadIntoFont(); +} + +// --------------------------------------------------------------------------- +// Interaction handler (called from OnOpenText hook) +// --------------------------------------------------------------------------- + +void GachaMachine_Interact(uint16_t* textId, bool* loadFromMessageTable) { + *loadFromMessageTable = false; + + // Clear any leftover pending RCs from a previous interaction. + while (!sGachaPendingRCs.empty()) sGachaPendingRCs.pop(); + + if (!GachaMachine_IsStoneActive()) { + GachaLoadMessage("This stone is dormant.^Find an active&Gacha Stone to spin."); + return; + } + + auto& saveData = gSaveContext.ship.quest.data.randomizer; + + if (saveData.gachaItemCount == 0) { + GachaLoadMessage("This gacha machine&has no items to give."); + return; + } + + if (saveData.gachaListIndex >= saveData.gachaItemCount) { + GachaLoadMessage("The gacha machine is empty.&You have received all items!"); + return; + } + + if (saveData.gachaTokens <= saveData.gachaListIndex) { + GachaLoadMessage("You have no Gacha Tokens.^Find them at locations&throughout the world."); + return; + } + + // Claim all unclaimed items: indices [gachaListIndex, gachaTokens), capped at the list size. + uint32_t newIndex = saveData.gachaTokens < saveData.gachaItemCount ? saveData.gachaTokens : saveData.gachaItemCount; + uint32_t count = newIndex - saveData.gachaListIndex; + + auto ctx = Context::GetInstance(); + for (uint32_t i = 0; i < count; i++) { + uint32_t idx = saveData.gachaListIndex + i; + RandomizerCheck rc = (RandomizerCheck)saveData.gachaChecks[idx]; + RandomizerGet rg = (RandomizerGet)saveData.gachaItems[idx]; + + // Restore the real item at this location and clear its obtained status + // so the standard RC queue handler can process it normally. + auto loc = ctx->GetItemLocation(rc); + loc->SetPlacedItem(rg); + loc->SetCheckStatus(RCSHOW_UNCHECKED); + + sGachaPendingRCs.push(rc); + } + + saveData.gachaListIndex = newIndex; + + GachaLoadMessage("You claimed " + std::to_string(count) + " items!"); +} + +// --------------------------------------------------------------------------- +// Instant redeem (called before item give when all stone types are disabled) +// --------------------------------------------------------------------------- + +bool GachaMachine_SubstituteToken(RandomizerCheck rc) { + if (RAND_GET_OPTION(RSK_GACHA_STONES_CITY).Is(RO_GENERIC_ON) || + RAND_GET_OPTION(RSK_GACHA_STONES_OVERWORLD).Is(RO_GENERIC_ON) || + RAND_GET_OPTION(RSK_GACHA_STONES_DUNGEON).Is(RO_GENERIC_ON)) { + return false; + } + + auto& saveData = gSaveContext.ship.quest.data.randomizer; + if (saveData.gachaItemCount == 0) return false; + if (saveData.gachaListIndex >= saveData.gachaItemCount) return false; + + saveData.gachaTokens++; + + uint32_t idx = saveData.gachaListIndex; + RandomizerGet rg = (RandomizerGet)saveData.gachaItems[idx]; + + auto loc = Context::GetInstance()->GetItemLocation(rc); + loc->SetPlacedItem(rg); + loc->SetCheckStatus(RCSHOW_UNCHECKED); + + saveData.gachaListIndex++; + return true; +} + +// --------------------------------------------------------------------------- +// RC delivery (called from RandomizerOnGameFrameUpdateHandler) +// --------------------------------------------------------------------------- + +RandomizerCheck GachaMachine_PopNextPendingRC() { + if (sGachaPendingRCs.empty()) return RC_UNKNOWN_CHECK; + RandomizerCheck rc = sGachaPendingRCs.front(); + sGachaPendingRCs.pop(); + return rc; +} diff --git a/soh/soh/Enhancements/randomizer/GachaMachine.h b/soh/soh/Enhancements/randomizer/GachaMachine.h new file mode 100644 index 00000000000..f893350f590 --- /dev/null +++ b/soh/soh/Enhancements/randomizer/GachaMachine.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include "randomizerTypes.h" + +// Returns whether the gossip stone currently being talked to is an active gacha machine. +// If false, the caller should show the "dormant stone" message instead of hints or gacha. +bool GachaMachine_IsStoneActive(); + +// Claim all items earned since the last visit: advances gachaListIndex up to gachaTokens, +// queues each RC for delivery, and loads the result message into the text system. +// Tokens are a monotonically increasing counter; nothing is ever deducted. +// Always sets *loadFromMessageTable = false so our message is used. +void GachaMachine_Interact(uint16_t* textId, bool* loadFromMessageTable); + +// If all stone categories are disabled, replaces the token at rc with its real gacha reward +// before the item give fires. Returns true if substitution happened; caller must re-fetch +// the GetItemEntry after a true return. +bool GachaMachine_SubstituteToken(RandomizerCheck rc); + +// Returns the next pending gacha RandomizerCheck to push into the randomizer queue, or +// RC_UNKNOWN_CHECK if nothing is pending. Called every frame from RandomizerOnGameFrameUpdateHandler. +RandomizerCheck GachaMachine_PopNextPendingRC(); diff --git a/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp b/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp index 7e2a2e193a2..1f7a1a8a51f 100644 --- a/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp +++ b/soh/soh/Enhancements/randomizer/Messages/GossipStoneHints.cpp @@ -1,8 +1,9 @@ /** * This file handles the custom messages for Gossip Stone - * hints. + * hints, and intercepts stone interaction for Gacha Mode. */ #include +#include "soh/Enhancements/randomizer/GachaMachine.h" extern "C" { extern PlayState* gPlayState; @@ -12,6 +13,12 @@ extern PlayState* gPlayState; } void BuildHintStoneMessage(uint16_t* textId, bool* loadFromMessageTable) { + // Gacha Mode: redirect all stone interactions to the gacha machine handler. + if (RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) { + GachaMachine_Interact(textId, loadFromMessageTable); + return; + } + if ((RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).Is(RO_GOSSIP_STONES_NEED_TRUTH) && Player_GetMask(gPlayState) == PLAYER_MASK_TRUTH) || (RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).Is(RO_GOSSIP_STONES_NEED_STONE) && @@ -52,8 +59,11 @@ void BuildHintStoneMessage(uint16_t* textId, bool* loadFromMessageTable) { } void RegisterGossipStoneHints() { + // Fire for hint mode OR gacha mode (gacha mode overrides hint behaviour inside BuildHintStoneMessage). COND_ID_HOOK(OnOpenText, TEXT_RANDOMIZER_GOSSIP_STONE_HINTS, - RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).IsNot(RO_GOSSIP_STONES_NONE), BuildHintStoneMessage); + RAND_GET_OPTION(RSK_GOSSIP_STONE_HINTS).IsNot(RO_GOSSIP_STONES_NONE) || + RAND_GET_OPTION(RSK_GACHA_MODE).Is(RO_GENERIC_ON), + BuildHintStoneMessage); } static RegisterShipInitFunc initFunc(RegisterGossipStoneHints, { "IS_RANDO" }); \ No newline at end of file diff --git a/soh/soh/Enhancements/randomizer/SeedContext.cpp b/soh/soh/Enhancements/randomizer/SeedContext.cpp index 512984117d7..b2e8a0ae6c8 100644 --- a/soh/soh/Enhancements/randomizer/SeedContext.cpp +++ b/soh/soh/Enhancements/randomizer/SeedContext.cpp @@ -421,6 +421,7 @@ void Context::ParseSpoiler(const char* spoilerFileName) { ParseTricksJson(spoilerFileJson); mEntranceShuffler->ParseJson(spoilerFileJson); ParseHintJson(spoilerFileJson); + ParseGachaListJson(spoilerFileJson); mDungeons->ParseJson(spoilerFileJson); mTrials->ParseJson(spoilerFileJson); mSpoilerLoaded = true; @@ -460,6 +461,15 @@ void Context::ParseItemLocationsJson(nlohmann::json spoilerFileJson) { } } +void Context::ParseGachaListJson(nlohmann::json spoilerFileJson) { + if (!spoilerFileJson.contains("gachaList")) return; + std::vector list; + for (auto& entry : spoilerFileJson["gachaList"]) { + list.push_back(StaticData::itemNameToEnum[entry["item"].get()]); + } + gachaList = std::move(list); +} + void Context::WriteHintJson(nlohmann::ordered_json& spoilerFileJson) { for (Hint hint : hintTable) { hint.logHint(spoilerFileJson); @@ -565,6 +575,22 @@ std::shared_ptr Context::GetKaleido() { return mKaleido; } +const std::vector& Context::GetGachaList() const { + return gachaList; +} + +void Context::SetGachaList(std::vector list) { + gachaList = std::move(list); +} + +const std::vector& Context::GetGachaCheckList() const { + return gachaCheckList; +} + +void Context::SetGachaCheckList(std::vector list) { + gachaCheckList = std::move(list); +} + std::string Context::GetHash() const { return mHash; } diff --git a/soh/soh/Enhancements/randomizer/SeedContext.h b/soh/soh/Enhancements/randomizer/SeedContext.h index 9ca42751a95..144a3566379 100644 --- a/soh/soh/Enhancements/randomizer/SeedContext.h +++ b/soh/soh/Enhancements/randomizer/SeedContext.h @@ -122,6 +122,7 @@ class Context { void ParseItemLocationsJson(nlohmann::json spoilerFileJson); void WriteHintJson(nlohmann::ordered_json& spoilerFileJson); void ParseHintJson(nlohmann::json spoilerFileJson); + void ParseGachaListJson(nlohmann::json spoilerFileJson); void ParseTricksJson(nlohmann::json spoilerFileJson); std::map overrides = {}; std::vector> playthroughLocations = {}; @@ -132,6 +133,12 @@ class Context { std::array hashIconIndexes = {}; bool playthroughBeatable = false; bool allLocationsReachable = false; + std::vector gachaList = {}; + std::vector gachaCheckList = {}; + const std::vector& GetGachaList() const; + void SetGachaList(std::vector list); + const std::vector& GetGachaCheckList() const; + void SetGachaCheckList(std::vector list); RandomizerArea GetAreaFromString(std::string str); int CountEmptyLocations(bool countShops); diff --git a/soh/soh/Enhancements/randomizer/draw.cpp b/soh/soh/Enhancements/randomizer/draw.cpp index f16ae7440cd..5d4e52a14a0 100644 --- a/soh/soh/Enhancements/randomizer/draw.cpp +++ b/soh/soh/Enhancements/randomizer/draw.cpp @@ -1392,3 +1392,15 @@ extern "C" void Randomizer_DrawOverworldKey(PlayState* play, GetItemEntry* getIt CLOSE_DISPS(play->state.gfxCtx); } + +extern "C" void Randomizer_DrawGachaToken(PlayState* play, GetItemEntry* getItemEntry) { + OPEN_DISPS(play->state.gfxCtx); + Gfx_SetupDL_25Opa(play->state.gfxCtx); + Color_RGBA8 color = CVarGetColor(CVAR_COSMETIC("World.GachaToken.Value"), Color_RGBA8{ 0, 100, 200, 255 }); + gDPSetEnvColor(POLY_OPA_DISP++, color.r, color.g, color.b, color.a); + Matrix_Scale(0.035f, 0.035f, 0.035f, MTXMODE_APPLY); + gSPMatrix(POLY_OPA_DISP++, Matrix_NewMtx(play->state.gfxCtx, (char*)__FILE__, __LINE__), + G_MTX_MODELVIEW | G_MTX_LOAD); + gSPDisplayList(POLY_OPA_DISP++, (Gfx*)gGachaTokenDL); + CLOSE_DISPS(play->state.gfxCtx); +} diff --git a/soh/soh/Enhancements/randomizer/draw.h b/soh/soh/Enhancements/randomizer/draw.h index d674f562fac..2ad8faf1e7e 100644 --- a/soh/soh/Enhancements/randomizer/draw.h +++ b/soh/soh/Enhancements/randomizer/draw.h @@ -33,6 +33,7 @@ void Randomizer_DrawMysteryItem(PlayState* play, GetItemEntry* getItemEntry); void Randomizer_DrawBombchuBagInLogic(PlayState* play, GetItemEntry* getItemEntry); void Randomizer_DrawBombchuBag(PlayState* play, GetItemEntry* getItemEntry); void Randomizer_DrawOverworldKey(PlayState* play, GetItemEntry* getItemEntry); +void Randomizer_DrawGachaToken(PlayState* play, GetItemEntry* getItemEntry); void Randomizer_DrawRocsFeather(PlayState* play, GetItemEntry* getItemEntry); #define GET_ITEM_MYSTERY \ diff --git a/soh/soh/Enhancements/randomizer/hook_handlers.cpp b/soh/soh/Enhancements/randomizer/hook_handlers.cpp index fb2a00b1636..2990f31f853 100644 --- a/soh/soh/Enhancements/randomizer/hook_handlers.cpp +++ b/soh/soh/Enhancements/randomizer/hook_handlers.cpp @@ -1,5 +1,6 @@ #include #include "soh/OTRGlobals.h" +#include "soh/Enhancements/randomizer/GachaMachine.h" #include "soh/ResourceManagerHelpers.h" #include "soh/Enhancements/enhancementTypes.h" #include "soh/Enhancements/custom-message/CustomMessageTypes.h" @@ -369,6 +370,9 @@ void RandomizerOnPlayerUpdateForRCQueueHandler() { auto loc = Rando::Context::GetInstance()->GetItemLocation(rc); RandomizerGet vanillaRandomizerGet = Rando::StaticData::GetLocation(rc)->GetVanillaItem(); GetItemID vanillaItem = (GetItemID)Rando::StaticData::RetrieveItem(vanillaRandomizerGet).GetItemID(); + if (loc->GetPlacedRandomizerGet() == RG_GACHA_TOKEN) { + GachaMachine_SubstituteToken(rc); + } GetItemEntry getItemEntry = Rando::Context::GetInstance()->GetFinalGIEntry(rc, true, (GetItemID)vanillaRandomizerGet); GetItemCategory getItemCategory = Randomizer_AdjustItemCategory(getItemEntry); @@ -2486,6 +2490,11 @@ void RandomizerOnActorInitHandler(void* actorRef) { } void RandomizerOnGameFrameUpdateHandler() { + RandomizerCheck gachaRC = GachaMachine_PopNextPendingRC(); + if (gachaRC != RC_UNKNOWN_CHECK) { + randomizerQueuedChecks.push(gachaRC); + } + if (Flags_GetRandomizerInf(RAND_INF_HAS_INFINITE_QUIVER)) { AMMO(ITEM_BOW) = static_cast(CUR_CAPACITY(UPG_QUIVER)); } diff --git a/soh/soh/Enhancements/randomizer/item_list.cpp b/soh/soh/Enhancements/randomizer/item_list.cpp index 87721760618..00f46959005 100644 --- a/soh/soh/Enhancements/randomizer/item_list.cpp +++ b/soh/soh/Enhancements/randomizer/item_list.cpp @@ -456,6 +456,8 @@ void Rando::StaticData::InitItemTable() { itemTable[RG_TRIFORCE_PIECE] = Item(RG_TRIFORCE_PIECE, Text{ "Triforce Piece", "Triforce Piece", "Triforce-Fragment" }, ITEMTYPE_ITEM, 0xDF, true, LOGIC_TRIFORCE_PIECES, RHT_TRIFORCE_PIECE, RG_TRIFORCE_PIECE, OBJECT_GI_BOMB_2, GID_TRIFORCE_PIECE, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_LONG, ITEM_CATEGORY_MAJOR, MOD_RANDOMIZER, {"a ", "ein ", "un "}).CustomIcon(gTriforcePieceTex); itemTable[RG_ROCS_FEATHER] = Item(RG_ROCS_FEATHER, Text{ "Roc's Feather", "Roc's Feather", "Roc's Feather" }, ITEMTYPE_ITEM, 0xE0, true, LOGIC_ROCS_FEATHER, RHT_ROCS_FEATHER, RG_ROCS_FEATHER, OBJECT_GI_BOMB_2, GID_ROCS_FEATHER, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_LONG, ITEM_CATEGORY_MAJOR, MOD_RANDOMIZER, {"a ", "ein ", "un "}).CustomIcon(gRocsFeatherTex); itemTable[RG_ROCS_FEATHER].SetCustomDrawFunc(Randomizer_DrawRocsFeather); + itemTable[RG_GACHA_TOKEN] = Item(RG_GACHA_TOKEN, Text{ "Gacha Token", "Jeton Gacha", "Gacha-Spielmarke" }, ITEMTYPE_ITEM, GI_RUPEE_GREEN, false, LOGIC_NONE, RHT_NONE, RG_GACHA_TOKEN, OBJECT_GI_SUTARU, GID_SKULL_TOKEN, TEXT_RANDOMIZER_CUSTOM_ITEM, 0x80, CHEST_ANIM_SHORT, ITEM_CATEGORY_LESSER, MOD_RANDOMIZER, {"a ", "ein ", "un "}); + itemTable[RG_GACHA_TOKEN].SetCustomDrawFunc(Randomizer_DrawGachaToken); // clang-format on diff --git a/soh/soh/Enhancements/randomizer/randomizer.cpp b/soh/soh/Enhancements/randomizer/randomizer.cpp index e6dc71acc26..4cfee5d2c5d 100644 --- a/soh/soh/Enhancements/randomizer/randomizer.cpp +++ b/soh/soh/Enhancements/randomizer/randomizer.cpp @@ -4626,6 +4626,9 @@ extern "C" u16 Randomizer_Item_Give(PlayState* play, GetItemEntry giEntry) { INV_CONTENT(ITEM_NAYRUS_LOVE) = ITEM_ROCS_FEATHER; } break; + case RG_GACHA_TOKEN: + gSaveContext.ship.quest.data.randomizer.gachaTokens++; + break; default: LUSLOG_WARN("Randomizer_Item_Give didn't have behaviour specified for getItemId=%d", item); assert(false); diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h index 3b7806385f6..23a0fe584c2 100644 --- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h +++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerGet.h @@ -320,6 +320,9 @@ RANDO_ENUM_ITEM(RG_FISHING_HOLE_KEY) // Custom Items RANDO_ENUM_ITEM(RG_ROCS_FEATHER) +// Gacha Mode +RANDO_ENUM_ITEM(RG_GACHA_TOKEN) + // Logic Only RANDO_ENUM_ITEM(RG_STICKS) RANDO_ENUM_ITEM(RG_NUTS) diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h index 3079132fc61..0fe53380c70 100644 --- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h +++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerMiscEnums.h @@ -339,6 +339,7 @@ RANDO_ENUM_ITEM(RSG_MENU_SECTION_STARTING_ITEMS) RANDO_ENUM_ITEM(RSG_MENU_COLUMN_STARTING_SONGS) RANDO_ENUM_ITEM(RSG_MENU_SECTION_NORMAL_SONGS) RANDO_ENUM_ITEM(RSG_MENU_SECTION_WARP_SONGS) +RANDO_ENUM_ITEM(RSG_MENU_SECTION_GACHA) RANDO_ENUM_ITEM(RSG_OPEN) RANDO_ENUM_ITEM(RSG_WORLD) RANDO_ENUM_ITEM(RSG_SHUFFLE) diff --git a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h index 5e1dc80f30b..d65a732a0fd 100644 --- a/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h +++ b/soh/soh/Enhancements/randomizer/randomizerEnums/RandomizerSettingKey.h @@ -250,6 +250,11 @@ RANDO_ENUM_ITEM(RSK_SHUFFLE_SIGNS) RANDO_ENUM_ITEM(RSK_ROCS_FEATHER) RANDO_ENUM_ITEM(RSK_SHUFFLE_ICICLES) RANDO_ENUM_ITEM(RSK_SHUFFLE_RED_ICE) +// Gacha Mode +RANDO_ENUM_ITEM(RSK_GACHA_MODE) +RANDO_ENUM_ITEM(RSK_GACHA_STONES_CITY) +RANDO_ENUM_ITEM(RSK_GACHA_STONES_OVERWORLD) +RANDO_ENUM_ITEM(RSK_GACHA_STONES_DUNGEON) RANDO_ENUM_ITEM(RSK_MAX) RANDO_ENUM_END(RandomizerSettingKey) diff --git a/soh/soh/Enhancements/randomizer/savefile.cpp b/soh/soh/Enhancements/randomizer/savefile.cpp index 064eabff646..9916a9380cb 100644 --- a/soh/soh/Enhancements/randomizer/savefile.cpp +++ b/soh/soh/Enhancements/randomizer/savefile.cpp @@ -249,6 +249,21 @@ extern "C" void Randomizer_InitSaveFile() { // Reset Bombchu Bag Upgrade gSaveContext.ship.quest.data.randomizer.bombchuUpgradeLevel = 0; + // Reset gacha state and bake the gacha list into the save so it's always available. + gSaveContext.ship.quest.data.randomizer.gachaTokens = 0; + gSaveContext.ship.quest.data.randomizer.gachaListIndex = 0; + gSaveContext.ship.quest.data.randomizer.gachaItemCount = 0; + if (ctx->GetOption(RSK_GACHA_MODE).Is(RO_GENERIC_ON)) { + const auto& gachaList = ctx->GetGachaList(); + const auto& gachaCheckList = ctx->GetGachaCheckList(); + uint32_t count = (uint32_t)(gachaList.size() < GACHA_MAX_ITEMS ? gachaList.size() : GACHA_MAX_ITEMS); + gSaveContext.ship.quest.data.randomizer.gachaItemCount = count; + for (uint32_t i = 0; i < count; i++) { + gSaveContext.ship.quest.data.randomizer.gachaItems[i] = (uint32_t)gachaList[i]; + gSaveContext.ship.quest.data.randomizer.gachaChecks[i] = (uint32_t)gachaCheckList[i]; + } + } + SetStartingItems(); // Set Cutscene flags and texts to skip them. diff --git a/soh/soh/Enhancements/randomizer/settings.cpp b/soh/soh/Enhancements/randomizer/settings.cpp index ad92fede41d..e60dce520cb 100644 --- a/soh/soh/Enhancements/randomizer/settings.cpp +++ b/soh/soh/Enhancements/randomizer/settings.cpp @@ -1335,6 +1335,10 @@ void Settings::CreateOptions() { OPT_CALLBACK(RSK_LOGIC_RULES, { HandleStartingAgeUI(); }); + OPT_BOOL(RSK_GACHA_MODE, "Gacha Mode", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaMode"), "Every location gives a Gacha Token. Spend tokens at Stones of Truth to receive items from a pre-ordered list that guarantees progression.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_OFF); + OPT_BOOL(RSK_GACHA_STONES_CITY, "Gacha: City/Village Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesCity"), "Stones in cities and villages (Kokiri Forest, Kakariko, Goron City, etc.) act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON); + OPT_BOOL(RSK_GACHA_STONES_OVERWORLD, "Gacha: Overworld/Grotto Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), "Stones in overworld areas and grottos act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON); + OPT_BOOL(RSK_GACHA_STONES_DUNGEON, "Gacha: Dungeon/Temple Stones", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), "Stones inside dungeons and temples act as Gacha Machines.", WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON); OPT_BOOL(RSK_ALL_LOCATIONS_REACHABLE, "All Locations Reachable", {"Off", "On"}, OptionCategory::Setting, CVAR_RANDOMIZER_SETTING("AllLocationsReachable"), mOptionDescriptions[RSK_ALL_LOCATIONS_REACHABLE], WIDGET_CVAR_CHECKBOX, RO_GENERIC_ON, false, nullptr, IMFLAG_SAME_LINE); OPT_BOOL(RSK_SKULLS_SUNS_SONG, "Night Skulltula's Expect Sun's Song", CVAR_RANDOMIZER_SETTING("GsExpectSunsSong"), mOptionDescriptions[RSK_SKULLS_SUNS_SONG]); OPT_U8(RSK_DAMAGE_MULTIPLIER, "Damage Multiplier", {"x1/2", "x1", "x2", "x4", "x8", "x16", "OHKO"}, OptionCategory::Setting, "", "", WIDGET_CVAR_SLIDER_INT, RO_DAMAGE_MULTIPLIER_DEFAULT); @@ -1722,6 +1726,14 @@ void Settings::CreateOptions() { } } mOptionGroups[RSG_TRICKS] = OptionGroup::SubGroup("Logical Tricks", tricksOption); + mOptionGroups[RSG_MENU_SECTION_GACHA] = OptionGroup::SubGroup("Gacha Mode", + { + &mOptions[RSK_GACHA_MODE], + &mOptions[RSK_GACHA_STONES_CITY], + &mOptions[RSK_GACHA_STONES_OVERWORLD], + &mOptions[RSK_GACHA_STONES_DUNGEON], + }, + WidgetContainerType::SECTION); mOptionGroups[RSG_MENU_SECTION_LOGIC] = OptionGroup::SubGroup("Logic", { &mOptions[RSK_LOGIC_RULES], @@ -1749,7 +1761,7 @@ void Settings::CreateOptions() { &mOptions[RSK_LACS_REWARD_COUNT], &mOptions[RSK_LACS_TOKEN_COUNT] }, WidgetContainerType::SECTION); mOptionGroups[RSG_MENU_COLUMN_LOGIC_WINCON] = OptionGroup::SubGroup("", - std::initializer_list{ + { &mOptionGroups[RSG_ITEM_POOL], &mOptionGroups[RSG_MENU_SECTION_LOGIC], &mOptionGroups[RSG_MENU_SECTION_WINCON], diff --git a/soh/soh/Network/Anchor/JsonConversions.hpp b/soh/soh/Network/Anchor/JsonConversions.hpp index 69ad1313607..089921e22ca 100644 --- a/soh/soh/Network/Anchor/JsonConversions.hpp +++ b/soh/soh/Network/Anchor/JsonConversions.hpp @@ -109,12 +109,16 @@ inline void to_json(json& j, const ShipRandomizerSaveContextData& shipRandomizer j = json{ { "triforcePiecesCollected", shipRandomizerSaveContextData.triforcePiecesCollected }, { "bombchuUpgradeLevel", shipRandomizerSaveContextData.bombchuUpgradeLevel }, + { "gachaTokens", shipRandomizerSaveContextData.gachaTokens }, + { "gachaListIndex", shipRandomizerSaveContextData.gachaListIndex }, }; } inline void from_json(const json& j, ShipRandomizerSaveContextData& shipRandomizerSaveContextData) { j.at("triforcePiecesCollected").get_to(shipRandomizerSaveContextData.triforcePiecesCollected); j.at("bombchuUpgradeLevel").get_to(shipRandomizerSaveContextData.bombchuUpgradeLevel); + shipRandomizerSaveContextData.gachaTokens = j.value("gachaTokens", (u32)0); + shipRandomizerSaveContextData.gachaListIndex = j.value("gachaListIndex", (u32)0); } inline void to_json(json& j, const ShipQuestSpecificSaveContextData& shipQuestSpecificSaveContextData) { diff --git a/soh/soh/SaveManager.cpp b/soh/soh/SaveManager.cpp index 89224c0ab57..6f755b1963f 100644 --- a/soh/soh/SaveManager.cpp +++ b/soh/soh/SaveManager.cpp @@ -231,6 +231,16 @@ void SaveManager::LoadRandomizer() { gSaveContext.ship.quest.data.randomizer.triforcePiecesCollected); SaveManager::Instance->LoadData("bombchuUpgradeLevel", gSaveContext.ship.quest.data.randomizer.bombchuUpgradeLevel); + SaveManager::Instance->LoadData("gachaTokens", gSaveContext.ship.quest.data.randomizer.gachaTokens, (uint32_t)0); + SaveManager::Instance->LoadData("gachaListIndex", gSaveContext.ship.quest.data.randomizer.gachaListIndex, (uint32_t)0); + SaveManager::Instance->LoadData("gachaItemCount", gSaveContext.ship.quest.data.randomizer.gachaItemCount, (uint32_t)0); + SaveManager::Instance->LoadArray("gachaItems", gSaveContext.ship.quest.data.randomizer.gachaItemCount, [&](size_t i) { + SaveManager::Instance->LoadData("", gSaveContext.ship.quest.data.randomizer.gachaItems[i], (uint32_t)0); + }); + SaveManager::Instance->LoadArray("gachaChecks", gSaveContext.ship.quest.data.randomizer.gachaItemCount, [&](size_t i) { + SaveManager::Instance->LoadData("", gSaveContext.ship.quest.data.randomizer.gachaChecks[i], (uint32_t)0); + }); + SaveManager::Instance->LoadData("pendingIceTrapCount", gSaveContext.ship.pendingIceTrapCount); std::shared_ptr randomizer = OTRGlobals::Instance->gRandomizer; @@ -385,6 +395,16 @@ void SaveManager::SaveRandomizer(SaveContext* saveContext, int sectionID, bool f saveContext->ship.quest.data.randomizer.triforcePiecesCollected); SaveManager::Instance->SaveData("bombchuUpgradeLevel", saveContext->ship.quest.data.randomizer.bombchuUpgradeLevel); + SaveManager::Instance->SaveData("gachaTokens", saveContext->ship.quest.data.randomizer.gachaTokens); + SaveManager::Instance->SaveData("gachaListIndex", saveContext->ship.quest.data.randomizer.gachaListIndex); + SaveManager::Instance->SaveData("gachaItemCount", saveContext->ship.quest.data.randomizer.gachaItemCount); + SaveManager::Instance->SaveArray("gachaItems", saveContext->ship.quest.data.randomizer.gachaItemCount, [&](size_t i) { + SaveManager::Instance->SaveData("", saveContext->ship.quest.data.randomizer.gachaItems[i]); + }); + SaveManager::Instance->SaveArray("gachaChecks", saveContext->ship.quest.data.randomizer.gachaItemCount, [&](size_t i) { + SaveManager::Instance->SaveData("", saveContext->ship.quest.data.randomizer.gachaChecks[i]); + }); + SaveManager::Instance->SaveData("pendingIceTrapCount", saveContext->ship.pendingIceTrapCount); std::shared_ptr randomizer = OTRGlobals::Instance->gRandomizer; diff --git a/soh/soh/SohGui/SohMenuRandomizer.cpp b/soh/soh/SohGui/SohMenuRandomizer.cpp index be64ac81587..ff0554bc4d0 100644 --- a/soh/soh/SohGui/SohMenuRandomizer.cpp +++ b/soh/soh/SohGui/SohMenuRandomizer.cpp @@ -603,6 +603,77 @@ void SohMenu::AddMenuRandomizer() { } }); + // Gacha Mode + AddWidget(path, "Gacha Mode", WIDGET_SEPARATOR_TEXT); + AddWidget(path, "Gacha Mode", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_RANDOMIZER_SETTING("GachaMode")) + .PreFunc([](WidgetInfo& info) { + if (GameInteractor::IsSaveLoaded()) { + info.options->disabled = true; + info.options->disabledTooltip = "Cannot change after a seed has been loaded."; + } + }) + .Options(CheckboxOptions().Tooltip( + "Every location gives a Gacha Token. Spend tokens at Gossip Stones to receive items.")); + AddWidget(path, "Gacha: Settlements", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesCity")) + .PreFunc([](WidgetInfo& info) { + if (GameInteractor::IsSaveLoaded()) { + info.options->disabled = true; + info.options->disabledTooltip = "Cannot change after a seed has been loaded."; + } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) { + info.options->disabled = true; + info.options->disabledTooltip = "Requires Gacha Mode to be enabled."; + } + }) + .Options(CheckboxOptions() + .DefaultValue(true) + .Tooltip("Stones in settlements (Kokiri Forest, Kakariko, Goron City, etc.) act as Gacha Machines.")); + AddWidget(path, "Gacha: Overworld/Grotto Stones", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld")) + .PreFunc([](WidgetInfo& info) { + if (GameInteractor::IsSaveLoaded()) { + info.options->disabled = true; + info.options->disabledTooltip = "Cannot change after a seed has been loaded."; + } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) { + info.options->disabled = true; + info.options->disabledTooltip = "Requires Gacha Mode to be enabled."; + } + }) + .Options(CheckboxOptions() + .DefaultValue(true) + .Tooltip("Stones in overworld areas and grottos act as Gacha Machines.")); + AddWidget(path, "Gacha: Dungeon/Temple Stones", WIDGET_CVAR_CHECKBOX) + .CVar(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon")) + .PreFunc([](WidgetInfo& info) { + if (GameInteractor::IsSaveLoaded()) { + info.options->disabled = true; + info.options->disabledTooltip = "Cannot change after a seed has been loaded."; + } else if (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0)) { + info.options->disabled = true; + info.options->disabledTooltip = "Requires Gacha Mode to be enabled."; + } + }) + .Options(CheckboxOptions() + .DefaultValue(true) + .Tooltip("Stones inside dungeons and temples act as Gacha Machines.")); + AddWidget(path, "Visit an active Gossip Stone to redeem your tokens for items.", WIDGET_TEXT) + .PreFunc([](WidgetInfo& info) { + info.isHidden = !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0) || + (!CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesCity"), 1) && + !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), 1) && + !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), 1)); + }) + .Options(TextOptions().Color(UIWidgets::Colors::Gray)); + AddWidget(path, "With all stone types disabled, tokens are redeemed instantly on pickup.", WIDGET_TEXT) + .PreFunc([](WidgetInfo& info) { + info.isHidden = !CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaMode"), 0) || + CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesCity"), 1) || + CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesOverworld"), 1) || + CVarGetInteger(CVAR_RANDOMIZER_SETTING("GachaStonesDungeon"), 1); + }) + .Options(TextOptions().Color(UIWidgets::Colors::Orange)); + // Enhancements AddWidget(path, "Enhancements", WIDGET_SEPARATOR_TEXT); AddWidget(path, "These enhancements are only useful in the Randomizer mode but do not affect the randomizer logic.",