use std::collections::HashSet;
use abstutil::prettyprint_usize;
use geom::{ArrowCap, Circle, Distance, Duration, PolyLine, Pt2D, Time};
use map_gui::tools::{ChooseSomething, ColorLegend, Minimap, MinimapControls};
use map_model::BuildingID;
use widgetry::{
Choice, Color, Drawable, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Image, Key, Line,
Outcome, Panel, State, Text, TextExt, UpdateType, VerticalAlignment, Widget,
};
use crate::after_level::{RecordPath, Results, Strategize};
use crate::animation::{Animator, Effect, SnowEffect};
use crate::buildings::{BldgState, Buildings};
use crate::levels::Level;
use crate::meters::{custom_bar, make_bar};
use crate::player::Player;
use crate::vehicles::Vehicle;
use crate::{App, Transition};
const MAX_BOOST: Duration = Duration::const_seconds(5.0);
const ACQUIRE_BOOST_RATE: f64 = 0.5;
const BOOST_SPEED_MULTIPLIER: f64 = 2.0;
const HANGRY_SPEED_MULTIPLIER: f64 = 0.3;
pub struct Game {
status_panel: Panel,
time_panel: Panel,
pause_panel: Panel,
minimap: Minimap<App, MinimapController>,
animator: Animator,
snow: SnowEffect,
state: GameState,
player: Player,
}
impl Game {
pub fn new_state(
ctx: &mut EventCtx,
app: &mut App,
level: Level,
vehicle: Vehicle,
upzones: HashSet<BuildingID>,
) -> Box<dyn State<App>> {
app.session.current_vehicle = vehicle.name.clone();
app.time = Time::START_OF_DAY;
app.session.music.specify_volume(crate::music::IN_GAME);
let status_panel = Panel::new_builder(Widget::col(vec![
"15-min Santa".text_widget(ctx).centered_vert(),
Widget::row(vec![
Image::from_path("system/assets/tools/map.svg")
.into_widget(ctx)
.centered_vert(),
Line(&level.title).into_widget(ctx),
])
.padding(10)
.bg(Color::hex("#003046")),
"Complete Deliveries".text_widget(ctx).named("score label"),
GeomBatch::new().into_widget(ctx).named("score"),
"Blood sugar".text_widget(ctx).named("energy label"),
GeomBatch::new().into_widget(ctx).named("energy"),
]))
.aligned(HorizontalAlignment::RightInset, VerticalAlignment::TopInset)
.build(ctx);
let time_panel = Panel::new_builder(Widget::row(vec![
GeomBatch::new().into_widget(ctx).named("time circle"),
"Time".text_widget(ctx).centered_vert().named("time label"),
]))
.aligned(HorizontalAlignment::LeftInset, VerticalAlignment::TopInset)
.build(ctx);
let pause_panel = Panel::new_builder(
ctx.style()
.btn_plain
.icon_text("system/assets/speed/pause.svg", "Pause")
.hotkey(Key::Escape)
.build_widget(ctx, "pause")
.container(),
)
.aligned(
HorizontalAlignment::Percent(0.05),
VerticalAlignment::BottomInset,
)
.build(ctx);
let start = app
.map
.find_i_by_osm_id(level.start)
.unwrap_or_else(|_| panic!("can't find {}", level.start));
let player = Player::new(ctx, app, start);
let bldgs = Buildings::new(ctx, app, upzones);
let state = GameState::new(ctx, level, vehicle, bldgs);
let mut game = Game {
status_panel,
time_panel,
pause_panel,
minimap: Minimap::new(ctx, app, MinimapController),
animator: Animator::new(ctx),
snow: SnowEffect::new(ctx),
state,
player,
};
game.update_time_panel(ctx, app);
game.update_status_panel(ctx, app);
game.minimap
.set_zoom(ctx, app, game.state.level.minimap_zoom);
game.update_boost_panel(ctx, app);
Box::new(game)
}
fn update_time_panel(&mut self, ctx: &mut EventCtx, app: &App) {
let pct = (app.time - Time::START_OF_DAY) / self.state.level.time_limit;
let text_color = if pct < 0.75 { Color::WHITE } else { Color::RED };
let label = Line(format!(
"{}",
self.state.level.time_limit - (app.time - Time::START_OF_DAY)
))
.small_heading()
.fg(text_color)
.into_widget(ctx)
.centered_vert();
self.time_panel.replace(ctx, "time label", label);
let center = Pt2D::new(0.0, 0.0);
let outer = Distance::meters(30.0);
let draw = GeomBatch::from(vec![
(Color::WHITE, Circle::new(center, outer).to_polygon()),
(
Color::hex("#5D92C2"),
Circle::new(center, outer).to_partial_polygon(pct),
),
])
.autocrop()
.into_widget(ctx);
self.time_panel.replace(ctx, "time circle", draw);
}
fn update_status_panel(&mut self, ctx: &mut EventCtx, app: &App) {
let score_bar = make_bar(
ctx,
app.session.colors.score,
self.state.score,
if self.state.met_goal() {
self.state.bldgs.total_housing_units
} else {
self.state.level.goal
},
);
self.status_panel.replace(ctx, "score", score_bar);
let energy_bar = make_bar(
ctx,
app.session.colors.energy,
self.state.energy,
self.state.vehicle.max_energy,
);
self.status_panel.replace(ctx, "energy", energy_bar);
}
fn update_boost_panel(&mut self, ctx: &mut EventCtx, app: &App) {
let boost_bar = custom_bar(
ctx,
app.session.colors.boost,
self.state.boost / MAX_BOOST,
if self.state.boost == Duration::ZERO {
Text::from("Find a bike or bus lane")
} else {
Text::from("Hold space to boost")
},
);
self.minimap.mut_panel().replace(ctx, "boost", boost_bar);
}
fn update(&mut self, ctx: &mut EventCtx, app: &mut App, dt: Duration) {
app.time += dt;
let orig_boost = self.state.boost;
let (orig_score, orig_energy) = (self.state.score, self.state.energy);
let orig_pos = self.player.get_pos();
self.update_time_panel(ctx, app);
let base_speed = if self.state.has_energy() {
self.state.vehicle.speed
} else {
HANGRY_SPEED_MULTIPLIER * self.state.vehicle.speed
};
let speed = if ctx.is_key_down(Key::Space) && self.state.boost > Duration::ZERO {
if !self.player.on_good_road(app) {
self.state.boost -= dt;
self.state.boost = self.state.boost.max(Duration::ZERO);
}
base_speed * BOOST_SPEED_MULTIPLIER
} else {
base_speed
};
let met_goal = self.state.met_goal();
for b in self.player.update_with_speed(ctx, app, speed) {
match self.state.bldgs.buildings[&b] {
BldgState::Undelivered(_) => {
if let Some(increase) = self.state.present_dropped(ctx, app, b) {
let path_speed = Duration::seconds(0.2);
self.animator.add(
app.time,
path_speed,
Effect::FollowPath {
color: app.session.colors.score,
width: map_model::NORMAL_LANE_THICKNESS,
pl: app.map.get_b(b).driveway_geom.reversed(),
},
);
self.animator.add(
app.time + path_speed,
Duration::seconds(0.5),
Effect::Scale {
lerp_scale: (1.0, 4.0),
center: app.map.get_b(b).label_center,
orig: Text::from(format!("+{}", prettyprint_usize(increase)))
.bg(app.session.colors.score)
.render_autocropped(ctx)
.scale(0.1),
},
);
}
}
BldgState::Store => {
let refill = self.state.vehicle.max_energy - self.state.energy;
if refill > 0 {
self.state.energy += refill;
self.state.warned_low_energy = false;
let path_speed = Duration::seconds(0.2);
self.animator.add(
app.time,
path_speed,
Effect::FollowPath {
color: app.session.colors.energy,
width: map_model::NORMAL_LANE_THICKNESS,
pl: app.map.get_b(b).driveway_geom.clone(),
},
);
self.animator.add(
app.time + path_speed,
Duration::seconds(0.5),
Effect::Scale {
lerp_scale: (1.0, 4.0),
center: app.map.get_b(b).label_center,
orig: Text::from(format!("Refilled {}", prettyprint_usize(refill)))
.bg(app.session.colors.energy)
.render_autocropped(ctx)
.scale(0.1),
},
);
}
}
BldgState::Done | BldgState::Ignore => {}
}
}
if !met_goal && self.state.met_goal() {
let label = "Goal met! Keep going".text_widget(ctx);
self.status_panel.replace(ctx, "score label", label);
}
if self.player.on_good_road(app) && !ctx.is_key_down(Key::Space) {
self.state.boost += dt * ACQUIRE_BOOST_RATE;
self.state.boost = self.state.boost.min(MAX_BOOST);
}
self.animator.event(ctx, app.time);
self.snow.event(ctx, app.time);
if self.state.has_energy() {
if self.state.energyless_arrow.is_some() {
self.state.energyless_arrow = None;
let label = "Blood sugar".text_widget(ctx);
self.status_panel.replace(ctx, "energy label", label);
}
} else {
if self.state.energyless_arrow.is_none() {
self.state.energyless_arrow = Some(EnergylessArrow::new(
ctx,
app.time,
self.state.bldgs.all_stores(),
));
let label = Text::from(
Line("SANTA'S HANGRY - grab some cookies from a store!").fg(Color::RED),
)
.into_widget(ctx);
self.status_panel.replace(ctx, "energy label", label);
}
self.state
.energyless_arrow
.as_mut()
.unwrap()
.update(ctx, app, self.player.get_pos());
}
if self.state.boost != orig_boost {
self.update_boost_panel(ctx, app);
}
if self.state.score != orig_score || self.state.energy != orig_energy {
self.update_status_panel(ctx, app);
}
if self.player.get_pos() == orig_pos {
self.state.idle_time += dt;
}
self.state.record_path.add_pt(self.player.get_pos());
}
}
impl State<App> for Game {
fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
if self.state.game_over {
if let Some(dt) = ctx.input.nonblocking_is_update_event() {
app.time += dt;
self.animator.event(ctx, app.time);
self.snow.event(ctx, app.time);
self.player.override_pos(self.player.get_pos().project_away(
dt * self.state.vehicle.speed,
self.player.get_angle().opposite(),
));
}
if self.animator.is_done() {
return Transition::Multi(vec![
Transition::Replace(Strategize::new_state(
ctx,
app,
self.state.score,
&self.state.level,
&self.state.bldgs,
std::mem::replace(&mut self.state.record_path, RecordPath::new()),
)),
Transition::Push(Results::new_state(
ctx,
app,
self.state.score,
&self.state.level,
)),
]);
}
ctx.request_update(UpdateType::Game);
return Transition::Keep;
}
if let Some(dt) = ctx.input.nonblocking_is_update_event() {
self.update(ctx, app, dt);
if app.time - Time::START_OF_DAY >= self.state.level.time_limit {
self.state.game_over = true;
self.animator.add(
app.time,
Duration::seconds(3.0),
Effect::Scale {
lerp_scale: (1.0, 4.0),
center: self.player.get_pos(),
orig: Text::from("Time's up!")
.bg(Color::RED)
.render_autocropped(ctx)
.scale(0.1),
},
);
}
if !self.state.warned_low_time
&& self.state.level.time_limit - (app.time - Time::START_OF_DAY)
<= Duration::seconds(20.0)
{
self.state.warned_low_time = true;
self.animator.add(
app.time,
Duration::seconds(2.0),
Effect::Flash {
alpha_scale: (0.1, 0.5),
cycles: 2,
orig: GeomBatch::from(vec![(
Color::RED,
app.map.get_boundary_polygon().clone(),
)]),
},
);
self.animator.add_screenspace(
app.time,
Duration::seconds(2.0),
Effect::Scale {
lerp_scale: (1.0, 4.0),
center: {
let pt = ctx.canvas.center_to_screen_pt();
Pt2D::new(pt.x, pt.y / 2.0)
},
orig: Text::from("Almost out of time!")
.bg(Color::RED)
.render_autocropped(ctx),
},
);
}
if !self.state.warned_low_energy && self.state.energy < 30 {
self.state.warned_low_energy = true;
self.animator.add(
app.time,
Duration::seconds(2.0),
Effect::Flash {
alpha_scale: (0.1, 0.5),
cycles: 2,
orig: GeomBatch::from(vec![(
Color::RED,
app.map.get_boundary_polygon().clone(),
)]),
},
);
self.animator.add_screenspace(
app.time,
Duration::seconds(2.0),
Effect::Scale {
lerp_scale: (1.0, 4.0),
center: {
let pt = ctx.canvas.center_to_screen_pt();
Pt2D::new(pt.x, pt.y / 2.0)
},
orig: Text::from("Low on blood sugar, refill soon!")
.bg(Color::RED)
.render_autocropped(ctx),
},
);
}
ctx.request_update(UpdateType::Game);
return Transition::Keep;
}
if let Some(t) = self.minimap.event(ctx, app) {
return t;
}
if let Outcome::Clicked(x) = self.pause_panel.event(ctx) {
match x.as_ref() {
"pause" => {
app.session.music.specify_volume(crate::music::OUT_OF_GAME);
return Transition::Push(ChooseSomething::new_state(
ctx,
"Game Paused",
vec![
Choice::string("Resume").key(Key::Escape),
Choice::string("Quit"),
],
Box::new(|resp, _, app| match resp.as_ref() {
"Resume" => {
app.session.music.specify_volume(crate::music::IN_GAME);
Transition::Pop
}
"Quit" => Transition::Multi(vec![Transition::Pop, Transition::Pop]),
_ => unreachable!(),
}),
));
}
_ => unreachable!(),
}
}
if let Some((_, dy)) = ctx.input.get_mouse_scroll() {
ctx.canvas.cam_zoom = 1.1_f64
.powf(ctx.canvas.cam_zoom.log(1.1) + dy)
.max(app.opts.min_zoom_for_detail)
.min(50.0);
ctx.canvas.center_on_map_pt(self.player.get_pos());
}
app.session.update_music(ctx);
ctx.request_update(UpdateType::Game);
Transition::Keep
}
fn draw(&self, g: &mut GfxCtx, app: &App) {
self.status_panel.draw(g);
self.time_panel.draw(g);
self.pause_panel.draw(g);
app.session.music.draw(g);
let santa_tracker = g.upload(GeomBatch::from(vec![(
Color::RED,
Circle::new(self.player.get_pos(), Distance::meters(20.0)).to_polygon(),
)]));
self.minimap.draw_with_extra_layers(
g,
app,
vec![
&self.state.bldgs.draw_all,
&self.state.draw_done_houses,
&santa_tracker,
],
);
g.redraw(&self.state.bldgs.draw_all);
g.redraw(&self.state.draw_done_houses);
if true {
self.state
.vehicle
.animate(g.prerender, app.time - self.state.idle_time)
.centered_on(self.player.get_pos())
.rotate_around_batch_center(self.player.get_angle())
.draw(g);
} else {
g.draw_polygon(
Color::RED,
Circle::new(self.player.get_pos(), Distance::meters(2.0)).to_polygon(),
);
}
self.snow.draw(g);
self.animator.draw(g);
if let Some(ref arrow) = self.state.energyless_arrow {
g.redraw(&arrow.draw);
}
}
fn on_destroy(&mut self, _: &mut EventCtx, app: &mut App) {
app.session.music.specify_volume(crate::music::OUT_OF_GAME);
}
}
struct GameState {
level: Level,
vehicle: Vehicle,
bldgs: Buildings,
score: usize,
energy: usize,
boost: Duration,
draw_done_houses: Drawable,
energyless_arrow: Option<EnergylessArrow>,
idle_time: Duration,
game_over: bool,
warned_low_time: bool,
warned_low_energy: bool,
record_path: RecordPath,
}
impl GameState {
fn new(ctx: &mut EventCtx, level: Level, vehicle: Vehicle, bldgs: Buildings) -> GameState {
let energy = vehicle.max_energy;
GameState {
level,
vehicle,
bldgs,
score: 0,
energy,
boost: Duration::ZERO,
draw_done_houses: Drawable::empty(ctx),
energyless_arrow: None,
idle_time: Duration::ZERO,
game_over: false,
warned_low_time: false,
warned_low_energy: false,
record_path: RecordPath::new(),
}
}
fn present_dropped(&mut self, ctx: &mut EventCtx, app: &App, id: BuildingID) -> Option<usize> {
if !self.has_energy() {
return None;
}
if let BldgState::Undelivered(num_housing_units) = self.bldgs.buildings[&id] {
self.score += num_housing_units;
self.bldgs.buildings.insert(id, BldgState::Done);
self.energy -= 1;
self.draw_done_houses = self.bldgs.draw_done_houses(ctx, app);
return Some(num_housing_units);
}
None
}
fn has_energy(&self) -> bool {
self.energy > 0
}
fn met_goal(&self) -> bool {
self.score >= self.level.goal
}
}
struct EnergylessArrow {
draw: Drawable,
started: Time,
last_update: Time,
all_stores: Vec<BuildingID>,
}
impl EnergylessArrow {
fn new(ctx: &EventCtx, started: Time, all_stores: Vec<BuildingID>) -> EnergylessArrow {
EnergylessArrow {
draw: Drawable::empty(ctx),
started,
last_update: Time::START_OF_DAY,
all_stores,
}
}
fn update(&mut self, ctx: &mut EventCtx, app: &App, sleigh: Pt2D) {
if self.last_update == app.time {
return;
}
self.last_update = app.time;
let store = app.map.get_b(
*self
.all_stores
.iter()
.min_by_key(|b| app.map.get_b(**b).driveway_geom.last_pt().fast_dist(sleigh))
.unwrap(),
);
let period = Duration::seconds(0.5);
let pct = ((app.time - self.started) % period) / period;
let shift = (pct * std::f64::consts::PI).sin();
let thickness = Distance::meters(5.0 + shift);
let goto = store.driveway_geom.last_pt();
let angle = sleigh.angle_to(goto);
if let Some(arrow) = PolyLine::new(vec![
sleigh.project_away(Distance::meters(20.0), angle),
goto,
])
.and_then(|pl| {
pl.maybe_exact_slice(Distance::ZERO, Distance::meters(20.0).min(pl.length()))
})
.ok()
.and_then(|slice| slice.maybe_make_arrow(thickness, ArrowCap::Triangle))
{
self.draw = ctx.upload(GeomBatch::from(vec![(Color::RED.alpha(0.8), arrow)]));
}
}
}
struct MinimapController;
impl MinimapControls<App> for MinimapController {
fn has_zorder(&self, _: &App) -> bool {
false
}
fn make_legend(&self, ctx: &mut EventCtx, app: &App) -> Widget {
Widget::col(vec![
Widget::row(vec![
ColorLegend::row(ctx, app.session.colors.house, "house"),
ColorLegend::row(ctx, app.session.colors.apartment, "apartment"),
ColorLegend::row(ctx, app.session.colors.store, "store"),
])
.evenly_spaced(),
Widget::row(vec![
"Boost".text_widget(ctx),
GeomBatch::new()
.into_widget(ctx)
.named("boost")
.align_right(),
]),
])
}
}