game/challenges/
mod.rs

1use std::collections::BTreeMap;
2
3use geom::{Duration, Percent};
4use synthpop::OrigPersonID;
5use widgetry::{EventCtx, Key, Line, Panel, SimpleState, State, Text, TextExt, Widget};
6
7use crate::app::App;
8use crate::app::Transition;
9use crate::sandbox::gameplay::Tutorial;
10use crate::sandbox::{GameplayMode, SandboxMode};
11
12pub mod cutscene;
13pub mod prebake;
14
15// TODO Also have some kind of screenshot to display for each challenge
16pub struct Challenge {
17    title: String,
18    pub description: Vec<String>,
19    pub alias: String,
20    pub gameplay: GameplayMode,
21    pub cutscene: Option<fn(&mut EventCtx, &App, &GameplayMode) -> Box<dyn State<App>>>,
22}
23
24pub struct HighScore {
25    // TODO This should be tied to the GameplayMode
26    pub goal: String,
27    // TODO Assuming we always want to maximize the score
28    pub score: Duration,
29    pub edits_name: String,
30}
31
32impl HighScore {
33    pub fn record(self, app: &mut App, mode: GameplayMode) {
34        // TODO dedupe
35        // TODO mention placement
36        // TODO show all of em
37        let scores = app.session.high_scores.entry(mode).or_insert_with(Vec::new);
38        scores.push(self);
39        scores.sort_by_key(|s| s.score);
40        scores.reverse();
41    }
42}
43
44impl Challenge {
45    pub fn all() -> BTreeMap<String, Vec<Challenge>> {
46        let mut tree = BTreeMap::new();
47        tree.insert(
48            "Optimize one commute".to_string(),
49            // TODO Need to tune both people and goals again.
50            vec![
51                Challenge {
52                    title: "Part 1".to_string(),
53                    description: vec!["Speed up one VIP's daily commute, at any cost!".to_string()],
54                    alias: "commute/pt1".to_string(),
55                    gameplay: GameplayMode::OptimizeCommute(
56                        OrigPersonID(140824, 2),
57                        Duration::minutes(2) + Duration::seconds(30.0),
58                    ),
59                    cutscene: Some(
60                        crate::sandbox::gameplay::commute::OptimizeCommute::cutscene_pt1,
61                    ),
62                },
63                Challenge {
64                    title: "Part 2".to_string(),
65                    description: vec!["Speed up another VIP's commute".to_string()],
66                    alias: "commute/pt2".to_string(),
67                    gameplay: GameplayMode::OptimizeCommute(
68                        OrigPersonID(141039, 2),
69                        Duration::minutes(5),
70                    ),
71                    cutscene: Some(
72                        crate::sandbox::gameplay::commute::OptimizeCommute::cutscene_pt2,
73                    ),
74                },
75            ],
76        );
77        tree.insert(
78            "Traffic signal survivor".to_string(),
79            vec![Challenge {
80                title: "Traffic signal survivor".to_string(),
81                description: vec!["Fix traffic signal timing and unblock vehicles".to_string()],
82                alias: "trafficsig/pt1".to_string(),
83                gameplay: GameplayMode::FixTrafficSignals,
84                cutscene: Some(
85                    crate::sandbox::gameplay::fix_traffic_signals::FixTrafficSignals::cutscene_pt1,
86                ),
87            }],
88        );
89
90        tree
91    }
92
93    // Also returns the next stage, if there is one
94    pub fn find(mode: &GameplayMode) -> (Challenge, Option<Challenge>) {
95        // Find the next stage
96        for (_, stages) in Challenge::all() {
97            let mut current = None;
98            for challenge in stages {
99                if let Some(c) = current {
100                    return (c, Some(challenge));
101                }
102                if &challenge.gameplay == mode {
103                    current = Some(challenge);
104                }
105            }
106            if let Some(c) = current {
107                return (c, None);
108            }
109        }
110        unreachable!()
111    }
112}
113
114pub struct ChallengesPicker {
115    links: BTreeMap<String, (String, usize)>,
116    challenge: Option<Challenge>,
117}
118
119impl ChallengesPicker {
120    pub fn new_state(ctx: &mut EventCtx, app: &App) -> Box<dyn State<App>> {
121        ChallengesPicker::make(ctx, app, None)
122    }
123
124    fn make(
125        ctx: &mut EventCtx,
126        app: &App,
127        challenge_and_stage: Option<(String, usize)>,
128    ) -> Box<dyn State<App>> {
129        let mut links = BTreeMap::new();
130        let mut master_col = vec![
131            Widget::row(vec![
132                Line("Challenges").small_heading().into_widget(ctx),
133                ctx.style().btn_close_widget(ctx),
134            ]),
135            ctx.style()
136                .btn_outline
137                .text("Introduction and tutorial")
138                .build_def(ctx)
139                .container()
140                .section(ctx),
141        ];
142
143        // First list challenges
144        let mut flex_row = Vec::new();
145        for (idx, (name, _)) in Challenge::all().into_iter().enumerate() {
146            let is_current_stage = challenge_and_stage
147                .as_ref()
148                .map(|(n, _)| n == &name)
149                .unwrap_or(false);
150            flex_row.push(
151                ctx.style()
152                    .btn_outline
153                    .text(&name)
154                    .disabled(is_current_stage)
155                    .hotkey(Key::NUM_KEYS[idx])
156                    .build_def(ctx),
157            );
158            links.insert(name.clone(), (name, 0));
159        }
160        master_col.push(
161            Widget::custom_row(flex_row)
162                .flex_wrap(ctx, Percent::int(80))
163                .section(ctx),
164        );
165
166        let mut main_row = Vec::new();
167
168        // List stages
169        if let Some((ref name, current)) = challenge_and_stage {
170            let mut col = Vec::new();
171            for (idx, stage) in Challenge::all()
172                .remove(name)
173                .unwrap()
174                .into_iter()
175                .enumerate()
176            {
177                col.push(
178                    ctx.style()
179                        .btn_outline
180                        .text(&stage.title)
181                        .disabled(current == idx)
182                        .build_def(ctx),
183                );
184                links.insert(stage.title, (name.to_string(), idx));
185            }
186            main_row.push(Widget::col(col).section(ctx));
187        }
188
189        // Describe the specific stage
190        let mut current_challenge = None;
191        if let Some((ref name, current)) = challenge_and_stage {
192            let challenge = Challenge::all().remove(name).unwrap().remove(current);
193            let mut txt = Text::new();
194            for l in &challenge.description {
195                txt.add_line(l);
196            }
197
198            let mut inner_col = vec![
199                txt.into_widget(ctx),
200                ctx.style()
201                    .btn_outline
202                    .text("Start!")
203                    .hotkey(Key::Enter)
204                    .build_def(ctx),
205            ];
206
207            if let Some(scores) = app.session.high_scores.get(&challenge.gameplay) {
208                let mut txt = Text::from(format!("{} high scores:", scores.len()));
209                txt.add_line(format!("Goal: {}", scores[0].goal));
210                let mut idx = 1;
211                for score in scores {
212                    txt.add_line(format!(
213                        "{}) {}, using proposal: {}",
214                        idx, score.score, score.edits_name
215                    ));
216                    idx += 1;
217                }
218                inner_col.push(txt.into_widget(ctx));
219            } else {
220                inner_col.push("No attempts yet".text_widget(ctx));
221            }
222
223            main_row.push(Widget::col(inner_col).section(ctx));
224            current_challenge = Some(challenge);
225        }
226
227        master_col.push(Widget::row(main_row));
228
229        let panel = Panel::new_builder(Widget::col(master_col)).build(ctx);
230        <dyn SimpleState<_>>::new_state(
231            panel,
232            Box::new(ChallengesPicker {
233                links,
234                challenge: current_challenge,
235            }),
236        )
237    }
238}
239
240impl SimpleState<App> for ChallengesPicker {
241    fn on_click(
242        &mut self,
243        ctx: &mut EventCtx,
244        app: &mut App,
245        x: &str,
246        _: &mut Panel,
247    ) -> Transition {
248        match x {
249            "close" => Transition::Pop,
250            "Introduction and tutorial" => Transition::Replace(Tutorial::start(ctx, app)),
251            "Start!" => {
252                #[cfg(not(target_arch = "wasm32"))]
253                {
254                    let map_name = self
255                        .challenge
256                        .as_ref()
257                        .map(|c| c.gameplay.map_name())
258                        .unwrap();
259                    if !abstio::file_exists(map_name.path()) {
260                        // Be lazy here and just tell the user what to do, instead of running the
261                        // code below. This is a rare case anyway.
262                        return map_gui::tools::prompt_to_download_missing_data(
263                            ctx,
264                            map_name,
265                            Box::new(|ctx, _| {
266                                Transition::Replace(widgetry::tools::PopupMsg::new_state(
267                                    ctx,
268                                    "Download complete",
269                                    vec!["Download complete. Click 'Start!' again"],
270                                ))
271                            }),
272                        );
273                    }
274                }
275
276                let challenge = self.challenge.take().unwrap();
277                // Constructing the cutscene doesn't require the map/scenario to be loaded
278                let sandbox = SandboxMode::simple_new(app, challenge.gameplay.clone());
279                if let Some(cutscene) = challenge.cutscene {
280                    Transition::Multi(vec![
281                        Transition::Replace(sandbox),
282                        Transition::Push(cutscene(ctx, app, &challenge.gameplay)),
283                    ])
284                } else {
285                    Transition::Replace(sandbox)
286                }
287            }
288            x => Transition::Replace(ChallengesPicker::make(ctx, app, self.links.remove(x))),
289        }
290    }
291}