The Battle for Wesnoth 1.21.0-dev
 
Loading...
Searching...
No Matches
campaign_selection.cpp
Go to the documentation of this file.
1/*
2 Copyright (C) 2009 - 2025
3 by Mark de Wever <koraq@xs4all.nl>
4 Part of the Battle for Wesnoth Project https://www.wesnoth.org/
5
6 This program is free software; you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation; either version 2 of the License, or
9 (at your option) any later version.
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY.
12
13 See the COPYING file for more details.
14*/
15
16#define GETTEXT_DOMAIN "wesnoth-lib"
17
19
20#include "filesystem.hpp"
34
35#include <functional>
36
37namespace gui2::dialogs
38{
39
40namespace
41{
42 // These arbitary strings are used instead of a campaign id for the special pages
43 // which can appear in the right-hand pane. The data for those pages is loaded
44 // from the tree_view's WML via ids hardcoded in pre_show().
45 const std::string PAGE_ID_GET_ADDONS = "////addons////";
46 const std::string PAGE_ID_LANDING_PAGE = "////landing-page////";
47 const std::string PAGE_ID_MISSING_CAMPAIGNS = "////missing-campaign////";
48
49 // The campaign_rng_mode preference stores a string mirroring the savefile's random_mode RNG.
50 const std::vector<std::string> rng_mode_prefs{"default", "deterministic", "biased"};
51};
52
53REGISTER_DIALOG(campaign_selection)
54
56 : modal_dialog(window_id())
57 , engine_(eng)
58 , choice_(-1)
59 , rng_mode_(RNG_DEFAULT)
60 , mod_states_()
61 , page_ids_()
62 , difficulties_()
63 , current_difficulty_()
64 , current_sorting_(RANK)
65 , currently_sorted_asc_(true)
66 , mod_ids_()
67{
68 set_show_even_without_video(true);
69 set_allow_plugin_skip(false);
70}
71
73{
74 tree_view& tree = find_widget<tree_view>("campaign_tree");
75 const std::string& campaign_id = tree.selected_item() ? tree.selected_item()->id() : PAGE_ID_LANDING_PAGE;
76 if(campaign_id.empty()) {
77 return;
78 }
79
80 const bool can_proceed = campaign_id != PAGE_ID_MISSING_CAMPAIGNS && campaign_id != PAGE_ID_LANDING_PAGE;
81 const bool actual_campaign = can_proceed && campaign_id != PAGE_ID_GET_ADDONS;
82
83 button& ok_button = find_widget<button>("ok");
84 ok_button.set_active(can_proceed);
85 ok_button.set_label((campaign_id == PAGE_ID_GET_ADDONS) ? _("game^Get Add-ons") : _("game^Play"));
86
87 auto iter = std::find(page_ids_.begin(), page_ids_.end(), campaign_id);
88 if(iter == page_ids_.end()) {
89 return;
90 }
91 const int choice = std::distance(page_ids_.begin(), iter);
92
93 multi_page& pages = find_widget<multi_page>("campaign_details");
94 pages.select_page(choice);
95
96 // The engine bounds-checks this and silently selects the first campaign if choice points to one
97 // of the non-campaign pages.
99
100 styled_widget& background = find_widget<styled_widget>("campaign_background");
101 background.set_label(engine_.current_level().data()["background"].str());
102
103 // Rebuild difficulty menu
104 difficulties_.clear();
105
106 auto& diff_menu = find_widget<menu_button>("difficulty_menu");
107
108 auto diff_config_range = engine_.current_level().data().child_range("difficulty");
109 const std::size_t difficulty_count = diff_config_range.size();
110
111 if(diff_config_range.empty() || !actual_campaign) {
112 // Cosmetic bug: this leaves the difficulties of a previously-selected campaign visible
113 diff_menu.set_active(false);
114 return;
115 }
116
117 std::vector<config> entry_list;
118 std::size_t n = 0, selection = 0;
119
120 for(const auto& cfg : diff_config_range) {
121 config entry;
122
123 // FIXME: description may have markup that will display weird on the menu_button proper
124 entry["label"] = cfg["label"].str() + " (" + cfg["description"].str() + ")";
125 entry["image"] = cfg["image"].str("misc/blank-hex.png");
126
127 if(prefs::get().is_campaign_completed(campaign_id, cfg["define"])) {
128 std::string laurel;
129
130 if(n + 1 >= difficulty_count) {
132 } else if(n == 0) {
134 } else {
136 }
137
138 entry["image"] = laurel + "~BLIT(" + entry["image"].str() + ")";
139 }
140
141 if(!cfg["description"].empty()) {
142 std::string desc;
143 if(cfg["auto_markup"].to_bool(true) == false) {
144 desc = cfg["description"].str();
145 } else {
146 desc = markup::span_color(font::GRAY_COLOR, "(", cfg["description"].str(), ")");
147 }
148
149 // Icons get displayed instead of the labels on the dropdown menu itself,
150 // so we want to prepend each label to its description here
151 desc = cfg["label"].str() + "\n" + desc;
152
153 entry["details"] = std::move(desc);
154 }
155
156 entry_list.emplace_back(std::move(entry));
157 difficulties_.emplace_back(cfg["define"].str());
158
159 if(cfg["default"].to_bool(false)) {
160 selection = n;
161 }
162
163 ++n;
164 }
165
166 diff_menu.set_active(true);
167 diff_menu.set_values(entry_list);
168 diff_menu.set_selected(selection);
169}
170
172{
173 const std::size_t selection = find_widget<menu_button>("difficulty_menu").get_value();
174 current_difficulty_ = difficulties_.at(std::min(difficulties_.size() - 1, selection));
175}
176
178{
179 using level_ptr = ng::create_engine::level_ptr;
180
181 auto levels = engine_.get_levels_by_type_unfiltered(level_type::type::sp_campaign);
182
183 switch(order) {
184 case RANK: // Already sorted by rank
185 // This'll actually never happen, but who knows if that'll ever change...
186 if(!ascending) {
187 std::reverse(levels.begin(), levels.end());
188 }
189
190 break;
191
192 case DATE:
193 std::sort(levels.begin(), levels.end(), [ascending](const level_ptr& a, const level_ptr& b) {
194 auto cpn_a = std::dynamic_pointer_cast<ng::campaign>(a);
195 auto cpn_b = std::dynamic_pointer_cast<ng::campaign>(b);
196
197 if(cpn_b == nullptr) {
198 return cpn_a != nullptr;
199 }
200
201 if(cpn_a == nullptr) {
202 return false;
203 }
204
205 return ascending
206 ? cpn_a->dates().first < cpn_b->dates().first
207 : cpn_a->dates().first > cpn_b->dates().first;
208 });
209
210 break;
211
212 case NAME:
213 std::sort(levels.begin(), levels.end(), [ascending](const level_ptr& a, const level_ptr& b) {
214 const int cmp = translation::icompare(a->name(), b->name());
215 return ascending ? cmp < 0 : cmp > 0;
216 });
217
218 break;
219 }
220
221 tree_view& tree = find_widget<tree_view>("campaign_tree");
222
223 // Remember which campaign was selected...
224 std::string was_selected;
225 if(tree.selected_item()) {
226 was_selected = tree.selected_item()->id();
227 }
228 tree.clear();
229
230 boost::dynamic_bitset<> show_items;
231 show_items.resize(levels.size(), true);
232
233 if(!last_search_words_.empty()) {
234 for(unsigned i = 0; i < levels.size(); ++i) {
235 bool found = false;
236 for(const auto& word : last_search_words_) {
237 found = translation::ci_search(levels[i]->name(), word) ||
238 translation::ci_search(levels[i]->data()["name"].t_str().base_str(), word) ||
239 translation::ci_search(levels[i]->description(), word) ||
240 translation::ci_search(levels[i]->data()["description"].t_str().base_str(), word) ||
241 translation::ci_search(levels[i]->data()["abbrev"], word) ||
242 translation::ci_search(levels[i]->data()["abbrev"].t_str().base_str(), word);
243
244 if(!found) {
245 break;
246 }
247 }
248
249 show_items[i] = found;
250 }
251 }
252
253 // List of which options has been selected in the completion filter multimenu_button
254 boost::dynamic_bitset<> filter_comp_options = find_widget<multimenu_button>("filter_completion").get_toggle_states();
255
256 bool exists_in_filtered_result = false;
257 for(unsigned i = 0; i < levels.size(); ++i) {
258 bool completed = prefs::get().is_campaign_completed(levels[i]->data()["id"]);
259 config::const_child_itors difficulties = levels[i]->data().child_range("difficulty");
260 auto did_complete_at = [](const config& c) { return c["completed_at"].to_bool(); };
261
262 // Check for non-completion on every difficulty save the first.
263 const bool only_first_completed = difficulties.size() > 1 &&
264 std::none_of(difficulties.begin() + 1, difficulties.end(), did_complete_at);
265 const bool completed_easy = only_first_completed && did_complete_at(difficulties.front());
266 const bool completed_hardest = !difficulties.empty() && did_complete_at(difficulties.back());
267 const bool completed_mid = completed && !completed_hardest && !completed_easy;
268
269 if( show_items[i] && (
270 ( (!completed) && filter_comp_options[0] ) // Selects all campaigns not finished by player
271 || ( completed && filter_comp_options[4] ) // Selects all campaigns finished by player
272 || ( completed_hardest && filter_comp_options[3] ) // Selects campaigns completed in hardest difficulty
273 || ( completed_easy && filter_comp_options[1] ) // Selects campaigns completed in easiest difficulty
274 || ( completed_mid && filter_comp_options[2]) // Selects campaigns completed in any other difficulty
275 )) {
276 add_campaign_to_tree(levels[i]->data());
277 if (!exists_in_filtered_result) {
278 exists_in_filtered_result = levels[i]->id() == was_selected;
279 }
280 }
281 }
282
283 if(!was_selected.empty() && exists_in_filtered_result) {
284 find_widget<tree_view_node>(was_selected).select_node();
285 } else {
286 campaign_selected();
287 }
288}
289
291{
292 static bool force = false;
293 if(force) {
294 return;
295 }
296
297 if(current_sorting_ == order) {
299 currently_sorted_asc_ = false;
300 } else {
303 }
304 } else if(current_sorting_ == RANK) {
306 current_sorting_ = order;
307 } else {
309 current_sorting_ = order;
310
311 force = true;
312
313 if(order == NAME) {
314 find_widget<toggle_button>("sort_time").set_value(0);
315 } else if(order == DATE) {
316 find_widget<toggle_button>("sort_name").set_value(0);
317 }
318
319 force = false;
320 }
321
323}
324
325void campaign_selection::filter_text_changed(const std::string& text)
326{
327 const std::vector<std::string> words = utils::split(text, ' ');
328
329 if(words == last_search_words_) {
330 return;
331 }
332
333 last_search_words_ = words;
335}
336
338{
339 text_box* filter = find_widget<text_box>("filter_box", false, true);
340 filter->on_modified([this](const auto& box) { filter_text_changed(box.text()); });
341
342 /***** Setup campaign tree. *****/
343 tree_view& tree = find_widget<tree_view>("campaign_tree");
344
345 connect_signal_notify_modified(tree,
346 std::bind(&campaign_selection::campaign_selected, this));
347
348 toggle_button& sort_name = find_widget<toggle_button>("sort_name");
349 toggle_button& sort_time = find_widget<toggle_button>("sort_time");
350
351 connect_signal_notify_modified(sort_name,
353
354 connect_signal_notify_modified(sort_time,
356
357#ifdef __IPHONEOS__
358 // On iOS, opening the campaign browser should not immediately summon the
359 // software keyboard just because the optional filter field exists.
360 keyboard_capture(&tree);
361#else
362 keyboard_capture(filter);
363#endif
365
366 /***** Setup campaign details. *****/
367 multi_page& pages = find_widget<multi_page>("campaign_details");
368
369 // Setup completion filter
370 multimenu_button& filter_comp = find_widget<multimenu_button>("filter_completion");
371 connect_signal_notify_modified(filter_comp,
372 std::bind(&campaign_selection::sort_campaigns, this, RANK, 1));
373 for (unsigned j = 0; j < filter_comp.num_options(); j++) {
374 filter_comp.select_option(j);
375 }
376
377 // Add campaigns to the list
378 for(const auto& level : engine_.get_levels_by_type_unfiltered(level_type::type::sp_campaign)) {
379 const config& campaign = level->data();
380
381 /*** Add tree item ***/
382 add_campaign_to_tree(campaign);
383
384 /*** Add detail item ***/
385 pages.add_page({
386 {"description", {
387 {"label", campaign["description"].t_str()},
388 {"text_alignment", campaign["description_alignment"].str("left")},
389 {"use_markup", "true"}
390 }},
391 {"image", {
392 {"label", campaign["image"].str()}
393 }}
394 });
395 page_ids_.push_back(campaign["id"]);
396 }
397
398 //
399 // Special-purpose pages that appear as if they were campaigns.
400 //
401
402 // The intro page is shown if nothing is selected in the tree_view, it isn't in the tree itself
403 pages.add_page("no_campaign_selected", -1, widget_data{});
404 page_ids_.push_back(PAGE_ID_LANDING_PAGE);
405
406 // Addon Manager link
407 config addons;
408 addons["icon"] = "icons/icon-game.png~BLIT(icons/icon-addon-publish.png)";
409 addons["name"] = _("More campaigns...");
410 addons["completed"] = false;
411 addons["id"] = PAGE_ID_GET_ADDONS;
412
413 add_campaign_to_tree(addons);
414
415 pages.add_page("go_download_more_stuff", -1, widget_data{});
416 page_ids_.push_back(PAGE_ID_GET_ADDONS);
417
418 // The data directory has few (or none) of the mainline campaigns
419 std::vector<std::string> dirs;
420 filesystem::get_files_in_dir(game_config::path + "/data/campaigns", nullptr, &dirs);
421 if(dirs.size() <= 15) {
422 config missing;
423 missing["icon"] = "units/unknown-unit.png";
424 missing["name"] = _("Missing Campaigns");
425 missing["completed"] = false;
426 missing["id"] = PAGE_ID_MISSING_CAMPAIGNS;
427
428 add_campaign_to_tree(missing);
429
430 pages.add_page("missing_campaign_warning", -1, widget_data{});
431 page_ids_.push_back(PAGE_ID_MISSING_CAMPAIGNS);
432 }
433
434 //
435 // Set up Mods selection dropdown
436 //
437 multimenu_button& mods_menu = find_widget<multimenu_button>("mods_menu");
438
440 std::vector<config> mod_menu_values;
441 std::vector<std::string> enabled = engine_.active_mods();
442
444 const bool active = std::find(enabled.begin(), enabled.end(), mod->id) != enabled.end();
445
446 mod_menu_values.emplace_back("label", mod->name, "tooltip", mod->description, "checkbox", active);
447
448 mod_states_.push_back(active);
449 mod_ids_.emplace_back(mod->id);
450 }
451
452 mods_menu.set_values(mod_menu_values);
453 mods_menu.select_options(mod_states_);
454
455 connect_signal_notify_modified(mods_menu, std::bind(&campaign_selection::mod_toggled, this));
456 } else {
457 mods_menu.set_active(false);
458 mods_menu.set_label(_("active_modifications^None"));
459 }
460
461 //
462 // Set up RNG mode dropdown
463 //
464 // New installs default to "Reduced RNG"; existing installs restore the last selection.
465 menu_button& rng_menu = find_widget<menu_button>("rng_menu");
466 auto rng_it = std::find(rng_mode_prefs.begin(), rng_mode_prefs.end(), prefs::get().campaign_rng_mode());
467 rng_menu.set_selected(rng_it != rng_mode_prefs.end()
468 ? static_cast<unsigned>(std::distance(rng_mode_prefs.begin(), rng_it))
469 : static_cast<unsigned>(RNG_DEFAULT));
470
471 //
472 // Set up Difficulty dropdown
473 //
474 menu_button& diff_menu = find_widget<menu_button>("difficulty_menu");
475
476 diff_menu.set_use_markup(true);
477 connect_signal_notify_modified(diff_menu, std::bind(&campaign_selection::difficulty_selected, this));
478
480
481 plugins_context_.reset(new plugins_context("Campaign Selection"));
482 plugins_context_->set_callback("create", [this](const config&) { set_retval(retval::OK); }, false);
483 plugins_context_->set_callback("quit", [this](const config&) { set_retval(retval::CANCEL); }, false);
484
485 plugins_context_->set_accessor("find_level", [this](const config& cfg) {
486 const std::string id = cfg["id"].str();
487 auto result = engine_.find_level_by_id(id);
488 return config {
489 "index", result.second,
490 "type", level_type::get_string(result.first),
491 };
492 });
493
494 plugins_context_->set_accessor_int("find_mod", [this](const config& cfg) {
496 });
497
498 plugins_context_->set_callback("select_level", [this](const config& cfg) {
499 choice_ = cfg["index"].to_int();
501 }, true);
502}
503
505{
506 // We completed the campaign! Calculate the appropriate victory laurel.
507 const auto get_laurel = [&campaign] {
508 if(!campaign["completed"].to_bool()) {
509 return std::string{};
510 }
511
512 config::const_child_itors difficulties = campaign.child_range("difficulty");
513 auto did_complete_at = [](const config& c) { return c["completed_at"].to_bool(); };
514
515 // Check for non-completion on every difficulty save the first.
516 const bool only_first_completed = difficulties.size() > 1 &&
517 std::none_of(difficulties.begin() + 1, difficulties.end(), did_complete_at);
518
519 /*
520 * Criteria:
521 *
522 * - Use the gold laurel (hardest) for campaigns with only one difficulty OR
523 * if out of two or more difficulties, the last one has been completed.
524 *
525 * - Use the bronze laurel (easiest) only if the first difficulty out of two
526 * or more has been completed.
527 *
528 * - Use the silver laurel otherwise.
529 */
530 if(!difficulties.empty() && did_complete_at(difficulties.back())) {
532 } else if(only_first_completed && did_complete_at(difficulties.front())) {
534 } else {
536 }
537 };
538
539 auto& node = find_widget<tree_view>("campaign_tree")
540 .add_node("campaign", {
541 {"icon", {
542 {"label", campaign["icon"].str()}
543 }},
544 {"name", {
545 {"label", campaign["name"].t_str()}
546 }},
547 {"victory", {
548 {"label", get_laurel()}
549 }}
550 });
551
552 node.set_id(campaign["id"]);
553}
554
556{
557 if(get_retval() != retval::OK) {
558 return;
559 }
560
561 tree_view& tree = find_widget<tree_view>("campaign_tree");
562 if(tree.empty() || !tree.selected_item()) {
563 return;
564 }
565
566 const std::string& campaign_id = tree.selected_item()->id();
567 if(!campaign_id.empty()) {
568 if (campaign_id == PAGE_ID_GET_ADDONS) {
570 } else {
571 auto iter = std::find(page_ids_.begin(), page_ids_.end(), campaign_id);
572 if(iter != page_ids_.end()) {
573 choice_ = std::distance(page_ids_.begin(), iter);
574 }
576 }
577 }
578
579 rng_mode_ = RNG_MODE(std::clamp<unsigned>(find_widget<menu_button>("rng_menu").get_value(), RNG_DEFAULT, RNG_BIASED));
580 prefs::get().set_campaign_rng_mode(rng_mode_prefs[rng_mode_]);
582}
583
585{
586 boost::dynamic_bitset<> new_mod_states =
587 find_widget<multimenu_button>("mods_menu").get_toggle_states();
588
589 // Get a mask of any mods that were toggled, regardless of new state
590 mod_states_ = mod_states_ ^ new_mod_states;
591
592 for(unsigned i = 0; i < mod_states_.size(); i++) {
593 if(mod_states_[i]) {
595 }
596 }
597
598 // Save the full toggle states for next time
599 mod_states_ = new_mod_states;
600}
601
602} // namespace dialogs
A config object defines a single node in a WML file, with access to child nodes.
Definition config.hpp:157
child_itors child_range(std::string_view key)
Definition config.cpp:268
boost::iterator_range< const_child_iterator > const_child_itors
Definition config.hpp:281
Simple push button.
Definition button.hpp:36
virtual void set_active(const bool active) override
See styled_widget::set_active.
Definition button.cpp:64
void toggle_sorting_selection(CAMPAIGN_ORDER order)
RNG_MODE rng_mode_
whether the player checked the "Deterministic" checkbox.
void difficulty_selected()
Called when the difficulty selection changes.
std::vector< std::string > mod_ids_
std::vector< std::string > difficulties_
virtual void pre_show() override
Actions to be taken before showing the window.
void sort_campaigns(CAMPAIGN_ORDER order, bool ascending)
virtual void post_show() override
Actions to be taken after the window has been shown.
void add_campaign_to_tree(const config &campaign)
void filter_text_changed(const std::string &text)
void campaign_selected()
Called when an item in the left-hand pane (the tree view) is selected.
std::vector< std::string > last_search_words_
std::vector< std::string > page_ids_
Abstract base class for all modal dialogs.
std::unique_ptr< plugins_context > plugins_context_
void set_selected(unsigned selected, bool fire_event=true)
grid & add_page(const widget_item &item)
Adds single page to the grid.
void select_page(const unsigned page, const bool select=true)
Selects a page.
void select_options(const boost::dynamic_bitset<> &states)
Set the options selected in the menu.
void set_values(const std::vector<::config > &values)
Set the available menu options.
unsigned num_options()
Get the number of options available in the menu.
virtual void set_active(const bool active) override
See styled_widget::set_active.
void select_option(const unsigned option, const bool selected=true)
Select an option in the menu.
virtual void set_label(const t_string &text)
virtual void set_use_markup(bool use_markup)
A widget that allows the user to input text in single line.
Definition text_box.hpp:125
tree_view_node * selected_item()
bool empty() const
Definition tree_view.cpp:99
const std::string & id() const
Definition widget.cpp:110
void set_retval(const int retval, const bool close_window=true)
Sets there return value of the window.
Definition window.hpp:387
void keyboard_capture(widget *widget)
Definition window.cpp:1197
void add_to_keyboard_chain(widget *widget)
Adds the widget to the keyboard chain.
Definition window.cpp:1211
int get_retval()
Definition window.hpp:394
int find_extra_by_id(const MP_EXTRA extra_type, const std::string &id) const
bool toggle_mod(const std::string &id, bool force=false)
std::vector< std::string > & active_mods()
const std::vector< extras_metadata_ptr > & get_const_extras_by_type(const MP_EXTRA extra_type) const
std::shared_ptr< level > level_ptr
std::pair< level_type::type, int > find_level_by_id(const std::string &id) const
std::vector< level_ptr > get_levels_by_type_unfiltered(level_type::type type) const
void set_current_level(const std::size_t index)
level & current_level() const
const config & data() const
static prefs & get()
bool is_campaign_completed(const std::string &campaign_id)
void set_modifications(const std::vector< std::string > &value, bool mp=true)
const config * cfg
Declarations for File-IO.
static std::string _(const char *str)
Definition gettext.hpp:100
This file contains the window object, this object is a top level container which has the event manage...
#define REGISTER_DIALOG(window_id)
Wrapper for REGISTER_DIALOG2.
void get_files_in_dir(const std::string &dir, std::vector< std::string > *files, std::vector< std::string > *dirs=nullptr, name_mode mode=name_mode::FILE_NAME_ONLY, filter_mode filter=filter_mode::NO_FILTER, reorder_mode reorder=reorder_mode::DONT_REORDER, file_tree_checksum *checksum=nullptr)
Get a list of all files and/or directories in a given directory.
const color_t GRAY_COLOR
std::string victory_laurel_hardest
std::string victory_laurel
std::string victory_laurel_easy
std::string path
std::map< std::string, widget_item > widget_data
Definition widget.hpp:36
@ OK
Dialog was closed with the OK button.
Definition retval.hpp:35
@ CANCEL
Dialog was closed with the CANCEL button.
Definition retval.hpp:38
std::string span_color(const color_t &color, Args &&... data)
Applies Pango markup to the input specifying its display color.
Definition markup.hpp:110
bool ci_search(const std::string &s1, const std::string &s2)
Case-insensitive search.
Definition gettext.cpp:559
std::vector< std::string > split(const config_attribute_value &val)
std::string_view data
Definition picture.cpp:188
static std::string get_string(enum_type key)
Converts a enum to its string equivalent.
Definition enum_base.hpp:46
static map_location::direction n
#define i
#define c
#define b