//! Title screen: a turning orrery over a drifting starfield, and the way in: //! carry on as the character you last played, make a new one, or load //! another. //! //! Every universe seed gets its own orrery, and everything here moves as a //! function of `ctx.ticks`, so it doubles as a check that the tick loop runs. use std::f64::consts::TAU; use rand::RngExt; use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::symbols::Marker; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::widgets::canvas::{Canvas, Points}; use super::character::{Create, Sheet}; use super::derelict::Derelict; use super::flight; use super::forge::Forge; use super::keys::ACCENT; use super::saves::Saves; use super::{Scene, Transition}; use crate::app::{Ctx, TICKS_PER_SECOND}; use crate::save; use crate::seed; const LOGO: [&str; 4] = [ r" ___ ___ ___ ___ ___ __ __", r" / _ \ | _ \ | _ \ | __| | _ \ \ \ / /", r"| (_) | | / | / | _| | / \ V / ", r" \___/ |_|_\ |_|_\ |___| |_|_\ |_| ", ]; /// Star layers: drift speed in screen-widths per second, glyph, colour. /// Nearer layers move faster, which reads as depth. const STAR_LAYERS: [(f64, char, Color); 3] = [(0.004, '.', Color::DarkGray), (0.010, '·', Color::Gray), (0.025, '*', Color::White)]; /// The orrery is seen from above at an angle, so circular orbits look like /// ellipses this many times as tall as they are wide. const TILT: f64 = 0.4; const PLANET_COLOURS: [Color; 6] = [ Color::LightRed, Color::LightBlue, Color::LightGreen, Color::LightMagenta, Color::LightCyan, Color::Rgb(205, 133, 63), ]; struct Star { x: f64, y: f64, layer: usize, } struct Planet { /// Orbit radius as a fraction of the largest orbit that fits. orbit: f64, /// Seconds per revolution. period: f64, phase: f64, glyph: &'static str, colour: Color, } pub struct Title { /// Whose orrery this is. Loading or making a character can change /// universe under it. universe: u64, stars: Vec, planets: Vec, /// Whether there are saved characters to load. saved: bool, } impl Title { pub fn new(ctx: &Ctx) -> Self { let mut rng = seed::rng(seed::derive(ctx.seed, "title", 0)); let stars = (0..160) .map(|_| Star { x: rng.random(), y: rng.random(), layer: rng.random_range(0..STAR_LAYERS.len()), }) .collect(); let count = rng.random_range(3..=5); let planets = (0..count) .map(|i| { let orbit = (i as f64 + rng.random_range(0.6..1.0)) / count as f64; Planet { orbit, // Kepler's third law, roughly: outer planets are slower. period: 45.0 * orbit.powf(1.5), phase: rng.random_range(0.0..TAU), glyph: if rng.random_bool(0.5) { "o" } else { "•" }, colour: PLANET_COLOURS[rng.random_range(..PLANET_COLOURS.len())], } }) .collect(); let saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); Title { universe: ctx.seed, stars, planets, saved } } fn draw_stars(&self, buf: &mut Buffer, area: Rect, seconds: f64) { for star in &self.stars { let (speed, glyph, colour) = STAR_LAYERS[star.layer]; let x = (star.x - speed * seconds).rem_euclid(1.0); let col = area.x + (x * f64::from(area.width)) as u16; let row = area.y + (star.y * f64::from(area.height)) as u16; if let Some(cell) = buf.cell_mut((col, row)) { cell.set_char(glyph).set_fg(colour); } } } fn draw_orrery(&self, frame: &mut Frame, area: Rect, seconds: f64) { // Braille gives each cell 2x4 dots. With these bounds one unit is one // dot, and dots are about square, so geometry here needs no aspect // correction (unlike plain cells, which are twice as tall as wide). let half_w = f64::from(area.width); let half_h = 2.0 * f64::from(area.height); let max_radius = (half_w * 0.95).min(half_h * 0.9 / TILT); let canvas = Canvas::default() .marker(Marker::Braille) .x_bounds([-half_w, half_w]) .y_bounds([-half_h, half_h]) .paint(|c| { for planet in &self.planets { let r = planet.orbit * max_radius; let steps = (r * 2.0) as usize + 16; let ring: Vec<(f64, f64)> = (0..steps) .map(|i| { let a = TAU * i as f64 / steps as f64; (r * a.cos(), r * TILT * a.sin()) }) .collect(); c.draw(&Points { coords: &ring, color: Color::DarkGray }); } c.print(0.0, 0.0, Span::styled("☼", Style::new().fg(Color::Yellow).bold())); for planet in &self.planets { let r = planet.orbit * max_radius; let a = planet.phase + TAU * seconds / planet.period; let glyph = Span::styled(planet.glyph, Style::new().fg(planet.colour).bold()); c.print(r * a.cos(), r * TILT * a.sin(), glyph); } }); frame.render_widget(canvas, area); } } /// The menu: key, what it does. fn menu(ctx: &Ctx, saved: bool) -> Vec<(&'static str, String)> { let mut items = Vec::new(); if ctx.slot.is_some() { let c = &ctx.character; items.push(("enter", format!("carry on as {}, of {}", c.name, c.ship_name()))); items.push(("c", "your character".into())); } items.push(("n", "a new character".into())); if saved { items.push(("l", "load a character".into())); } if ctx.slot.is_some() { items.push(("d", "board a derelict, straight away".into())); } items.push(("g", "the name forge".into())); items.push(("q", "leave".into())); items } impl Scene for Title { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { let playing = ctx.slot.is_some(); match key.code { KeyCode::Enter if playing => Transition::Open(flight::resume(ctx)), KeyCode::Enter => Transition::Push(Box::new(Create::new(ctx))), KeyCode::Char(c) => match c.to_ascii_lowercase() { 'f' if playing => Transition::Open(flight::resume(ctx)), 'c' if playing => Transition::Push(Box::new(Sheet::new())), 'n' => Transition::Push(Box::new(Create::new(ctx))), 'l' if self.saved => Transition::Push(Box::new(Saves::new(ctx))), 'd' if playing => { ctx.boarded += 1; let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); Transition::Push(Box::new(Derelict::board(ship, ctx))) } 'g' => Transition::Push(Box::new(Forge::new(ctx))), 'q' => Transition::Quit, _ => Transition::None, }, KeyCode::Esc => Transition::Quit, _ => Transition::None, } } fn on_top(&mut self, ctx: &Ctx) { if ctx.seed != self.universe { *self = Title::new(ctx); } else { self.saved = ctx.saves.as_deref().is_some_and(|dir| !save::list(dir).is_empty()); } } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let seconds = ctx.ticks as f64 / f64::from(TICKS_PER_SECOND); let area = frame.area(); self.draw_stars(frame.buffer_mut(), area, seconds); let items = menu(ctx, self.saved); let [sky, logo, _, menu, hold, footer] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(LOGO.len() as u16), Constraint::Length(1), Constraint::Length(items.len() as u16), Constraint::Length(2), Constraint::Length(1), ]) .areas(area); self.draw_orrery(frame, sky, seconds); let logo_lines: Vec = LOGO.iter().map(|l| Line::from(*l).bold()).collect(); frame.render_widget(Paragraph::new(logo_lines).centered().fg(Color::Yellow), logo); // Keys in a column, in the colour key hints are everywhere. let keys = items.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0) + 2; let width = items.iter().map(|(_, l)| keys + l.chars().count()).max(); let [menu] = Layout::horizontal([Constraint::Length(width.unwrap_or(0) as u16)]) .flex(Flex::Center) .areas(menu); let lines: Vec = items .into_iter() .map(|(key, label)| { Line::from(vec![ Span::raw(format!("{key: format!("aboard {}", c.name), None => format!("at {}", ctx.system.places[ctx.location].name), }; let text = format!( "day {:.1} {place} in {} · {} artifact{plural}, {} t of ore, {} ¤", ctx.day, ctx.system.name, h.artifacts(), h.ore_tons(), h.scrip ); let [_, hold] = Layout::vertical([Constraint::Length(1); 2]).areas(hold); frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold); } let footer_text = match &ctx.save_error { Some(problem) => Paragraph::new(problem.as_str()).centered().light_red(), None => { let keys = if ctx.keyboard.key_release { "held keys detectable (kitty keyboard protocol)" } else { "held keys not detectable: flight will need a throttle" }; Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray() } }; frame.render_widget(footer_text, footer); } } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; use crate::scenes::tests::test_ctx; fn press(title: &mut Title, ctx: &mut Ctx, code: KeyCode) -> Transition { title.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } #[test] fn without_a_character_you_make_one() { let mut ctx = test_ctx(); let mut title = Title::new(&ctx); let keys: Vec<&str> = menu(&ctx, false).iter().map(|&(key, _)| key).collect(); assert_eq!(keys, ["n", "g", "q"]); assert!(matches!(press(&mut title, &mut ctx, KeyCode::Enter), Transition::Push(_))); assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('d')), Transition::None)); assert!(matches!(press(&mut title, &mut ctx, KeyCode::Char('l')), Transition::None)); } #[test] fn with_one_you_carry_on_where_they_were() { let mut ctx = test_ctx(); ctx.slot = Some("someone.json".into()); let mut title = Title::new(&ctx); let c = &ctx.character; let carry_on = format!("carry on as {}, of {}", c.name, c.ship_name()); assert_eq!(menu(&ctx, true)[0], ("enter", carry_on)); let Transition::Open(scenes) = press(&mut title, &mut ctx, KeyCode::Enter) else { panic!("enter carries on"); }; assert_eq!(scenes.len(), 1, "flying, as they weren't landed"); // Whoever comes back to the title may be from another universe. ctx.begin(99, ctx.character.clone()); title.on_top(&ctx); assert_eq!(title.universe, 99); } }