diff --git a/crates/city/src/render/minimap.rs b/crates/city/src/render/minimap.rs new file mode 100644 index 0000000..9307838 --- /dev/null +++ b/crates/city/src/render/minimap.rs @@ -0,0 +1,154 @@ +//! A downscaled district map with the viewport rectangle and the player. + +use crate::render::Rgb; +use crate::render::style::StylePack; +use citygen::{City, Pos, Rect as TileRect, StratumKind}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +pub struct Minimap<'a> { + pub city: &'a City, + pub stratum: StratumKind, + /// The main viewport, in tiles. + pub view: TileRect, + pub player: Option, + pub style: &'a StylePack, +} + +impl Widget for Minimap<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let Some(st) = self.city.stratum(self.stratum) else { + return; + }; + let size = self.city.size; + let scale = (size.w as f32 / area.width as f32) + .max(size.h as f32 / area.height as f32) + .max(1.0) + .ceil() as i32; + let to_cell = |p: Pos| -> Option<(u16, u16)> { + let cx = p.x / scale; + let cy = p.y / scale; + if cx >= 0 && cy >= 0 && cx < area.width as i32 && cy < area.height as i32 { + Some((area.x + cx as u16, area.y + cy as u16)) + } else { + None + } + }; + for cy in 0..area.height { + for cx in 0..area.width { + let block = TileRect::new(cx as i32 * scale, cy as i32 * scale, scale, scale); + let mut walk = 0usize; + let mut total = 0usize; + let mut theme = None; + let mut water = 0usize; + for p in block.iter() { + let Some(t) = st.tiles.get(p) else { continue }; + total += 1; + if t.is_walkable() { + walk += 1; + } + if t.terrain == citygen::Terrain::Water { + water += 1; + } + if theme.is_none() { + theme = t.district.map(|d| self.city.district(d).theme); + } + } + let cell = &mut buf[(area.x + cx, area.y + cy)]; + if total == 0 { + cell.set_char(' ').set_bg(self.style.background.to_color()); + continue; + } + let bg = if water * 2 > total { + Rgb(10, 24, 50) + } else { + let accent = self.style.theme_or_neutral(theme).accent; + let f = 0.15 + 0.45 * (walk as f32 / total as f32); + accent.scale(f) + }; + cell.set_char(' ').set_bg(bg.to_color()); + } + } + // Viewport rectangle. + let v = self.view; + let corners = [ + (Pos::new(v.x, v.y), '┌'), + (Pos::new(v.right() - 1, v.y), '┐'), + (Pos::new(v.x, v.bottom() - 1), '└'), + (Pos::new(v.right() - 1, v.bottom() - 1), '┘'), + ]; + let frame = Color::Rgb(230, 230, 240); + for x in v.x..v.right() { + for y in [v.y, v.bottom() - 1] { + if let Some((sx, sy)) = to_cell(Pos::new(x, y)) { + buf[(sx, sy)].set_char('─').set_fg(frame); + } + } + } + for y in v.y..v.bottom() { + for x in [v.x, v.right() - 1] { + if let Some((sx, sy)) = to_cell(Pos::new(x, y)) { + buf[(sx, sy)].set_char('│').set_fg(frame); + } + } + } + for (p, ch) in corners { + if let Some((sx, sy)) = to_cell(p) { + buf[(sx, sy)].set_char(ch).set_fg(frame); + } + } + if let Some(p) = self.player + && let Some((sx, sy)) = to_cell(p) + { + buf[(sx, sy)] + .set_char('@') + .set_fg(Color::Rgb(255, 255, 255)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::style::StylePack; + use citygen::{GenParams, Generator, Pos, Rect, SizePreset, StratumKind}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::widgets::Widget; + + #[test] + fn minimap_fits_any_area_and_marks_player() { + let c = Generator::run(GenParams { + seed: 3, + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }) + .unwrap(); + let s = StylePack::load().unwrap(); + for (w, h) in [(1u16, 1u16), (24, 12), (80, 40)] { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(|f| { + Minimap { + city: &c, + stratum: StratumKind::Street, + view: Rect::new(10, 10, 30, 15), + player: Some(Pos::new(20, 20)), + style: &s, + } + .render(f.area(), f.buffer_mut()) + }) + .unwrap(); + if (w, h) == (80, 40) { + let b = t.backend().buffer(); + let found = (0..40).any(|y| (0..80).any(|x| b[(x, y)].symbol() == "@")); + assert!(found); + } + } + } +} diff --git a/crates/city/src/render/mod.rs b/crates/city/src/render/mod.rs index ed6ad0f..1fbadd5 100644 --- a/crates/city/src/render/mod.rs +++ b/crates/city/src/render/mod.rs @@ -1,8 +1,12 @@ //! Rendering: colours, cells, the render context and the tileset. pub mod lighting; +pub mod minimap; pub mod style; pub mod tileset; +pub mod traffic; +pub mod viewport; +pub mod weather; use citygen::{BuildingKind, ConnectorKind, Pos, StratumKind, Theme}; use ratatui::buffer::Buffer; diff --git a/crates/city/src/render/traffic.rs b/crates/city/src/render/traffic.rs new file mode 100644 index 0000000..872dc41 --- /dev/null +++ b/crates/city/src/render/traffic.rs @@ -0,0 +1,207 @@ +//! Ambient traffic on the arterials: purely visual, no simulation beyond position. + +use crate::render::style::StylePack; +use crate::render::viewport::{Overlay, tile_to_screen}; +use citygen::rng::hash64; +use citygen::{City, Dir, Hue, Pos, Rect as TileRect, StratumKind, Terrain}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use std::collections::HashSet; + +pub const MAX_CARS: usize = 24; +const MAX_AGE: u32 = 600; + +#[derive(Clone, Copy, Debug)] +pub struct Car { + pub pos: Pos, + pub dir: Dir, + pub hue: Hue, + pub age: u32, +} + +#[derive(Default)] +pub struct Traffic { + pub cars: Vec, + arterial: HashSet, + arterial_key: Option<(u64, u32, u32)>, +} + +impl Traffic { + fn ensure_arterials(&mut self, city: &City) { + let key = (city.seed, city.size.w, city.size.h); + if self.arterial_key == Some(key) { + return; + } + self.arterial.clear(); + for path in &city.arterials { + for p in path { + for q in TileRect::new(p.x - 1, p.y - 1, 4, 4).iter() { + self.arterial.insert(q); + } + } + } + self.arterial_key = Some(key); + self.cars.clear(); + } + + fn drivable(&self, city: &City, p: Pos) -> bool { + self.arterial.contains(&p) + && city + .tile(StratumKind::Street, p) + .is_some_and(|t| t.terrain == Terrain::Road && t.feature.is_none()) + } + + /// Advances cars and spawns new ones at the edge of `view` (in tiles). Only the Street + /// has traffic. + pub fn update(&mut self, city: &City, stratum: StratumKind, view: TileRect, tick: u64) { + if stratum != StratumKind::Street { + return; + } + self.ensure_arterials(city); + if tick.is_multiple_of(2) { + let cars = std::mem::take(&mut self.cars); + let mut kept = Vec::with_capacity(cars.len()); + for mut car in cars { + car.age += 1; + if car.age > MAX_AGE { + continue; + } + let ahead = car.pos.step(car.dir); + if self.drivable(city, ahead) { + car.pos = ahead; + kept.push(car); + continue; + } + let turns = [turn_left(car.dir), turn_right(car.dir), car.dir.opposite()]; + if let Some(d) = turns + .into_iter() + .find(|d| self.drivable(city, car.pos.step(*d))) + { + car.dir = d; + car.pos = car.pos.step(d); + kept.push(car); + } + } + self.cars = kept; + } + if tick.is_multiple_of(10) && self.cars.len() < MAX_CARS { + let edge: Vec = view + .border() + .filter(|p| self.drivable(city, *p)) + .filter(|p| self.cars.iter().all(|c| c.pos != *p)) + .collect(); + let h = hash64(tick, city.seed); + // No arterial crosses the view edge: pick a drivable tile inside instead. + let inside = (0..64).find_map(|i| { + let hh = hash64(h, i); + let p = Pos::new( + view.x + (hh % view.w.max(1) as u64) as i32, + view.y + ((hh >> 20) % view.h.max(1) as u64) as i32, + ); + (self.drivable(city, p) && self.cars.iter().all(|c| c.pos != p)).then_some(p) + }); + let pick = if edge.is_empty() { + inside + } else { + Some(edge[(h % edge.len() as u64) as usize]) + }; + if let Some(pos) = pick { + let order = [Dir::N, Dir::E, Dir::S, Dir::W]; + let start = ((h >> 8) % 4) as usize; + let dir = (0..4) + .map(|i| order[(start + i) % 4]) + .find(|d| self.drivable(city, pos.step(*d))); + if let Some(dir) = dir { + let hues = [Hue::White, Hue::Yellow, Hue::Red, Hue::Cyan]; + self.cars.push(Car { + pos, + dir, + hue: hues[((h >> 16) % 4) as usize], + age: 0, + }); + } + } + } + } +} + +fn turn_left(d: Dir) -> Dir { + match d { + Dir::N => Dir::W, + Dir::W => Dir::S, + Dir::S => Dir::E, + Dir::E => Dir::N, + other => other, + } +} + +fn turn_right(d: Dir) -> Dir { + match d { + Dir::N => Dir::E, + Dir::E => Dir::S, + Dir::S => Dir::W, + Dir::W => Dir::N, + other => other, + } +} + +impl Overlay for Traffic { + fn draw( + &self, + _city: &City, + stratum: StratumKind, + origin: Pos, + area: Rect, + _tick: u64, + buf: &mut Buffer, + style: &StylePack, + ) { + if stratum != StratumKind::Street { + return; + } + for car in &self.cars { + let color = style.hue(car.hue); + if let Some((sx, sy)) = tile_to_screen(area, origin, car.pos.step(car.dir)) { + let c = &mut buf[(sx, sy)]; + let bg = match c.bg { + ratatui::style::Color::Rgb(r, g, b) => crate::render::Rgb(r, g, b), + _ => crate::render::Rgb(0, 0, 0), + }; + c.set_bg(bg.mix(color, 0.3).to_color()); + } + if let Some((sx, sy)) = tile_to_screen(area, origin, car.pos) { + buf[(sx, sy)].set_char('▬').set_fg(color.to_color()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use citygen::{GenParams, Generator, Rect, SizePreset, StratumKind, Terrain}; + + #[test] + fn traffic_stays_on_roads() { + let c = Generator::run(GenParams { + seed: 3, + size: SizePreset::Custom { w: 120, h: 60 }, + districts: 5, + ..Default::default() + }) + .unwrap(); + let mut tr = Traffic::default(); + for tick in 0..400 { + tr.update(&c, StratumKind::Street, Rect::new(0, 0, 120, 60), tick); + } + assert!(!tr.cars.is_empty()); + assert!(tr.cars.len() <= MAX_CARS); + for car in &tr.cars { + assert_eq!( + c.tile(StratumKind::Street, car.pos).unwrap().terrain, + Terrain::Road + ); + } + tr.update(&c, StratumKind::Skyways, Rect::new(0, 0, 120, 60), 401); + } +} diff --git a/crates/city/src/render/viewport.rs b/crates/city/src/render/viewport.rs new file mode 100644 index 0000000..ec9a2c6 --- /dev/null +++ b/crates/city/src/render/viewport.rs @@ -0,0 +1,301 @@ +//! The map viewport: camera, screen/tile mapping and the MapView widget with overlays. + +use crate::render::lighting::apply_vis; +use crate::render::style::StylePack; +use crate::render::tileset::{MASK_E, MASK_N, MASK_S, MASK_W, cell, connects}; +use crate::render::{Cell, ConnDir, RenderCtx, Visibility}; +use citygen::{City, Connector, Grid, Pos, Size, StratumKind}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; +use std::collections::HashMap; + +/// Eased camera centred on a tile. +#[derive(Clone, Copy, Debug, Default)] +pub struct Camera { + pub x: f32, + pub y: f32, + pub tx: i32, + pub ty: i32, +} + +impl Camera { + pub fn snap(&mut self, p: Pos) { + self.tx = p.x; + self.ty = p.y; + self.x = p.x as f32; + self.y = p.y as f32; + } + + pub fn target(&mut self, p: Pos) { + self.tx = p.x; + self.ty = p.y; + } + + pub fn pan(&mut self, dx: i32, dy: i32) { + self.tx += dx; + self.ty += dy; + } + + /// Moves the target only when `p` leaves the middle third of the view. + pub fn follow(&mut self, p: Pos, view_w: u16, view_h: u16) { + let zx = (view_w as i32 / 6).max(1); + let zy = (view_h as i32 / 6).max(1); + if p.x < self.tx - zx { + self.tx = p.x + zx; + } else if p.x > self.tx + zx { + self.tx = p.x - zx; + } + if p.y < self.ty - zy { + self.ty = p.y + zy; + } else if p.y > self.ty + zy { + self.ty = p.y - zy; + } + } + + /// Eases a quarter of the way toward the target each tick. + pub fn update(&mut self) { + self.x += (self.tx as f32 - self.x) * 0.25; + self.y += (self.ty as f32 - self.y) * 0.25; + if (self.tx as f32 - self.x).abs() < 0.05 { + self.x = self.tx as f32; + } + if (self.ty as f32 - self.y).abs() < 0.05 { + self.y = self.ty as f32; + } + } + + /// Keeps the target inside the map, with the view never scrolling past the edge. + pub fn clamp(&mut self, size: Size, view_w: u16, view_h: u16) { + let half_w = view_w as i32 / 2; + let half_h = view_h as i32 / 2; + let w = size.w as i32; + let h = size.h as i32; + self.tx = if w > view_w as i32 { + self.tx.clamp(half_w, w - half_w) + } else { + w / 2 + }; + self.ty = if h > view_h as i32 { + self.ty.clamp(half_h, h - half_h) + } else { + h / 2 + }; + } + + /// Top-left tile shown in a view of the given size. + pub fn origin(&self, view_w: u16, view_h: u16) -> Pos { + Pos::new( + self.x.round() as i32 - view_w as i32 / 2, + self.y.round() as i32 - view_h as i32 / 2, + ) + } +} + +pub fn screen_to_tile(area: Rect, origin: Pos, (sx, sy): (u16, u16)) -> Pos { + Pos::new( + origin.x + (sx as i32 - area.x as i32), + origin.y + (sy as i32 - area.y as i32), + ) +} + +pub fn tile_to_screen(area: Rect, origin: Pos, p: Pos) -> Option<(u16, u16)> { + let sx = p.x - origin.x + area.x as i32; + let sy = p.y - origin.y + area.y as i32; + let inside = sx >= area.x as i32 + && sy >= area.y as i32 + && sx < area.right() as i32 + && sy < area.bottom() as i32; + inside.then_some((sx as u16, sy as u16)) +} + +/// Something drawn over the base tiles. +pub trait Overlay { + #[allow(clippy::too_many_arguments)] + fn draw( + &self, + city: &City, + stratum: StratumKind, + origin: Pos, + area: Rect, + tick: u64, + buf: &mut Buffer, + style: &StylePack, + ); +} + +/// Draws one stratum of a city into an area, then the overlays, then the cursor. +pub struct MapView<'a> { + pub city: &'a City, + pub stratum: StratumKind, + pub origin: Pos, + pub tick: u64, + pub style: &'a StylePack, + pub vis: Option<&'a Grid>, + pub overlays: &'a [&'a dyn Overlay], + pub cursor: Option, +} + +impl Widget for MapView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let Some(st) = self.city.stratum(self.stratum) else { + return; + }; + let background = Cell { + ch: ' ', + fg: self.style.background, + bg: self.style.background, + bold: false, + dim: false, + }; + let conns: HashMap = self + .city + .connectors + .iter() + .filter(|c| c.serves.contains(&self.stratum)) + .map(|c| (c.pos, c)) + .collect(); + for sy in area.y..area.bottom() { + for sx in area.x..area.right() { + let p = screen_to_tile(area, self.origin, (sx, sy)); + let Some(tile) = st.tiles.get(p) else { + background.write(buf, sx, sy); + continue; + }; + let mut mask = 0u8; + for (d, bit) in [ + ((0, -1), MASK_N), + ((1, 0), MASK_E), + ((0, 1), MASK_S), + ((-1, 0), MASK_W), + ] { + if let Some(n) = st.tiles.get(p.offset(d.0, d.1)) + && connects(tile.terrain, n.terrain) + { + mask |= bit; + } + } + let connector = conns.get(&p).and_then(|c| { + c.serves_from(self.stratum).map(|(below, above)| ConnDir { + kind: c.kind, + up: above.is_some(), + down: below.is_some(), + }) + }); + let vis = self + .vis + .and_then(|g| g.get(p).copied()) + .unwrap_or(Visibility::Visible); + let ctx = RenderCtx { + style: self.style, + theme: tile.district.map(|d| self.city.district(d).theme), + stratum: self.stratum, + light: st.light.get(p).copied().unwrap_or(0), + tick: self.tick, + pos: p, + vis, + connector, + kind: tile.building.map(|b| self.city.building(b).kind), + wall_mask: mask, + }; + apply_vis(cell(tile, &ctx), vis).write(buf, sx, sy); + } + } + for o in self.overlays { + o.draw( + self.city, + self.stratum, + self.origin, + area, + self.tick, + buf, + self.style, + ); + } + if let Some(c) = self.cursor + && let Some((sx, sy)) = tile_to_screen(area, self.origin, c) + { + let cur = &mut buf[(sx, sy)]; + let fg = cur.fg; + let bg = cur.bg; + cur.set_fg(bg).set_bg(fg); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::style::StylePack; + use crate::render::weather::Weather; + use citygen::{GenParams, Generator, Pos, SizePreset, StratumKind}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::widgets::Widget; + + #[test] + fn mapview_clips_at_all_edges_and_tiny_areas() { + let c = Generator::run(GenParams { + seed: 3, + size: SizePreset::Custom { w: 80, h: 40 }, + districts: 4, + ..Default::default() + }) + .unwrap(); + let s = StylePack::load().unwrap(); + for origin in [ + Pos::new(-50, -50), + Pos::new(70, 30), + Pos::new(0, 0), + Pos::new(200, 200), + ] { + for (w, h) in [(1u16, 1u16), (120, 40), (10, 3)] { + let mut t = Terminal::new(TestBackend::new(w, h)).unwrap(); + t.draw(|f| { + MapView { + city: &c, + stratum: StratumKind::Street, + origin, + tick: 5, + style: &s, + vis: None, + overlays: &[&Weather], + cursor: Some(origin), + } + .render(f.area(), f.buffer_mut()) + }) + .unwrap(); + } + } + let area = ratatui::layout::Rect::new(2, 3, 50, 20); + assert_eq!( + screen_to_tile(area, Pos::new(10, 10), (2, 3)), + Pos::new(10, 10) + ); + assert_eq!( + tile_to_screen(area, Pos::new(10, 10), Pos::new(12, 11)), + Some((4, 4)) + ); + assert_eq!( + tile_to_screen(area, Pos::new(10, 10), Pos::new(9, 11)), + None + ); + } + + #[test] + fn camera_follow_has_deadzone_and_eases() { + let mut cam = Camera::default(); + cam.snap(Pos::new(50, 50)); + cam.follow(Pos::new(52, 50), 60, 30); + assert_eq!((cam.tx, cam.ty), (50, 50)); + cam.follow(Pos::new(90, 50), 60, 30); + assert!(cam.tx > 50); + let before = cam.x; + cam.update(); + assert!(cam.x > before && cam.x < cam.tx as f32); + cam.clamp(citygen::Size { w: 100, h: 40 }, 60, 30); + assert!(cam.tx <= 70 && cam.ty <= 25); + let o = cam.origin(60, 30); + assert_eq!(o.x, cam.x.round() as i32 - 30); + } +} diff --git a/crates/city/src/render/weather.rs b/crates/city/src/render/weather.rs new file mode 100644 index 0000000..10afebd --- /dev/null +++ b/crates/city/src/render/weather.rs @@ -0,0 +1,99 @@ +//! Rain and fog overlays, pure functions of tick and screen position. + +use crate::render::style::StylePack; +use crate::render::viewport::{Overlay, screen_to_tile}; +use citygen::rng::hash64; +use citygen::{City, StratumKind, Terrain}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; + +/// Rain on the current stratum and fog where the style asks for it. +pub struct Weather; + +/// Whether a raindrop occupies screen cell `(sx, sy)` at `tick`. `div` is the density +/// divisor (0 = no rain). Drops fall one cell per tick and drift right every four ticks. +pub fn drop_at(sx: u16, sy: u16, tick: u64, div: u64) -> bool { + if div == 0 { + return false; + } + let drift = (tick / 4) as i64; + let column = sx as i64 - drift; + let seed_row = sy as i64 - tick as i64; + hash64(column as u64, seed_row as u64).is_multiple_of(div) +} + +fn density_div(level: u8) -> u64 { + match level { + 0 => 0, + 1 => 40, + 2 => 20, + _ => 10, + } +} + +impl Overlay for Weather { + fn draw( + &self, + city: &City, + stratum: StratumKind, + origin: citygen::Pos, + area: Rect, + tick: u64, + buf: &mut Buffer, + style: &StylePack, + ) { + let ss = style.stratum(stratum); + let div = density_div(ss.rain); + let Some(st) = city.stratum(stratum) else { + return; + }; + let rain = &style.glyphs.rain; + for sy in area.y..area.bottom() { + for sx in area.x..area.right() { + let p = screen_to_tile(area, origin, (sx, sy)); + let Some(t) = st.tiles.get(p) else { continue }; + let outdoors = !matches!( + t.terrain, + Terrain::Wall + | Terrain::Floor + | Terrain::Door + | Terrain::Window + | Terrain::Stair + | Terrain::Lift + ); + if div > 0 && outdoors && drop_at(sx, sy, tick, div) { + let g = + rain[(hash64(sx as u64, sy as u64 + tick) % rain.len() as u64) as usize]; + buf[(sx, sy)].set_char(g).set_fg(Color::Rgb(90, 115, 160)); + continue; + } + if ss.fog { + let light = st.light.get(p).copied().unwrap_or(0); + if light < 40 && hash64(sx as u64 * 31 + sy as u64, tick / 8).is_multiple_of(6) + { + buf[(sx, sy)].set_char('░').set_fg(Color::Rgb(40, 62, 46)); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rain_falls_one_cell_per_tick() { + // A drop at (x, y) on tick t is at (x + drift, y + 1) on tick t + 1 when drift is 0. + for t in 0..50u64 { + // Drift happens when (t + 1) crosses a multiple of four. + if !(t + 1).is_multiple_of(4) { + assert_eq!(drop_at(10, 5, t, 10), drop_at(10, 6, t + 1, 10)); + } + } + assert!((0..400).any(|t| drop_at(3, 3, t, 10))); + assert!(!(0..400).any(|t| drop_at(3, 3, t, 0))); + } +}