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 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 .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 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 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 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 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 score: usize,
548 energy: usize,
549 boost: Duration,
550
551 draw_done_houses: Drawable,
552 energyless_arrow: Option<EnergylessArrow>,
553
554 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 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 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 let period = Duration::seconds(0.5);
649 let pct = ((app.time - self.started) % period) / period;
650 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 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 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}