The Battle for Wesnoth  1.19.20+dev
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"
21 #include "gui/widgets/button.hpp"
25 #include "gui/widgets/text_box.hpp"
30 #include "gui/widgets/window.hpp"
32 #include "serialization/markup.hpp"
33 #include "utils/irdya_datetime.hpp"
34 
35 #include <functional>
36 
37 namespace gui2::dialogs
38 {
39 
40 namespace
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 
50 REGISTER_DIALOG(campaign_selection)
51 
53  : modal_dialog(window_id())
54  , engine_(eng)
55  , choice_(-1)
56  , rng_mode_(RNG_DEFAULT)
57  , mod_states_()
58  , page_ids_()
59  , difficulties_()
60  , current_difficulty_()
61  , current_sorting_(RANK)
62  , currently_sorted_asc_(true)
63  , mod_ids_()
64 {
65  set_show_even_without_video(true);
66  set_allow_plugin_skip(false);
67 }
68 
70 {
71  tree_view& tree = find_widget<tree_view>("campaign_tree");
72  const std::string& campaign_id = tree.selected_item() ? tree.selected_item()->id() : PAGE_ID_LANDING_PAGE;
73  if(campaign_id.empty()) {
74  return;
75  }
76 
77  const bool can_proceed = campaign_id != PAGE_ID_MISSING_CAMPAIGNS && campaign_id != PAGE_ID_LANDING_PAGE;
78  const bool actual_campaign = can_proceed && campaign_id != PAGE_ID_GET_ADDONS;
79 
80  button& ok_button = find_widget<button>("proceed");
81  ok_button.set_active(can_proceed);
82  ok_button.set_label((campaign_id == PAGE_ID_GET_ADDONS) ? _("game^Get Add-ons") : _("game^Play"));
83 
84  auto iter = std::find(page_ids_.begin(), page_ids_.end(), campaign_id);
85  if(iter == page_ids_.end()) {
86  return;
87  }
88  const int choice = std::distance(page_ids_.begin(), iter);
89 
90  multi_page& pages = find_widget<multi_page>("campaign_details");
91  pages.select_page(choice);
92 
93  // The engine bounds-checks this and silently selects the first campaign if choice points to one
94  // of the non-campaign pages.
95  engine_.set_current_level(choice);
96 
97  styled_widget& background = find_widget<styled_widget>("campaign_background");
98  background.set_label(engine_.current_level().data()["background"].str());
99 
100  // Rebuild difficulty menu
101  difficulties_.clear();
102 
103  auto& diff_menu = find_widget<menu_button>("difficulty_menu");
104 
105  auto diff_config_range = engine_.current_level().data().child_range("difficulty");
106  const std::size_t difficulty_count = diff_config_range.size();
107 
108  if(diff_config_range.empty() || !actual_campaign) {
109  // Cosmetic bug: this leaves the difficulties of a previously-selected campaign visible
110  diff_menu.set_active(false);
111  return;
112  }
113 
114  std::vector<config> entry_list;
115  std::size_t n = 0, selection = 0;
116 
117  for(const auto& cfg : diff_config_range) {
118  config entry;
119 
120  // FIXME: description may have markup that will display weird on the menu_button proper
121  entry["label"] = cfg["label"].str() + " (" + cfg["description"].str() + ")";
122  entry["image"] = cfg["image"].str("misc/blank-hex.png");
123 
124  if(prefs::get().is_campaign_completed(campaign_id, cfg["define"])) {
125  std::string laurel;
126 
127  if(n + 1 >= difficulty_count) {
129  } else if(n == 0) {
131  } else {
133  }
134 
135  entry["image"] = laurel + "~BLIT(" + entry["image"].str() + ")";
136  }
137 
138  if(!cfg["description"].empty()) {
139  std::string desc;
140  if(cfg["auto_markup"].to_bool(true) == false) {
141  desc = cfg["description"].str();
142  } else {
143  desc = markup::span_color(font::GRAY_COLOR, "(", cfg["description"].str(), ")");
144  }
145 
146  // Icons get displayed instead of the labels on the dropdown menu itself,
147  // so we want to prepend each label to its description here
148  desc = cfg["label"].str() + "\n" + desc;
149 
150  entry["details"] = std::move(desc);
151  }
152 
153  entry_list.emplace_back(std::move(entry));
154  difficulties_.emplace_back(cfg["define"].str());
155 
156  if(cfg["default"].to_bool(false)) {
157  selection = n;
158  }
159 
160  ++n;
161  }
162 
163  diff_menu.set_active(true);
164  diff_menu.set_values(entry_list);
165  diff_menu.set_selected(selection);
166 }
167 
169 {
170  const std::size_t selection = find_widget<menu_button>("difficulty_menu").get_value();
171  current_difficulty_ = difficulties_.at(std::min(difficulties_.size() - 1, selection));
172 }
173 
175 {
176  using level_ptr = ng::create_engine::level_ptr;
177 
178  auto levels = engine_.get_levels_by_type_unfiltered(level_type::type::sp_campaign);
179 
180  switch(order) {
181  case RANK: // Already sorted by rank
182  // This'll actually never happen, but who knows if that'll ever change...
183  if(!ascending) {
184  std::reverse(levels.begin(), levels.end());
185  }
186 
187  break;
188 
189  case DATE:
190  std::sort(levels.begin(), levels.end(), [ascending](const level_ptr& a, const level_ptr& b) {
191  auto cpn_a = std::dynamic_pointer_cast<ng::campaign>(a);
192  auto cpn_b = std::dynamic_pointer_cast<ng::campaign>(b);
193 
194  if(cpn_b == nullptr) {
195  return cpn_a != nullptr;
196  }
197 
198  if(cpn_a == nullptr) {
199  return false;
200  }
201 
202  return ascending
203  ? cpn_a->dates().first < cpn_b->dates().first
204  : cpn_a->dates().first > cpn_b->dates().first;
205  });
206 
207  break;
208 
209  case NAME:
210  std::sort(levels.begin(), levels.end(), [ascending](const level_ptr& a, const level_ptr& b) {
211  const int cmp = translation::icompare(a->name(), b->name());
212  return ascending ? cmp < 0 : cmp > 0;
213  });
214 
215  break;
216  }
217 
218  tree_view& tree = find_widget<tree_view>("campaign_tree");
219 
220  // Remember which campaign was selected...
221  std::string was_selected;
222  if(tree.selected_item()) {
223  was_selected = tree.selected_item()->id();
224  }
225  tree.clear();
226 
227  boost::dynamic_bitset<> show_items;
228  show_items.resize(levels.size(), true);
229 
230  if(!last_search_words_.empty()) {
231  for(unsigned i = 0; i < levels.size(); ++i) {
232  bool found = false;
233  for(const auto& word : last_search_words_) {
234  found = translation::ci_search(levels[i]->name(), word) ||
235  translation::ci_search(levels[i]->data()["name"].t_str().base_str(), word) ||
236  translation::ci_search(levels[i]->description(), word) ||
237  translation::ci_search(levels[i]->data()["description"].t_str().base_str(), word) ||
238  translation::ci_search(levels[i]->data()["abbrev"], word) ||
239  translation::ci_search(levels[i]->data()["abbrev"].t_str().base_str(), word);
240 
241  if(!found) {
242  break;
243  }
244  }
245 
246  show_items[i] = found;
247  }
248  }
249 
250  // List of which options has been selected in the completion filter multimenu_button
251  boost::dynamic_bitset<> filter_comp_options = find_widget<multimenu_button>("filter_completion").get_toggle_states();
252 
253  bool exists_in_filtered_result = false;
254  for(unsigned i = 0; i < levels.size(); ++i) {
255  bool completed = prefs::get().is_campaign_completed(levels[i]->data()["id"]);
256  config::const_child_itors difficulties = levels[i]->data().child_range("difficulty");
257  auto did_complete_at = [](const config& c) { return c["completed_at"].to_bool(); };
258 
259  // Check for non-completion on every difficulty save the first.
260  const bool only_first_completed = difficulties.size() > 1 &&
261  std::none_of(difficulties.begin() + 1, difficulties.end(), did_complete_at);
262  const bool completed_easy = only_first_completed && did_complete_at(difficulties.front());
263  const bool completed_hardest = !difficulties.empty() && did_complete_at(difficulties.back());
264  const bool completed_mid = completed && !completed_hardest && !completed_easy;
265 
266  if( show_items[i] && (
267  ( (!completed) && filter_comp_options[0] ) // Selects all campaigns not finished by player
268  || ( completed && filter_comp_options[4] ) // Selects all campaigns finished by player
269  || ( completed_hardest && filter_comp_options[3] ) // Selects campaigns completed in hardest difficulty
270  || ( completed_easy && filter_comp_options[1] ) // Selects campaigns completed in easiest difficulty
271  || ( completed_mid && filter_comp_options[2]) // Selects campaigns completed in any other difficulty
272  )) {
273  add_campaign_to_tree(levels[i]->data());
274  if (!exists_in_filtered_result) {
275  exists_in_filtered_result = levels[i]->id() == was_selected;
276  }
277  }
278  }
279 
280  if(!was_selected.empty() && exists_in_filtered_result) {
281  find_widget<tree_view_node>(was_selected).select_node();
282  } else {
283  campaign_selected();
284  }
285 }
286 
288 {
289  static bool force = false;
290  if(force) {
291  return;
292  }
293 
294  if(current_sorting_ == order) {
296  currently_sorted_asc_ = false;
297  } else {
298  currently_sorted_asc_ = true;
300  }
301  } else if(current_sorting_ == RANK) {
302  currently_sorted_asc_ = true;
303  current_sorting_ = order;
304  } else {
305  currently_sorted_asc_ = true;
306  current_sorting_ = order;
307 
308  force = true;
309 
310  if(order == NAME) {
311  find_widget<toggle_button>("sort_time").set_value(0);
312  } else if(order == DATE) {
313  find_widget<toggle_button>("sort_name").set_value(0);
314  }
315 
316  force = false;
317  }
318 
320 }
321 
322 void campaign_selection::filter_text_changed(const std::string& text)
323 {
324  const std::vector<std::string> words = utils::split(text, ' ');
325 
326  if(words == last_search_words_) {
327  return;
328  }
329 
330  last_search_words_ = words;
332 }
333 
335 {
336  text_box* filter = find_widget<text_box>("filter_box", false, true);
337  filter->on_modified([this](const auto& box) { filter_text_changed(box.text()); });
338 
339  /***** Setup campaign tree. *****/
340  tree_view& tree = find_widget<tree_view>("campaign_tree");
341 
343  std::bind(&campaign_selection::campaign_selected, this));
344 
345  toggle_button& sort_name = find_widget<toggle_button>("sort_name");
346  toggle_button& sort_time = find_widget<toggle_button>("sort_time");
347 
350 
353 
354  connect_signal_mouse_left_click(find_widget<button>("proceed"),
355  std::bind(&campaign_selection::proceed, this));
356 
358  add_to_keyboard_chain(&tree);
359 
360  /***** Setup campaign details. *****/
361  multi_page& pages = find_widget<multi_page>("campaign_details");
362 
363  // Setup completion filter
364  multimenu_button& filter_comp = find_widget<multimenu_button>("filter_completion");
365  connect_signal_notify_modified(filter_comp,
366  std::bind(&campaign_selection::sort_campaigns, this, RANK, 1));
367  for (unsigned j = 0; j < filter_comp.num_options(); j++) {
368  filter_comp.select_option(j);
369  }
370 
371  // Add campaigns to the list
372  for(const auto& level : engine_.get_levels_by_type_unfiltered(level_type::type::sp_campaign)) {
373  const config& campaign = level->data();
374 
375  /*** Add tree item ***/
376  add_campaign_to_tree(campaign);
377 
378  /*** Add detail item ***/
379  pages.add_page({
380  {"description", {
381  {"label", campaign["description"].t_str()},
382  {"text_alignment", campaign["description_alignment"].str("left")},
383  {"use_markup", "true"}
384  }},
385  {"image", {
386  {"label", campaign["image"].str()}
387  }}
388  });
389  page_ids_.push_back(campaign["id"]);
390  }
391 
392  //
393  // Special-purpose pages that appear as if they were campaigns.
394  //
395 
396  // The intro page is shown if nothing is selected in the tree_view, it isn't in the tree itself
397  pages.add_page("no_campaign_selected", -1, widget_data{});
398  page_ids_.push_back(PAGE_ID_LANDING_PAGE);
399 
400  // Addon Manager link
401  config addons;
402  addons["icon"] = "icons/icon-game.png~BLIT(icons/icon-addon-publish.png)";
403  addons["name"] = _("More campaigns...");
404  addons["completed"] = false;
405  addons["id"] = PAGE_ID_GET_ADDONS;
406 
407  add_campaign_to_tree(addons);
408 
409  pages.add_page("go_download_more_stuff", -1, widget_data{});
410  page_ids_.push_back(PAGE_ID_GET_ADDONS);
411 
412  // The data directory has few (or none) of the mainline campaigns
413  std::vector<std::string> dirs;
414  filesystem::get_files_in_dir(game_config::path + "/data/campaigns", nullptr, &dirs);
415  if(dirs.size() <= 15) {
416  config missing;
417  missing["icon"] = "units/unknown-unit.png";
418  missing["name"] = _("Missing Campaigns");
419  missing["completed"] = false;
420  missing["id"] = PAGE_ID_MISSING_CAMPAIGNS;
421 
423 
424  pages.add_page("missing_campaign_warning", -1, widget_data{});
425  page_ids_.push_back(PAGE_ID_MISSING_CAMPAIGNS);
426  }
427 
428  //
429  // Set up Mods selection dropdown
430  //
431  multimenu_button& mods_menu = find_widget<multimenu_button>("mods_menu");
432 
434  std::vector<config> mod_menu_values;
435  std::vector<std::string> enabled = engine_.active_mods();
436 
438  const bool active = std::find(enabled.begin(), enabled.end(), mod->id) != enabled.end();
439 
440  mod_menu_values.emplace_back("label", mod->name, "tooltip", mod->description, "checkbox", active);
441 
442  mod_states_.push_back(active);
443  mod_ids_.emplace_back(mod->id);
444  }
445 
446  mods_menu.set_values(mod_menu_values);
447  mods_menu.select_options(mod_states_);
448 
450  } else {
451  mods_menu.set_active(false);
452  mods_menu.set_label(_("active_modifications^None"));
453  }
454 
455  //
456  // Set up Difficulty dropdown
457  //
458  menu_button& diff_menu = find_widget<menu_button>("difficulty_menu");
459 
460  diff_menu.set_use_markup(true);
462 
464 
465  plugins_context_.reset(new plugins_context("Campaign Selection"));
466  plugins_context_->set_callback("create", [this](const config&) { set_retval(retval::OK); }, false);
467  plugins_context_->set_callback("quit", [this](const config&) { set_retval(retval::CANCEL); }, false);
468 
469  plugins_context_->set_accessor("find_level", [this](const config& cfg) {
470  const std::string id = cfg["id"].str();
471  auto result = engine_.find_level_by_id(id);
472  return config {
473  "index", result.second,
474  "type", level_type::get_string(result.first),
475  };
476  });
477 
478  plugins_context_->set_accessor_int("find_mod", [this](const config& cfg) {
480  });
481 
482  plugins_context_->set_callback("select_level", [this](const config& cfg) {
483  choice_ = cfg["index"].to_int();
485  }, true);
486 }
487 
489 {
490  // We completed the campaign! Calculate the appropriate victory laurel.
491  const auto get_laurel = [&campaign] {
492  if(!campaign["completed"].to_bool()) {
493  return std::string{};
494  }
495 
496  config::const_child_itors difficulties = campaign.child_range("difficulty");
497  auto did_complete_at = [](const config& c) { return c["completed_at"].to_bool(); };
498 
499  // Check for non-completion on every difficulty save the first.
500  const bool only_first_completed = difficulties.size() > 1 &&
501  std::none_of(difficulties.begin() + 1, difficulties.end(), did_complete_at);
502 
503  /*
504  * Criteria:
505  *
506  * - Use the gold laurel (hardest) for campaigns with only one difficulty OR
507  * if out of two or more difficulties, the last one has been completed.
508  *
509  * - Use the bronze laurel (easiest) only if the first difficulty out of two
510  * or more has been completed.
511  *
512  * - Use the silver laurel otherwise.
513  */
514  if(!difficulties.empty() && did_complete_at(difficulties.back())) {
516  } else if(only_first_completed && did_complete_at(difficulties.front())) {
518  } else {
520  }
521  };
522 
523  auto& node = find_widget<tree_view>("campaign_tree")
524  .add_node("campaign", {
525  {"icon", {
526  {"label", campaign["icon"].str()}
527  }},
528  {"name", {
529  {"label", campaign["name"].t_str()}
530  }},
531  {"victory", {
532  {"label", get_laurel()}
533  }}
534  });
535 
536  node.set_id(campaign["id"]);
538  node.find_widget<toggle_panel>("tree_view_node_label"),
539  std::bind(&campaign_selection::proceed, this)
540  );
541 }
542 
544 {
545  tree_view& tree = find_widget<tree_view>("campaign_tree");
546 
547  if(tree.empty() || !tree.selected_item()) {
548  return;
549  }
550 
551  const std::string& campaign_id = tree.selected_item()->id();
552  if(!campaign_id.empty()) {
553  if (campaign_id == PAGE_ID_GET_ADDONS) {
555  } else {
556  auto iter = std::find(page_ids_.begin(), page_ids_.end(), campaign_id);
557  if(iter != page_ids_.end()) {
558  choice_ = std::distance(page_ids_.begin(), iter);
559  }
561  }
562  }
563 
564 
565  rng_mode_ = RNG_MODE(std::clamp<unsigned>(find_widget<menu_button>("rng_menu").get_value(), RNG_DEFAULT, RNG_BIASED));
566 
568 }
569 
571 {
572  boost::dynamic_bitset<> new_mod_states =
573  find_widget<multimenu_button>("mods_menu").get_toggle_states();
574 
575  // Get a mask of any mods that were toggled, regardless of new state
576  mod_states_ = mod_states_ ^ new_mod_states;
577 
578  for(unsigned i = 0; i < mod_states_.size(); i++) {
579  if(mod_states_[i]) {
581  }
582  }
583 
584  // Save the full toggle states for next time
585  mod_states_ = new_mod_states;
586 }
587 
588 } // 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_
RNG_MODE
RNG mode selection values.
virtual void pre_show() override
Actions to be taken before showing the window.
void sort_campaigns(CAMPAIGN_ORDER order, bool ascending)
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_
grid & add_page(const widget_item &item)
Adds single page to the grid.
Definition: multi_page.cpp:58
void select_page(const unsigned page, const bool select=true)
Selects a page.
Definition: multi_page.cpp:119
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
bool empty() const
Definition: tree_view.cpp:99
tree_view_node * selected_item()
Definition: tree_view.hpp:108
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 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.
std::size_t i
Definition: function.cpp:1031
static std::string _(const char *str)
Definition: gettext.hpp:97
This file contains the window object, this object is a top level container which has the event manage...
void get_files_in_dir(const std::string &dir, std::vector< std::string > *files, std::vector< std::string > *dirs, name_mode mode, filter_mode filter, reorder_mode reorder, file_tree_checksum *checksum)
Get a list of all files and/or directories in a given directory.
Definition: filesystem.cpp:466
const color_t GRAY_COLOR
std::string victory_laurel_hardest
std::string victory_laurel
std::string victory_laurel_easy
std::string path
Definition: filesystem.cpp:106
REGISTER_DIALOG(editor_edit_unit)
void connect_signal_notify_modified(dispatcher &dispatcher, const signal_notification &signal)
Connects a signal handler for getting a notification upon modification.
Definition: dispatcher.cpp:189
void connect_signal_mouse_left_click(dispatcher &dispatcher, const signal &signal)
Connects a signal handler for a left mouse button click.
Definition: dispatcher.cpp:163
void connect_signal_mouse_left_double_click(dispatcher &dispatcher, const signal &signal)
Connects a signal handler for a left mouse button double click.
Definition: dispatcher.cpp:184
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
constexpr auto reverse
Definition: ranges.hpp:44
constexpr auto filter
Definition: ranges.hpp:42
std::vector< std::string > split(const config_attribute_value &val)
auto * find(Container &container, const Value &value)
Convenience wrapper for using find on a container without needing to comare to end()
Definition: general.hpp:141
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
mock_char c
static map_location::direction n
#define b