//! Title screen: a turning orrery over a drifting starfield. //! //! 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::derelict::Derelict; use super::forge::Forge; use super::{Scene, Transition}; use crate::app::{Ctx, TICKS_PER_SECOND}; 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 { stars: Vec, planets: Vec, } 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(); Title { stars, planets } } 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); } } impl Scene for Title { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { match key.code { KeyCode::Char(c) => match c.to_ascii_lowercase() { 'd' => { ctx.boarded += 1; let ship = seed::derive(ctx.seed, "derelict", ctx.boarded); Transition::Push(Box::new(Derelict::new(ship))) } 'n' => Transition::Push(Box::new(Forge::new(ctx))), 'q' => Transition::Quit, _ => Transition::None, }, KeyCode::Esc => Transition::Quit, _ => Transition::None, } } 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 [sky, logo, _, menu, hold, footer] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(LOGO.len() as u16), Constraint::Length(1), Constraint::Length(4), Constraint::Length(1), 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); let item = |key: &'static str, label: &'static str, built: bool| { let style = if built { Style::new() } else { Style::new().fg(Color::DarkGray) }; let mut spans = vec![Span::styled(key, style.bold()), Span::styled(label, style)]; if !built { spans.push(Span::styled(" (not yet built)", style.italic())); } Line::from(spans) }; let [menu] = Layout::horizontal([Constraint::Length(46)]).flex(Flex::Center).areas(menu); frame.render_widget( Paragraph::new(vec![ item("[d] ", "board a derelict", true), item("[n] ", "the name forge", true), item("[f] ", "fly a star system", false), item("[q] ", "leave", true), ]), menu, ); let h = &ctx.hold; if !h.artifacts.is_empty() || h.scrip > 0 { let plural = if h.artifacts.len() == 1 { "" } else { "s" }; let text = format!( "your hold: {} artifact{plural}, {} ¤ of salvage", h.artifacts.len(), h.scrip ); frame.render_widget(Paragraph::new(text).centered().fg(Color::Yellow), hold); } let keys = if ctx.keyboard.key_release { "held keys detectable (kitty keyboard protocol)" } else { "held keys not detectable: flight will need a throttle" }; frame.render_widget( Paragraph::new(format!("universe {} · {keys}", ctx.seed)).centered().dark_gray(), footer, ); } }