implement excluded locations menu

This commit is contained in:
gymnast86
2026-06-05 21:10:57 -07:00
parent fbf4fcc50c
commit 7d2c9776ea
8 changed files with 423 additions and 17 deletions
+12
View File
@@ -105,6 +105,18 @@ window content pane > * {
flex: 0 0 auto;
}
pane.excluded-locations-pane {
display: flex;
flex-flow: column;
flex: 1 1 0;
min-width: 0;
min-height: 0;
gap: 8dp;
overflow-y: scroll;
font-size: 20dp;
border-top: 1dp #92875B;
}
window content pane:last-of-type > div {
line-height: 1.625;
}
@@ -201,6 +201,7 @@ namespace randomizer::seedgen::settings
const std::set<std::string>& GetExcludedLocations() const { return this->_excludedLocations; }
const std::list<std::list<std::string>>& GetMixedEntrancePools() const { return this->_mixedEntrancePools; }
std::map<std::string, int>& GetModifiableStartingInventory() { return this->_startingInventory; }
std::set<std::string>& GetModifiableExcludedLocations() { return this->_excludedLocations; }
private:
std::map<std::string, Setting> _map = {};
+1
View File
@@ -45,6 +45,7 @@ public:
}
Rml::Element* root() const { return mRoot; }
std::vector<std::unique_ptr<Component> >& children() { return mChildren; }
protected:
void clear_children();
+4 -2
View File
@@ -15,7 +15,7 @@ Rml::Element* createRoot(Rml::Element* parent) {
} // namespace
Pane::Pane(Rml::Element* parent, Type type) : FluentComponent(createRoot(parent)), mType(type) {
Pane::Pane(Rml::Element* parent, Type type, bool bottomSpacer) : FluentComponent(createRoot(parent)), mType(type), mBottomSpacer(bottomSpacer) {
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
@@ -192,7 +192,9 @@ void Pane::finalize() {
// padding-bottom or margin-bottom on a scrollable flex container, so
// we need to create a fake spacer with an actual layout height to get
// padding at the bottom of a scrollable container.
append(mRoot, "spacer");
if (mBottomSpacer) {
append(mRoot, "spacer");
}
}
void Pane::clear() {
+2 -2
View File
@@ -13,8 +13,7 @@ public:
Uncontrolled,
};
explicit Pane(Rml::Element* parent, Type type);
explicit Pane(Rml::Element* parent, Type type, bool bottomSpacer = true);
bool focus() override;
void update() override;
@@ -37,6 +36,7 @@ public:
private:
Type mType;
bool mBottomSpacer = true;
bool finalized = false;
};
+392 -13
View File
@@ -4,15 +4,16 @@
#include <thread>
#include "bool_button.hpp"
#include "modal.hpp"
#include "dusk/config.hpp"
#include "dusk/data.hpp"
#include "dusk/logging.h"
#include "dusk/randomizer/game/tools.h"
#include "dusk/randomizer/generator/seedgen/seed.hpp"
#include "dusk/randomizer/generator/utility/string.hpp"
#include "modal.hpp"
#include "number_button.hpp"
#include "pane.hpp"
#include "string_button.hpp"
#include "dusk/randomizer/game/tools.h"
#include "dusk/randomizer/generator/seedgen/seed.hpp"
namespace dusk::ui {
struct ConfigBoolProps {
@@ -46,7 +47,7 @@ randomizer::seedgen::settings::Setting* FindSetting(const std::string& key) {
}
}
void SaveConfig() {
void SaveRandomizerConfig() {
GetRandomizerConfig().WriteToFile(GetRandomizerSettingsPath(), GetRandomizerPreferencesPath());
}
@@ -56,7 +57,7 @@ bool TryCreateRandomSeed() {
const std::string& configSeed = config.GetSeed();
if (configSeed.empty()) {
config.SetSeed(randomizer::seedgen::seed::GenerateSeed());
SaveConfig();
SaveRandomizerConfig();
return true;
}
return false;
@@ -128,7 +129,7 @@ void rando_config_group(Pane& leftPane, Pane& rightPane, std::string settingKey,
curSetting->SetCurrentOption(i);
text_elem->SetInnerRML(settingInfo->GetDescriptions().at(i));
SaveConfig();
SaveRandomizerConfig();
});
}
@@ -154,7 +155,7 @@ SelectButton& rando_config_toggle(
setting->SetCurrentOption(value);
SaveConfig();
SaveRandomizerConfig();
},
});
auto& comp = leftPane.register_control(
@@ -193,7 +194,7 @@ NumberButton* rando_add_optional_setting(std::string optionValue, std::string op
.getValue = [curSetting] { return curSetting->GetCurrentOptionAsNumber(); },
.setValue = [curSetting](int value) {
curSetting->SetCurrentOption(std::to_string(value));
SaveConfig();
SaveRandomizerConfig();
},
.min = std::stoi(options.front()),
.max = std::stoi(options.back()),
@@ -316,7 +317,7 @@ void rando_starting_item_toggle(Pane& leftPane, Pane& rightPane, std::string ite
} else {
inventory.at(itemName) = newCount;
}
SaveConfig();
SaveRandomizerConfig();
};
// Helper function for decreasing a starting item count by 1
@@ -332,7 +333,7 @@ void rando_starting_item_toggle(Pane& leftPane, Pane& rightPane, std::string ite
} else {
inventory.at(itemName) = newCount;
}
SaveConfig();
SaveRandomizerConfig();
};
leftPane.add_select_button({
@@ -395,7 +396,7 @@ void rando_starting_item_number_toggle(Pane& leftPane, Pane& rightPane, std::str
} else {
inventory[itemName] = value;
}
SaveConfig();
SaveRandomizerConfig();
},
.min = 0,
.max = max,
@@ -423,6 +424,288 @@ Document* show_seed_gen_modal(std::string_view message) {
return modal;
}
struct ExcludedTabLocData {
std::string name {};
std::string lowercaseName{};
std::unordered_set<std::string> categories{};
};
auto& RandomizerWindow::get_locations_for_left_pane() {
static std::list<ExcludedTabLocData> locationsForExcludedTab;
// If we haven't loaded the locations to display for the excluded locations tab, load them up
// TODO: Maybe preload this before any graphics stuff happens?
if (locationsForExcludedTab.empty()) {
auto locationDataTree = LOAD_EMBED_YAML(RANDO_DATA_PATH "locations.yaml");
for (const auto& locationNode : locationDataTree) {
ExcludedTabLocData excludedTabLocData{};
auto& name = excludedTabLocData.name;
auto& lowercaseName = excludedTabLocData.lowercaseName;
name = locationNode["Name"].as<std::string>();
lowercaseName = name;
std::transform(lowercaseName.begin(), lowercaseName.end(), lowercaseName.begin(),
[](unsigned char c) { return std::tolower(c); });
for (const auto& category : locationNode["Categories"]) {
excludedTabLocData.categories.insert(category.as<std::string>());
}
if (locationNode["Metadata"].IsMap()) {
for (const auto& data : locationNode["Metadata"]) {
excludedTabLocData.categories.insert(data.first.as<std::string>());
}
}
// Don't include warp portals
if (excludedTabLocData.categories.contains("Warp Portal")) {
continue;
}
// Certain locations we don't include for now
if (randomizer::utility::str::Contains(excludedTabLocData.name,
"Renados Letter", "Telma Invoice", "Wooden Statue", "Ilia Charm",
"Defeat Ganondorf", "Twilit Insect", "Twilit Bloat", "Hint"))
{
continue;
}
locationsForExcludedTab.push_back(excludedTabLocData);
}
locationsForExcludedTab.sort([](const auto& a, const auto& b) {
return a.name < b.name;
});
}
// Create the vector we're going to return
static std::vector<const std::string*> locationNames{};
locationNames.clear();
// Get settings values
auto& randoSettings = GetRandomizerConfig().GetSettings().GetMap();
bool goldenBugs = randoSettings.at("Golden Bugs") == "On";
bool skyCharacters = randoSettings.at("Sky Characters") == "On";
bool npcs = randoSettings.at("Gifts From NPCs") == "On";
bool shops = randoSettings.at("Shop Items") == "On";
bool goldenWolves = randoSettings.at("Hidden Skills") == "On";
bool hiddenRupees = randoSettings.at("Hidden Rupees") == "On";
bool freestandingRupees = randoSettings.at("Freestanding Rupees") == "On";
bool overworldPoes = randoSettings.at("Poe Souls").IsAnyOf("Overworld", "All");
bool dungeonPoes = randoSettings.at("Poe Souls").IsAnyOf("Dungeon", "All");
// Create lowercase filter
std::string lowercaseFilter = m_excludedLocationsFilter;
std::transform(lowercaseFilter.begin(), lowercaseFilter.end(), lowercaseFilter.begin(),
[](unsigned char c) { return std::tolower(c); });
// Add relevant location names
for (const auto& locData : locationsForExcludedTab) {
// Skip categories that aren't shuffled
auto& cats = locData.categories;
if ((!goldenBugs && cats.contains("Golden Bug")) ||
(!skyCharacters && cats.contains("Sky Character")) ||
(!npcs && cats.contains("Npc")) ||
(!shops && cats.contains("Shop")) ||
(!goldenWolves && cats.contains("Golden Wolf")) ||
(!hiddenRupees && cats.contains("Rupee - Hidden")) ||
(!freestandingRupees && cats.contains("Rupee - Freestanding")) ||
(!overworldPoes && cats.contains("Poe") && cats.contains("Overworld")) ||
(!dungeonPoes && cats.contains("Poe") && cats.contains("Dungeon")))
{
continue;
}
// Don't add this location if it doesn't match the current filter
if (locData.lowercaseName.find(lowercaseFilter) == std::string::npos) {
continue;
}
locationNames.push_back(&locData.name);
}
return locationNames;
}
// Forward declaration
void rando_excluded_locations_update_right_pane(Pane& innerRightPane, bool forceUpdate = false);
// Update the specified inner pane with the necessary button layout
void rando_excluded_locations_update_inner_pane(Pane& paneToUpdate, Pane& rightPane,
const std::vector<const std::string*>& locations, bool forceUpdate)
{
constexpr float buttonHeightDp = 48.0f;
Rml::Element* scrollContainer = paneToUpdate.root();
if (!scrollContainer) return;
// Get the density-independent scale factor to convert DP to physical pixels
float scaleFactor = scrollContainer->GetContext()->GetDensityIndependentPixelRatio();
const float buttonHeightPx = buttonHeightDp * scaleFactor;
int totalItems = static_cast<int>(locations.size());
// Clear pane and return early if no locations
if (totalItems == 0) {
paneToUpdate.clear();
return;
}
// Calculate viewport metrics
const float paneHeightPx = scrollContainer->GetParentNode()->GetParentNode()->GetClientHeight();
const float paneHeightDp = paneHeightPx / scaleFactor;
// Determine how many buttons are needed to fill the screen (+ 2 to prevent pop-in)
auto maxVisibleButtons = static_cast<int>(std::ceil(paneHeightDp / buttonHeightDp)) + 2;
if (maxVisibleButtons > totalItems) {
maxVisibleButtons = totalItems;
}
// Track the active scroll position
float scrollTopPx = scrollContainer->GetScrollTop();
// Find the index of the location that should be visible on the first button
auto topButtonIdx = static_cast<int>(std::floor(scrollTopPx / buttonHeightPx)) - 1;
// Clamp the index to ensure we don't calculate past the boundaries of the dataset
if (topButtonIdx < 0) topButtonIdx = 0;
if (topButtonIdx + maxVisibleButtons > totalItems) {
topButtonIdx = totalItems - maxVisibleButtons;
if (topButtonIdx < 0) topButtonIdx = 0;
}
// If the pane is empty, or if we've scrolled enough to need a new button,
// or we're forcing an update, make all the new buttons.
if (forceUpdate || paneToUpdate.children().empty() ||
paneToUpdate.children()[0]->root()->GetInnerRML() != *locations[topButtonIdx])
{
// Get the text of the currently focused element
std::string focusedText{};
for (const auto& button : paneToUpdate.children()) {
if (button->root()->IsPseudoClassSet("focus")) {
focusedText = dynamic_cast<ControlledButton*>(button.get())->get_text();
break;
}
}
// Clear all buttons from the element
paneToUpdate.clear();
// Add the top spacer before all other elements
auto topSpacer = append(paneToUpdate.root(), "div");
// Remake all the buttons with updated text and top/bottom padding
// TODO: Could improve this by only creating/deleting buttons from the top/bottom as necessary
// but I couldn't get that to work well
for (int i = 0; i < maxVisibleButtons; ++i) {
auto& name = *locations[topButtonIdx + i];
auto& button = paneToUpdate.add_button({
.text = name,
.isSelected = [name]{return GetRandomizerConfig().GetSettings().GetExcludedLocations().contains(name);},
})
.on_pressed([&rightPane, name] {
auto& excludedLocations = GetRandomizerConfig().GetSettings().GetModifiableExcludedLocations();
if (excludedLocations.contains(name)) {
excludedLocations.erase(name);
rando_excluded_locations_update_right_pane(rightPane, true);
} else {
excludedLocations.insert(name);
rando_excluded_locations_update_right_pane(rightPane, true);
}
SaveRandomizerConfig();
});
button.root()->SetProperty("padding-left", "8dp");
button.root()->SetProperty("font-size", "15dp");
// Call update now to prevent background opacity flickering
button.update();
// Focus this button if it has the same text as the one which was focused before
if (button.get_text() == focusedText) {
button.root()->SetPseudoClass("focus-visible", true);
button.root()->Focus();
}
}
// Add the bottom spacer after all other elements
auto bottomSpacer = append(paneToUpdate.root(), "div");
// Calculate and apply the top and bottom padding offsets.
// Using padding on the pane itself doesn't work for some
// reason, so instead we lock the height of the top and bottom spacers
int paddingTop = topButtonIdx * buttonHeightDp - 8;
int paddingBottom = (totalItems - topButtonIdx - paneToUpdate.children().size()) * buttonHeightDp + 8;
if (paddingBottom < 0) paddingBottom = 0;
topSpacer->SetProperty("height", std::to_string(paddingTop) + "dp");
bottomSpacer->SetProperty("height", std::to_string(paddingBottom) + "dp");
}
}
void rando_excluded_locations_update_right_pane(Pane& innerRightPane, bool forceUpdate /*= false*/) {
// Data for right pane is the current list of excluded locations
std::vector<const std::string*> excludedLocationsVec{};
for (const auto& location : GetRandomizerConfig().GetSettings().GetExcludedLocations()) {
excludedLocationsVec.push_back(&location);
}
// When the user removes an excluded location from the right pane, we have to track
// which child was focused here to properly restore focus to the previous child
int focusedId{-1};
for (int i = 0; i < innerRightPane.root()->GetNumChildren(); ++i) {
if (innerRightPane.root()->GetChild(i)->IsPseudoClassSet("focus")) {
focusedId = i;
break;
}
}
rando_excluded_locations_update_inner_pane(innerRightPane, innerRightPane, excludedLocationsVec, forceUpdate);
// Refocus the child with the same id (or the previous child if the user had deleted the last one)
// Subtract one more than normal for these calculations to take into account the spacer
if (focusedId >= innerRightPane.root()->GetNumChildren() - 1) {
focusedId = innerRightPane.root()->GetNumChildren() - 2;
}
if (focusedId >= 0) {
auto child = innerRightPane.root()->GetChild(focusedId);
child->SetPseudoClass("focus-visible", true);
child->Focus();
}
}
void RandomizerWindow::rando_excluded_locations_update_left_pane(Pane& innerLeftPane, Pane& rightPane, bool forceUpdate /* = false*/) {
// Data for left pane is all possible locations to exclude
auto& locations = get_locations_for_left_pane();
rando_excluded_locations_update_inner_pane(innerLeftPane, rightPane, locations, forceUpdate);
}
// Focus the closest child in the next Pane. Returns true if a child was found to focus
bool focus_closest_child_on_next_pane(Pane& currentPane, Pane& nextPane) {
float childToFocusY = 0.f;
for (const auto& child : currentPane.children()) {
if (child->root()->IsPseudoClassSet("focus")) {
childToFocusY = child->root()->GetAbsoluteTop();
}
}
Rml::Element* closestchild = nullptr;
// If there was no focused child in this pane, select the middle one of the next pane
if (childToFocusY == 0.f && !nextPane.children().empty()) {
closestchild = nextPane.children().at(nextPane.children().size() / 2)->root();
// Otherwise, choose the closest one
} else if (childToFocusY > 0.f) {
float closestRightChildDistance = 100000.f;
for (const auto& child : nextPane.children()) {
float distance = std::abs(childToFocusY - child->root()->GetAbsoluteTop());
if (distance < closestRightChildDistance) {
closestchild = child->root();
closestRightChildDistance = distance;
}
}
}
if (closestchild) {
closestchild->SetPseudoClass("focus-visible", true);
closestchild->Focus();
return true;
}
return false;
}
RandomizerWindow::RandomizerWindow() {
// Create rando directories if they don't exist
@@ -551,7 +834,7 @@ RandomizerWindow::RandomizerWindow() {
},
.setValue = [](Rml::String value) {
GetRandomizerConfig().SetSeed(value);
SaveConfig();
SaveRandomizerConfig();
},
.maxLength = 32,
}),
@@ -694,7 +977,7 @@ RandomizerWindow::RandomizerWindow() {
.on_pressed([&rightPane]() {
auto& inventory = GetRandomizerConfig().GetSettings().GetModifiableStartingInventory();
inventory.clear();
SaveConfig();
SaveRandomizerConfig();
rando_starting_inventory_update_right_pane(rightPane);
});
@@ -767,6 +1050,102 @@ RandomizerWindow::RandomizerWindow() {
rando_starting_item_toggle(leftPane, rightPane, "Upper Zora's River Portal", "Upper Zoras River Portal");
});
add_tab("Excluded Locations", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled, false);
auto& rightPane = add_child<Pane>(content, Pane::Type::Controlled, false);
// Setup right pane
rightPane.root()->SetProperty("overflow", "hidden");
rightPane.root()->SetProperty("padding", "0dp");
rightPane.root()->SetProperty("gap", "0dp");
auto excludedLocationsSection = rightPane.add_section("Current Excluded Locations");
excludedLocationsSection->SetProperty("margin-top", "22dp");
excludedLocationsSection->SetProperty("margin-bottom", "0dp");
excludedLocationsSection->SetProperty("text-align", "center");
excludedLocationsSection->SetProperty("font-size", "25dp");
excludedLocationsSection->SetProperty("opacity", "1");
auto clickToRemoveSection = rightPane.add_section("Select a location to remove it");
clickToRemoveSection->SetProperty("margin-bottom", "21dp");
clickToRemoveSection->SetProperty("margin-top", "0dp");
clickToRemoveSection->SetProperty("text-align", "center");
clickToRemoveSection->SetProperty("font-size", "15dp");
auto& innerRightPane = rightPane.add_child<Pane>(Pane::Type::Controlled);
innerRightPane.root()->SetPseudoClass("excluded-locations-pane", true);
// Setup left pane
leftPane.root()->SetProperty("overflow", "hidden");
leftPane.root()->SetProperty("padding", "0dp");
auto& clearExcluded = leftPane.add_button(ControlledButton::Props{
.text = "Clear All",
});
clearExcluded.root()->SetProperty("margin", "12dp 24dp");
clearExcluded.root()->SetProperty("margin-bottom", "0dp");
clearExcluded.on_pressed([&innerRightPane] {
GetRandomizerConfig().GetSettings().GetModifiableExcludedLocations().clear();
rando_excluded_locations_update_right_pane(innerRightPane);
});
auto& filter = leftPane.add_child<StringButton>(StringButton::Props{
.key = "Filter",
.getValue = [this] { return m_excludedLocationsFilter; },
.setValue = [this](Rml::String str) { m_excludedLocationsFilter = str; },
});
filter.root()->SetProperty("margin", "6dp 24dp");
filter.root()->SetProperty("height", "40dp");
auto& innerLeftPane = leftPane.add_child<Pane>(Pane::Type::Controlled, false);
innerLeftPane.root()->SetPseudoClass("excluded-locations-pane", true);
// Attach listeners for left pane
filter.listen(Rml::EventId::Change, [this, &innerLeftPane, &innerRightPane](Rml::Event& event) {
if (event.GetParameters().find("text") != event.GetParameters().end()) {
const Rml::String text = event.GetParameter("text", Rml::String{});
m_excludedLocationsFilter = text;
this->rando_excluded_locations_update_left_pane(innerLeftPane, innerRightPane, true);
event.StopImmediatePropagation();
}
});
// Check for updating the left pane each time we scroll on it
innerLeftPane.listen(Rml::EventId::Scroll, [&innerLeftPane, &innerRightPane, this](Rml::Event&) {
this->rando_excluded_locations_update_left_pane(innerLeftPane, innerRightPane);
});
// Hijack the right nav command to make switching to the other pan more intuitive
innerLeftPane.listen(Rml::EventId::Keydown, [&innerLeftPane, &innerRightPane](Rml::Event& event) {
auto cmd = map_nav_event(event);
if (cmd == NavCommand::Right) {
if (focus_closest_child_on_next_pane(innerLeftPane, innerRightPane)) {
event.StopPropagation();
}
}
});
// Attach listeners for right pane
innerRightPane.listen(Rml::EventId::Scroll, [&innerRightPane](Rml::Event&) {
rando_excluded_locations_update_right_pane(innerRightPane);
});
// Hijack the right nav command to make switching to the other pan more intuitive
innerRightPane.listen(Rml::EventId::Keydown, [&innerLeftPane, &innerRightPane](Rml::Event& event) {
auto cmd = map_nav_event(event);
if (cmd == NavCommand::Left) {
if (focus_closest_child_on_next_pane(innerRightPane, innerLeftPane)) {
event.StopPropagation();
}
}
});
// Initial update of panes
rando_excluded_locations_update_right_pane(innerRightPane);
this->rando_excluded_locations_update_left_pane(innerLeftPane, innerRightPane);
});
if (randomizer_IsActive()) {
add_tab("In-Game", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
+4
View File
@@ -7,6 +7,7 @@ namespace randomizer::seedgen::config {
}
namespace dusk::ui {
class Pane;
std::filesystem::path GetRandomizerPath();
std::filesystem::path GetRandomizerSettingsPath();
@@ -18,7 +19,10 @@ namespace dusk::ui {
public:
RandomizerWindow();
void update() override;
void rando_excluded_locations_update_left_pane(Pane& innerLeftPane, Pane& rightPane, bool forceUpdate = false);
auto& get_locations_for_left_pane();
private:
Document* m_genSeedModal = nullptr;
std::string m_excludedLocationsFilter = "";
};
}
+7
View File
@@ -63,6 +63,13 @@ void BaseStringButton::start_editing() {
}
}
}));
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(
mInputElem, Rml::EventId::Change, [this](Rml::Event& event) {
if (event.GetTargetElement() == mInputElem) {
const Rml::String text = mInputElem->GetValue();
mRoot->DispatchEvent(Rml::EventId::Change, Rml::Dictionary{{"text", Rml::Variant{mInputElem->GetValue()}}});
}
}));
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(
mInputElem, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);