1mod connecting;
2mod credits;
5mod login;
6mod servers;
7#[cfg(feature = "singleplayer")]
8mod world_selector;
9
10use crate::{
11 GlobalState,
12 credits::Credits,
13 render::UiDrawer,
14 ui::{
15 self, Graphic,
16 fonts::IcedFonts as Fonts,
17 ice::{Element, IcedUi as Ui, load_font, style, widget},
18 img_ids::ImageGraphic,
19 },
20 window,
21};
22use i18n::{LanguageMetadata, LocalizationHandle};
23use iced::{Column, Container, HorizontalAlignment, Length, Row, Space, text_input};
24use crate::settings::Settings;
26use common::assets::{AssetExt, Image, Ron};
27use rand::{rng, seq::IndexedRandom};
28use std::time::Duration;
29use tracing::warn;
30
31use super::DetailedInitializationStage;
32
33pub const TEXT_COLOR: iced::Color = iced::Color::from_rgb(1.0, 1.0, 1.0);
37pub const DISABLED_TEXT_COLOR: iced::Color = iced::Color::from_rgba(1.0, 1.0, 1.0, 0.2);
38
39pub const FILL_FRAC_ONE: f32 = 0.67;
40pub const FILL_FRAC_TWO: f32 = 0.53;
41
42image_ids_ice! {
43 struct Imgs {
44 <ImageGraphic>
45 v_logo: "voxygen.element.v_logo",
46 bg: "voxygen.background.bg_main",
47 banner_top: "voxygen.element.ui.generic.frames.banner_top",
48 banner_gradient_bottom: "voxygen.element.ui.generic.frames.banner_gradient_bottom",
49 button: "voxygen.element.ui.generic.buttons.button",
50 button_hover: "voxygen.element.ui.generic.buttons.button_hover",
51 button_press: "voxygen.element.ui.generic.buttons.button_press",
52 input_bg: "voxygen.element.ui.generic.textbox",
53 loading_art: "voxygen.element.ui.generic.frames.loading_screen.loading_bg",
54 loading_art_l: "voxygen.element.ui.generic.frames.loading_screen.loading_bg_l",
55 loading_art_r: "voxygen.element.ui.generic.frames.loading_screen.loading_bg_r",
56 selection: "voxygen.element.ui.generic.frames.selection",
57 selection_hover: "voxygen.element.ui.generic.frames.selection_hover",
58 selection_press: "voxygen.element.ui.generic.frames.selection_press",
59
60 #[cfg(feature = "singleplayer")]
61 slider_range: "voxygen.element.ui.generic.slider.track",
62 #[cfg(feature = "singleplayer")]
63 slider_indicator: "voxygen.element.ui.generic.slider.indicator",
64
65 unlock: "voxygen.element.ui.generic.buttons.unlock",
66 unlock_hover: "voxygen.element.ui.generic.buttons.unlock_hover",
67 unlock_press: "voxygen.element.ui.generic.buttons.unlock_press",
68 }
69}
70
71const BG_IMGS: [&str; 41] = [
73 "voxygen.background.bg_1",
74 "voxygen.background.bg_2",
75 "voxygen.background.bg_3",
76 "voxygen.background.bg_4",
77 "voxygen.background.bg_5",
78 "voxygen.background.bg_6",
79 "voxygen.background.bg_7",
80 "voxygen.background.bg_8",
81 "voxygen.background.bg_9",
82 "voxygen.background.bg_10",
83 "voxygen.background.bg_11",
84 "voxygen.background.bg_12",
85 "voxygen.background.bg_13",
86 "voxygen.background.bg_14",
87 "voxygen.background.bg_15",
88 "voxygen.background.bg_16",
89 "voxygen.background.bg_17",
90 "voxygen.background.bg_18",
91 "voxygen.background.bg_19",
92 "voxygen.background.bg_20",
93 "voxygen.background.bg_21",
94 "voxygen.background.bg_22",
95 "voxygen.background.bg_23",
96 "voxygen.background.bg_24",
97 "voxygen.background.bg_25",
98 "voxygen.background.bg_26",
99 "voxygen.background.bg_27",
100 "voxygen.background.bg_28",
101 "voxygen.background.bg_29",
102 "voxygen.background.bg_30",
103 "voxygen.background.bg_31",
104 "voxygen.background.bg_32",
105 "voxygen.background.bg_33",
106 "voxygen.background.bg_34",
107 "voxygen.background.bg_35",
108 "voxygen.background.bg_36",
109 "voxygen.background.bg_37",
110 "voxygen.background.bg_38",
111 "voxygen.background.bg_39",
112 "voxygen.background.bg_40",
113 "voxygen.background.bg_41",
114];
115
116#[cfg(feature = "singleplayer")]
117#[derive(Clone)]
118pub enum WorldChange {
119 Name(String),
120 Seed(u32),
121 DayLength(f64),
122 SizeX(u32),
123 SizeY(u32),
124 Scale(f64),
125 MapKind(common::resources::MapKind),
126 ErosionQuality(f32),
127 DefaultGenOps,
128}
129
130#[cfg(feature = "singleplayer")]
131impl WorldChange {
132 pub fn apply(self, world: &mut crate::singleplayer::SingleplayerWorld) {
133 let mut def = Default::default();
134 let gen_opts = world.gen_opts.as_mut().unwrap_or(&mut def);
135 match self {
136 WorldChange::Name(name) => world.name = name,
137 WorldChange::Seed(seed) => world.seed = seed,
138 WorldChange::DayLength(d) => world.day_length = d,
139 WorldChange::SizeX(s) => gen_opts.x_lg = s,
140 WorldChange::SizeY(s) => gen_opts.y_lg = s,
141 WorldChange::Scale(scale) => gen_opts.scale = scale,
142 WorldChange::MapKind(kind) => gen_opts.map_kind = kind,
143 WorldChange::ErosionQuality(q) => gen_opts.erosion_quality = q,
144 WorldChange::DefaultGenOps => world.gen_opts = Some(Default::default()),
145 }
146 }
147}
148
149#[cfg(feature = "singleplayer")]
150#[derive(Clone)]
151pub enum WorldsChange {
152 SetActive(Option<usize>),
153 Delete(usize),
154 Regenerate(usize),
155 AddNew,
156 CurrentWorldChange(WorldChange),
157}
158
159pub enum Event {
160 LoginAttempt {
161 username: String,
162 password: String,
163 server_address: String,
164 },
165 CancelLoginAttempt,
166 ChangeLanguage(LanguageMetadata),
167 #[cfg(feature = "singleplayer")]
168 StartSingleplayer,
169 #[cfg(feature = "singleplayer")]
170 InitSingleplayer,
171 #[cfg(feature = "singleplayer")]
172 SinglePlayerChange(WorldsChange),
173 Quit,
174 AuthServerTrust(String, bool),
177 DeleteServer {
178 server_index: usize,
179 },
180}
181
182pub struct LoginInfo {
183 pub username: String,
184 pub password: String,
185 pub server: String,
186}
187
188enum ConnectionState {
189 InProgress,
190 AuthTrustPrompt { auth_server: String, msg: String },
191}
192
193enum Screen {
194 Credits {
199 screen: credits::Screen,
200 },
201 Login {
202 screen: Box<login::Screen>, error: Option<String>,
205 },
206 Servers {
207 screen: servers::Screen,
208 },
209 Connecting {
210 screen: connecting::Screen,
211 connection_state: ConnectionState,
212 init_stage: DetailedInitializationStage,
213 },
214 #[cfg(feature = "singleplayer")]
215 WorldSelector {
216 screen: world_selector::Screen,
217 },
218}
219
220#[derive(PartialEq, Eq)]
221enum Showing {
222 Login,
223 Languages,
224}
225
226impl Showing {
227 fn toggle(&mut self, other: Showing) {
228 if *self == other {
229 *self = Showing::Login;
230 } else {
231 *self = other;
232 }
233 }
234}
235
236pub struct Controls {
237 fonts: Fonts,
238 imgs: Imgs,
239 bg_img: widget::image::Handle,
240 i18n: LocalizationHandle,
241 version: String,
243 credits: Credits,
244
245 server_field_locked: bool,
249 selected_server_index: Option<usize>,
250 login_info: LoginInfo,
251
252 show: Showing,
253 selected_language_index: Option<usize>,
254
255 time: f64,
256
257 screen: Screen,
258}
259
260#[derive(Clone)]
261enum Message {
262 Quit,
263 Back,
264 ShowServers,
265 ShowCredits,
266 #[cfg(feature = "singleplayer")]
267 Singleplayer,
268 #[cfg(feature = "singleplayer")]
269 SingleplayerPlay,
270 #[cfg(feature = "singleplayer")]
271 WorldChanged(WorldsChange),
272 #[cfg(feature = "singleplayer")]
273 WorldCancelConfirmation,
274 #[cfg(feature = "singleplayer")]
275 WorldConfirmation(world_selector::Confirmation),
276 Multiplayer,
277 UnlockServerField,
278 LanguageChanged(usize),
279 OpenLanguageMenu,
280 Username(String),
281 Password(String),
282 Server(String),
283 ServerChanged(usize),
284 FocusPassword,
285 CancelConnect,
286 TrustPromptAdd,
287 TrustPromptCancel,
288 CloseError,
289 DeleteServer,
290 }
293
294impl Controls {
295 fn new(
296 fonts: Fonts,
297 imgs: Imgs,
298 bg_img: widget::image::Handle,
299 i18n: LocalizationHandle,
300 settings: &Settings,
301 server: Option<String>,
302 ) -> Self {
303 let version = format!("Veloren {}", *common::util::DISPLAY_VERSION);
304
305 let credits = Ron::<Credits>::load_expect_cloned("credits").into_inner();
306
307 let screen = Screen::Login {
314 screen: Box::default(),
315 error: None,
316 };
317 let server_field_locked = server.is_some();
320 let login_info = LoginInfo {
321 username: settings.networking.username.clone(),
322 password: String::new(),
323 server: server.unwrap_or_else(|| settings.networking.default_server.clone()),
324 };
325 let selected_server_index = settings
326 .networking
327 .servers
328 .iter()
329 .position(|f| f == &login_info.server);
330
331 let language_metadatas = i18n::list_localizations();
332 let selected_language_index = language_metadatas
333 .iter()
334 .position(|f| f.language_identifier == settings.language.selected_language);
335
336 Self {
337 fonts,
338 imgs,
339 bg_img,
340 i18n,
341 version,
342 credits,
343
344 server_field_locked,
345 selected_server_index,
346 login_info,
347
348 show: Showing::Login,
349 selected_language_index,
350
351 time: 0.0,
352
353 screen,
354 }
355 }
356
357 fn view(
358 &mut self,
359 settings: &Settings,
360 dt: f32,
361 #[cfg(feature = "singleplayer")] worlds: &crate::singleplayer::SingleplayerWorlds,
362 ) -> Element<'_, Message> {
363 self.time += dt as f64;
364
365 let button_style = style::button::Style::new(self.imgs.button)
367 .hover_image(self.imgs.button_hover)
368 .press_image(self.imgs.button_press)
369 .text_color(TEXT_COLOR)
370 .disabled_text_color(DISABLED_TEXT_COLOR);
371
372 let version = iced::Text::new(&self.version)
373 .size(self.fonts.cyri.scale(12))
374 .width(Length::Fill)
375 .horizontal_alignment(HorizontalAlignment::Center);
376
377 let top_text = Row::with_children(vec![
378 Space::new(Length::Fill, Length::Shrink).into(),
379 version.into(),
380 Space::new(Length::Fill, Length::Shrink).into(),
381 ])
382 .padding(3)
383 .width(Length::Fill);
384
385 let bg_img = if matches!(&self.screen, Screen::Connecting { .. }) {
386 self.bg_img
387 } else {
388 self.imgs.bg
389 };
390
391 let language_metadatas = i18n::list_localizations();
392
393 let content = match &mut self.screen {
396 Screen::Credits { screen } => {
399 screen.view(&self.fonts, &self.i18n.read(), &self.credits, button_style)
400 },
401 Screen::Login { screen, error } => screen.view(
402 &self.fonts,
403 &self.imgs,
404 self.server_field_locked,
405 &self.login_info,
406 error.as_deref(),
407 &self.i18n.read(),
408 &self.show,
409 self.selected_language_index,
410 &language_metadatas,
411 button_style,
412 ),
413 Screen::Servers { screen } => screen.view(
414 &self.fonts,
415 &self.imgs,
416 &settings.networking.servers,
417 self.selected_server_index,
418 &self.i18n.read(),
419 button_style,
420 ),
421 Screen::Connecting {
422 screen,
423 connection_state,
424 init_stage,
425 } => screen.view(
426 &self.fonts,
427 &self.imgs,
428 connection_state,
429 init_stage,
430 self.time,
431 &self.i18n.read(),
432 button_style,
433 settings.interface.loading_tips,
434 &settings.controls,
435 ),
436 #[cfg(feature = "singleplayer")]
437 Screen::WorldSelector { screen } => screen.view(
438 &self.fonts,
439 &self.imgs,
440 worlds,
441 &self.i18n.read(),
442 button_style,
443 ),
444 };
445
446 Container::new(
447 Column::with_children(vec![top_text.into(), content])
448 .spacing(3)
449 .width(Length::Fill)
450 .height(Length::Fill),
451 )
452 .style(style::container::Style::image(bg_img))
453 .into()
454 }
455
456 fn update(
457 &mut self,
458 message: Message,
459 events: &mut Vec<Event>,
460 settings: &Settings,
461 ui: &mut Ui,
462 ) {
463 let servers = &settings.networking.servers;
464 let mut language_metadatas = i18n::list_localizations();
465
466 match message {
467 Message::Quit => events.push(Event::Quit),
468 Message::Back => {
469 self.screen = Screen::Login {
470 screen: Box::default(),
471 error: None,
472 };
473 },
474 Message::ShowServers => {
475 if matches!(&self.screen, Screen::Login { .. }) {
476 self.selected_server_index =
477 servers.iter().position(|f| f == &self.login_info.server);
478 self.screen = Screen::Servers {
479 screen: servers::Screen::new(),
480 };
481 }
482 },
483 Message::ShowCredits => {
484 self.screen = Screen::Credits {
485 screen: credits::Screen::new(),
486 };
487 },
488 #[cfg(feature = "singleplayer")]
489 Message::Singleplayer => {
490 self.screen = Screen::WorldSelector {
491 screen: world_selector::Screen::default(),
492 };
493 events.push(Event::InitSingleplayer);
494 },
495 #[cfg(feature = "singleplayer")]
496 Message::SingleplayerPlay => {
497 self.screen = Screen::Connecting {
498 screen: connecting::Screen::new(ui),
499 connection_state: ConnectionState::InProgress,
500 init_stage: DetailedInitializationStage::Singleplayer,
501 };
502 events.push(Event::StartSingleplayer);
503 },
504 #[cfg(feature = "singleplayer")]
505 Message::WorldChanged(change) => {
506 match change {
507 WorldsChange::Delete(_) | WorldsChange::Regenerate(_) => {
508 if let Screen::WorldSelector {
509 screen: world_selector::Screen { confirmation, .. },
510 } = &mut self.screen
511 {
512 *confirmation = None;
513 }
514 },
515 _ => {},
516 }
517 events.push(Event::SinglePlayerChange(change))
518 },
519 #[cfg(feature = "singleplayer")]
520 Message::WorldCancelConfirmation => {
521 if let Screen::WorldSelector {
522 screen: world_selector::Screen { confirmation, .. },
523 } = &mut self.screen
524 {
525 *confirmation = None;
526 }
527 },
528 #[cfg(feature = "singleplayer")]
529 Message::WorldConfirmation(new_confirmation) => {
530 if let Screen::WorldSelector {
531 screen: world_selector::Screen { confirmation, .. },
532 } = &mut self.screen
533 {
534 *confirmation = Some(new_confirmation);
535 }
536 },
537 Message::Multiplayer => {
538 self.screen = Screen::Connecting {
539 screen: connecting::Screen::new(ui),
540 connection_state: ConnectionState::InProgress,
541 init_stage: DetailedInitializationStage::StartingMultiplayer,
542 };
543
544 events.push(Event::LoginAttempt {
545 username: self.login_info.username.trim().to_string(),
546 password: self.login_info.password.clone(),
547 server_address: self.login_info.server.trim().to_string(),
548 });
549 },
550 Message::UnlockServerField => self.server_field_locked = false,
551 Message::Username(new_value) => self.login_info.username = new_value,
552 Message::LanguageChanged(new_value) => {
553 events.push(Event::ChangeLanguage(language_metadatas.remove(new_value)));
554 },
555 Message::OpenLanguageMenu => self.show.toggle(Showing::Languages),
556 Message::Password(new_value) => self.login_info.password = new_value,
557 Message::Server(new_value) => {
558 self.login_info.server = new_value;
559 },
560 Message::ServerChanged(new_value) => {
561 self.selected_server_index = Some(new_value);
562 self.login_info.server.clone_from(&servers[new_value]);
563 },
564 Message::FocusPassword => {
565 if let Screen::Login { screen, .. } = &mut self.screen {
566 screen.banner.password = text_input::State::focused();
567 screen.banner.username = text_input::State::new();
568 }
569 },
570 Message::CancelConnect => {
571 self.exit_connect_screen();
572 events.push(Event::CancelLoginAttempt);
573 },
574 msg @ Message::TrustPromptAdd | msg @ Message::TrustPromptCancel => {
575 if let Screen::Connecting {
576 connection_state, ..
577 } = &mut self.screen
578 && let ConnectionState::AuthTrustPrompt { auth_server, .. } = connection_state
579 {
580 let auth_server = std::mem::take(auth_server);
581 let added = matches!(msg, Message::TrustPromptAdd);
582
583 *connection_state = ConnectionState::InProgress;
584 events.push(Event::AuthServerTrust(auth_server, added));
585 }
586 },
587 Message::CloseError => {
588 if let Screen::Login { error, .. } = &mut self.screen {
589 *error = None;
590 }
591 },
592 Message::DeleteServer => {
593 if let Some(server_index) = self.selected_server_index {
594 events.push(Event::DeleteServer { server_index });
595 self.selected_server_index = None;
596 }
597 },
598 }
609 }
610
611 fn exit_connect_screen(&mut self) {
613 if matches!(&self.screen, Screen::Connecting { .. }) {
614 self.screen = Screen::Login {
615 screen: Box::default(),
616 error: None,
617 }
618 }
619 }
620
621 fn auth_trust_prompt(&mut self, auth_server: String) {
622 if let Screen::Connecting {
623 connection_state, ..
624 } = &mut self.screen
625 {
626 let msg = format!(
627 "Warning: The server you are trying to connect to has provided this \
628 authentication server address:\n\n{}\n\nbut it is not in your list of trusted \
629 authentication servers.\n\nMake sure that you trust this site and owner to not \
630 try and bruteforce your password!",
631 &auth_server
632 );
633
634 *connection_state = ConnectionState::AuthTrustPrompt { auth_server, msg };
635 }
636 }
637
638 fn connection_error(&mut self, error: String) {
639 if matches!(&self.screen, Screen::Connecting { .. })
640 || matches!(&self.screen, Screen::Login { .. })
641 {
642 self.screen = Screen::Login {
643 screen: Box::default(),
644 error: Some(error),
645 }
646 } else {
647 warn!("connection_error invoked on unhandled screen!");
648 }
649 }
650
651 fn update_init_stage(&mut self, stage: DetailedInitializationStage) {
652 if let Screen::Connecting { init_stage, .. } = &mut self.screen {
653 *init_stage = stage
654 }
655 }
656
657 fn tab(&mut self) {
658 if let Screen::Login { screen, .. } = &mut self.screen {
659 if screen.banner.username.is_focused() {
661 screen.banner.username = text_input::State::new();
662 screen.banner.password = text_input::State::focused();
663 screen.banner.password.move_cursor_to_end();
664 } else if screen.banner.password.is_focused() {
665 screen.banner.password = text_input::State::new();
666 if self.server_field_locked {
668 screen.banner.username = text_input::State::focused();
669 } else {
670 screen.banner.server = text_input::State::focused();
671 }
672 screen.banner.server.move_cursor_to_end();
673 } else if screen.banner.server.is_focused() {
674 screen.banner.server = text_input::State::new();
675 screen.banner.username = text_input::State::focused();
676 screen.banner.username.move_cursor_to_end();
677 } else {
678 screen.banner.username = text_input::State::focused();
679 screen.banner.username.move_cursor_to_end();
680 }
681 }
682 }
683}
684
685pub struct MainMenuUi {
686 ui: Ui,
687 controls: Controls,
690 bg_img_spec: &'static str,
691}
692
693impl MainMenuUi {
694 pub fn new(global_state: &mut GlobalState) -> Self {
695 let i18n = &global_state.i18n.read();
697 let font = load_font(&i18n.fonts().get("cyri").unwrap().asset_key);
699
700 let mut ui = Ui::new(
701 &mut global_state.window,
702 font,
703 global_state.settings.interface.ui_scale,
704 )
705 .unwrap();
706
707 let fonts = Fonts::load(i18n.fonts(), &mut ui).expect("Impossible to load fonts");
708
709 let bg_img_spec = rand_bg_image_spec();
710
711 let bg_img = Image::load_expect(bg_img_spec).read().to_image();
712 let controls = Controls::new(
713 fonts,
714 Imgs::load(&mut ui).expect("Failed to load images"),
715 ui.add_graphic(Graphic::Image(bg_img, None)),
716 global_state.i18n,
717 &global_state.settings,
718 global_state.args.server.clone(),
719 );
720
721 Self {
722 ui,
723 controls,
724 bg_img_spec,
725 }
726 }
727
728 pub fn bg_img_spec(&self) -> &'static str { self.bg_img_spec }
729
730 pub fn update_language(&mut self, i18n: LocalizationHandle, settings: &Settings) {
731 self.controls.i18n = i18n;
732 let i18n = &i18n.read();
733 let font = load_font(&i18n.fonts().get("cyri").unwrap().asset_key);
734 self.ui.clear_fonts(font);
735 self.controls.fonts =
736 Fonts::load(i18n.fonts(), &mut self.ui).expect("Impossible to load fonts!");
737 let language_metadatas = i18n::list_localizations();
738 self.controls.selected_language_index = language_metadatas
739 .iter()
740 .position(|f| f.language_identifier == settings.language.selected_language);
741 }
742
743 pub fn auth_trust_prompt(&mut self, auth_server: String) {
744 self.controls.auth_trust_prompt(auth_server);
745 }
746
747 pub fn show_info(&mut self, msg: String) { self.controls.connection_error(msg); }
748
749 pub fn update_stage(&mut self, stage: DetailedInitializationStage) {
750 tracing::trace!(?stage, "Updating stage");
751 self.controls.update_init_stage(stage);
752 }
753
754 pub fn connected(&mut self) { self.controls.exit_connect_screen(); }
755
756 pub fn cancel_connection(&mut self) { self.controls.exit_connect_screen(); }
757
758 pub fn handle_event(&mut self, event: window::Event) -> bool {
759 match event {
760 window::Event::IcedUi(event) => {
762 self.handle_ui_event(event);
763 true
764 },
765 window::Event::ScaleFactorChanged(s) => {
766 self.ui.scale_factor_changed(s);
767 false
768 },
769 _ => false,
770 }
771 }
772
773 pub fn handle_ui_event(&mut self, event: ui::ice::Event) {
774 use iced::keyboard;
776 if matches!(
777 &event,
778 iced::Event::Keyboard(keyboard::Event::KeyPressed {
779 key_code: keyboard::KeyCode::Tab,
780 ..
781 })
782 ) {
783 self.controls.tab();
784 }
785
786 self.ui.handle_event(event);
787 }
788
789 pub fn set_scale_mode(&mut self, scale_mode: ui::ScaleMode) {
790 self.ui.set_scaling_mode(scale_mode);
791 }
792
793 pub fn maintain(&mut self, global_state: &mut GlobalState, dt: Duration) -> Vec<Event> {
794 let mut events = Vec::new();
795
796 #[cfg(feature = "singleplayer")]
797 let worlds_default = crate::singleplayer::SingleplayerWorlds::default();
798 #[cfg(feature = "singleplayer")]
799 let worlds = global_state
800 .singleplayer
801 .as_init()
802 .unwrap_or(&worlds_default);
803
804 let (messages, _) = self.ui.maintain(
805 self.controls.view(
806 &global_state.settings,
807 dt.as_secs_f32(),
808 #[cfg(feature = "singleplayer")]
809 worlds,
810 ),
811 global_state.window.renderer_mut(),
812 None,
813 &mut global_state.clipboard,
814 );
815
816 messages.into_iter().for_each(|message| {
817 self.controls
818 .update(message, &mut events, &global_state.settings, &mut self.ui)
819 });
820
821 events
822 }
823
824 pub fn render<'a>(&'a self, drawer: &mut UiDrawer<'_, 'a>) { self.ui.render(drawer); }
825}
826
827pub fn rand_bg_image_spec() -> &'static str { BG_IMGS.choose(&mut rng()).unwrap() }