summaryrefslogtreecommitdiff
path: root/src/scenes/title.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/scenes/title.rs')
-rw-r--r--src/scenes/title.rs224
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,
+ );
+ }
+}