feat(city): map viewport, camera, minimap, weather and traffic overlays
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
154
crates/city/src/render/minimap.rs
Normal file
154
crates/city/src/render/minimap.rs
Normal file
@@ -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<Pos>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
207
crates/city/src/render/traffic.rs
Normal file
207
crates/city/src/render/traffic.rs
Normal file
@@ -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<Car>,
|
||||
arterial: HashSet<Pos>,
|
||||
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<Pos> = 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);
|
||||
}
|
||||
}
|
||||
301
crates/city/src/render/viewport.rs
Normal file
301
crates/city/src/render/viewport.rs
Normal file
@@ -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<Visibility>>,
|
||||
pub overlays: &'a [&'a dyn Overlay],
|
||||
pub cursor: Option<Pos>,
|
||||
}
|
||||
|
||||
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<Pos, &Connector> = 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);
|
||||
}
|
||||
}
|
||||
99
crates/city/src/render/weather.rs
Normal file
99
crates/city/src/render/weather.rs
Normal file
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user