santa/
game.rs

1use std::collections::HashSet;
2
3use abstutil::prettyprint_usize;
4use geom::{ArrowCap, Circle, Distance, Duration, PolyLine, Pt2D, Time};
5use map_gui::tools::{Minimap, MinimapControls};
6use map_model::BuildingID;
7use widgetry::tools::{ChooseSomething, ColorLegend};
8use widgetry::{
9    Choice, Color, Drawable, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Image, Key, Line,
10    Outcome, Panel, State, Text, TextExt, UpdateType, VerticalAlignment, Widget,
11};
12
13use crate::after_level::{RecordPath, Results, Strategize};
14use crate::animation::{Animator, Effect, SnowEffect};
15use crate::buildings::{BldgState, Buildings};
16use crate::levels::Level;
17use crate::meters::{custom_bar, make_bar};
18use crate::player::Player;
19use crate::vehicles::Vehicle;
20use crate::{App, Transition};
21
22const MAX_BOOST: Duration = Duration::const_seconds(5.0);
23const ACQUIRE_BOOST_RATE: f64 = 0.5;
24const BOOST_SPEED_MULTIPLIER: f64 = 2.0;
25const HANGRY_SPEED_MULTIPLIER: f64 = 0.3;
26
27pub struct Game {
28    status_panel: Panel,
29    time_panel: Panel,
30    pause_panel: Panel,
31    minimap: Minimap<App, MinimapController>,
32
33    animator: Animator,
34    snow: SnowEffect,
35
36    state: GameState,
37    player: Player,
38}
39
40impl Game {
41    pub fn new_state(
42        ctx: &mut EventCtx,
43        app: &mut App,
44        level: Level,
45        vehicle: Vehicle,
46        upzones: HashSet<BuildingID>,
47    ) -> Box<dyn State<App>> {
48        app.session.current_vehicle = vehicle.name.clone();
49        app.time = Time::START_OF_DAY;
50        app.session.music.specify_volume(crate::music::IN_GAME);
51
52        let status_panel = Panel::new_builder(Widget::col(vec![
53            "15-min Santa".text_widget(ctx).centered_vert(),
54            Widget::row(vec![
55                // TODO The blur is messed up
56                Image::from_path("system/assets/tools/map.svg")
57                    .into_widget(ctx)
58                    .centered_vert(),
59                Line(&level.title).into_widget(ctx),
60            ])
61            .padding(10)
62            .bg(Color::hex("#003046")),
63            "Complete Deliveries".text_widget(ctx).named("score label"),
64            GeomBatch::new().into_widget(ctx).named("score"),
65            "Blood sugar".text_widget(ctx).named("energy label"),
66            GeomBatch::new().into_widget(ctx).named("energy"),
67        ]))
68        .aligned(HorizontalAlignment::RightInset, VerticalAlignment::TopInset)
69        .build(ctx);
70
71        let time_panel = Panel::new_builder(Widget::row(vec![
72            GeomBatch::new().into_widget(ctx).named("time circle"),
73            "Time".text_widget(ctx).centered_vert().named("time label"),
74        ]))
75        .aligned(HorizontalAlignment::LeftInset, VerticalAlignment::TopInset)
76        .build(ctx);
77
78        let pause_panel = Panel::new_builder(
79            ctx.style()
80                .btn_plain
81                .icon_text("system/assets/speed/pause.svg", "Pause")
82                .hotkey(Key::Escape)
83                .build_widget(ctx, "pause")
84                .container(),
85        )
86        // TODO Very brittle layout to wind up to the right of the volume panel...
87        .aligned(
88            HorizontalAlignment::Percent(0.05),
89            VerticalAlignment::BottomInset,
90        )
91        .build(ctx);
92
93        let start = app
94            .map
95            .find_i_by_pt2d(app.map.localise_lon_lat_to_map(level.start))
96            .expect("To find starting point");
97
98        let player = Player::new(ctx, app, start);
99
100        let bldgs = Buildings::new(ctx, app, upzones);
101        let state = GameState::new(ctx, level, vehicle, bldgs);
102
103        let mut game = Game {
104            status_panel,
105            time_panel,
106            pause_panel,
107            minimap: Minimap::new(ctx, app, MinimapController),
108
109            animator: Animator::new(ctx),
110            snow: SnowEffect::new(ctx),
111
112            state,
113            player,
114        };
115        game.update_time_panel(ctx, app);
116        game.update_status_panel(ctx, app);
117        game.minimap
118            .set_zoom(ctx, app, game.state.level.minimap_zoom);
119        game.update_boost_panel(ctx, app);
120        Box::new(game)
121    }
122
123    fn update_time_panel(&mut self, ctx: &mut EventCtx, app: &App) {
124        let pct = ((app.time - Time::START_OF_DAY) / self.state.level.time_limit).min(1.0);
125
126        let text_color = if pct < 0.75 { Color::WHITE } else { Color::RED };
127        let label = Line(format!(
128            "{}",
129            self.state.level.time_limit - (app.time - Time::START_OF_DAY)
130        ))
131        .small_heading()
132        .fg(text_color)
133        .into_widget(ctx)
134        .centered_vert();
135        self.time_panel.replace(ctx, "time label", label);
136
137        // TODO I couldn't quite work out how to get the partial outline from Figma working
138        let center = Pt2D::new(0.0, 0.0);
139        let outer = Distance::meters(30.0);
140        let mut batch = GeomBatch::new();
141        batch.push(Color::WHITE, Circle::new(center, outer).to_polygon());
142        batch.push(
143            Color::hex("#5D92C2"),
144            Circle::new(center, outer).to_partial_tessellation(pct),
145        );
146        let draw = batch.autocrop().into_widget(ctx);
147        self.time_panel.replace(ctx, "time circle", draw);
148    }
149
150    fn update_status_panel(&mut self, ctx: &mut EventCtx, app: &App) {
151        let score_bar = make_bar(
152            ctx,
153            app.session.colors.score,
154            self.state.score,
155            if self.state.met_goal() {
156                self.state.bldgs.total_housing_units
157            } else {
158                self.state.level.goal
159            },
160        );
161        self.status_panel.replace(ctx, "score", score_bar);
162
163        let energy_bar = make_bar(
164            ctx,
165            app.session.colors.energy,
166            self.state.energy,
167            self.state.vehicle.max_energy,
168        );
169        self.status_panel.replace(ctx, "energy", energy_bar);
170    }
171
172    fn update_boost_panel(&mut self, ctx: &mut EventCtx, app: &App) {
173        let boost_bar = custom_bar(
174            ctx,
175            app.session.colors.boost,
176            self.state.boost / MAX_BOOST,
177            if self.state.boost == Duration::ZERO {
178                Text::from("Find a bike or bus lane")
179            } else {
180                Text::from("Hold space to boost")
181            },
182        );
183        self.minimap.mut_panel().replace(ctx, "boost", boost_bar);
184    }
185
186    fn update(&mut self, ctx: &mut EventCtx, app: &mut App, dt: Duration) {
187        app.time += dt;
188
189        let orig_boost = self.state.boost;
190        let (orig_score, orig_energy) = (self.state.score, self.state.energy);
191        let orig_pos = self.player.get_pos();
192
193        self.update_time_panel(ctx, app);
194
195        let base_speed = if self.state.has_energy() {
196            self.state.vehicle.speed
197        } else {
198            HANGRY_SPEED_MULTIPLIER * self.state.vehicle.speed
199        };
200        let speed = if ctx.is_key_down(Key::Space) && self.state.boost > Duration::ZERO {
201            if !self.player.on_good_road(app) {
202                self.state.boost -= dt;
203                self.state.boost = self.state.boost.max(Duration::ZERO);
204            }
205            base_speed * BOOST_SPEED_MULTIPLIER
206        } else {
207            base_speed
208        };
209
210        let met_goal = self.state.met_goal();
211        for b in self.player.update_with_speed(ctx, app, speed) {
212            match self.state.bldgs.buildings[&b] {
213                BldgState::Undelivered(_) => {
214                    if let Some(increase) = self.state.present_dropped(ctx, app, b) {
215                        let path_speed = Duration::seconds(0.2);
216                        self.animator.add(
217                            app.time,
218                            path_speed,
219                            Effect::FollowPath {
220                                color: app.session.colors.score,
221                                width: map_model::NORMAL_LANE_THICKNESS,
222                                pl: app.map.get_b(b).driveway_geom.reversed(),
223                            },
224                        );
225                        self.animator.add(
226                            app.time + path_speed,
227                            Duration::seconds(0.5),
228                            Effect::Scale {
229                                lerp_scale: (1.0, 4.0),
230                                center: app.map.get_b(b).label_center,
231                                orig: Text::from(format!("+{}", prettyprint_usize(increase)))
232                                    .bg(app.session.colors.score)
233                                    .render_autocropped(ctx)
234                                    .scale(0.1),
235                            },
236                        );
237                    }
238                }
239                BldgState::Store => {
240                    let refill = self.state.vehicle.max_energy - self.state.energy;
241                    if refill > 0 {
242                        self.state.energy += refill;
243                        self.state.warned_low_energy = false;
244                        let path_speed = Duration::seconds(0.2);
245                        self.animator.add(
246                            app.time,
247                            path_speed,
248                            Effect::FollowPath {
249                                color: app.session.colors.energy,
250                                width: map_model::NORMAL_LANE_THICKNESS,
251                                pl: app.map.get_b(b).driveway_geom.clone(),
252                            },
253                        );
254                        self.animator.add(
255                            app.time + path_speed,
256                            Duration::seconds(0.5),
257                            Effect::Scale {
258                                lerp_scale: (1.0, 4.0),
259                                center: app.map.get_b(b).label_center,
260                                orig: Text::from(format!("Refilled {}", prettyprint_usize(refill)))
261                                    .bg(app.session.colors.energy)
262                                    .render_autocropped(ctx)
263                                    .scale(0.1),
264                            },
265                        );
266                    }
267                }
268                BldgState::Done | BldgState::Ignore => {}
269            }
270        }
271        if !met_goal && self.state.met_goal() {
272            // TODO What should we say here? Should we add some kind of animation to call this
273            // out?
274            let label = "Goal met! Keep going".text_widget(ctx);
275            self.status_panel.replace(ctx, "score label", label);
276        }
277
278        if self.player.on_good_road(app) && !ctx.is_key_down(Key::Space) {
279            self.state.boost += dt * ACQUIRE_BOOST_RATE;
280            self.state.boost = self.state.boost.min(MAX_BOOST);
281        }
282
283        self.animator.event(ctx, app.time);
284        self.snow.event(ctx, app.time);
285        if self.state.has_energy() {
286            if self.state.energyless_arrow.is_some() {
287                self.state.energyless_arrow = None;
288                let label = "Blood sugar".text_widget(ctx);
289                self.status_panel.replace(ctx, "energy label", label);
290            }
291        } else {
292            if self.state.energyless_arrow.is_none() {
293                self.state.energyless_arrow = Some(EnergylessArrow::new(
294                    ctx,
295                    app.time,
296                    self.state.bldgs.all_stores(),
297                ));
298                let label = Text::from(
299                    Line("SANTA'S HANGRY - grab some cookies from a store!").fg(Color::RED),
300                )
301                .into_widget(ctx);
302                self.status_panel.replace(ctx, "energy label", label);
303            }
304            self.state
305                .energyless_arrow
306                .as_mut()
307                .unwrap()
308                .update(ctx, app, self.player.get_pos());
309        }
310
311        if self.state.boost != orig_boost {
312            self.update_boost_panel(ctx, app);
313        }
314        if self.state.score != orig_score || self.state.energy != orig_energy {
315            self.update_status_panel(ctx, app);
316        }
317        if self.player.get_pos() == orig_pos {
318            self.state.idle_time += dt;
319        }
320
321        self.state.record_path.add_pt(self.player.get_pos());
322    }
323}
324
325impl State<App> for Game {
326    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
327        if self.state.game_over {
328            if let Some(dt) = ctx.input.nonblocking_is_update_event() {
329                app.time += dt;
330                self.animator.event(ctx, app.time);
331                self.snow.event(ctx, app.time);
332                self.player.override_pos(self.player.get_pos().project_away(
333                    dt * self.state.vehicle.speed,
334                    self.player.get_angle().opposite(),
335                ));
336            }
337
338            if self.animator.is_done() {
339                return Transition::Multi(vec![
340                    Transition::Replace(Strategize::new_state(
341                        ctx,
342                        app,
343                        self.state.score,
344                        &self.state.level,
345                        &self.state.bldgs,
346                        std::mem::replace(&mut self.state.record_path, RecordPath::new()),
347                    )),
348                    Transition::Push(Results::new_state(
349                        ctx,
350                        app,
351                        self.state.score,
352                        &self.state.level,
353                    )),
354                ]);
355            }
356
357            ctx.request_update(UpdateType::Game);
358            return Transition::Keep;
359        }
360
361        // Most things depend on time passing and don't care about other events
362        if let Some(dt) = ctx.input.nonblocking_is_update_event() {
363            self.update(ctx, app, dt);
364
365            if app.time - Time::START_OF_DAY >= self.state.level.time_limit {
366                self.state.game_over = true;
367                self.animator.add(
368                    app.time,
369                    Duration::seconds(3.0),
370                    Effect::Scale {
371                        lerp_scale: (1.0, 4.0),
372                        center: self.player.get_pos(),
373                        orig: Text::from("Time's up!")
374                            .bg(Color::RED)
375                            .render_autocropped(ctx)
376                            .scale(0.1),
377                    },
378                );
379            }
380
381            if !self.state.warned_low_time
382                && self.state.level.time_limit - (app.time - Time::START_OF_DAY)
383                    <= Duration::seconds(20.0)
384            {
385                self.state.warned_low_time = true;
386                self.animator.add(
387                    app.time,
388                    Duration::seconds(2.0),
389                    Effect::Flash {
390                        alpha_scale: (0.1, 0.5),
391                        cycles: 2,
392                        orig: GeomBatch::from(vec![(
393                            Color::RED,
394                            app.map.get_boundary_polygon().clone(),
395                        )]),
396                    },
397                );
398                self.animator.add_screenspace(
399                    app.time,
400                    Duration::seconds(2.0),
401                    Effect::Scale {
402                        lerp_scale: (1.0, 4.0),
403                        center: {
404                            let pt = ctx.canvas.center_to_screen_pt();
405                            Pt2D::new(pt.x, pt.y / 2.0)
406                        },
407                        orig: Text::from("Almost out of time!")
408                            .bg(Color::RED)
409                            .render_autocropped(ctx),
410                    },
411                );
412            }
413
414            if !self.state.warned_low_energy && self.state.energy < 30 {
415                self.state.warned_low_energy = true;
416                self.animator.add(
417                    app.time,
418                    Duration::seconds(2.0),
419                    Effect::Flash {
420                        alpha_scale: (0.1, 0.5),
421                        cycles: 2,
422                        orig: GeomBatch::from(vec![(
423                            Color::RED,
424                            app.map.get_boundary_polygon().clone(),
425                        )]),
426                    },
427                );
428                self.animator.add_screenspace(
429                    app.time,
430                    Duration::seconds(2.0),
431                    Effect::Scale {
432                        lerp_scale: (1.0, 4.0),
433                        center: {
434                            let pt = ctx.canvas.center_to_screen_pt();
435                            Pt2D::new(pt.x, pt.y / 2.0)
436                        },
437                        orig: Text::from("Low on blood sugar, refill soon!")
438                            .bg(Color::RED)
439                            .render_autocropped(ctx),
440                    },
441                );
442            }
443
444            ctx.request_update(UpdateType::Game);
445            return Transition::Keep;
446        }
447
448        if let Some(t) = self.minimap.event(ctx, app) {
449            return t;
450        }
451
452        if let Outcome::Clicked(x) = self.pause_panel.event(ctx) {
453            match x.as_ref() {
454                "pause" => {
455                    app.session.music.specify_volume(crate::music::OUT_OF_GAME);
456                    return Transition::Push(ChooseSomething::new_state(
457                        ctx,
458                        "Game Paused",
459                        vec![
460                            Choice::string("Resume").key(Key::Escape),
461                            Choice::string("Quit"),
462                        ],
463                        Box::new(|resp, _, app| match resp.as_ref() {
464                            "Resume" => {
465                                app.session.music.specify_volume(crate::music::IN_GAME);
466                                Transition::Pop
467                            }
468                            "Quit" => Transition::Multi(vec![Transition::Pop, Transition::Pop]),
469                            _ => unreachable!(),
470                        }),
471                    ));
472                }
473                _ => unreachable!(),
474            }
475        }
476
477        if let Some((_, dy)) = ctx.input.get_mouse_scroll() {
478            ctx.canvas.cam_zoom = 1.1_f64
479                .powf(ctx.canvas.cam_zoom.log(1.1) + dy)
480                .max(ctx.canvas.settings.min_zoom_for_detail)
481                .min(50.0);
482            ctx.canvas.center_on_map_pt(self.player.get_pos());
483        }
484
485        app.session.update_music(ctx);
486
487        ctx.request_update(UpdateType::Game);
488        Transition::Keep
489    }
490
491    fn draw(&self, g: &mut GfxCtx, app: &App) {
492        self.status_panel.draw(g);
493        self.time_panel.draw(g);
494        self.pause_panel.draw(g);
495        app.session.music.draw(g);
496
497        let santa_tracker = g.upload(GeomBatch::from(vec![(
498            Color::RED,
499            Circle::new(self.player.get_pos(), Distance::meters(20.0)).to_polygon(),
500        )]));
501        self.minimap.draw_with_extra_layers(
502            g,
503            app,
504            vec![
505                &self.state.bldgs.draw_all,
506                &self.state.draw_done_houses,
507                &santa_tracker,
508            ],
509        );
510
511        g.redraw(&self.state.bldgs.draw_all);
512        g.redraw(&self.state.draw_done_houses);
513
514        if true {
515            self.state
516                .vehicle
517                .animate(g.prerender, app.time - self.state.idle_time)
518                .centered_on(self.player.get_pos())
519                .rotate_around_batch_center(self.player.get_angle())
520                .draw(g);
521        } else {
522            // Debug
523            g.draw_polygon(
524                Color::RED,
525                Circle::new(self.player.get_pos(), Distance::meters(2.0)).to_polygon(),
526            );
527        }
528
529        self.snow.draw(g);
530        self.animator.draw(g);
531        if let Some(ref arrow) = self.state.energyless_arrow {
532            g.redraw(&arrow.draw);
533        }
534    }
535
536    fn on_destroy(&mut self, _: &mut EventCtx, app: &mut App) {
537        app.session.music.specify_volume(crate::music::OUT_OF_GAME);
538    }
539}
540
541struct GameState {
542    level: Level,
543    vehicle: Vehicle,
544    bldgs: Buildings,
545
546    // Number of deliveries
547    score: usize,
548    energy: usize,
549    boost: Duration,
550
551    draw_done_houses: Drawable,
552    energyless_arrow: Option<EnergylessArrow>,
553
554    // For animation
555    idle_time: Duration,
556
557    game_over: bool,
558    warned_low_time: bool,
559    warned_low_energy: bool,
560
561    record_path: RecordPath,
562}
563
564impl GameState {
565    fn new(ctx: &mut EventCtx, level: Level, vehicle: Vehicle, bldgs: Buildings) -> GameState {
566        let energy = vehicle.max_energy;
567        GameState {
568            level,
569            vehicle,
570            bldgs,
571
572            score: 0,
573            energy,
574            boost: Duration::ZERO,
575
576            draw_done_houses: Drawable::empty(ctx),
577            energyless_arrow: None,
578
579            idle_time: Duration::ZERO,
580
581            game_over: false,
582            warned_low_time: false,
583            warned_low_energy: false,
584
585            record_path: RecordPath::new(),
586        }
587    }
588
589    // If something changed, return the update to the score
590    fn present_dropped(&mut self, ctx: &mut EventCtx, app: &App, id: BuildingID) -> Option<usize> {
591        if !self.has_energy() {
592            return None;
593        }
594        if let BldgState::Undelivered(num_housing_units) = self.bldgs.buildings[&id] {
595            self.score += num_housing_units;
596            self.bldgs.buildings.insert(id, BldgState::Done);
597            self.energy -= 1;
598            self.draw_done_houses = self.bldgs.draw_done_houses(ctx, app);
599            return Some(num_housing_units);
600        }
601        None
602    }
603
604    fn has_energy(&self) -> bool {
605        self.energy > 0
606    }
607
608    fn met_goal(&self) -> bool {
609        self.score >= self.level.goal
610    }
611}
612
613struct EnergylessArrow {
614    draw: Drawable,
615    started: Time,
616    last_update: Time,
617    all_stores: Vec<BuildingID>,
618}
619
620impl EnergylessArrow {
621    fn new(ctx: &EventCtx, started: Time, all_stores: Vec<BuildingID>) -> EnergylessArrow {
622        EnergylessArrow {
623            draw: Drawable::empty(ctx),
624            started,
625            last_update: Time::START_OF_DAY,
626            all_stores,
627        }
628    }
629
630    fn update(&mut self, ctx: &mut EventCtx, app: &App, sleigh: Pt2D) {
631        if self.last_update == app.time {
632            return;
633        }
634        self.last_update = app.time;
635        // Find the closest store as the crow -- or Santa -- flies. Point to the end of the
636        // driveway, since sometimes it's hard to quickly spot which road a building is connected
637        // to.
638        // TODO Or pathfind and show them that?
639        let store = app.map.get_b(
640            *self
641                .all_stores
642                .iter()
643                .min_by_key(|b| app.map.get_b(**b).driveway_geom.last_pt().fast_dist(sleigh))
644                .unwrap(),
645        );
646
647        // Vibrate in size slightly
648        let period = Duration::seconds(0.5);
649        let pct = ((app.time - self.started) % period) / period;
650        // -1 to 1
651        let shift = (pct * std::f64::consts::PI).sin();
652        let thickness = Distance::meters(5.0 + shift);
653
654        let goto = store.driveway_geom.last_pt();
655        let angle = sleigh.angle_to(goto);
656        // TODO When we're too close, we get an awkward arrowcap; the intention was for it to
657        // disappear...
658        if let Some(arrow) = PolyLine::new(vec![
659            sleigh.project_away(Distance::meters(20.0), angle),
660            goto,
661        ])
662        .and_then(|pl| {
663            pl.maybe_exact_slice(Distance::ZERO, Distance::meters(20.0).min(pl.length()))
664        })
665        .ok()
666        .and_then(|slice| slice.maybe_make_arrow(thickness, ArrowCap::Triangle))
667        {
668            self.draw = ctx.upload(GeomBatch::from(vec![(Color::RED.alpha(0.8), arrow)]));
669        }
670    }
671}
672
673struct MinimapController;
674
675impl MinimapControls<App> for MinimapController {
676    fn has_zorder(&self, _: &App) -> bool {
677        false
678    }
679
680    fn make_legend(&self, ctx: &mut EventCtx, app: &App) -> Widget {
681        Widget::col(vec![
682            Widget::row(vec![
683                ColorLegend::row(ctx, app.session.colors.house, "house"),
684                ColorLegend::row(ctx, app.session.colors.apartment, "apartment"),
685                ColorLegend::row(ctx, app.session.colors.store, "store"),
686            ])
687            .evenly_spaced(),
688            // TODO If the player messes with the minimap, the panel gets recreated, and we'll
689            // clobber the boost bar. No easy way to plumb everything we need for
690            // update_boost_panel here. It's not super common to actually mess with those controls,
691            // so fine with this for now.
692            Widget::row(vec![
693                "Boost".text_widget(ctx),
694                GeomBatch::new()
695                    .into_widget(ctx)
696                    .named("boost")
697                    .align_right(),
698            ]),
699        ])
700    }
701}