diff options
Diffstat (limited to 'src/scenes/title.rs')
| -rw-r--r-- | src/scenes/title.rs | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/src/scenes/title.rs b/src/scenes/title.rs new file mode 100644 index 0000000..7c75888 --- /dev/null +++ b/src/scenes/title.rs @@ -0,0 +1,224 @@ +//! 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<Star>, + planets: Vec<Planet>, +} + +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<Line> = 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, + ); + } +} |