diff --git a/crates/city/src/app.rs b/crates/city/src/app.rs index 214b109..de12c3f 100644 --- a/crates/city/src/app.rs +++ b/crates/city/src/app.rs @@ -1,5 +1,6 @@ //! Application state, the screen trait and the fixed-rate frame loop. +use crate::render::style::StylePack; use crate::term::{TerminalGuard, install_panic_hook}; use anyhow::Result; use citygen::{City, GenParams}; @@ -19,6 +20,7 @@ pub const FRAME: Duration = Duration::from_micros(33_333); pub struct AppState { pub params: GenParams, pub city: Option, + pub style: StylePack, pub tick: u64, status: Option<(String, u64)>, } @@ -34,6 +36,7 @@ impl AppState { AppState { params: GenParams::default(), city: None, + style: StylePack::load().expect("embedded style pack is valid"), tick: 0, status: None, } diff --git a/crates/city/src/main.rs b/crates/city/src/main.rs index f9bed1f..022e279 100644 --- a/crates/city/src/main.rs +++ b/crates/city/src/main.rs @@ -6,6 +6,7 @@ mod anim; mod app; mod cli; +mod render; mod save; mod screens; mod term; diff --git a/crates/city/src/render/lighting.rs b/crates/city/src/render/lighting.rs new file mode 100644 index 0000000..8ce6144 --- /dev/null +++ b/crates/city/src/render/lighting.rs @@ -0,0 +1,51 @@ +//! Light and visibility applied to cells. + +use crate::render::{Cell, Rgb, Visibility}; + +/// Darkens a colour by the tile's light level; never below a quarter so the map stays legible. +pub fn lit(c: Rgb, light: u8) -> Rgb { + c.scale(0.25 + 0.75 * (light as f32) / 255.0) +} + +/// Remembered tiles are dim and static; unseen tiles are black. +pub fn apply_vis(cell: Cell, vis: Visibility) -> Cell { + match vis { + Visibility::Visible => cell, + Visibility::Remembered => Cell { + fg: cell.fg.scale(0.35), + bg: cell.bg.scale(0.35), + bold: false, + dim: true, + ..cell + }, + Visibility::Unseen => Cell { + ch: ' ', + fg: Rgb(0, 0, 0), + bg: Rgb(0, 0, 0), + bold: false, + dim: false, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::{Cell, Rgb}; + + #[test] + fn lit_scales_between_quarter_and_full() { + assert_eq!(lit(Rgb(200, 100, 40), 255), Rgb(200, 100, 40)); + assert_eq!(lit(Rgb(200, 100, 40), 0), Rgb(50, 25, 10)); + let c = Cell { + ch: 'x', + fg: Rgb(200, 200, 200), + bg: Rgb(20, 20, 20), + bold: true, + dim: false, + }; + let r = apply_vis(c, Visibility::Remembered); + assert!(r.fg.0 < 100 && r.dim && !r.bold); + assert_eq!(apply_vis(c, Visibility::Visible), c); + } +} diff --git a/crates/city/src/render/mod.rs b/crates/city/src/render/mod.rs new file mode 100644 index 0000000..ed6ad0f --- /dev/null +++ b/crates/city/src/render/mod.rs @@ -0,0 +1,112 @@ +//! Rendering: colours, cells, the render context and the tileset. + +pub mod lighting; +pub mod style; +pub mod tileset; + +use citygen::{BuildingKind, ConnectorKind, Pos, StratumKind, Theme}; +use ratatui::buffer::Buffer; +use ratatui::style::{Color, Modifier, Style}; +use serde::{Deserialize, Serialize}; +use style::StylePack; + +/// A truecolor value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + pub fn to_color(self) -> Color { + Color::Rgb(self.0, self.1, self.2) + } + + /// Multiplies every channel, saturating at 255. + pub fn scale(self, f: f32) -> Rgb { + let s = |c: u8| ((c as f32) * f).round().clamp(0.0, 255.0) as u8; + Rgb(s(self.0), s(self.1), s(self.2)) + } + + /// Linear blend toward `other` by `t` in 0..=1. + pub fn mix(self, other: Rgb, t: f32) -> Rgb { + let t = t.clamp(0.0, 1.0); + let m = |a: u8, b: u8| ((a as f32) + ((b as f32) - (a as f32)) * t).round() as u8; + Rgb(m(self.0, other.0), m(self.1, other.1), m(self.2, other.2)) + } + + /// Adds a signed offset per channel, clamped. + pub fn tint(self, dr: i16, dg: i16, db: i16) -> Rgb { + let t = |c: u8, d: i16| (c as i16 + d).clamp(0, 255) as u8; + Rgb(t(self.0, dr), t(self.1, dg), t(self.2, db)) + } +} + +/// One rendered terminal cell. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cell { + pub ch: char, + pub fg: Rgb, + pub bg: Rgb, + pub bold: bool, + pub dim: bool, +} + +impl Cell { + pub fn write(&self, buf: &mut Buffer, x: u16, y: u16) { + if x < buf.area.x || y < buf.area.y || x >= buf.area.right() || y >= buf.area.bottom() { + return; + } + let mut style = Style::default() + .fg(self.fg.to_color()) + .bg(self.bg.to_color()); + if self.bold { + style = style.add_modifier(Modifier::BOLD); + } + if self.dim { + style = style.add_modifier(Modifier::DIM); + } + buf[(x, y)].set_char(self.ch).set_style(style); + } +} + +/// What the walker knows about a tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Visibility { + Unseen, + Remembered, + Visible, +} + +/// A connector under the tile and which way it leads from the current stratum. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConnDir { + pub kind: ConnectorKind, + pub up: bool, + pub down: bool, +} + +/// Everything `tileset::cell` needs besides the tile itself. +pub struct RenderCtx<'a> { + pub style: &'a StylePack, + pub theme: Option, + pub stratum: StratumKind, + pub light: u8, + pub tick: u64, + pub pos: Pos, + pub vis: Visibility, + pub connector: Option, + pub kind: Option, + /// Bits N=1, E=2, S=4, W=8: neighbours that connect to this tile (see `tileset::connects`). + pub wall_mask: u8, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rgb_math() { + assert_eq!(Rgb(100, 200, 50).scale(0.5), Rgb(50, 100, 25)); + assert_eq!(Rgb(100, 100, 100).scale(10.0), Rgb(255, 255, 255)); + assert_eq!(Rgb(0, 0, 0).mix(Rgb(100, 200, 50), 0.5), Rgb(50, 100, 25)); + assert_eq!(Rgb(10, 10, 10).tint(-20, 5, 300), Rgb(0, 15, 255)); + } +} diff --git a/crates/city/src/render/style.rs b/crates/city/src/render/style.rs new file mode 100644 index 0000000..0097ac8 --- /dev/null +++ b/crates/city/src/render/style.rs @@ -0,0 +1,122 @@ +//! The style pack: palettes and glyph sets loaded from `flavor/style.ron`. + +use crate::render::Rgb; +use anyhow::{Context, Result}; +use citygen::{Hue, StratumKind, Theme}; +use serde::{Deserialize, Serialize}; + +const DEFAULT_STYLE: &str = include_str!("../../../../flavor/style.ron"); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ThemeStyle { + pub accent: Rgb, + pub accent2: Rgb, + pub wall: Rgb, + pub floor: Rgb, + pub road: Rgb, + pub sidewalk: Rgb, + pub roof: Rgb, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StratumStyle { + pub tint: (i16, i16, i16), + pub fog: bool, + /// 0 none, 1 light, 2 medium, 3 heavy. + pub rain: u8, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GlyphSets { + pub holo: Vec, + pub rain: Vec, + pub water: Vec, + pub steam: Vec, + pub mass: Vec, + pub net: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StylePack { + pub background: Rgb, + pub neutral: ThemeStyle, + pub themes: Vec<(Theme, ThemeStyle)>, + pub strata: Vec<(StratumKind, StratumStyle)>, + pub hues: Vec<(Hue, Rgb)>, + pub glyphs: GlyphSets, +} + +impl StylePack { + /// Loads the embedded default pack. + pub fn load() -> Result { + let pack: StylePack = ron::from_str(DEFAULT_STYLE).context("parsing flavor/style.ron")?; + for set in [ + &pack.glyphs.holo, + &pack.glyphs.rain, + &pack.glyphs.water, + &pack.glyphs.steam, + &pack.glyphs.mass, + &pack.glyphs.net, + ] { + anyhow::ensure!(!set.is_empty(), "style.ron: a glyph set is empty"); + } + Ok(pack) + } + + pub fn theme(&self, t: Theme) -> &ThemeStyle { + self.themes + .iter() + .find(|(k, _)| *k == t) + .map(|(_, s)| s) + .unwrap_or(&self.neutral) + } + + pub fn theme_or_neutral(&self, t: Option) -> &ThemeStyle { + match t { + Some(t) => self.theme(t), + None => &self.neutral, + } + } + + pub fn stratum(&self, k: StratumKind) -> &StratumStyle { + static FALLBACK: StratumStyle = StratumStyle { + tint: (0, 0, 0), + fog: false, + rain: 0, + }; + self.strata + .iter() + .find(|(s, _)| *s == k) + .map(|(_, s)| s) + .unwrap_or(&FALLBACK) + } + + pub fn hue(&self, h: Hue) -> Rgb { + self.hues + .iter() + .find(|(k, _)| *k == h) + .map(|(_, c)| *c) + .unwrap_or(Rgb(255, 255, 255)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use citygen::{Hue, StratumKind, Theme}; + + #[test] + fn style_pack_loads_and_covers_every_theme() { + let s = StylePack::load().unwrap(); + for t in Theme::ALL { + let _ = s.theme(t); + } + for k in StratumKind::PHYSICAL { + let _ = s.stratum(k); + } + assert_ne!(s.hue(Hue::Magenta), s.hue(Hue::Cyan)); + assert!(s.glyphs.holo.len() >= 8 && s.glyphs.rain.len() >= 4); + assert_eq!(s.stratum(StratumKind::Undercity).rain, 0); + assert!(s.stratum(StratumKind::Heights).rain >= 3); + } +} diff --git a/crates/city/src/render/tileset.rs b/crates/city/src/render/tileset.rs new file mode 100644 index 0000000..c5705cb --- /dev/null +++ b/crates/city/src/render/tileset.rs @@ -0,0 +1,429 @@ +//! The tileset: one pure function from a tile and its context to a styled cell. + +use crate::anim::effects::{flicker, pulse}; +use crate::render::lighting::lit; +use crate::render::style::StylePack; +use crate::render::{Cell, ConnDir, RenderCtx, Rgb, Visibility}; +use citygen::{BuildingKind, ConnectorKind, Feature, StratumKind, Terrain, Tile}; + +const LIGHT: [char; 16] = [ + '─', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼', +]; +const HEAVY: [char; 16] = [ + '━', '┃', '━', '┗', '┃', '┃', '┏', '┣', '━', '┛', '━', '┻', '┓', '┫', '┳', '╋', +]; +const DOUBLE: [char; 16] = [ + '═', '║', '═', '╚', '║', '║', '╔', '╠', '═', '╝', '═', '╩', '╗', '╣', '╦', '╬', +]; + +pub const MASK_N: u8 = 1; +pub const MASK_E: u8 = 2; +pub const MASK_S: u8 = 4; +pub const MASK_W: u8 = 8; + +/// Whether two adjacent terrains draw as one continuous structure (walls, walkways). +pub fn connects(a: Terrain, b: Terrain) -> bool { + use Terrain::*; + let wallish = |t| matches!(t, Wall | Door | Window); + let spanish = |t| matches!(t, Walkway | Bridge); + (wallish(a) && wallish(b)) + || (spanish(a) && (spanish(b) || matches!(b, Door | Floor | Stair | Lift | Ladder))) +} + +fn wall_set(kind: Option) -> &'static [char; 16] { + match kind { + Some(k) if k.is_corp() => &DOUBLE, + Some( + BuildingKind::Apartment + | BuildingKind::Hotel + | BuildingKind::Factory + | BuildingKind::Warehouse, + ) => &HEAVY, + _ => &LIGHT, + } +} + +/// The glyph for a tile, before colour. Pure in all of its inputs. +#[allow(clippy::too_many_arguments)] +pub fn glyph_for( + terrain: Terrain, + feature: Option, + connector: Option, + kind: Option, + variant: u8, + tick: u64, + mask: u8, + style: &StylePack, +) -> char { + let g = &style.glyphs; + let pick = |set: &[char], i: u64| set[(i % set.len() as u64) as usize]; + if let Some(f) = feature { + return match f { + Feature::Neon(_) => '▪', + Feature::HoloAd => pick(&g.holo, tick / 8 + variant as u64), + Feature::Lamp => '●', + Feature::Vent => { + if (tick / 4 + variant as u64) % 10 < 3 { + pick(&g.steam, tick / 4 + variant as u64) + } else { + '☰' + } + } + Feature::Puddle => '~', + Feature::Trash => '%', + Feature::Barrier => '▬', + Feature::Stall => '▤', + Feature::Sign(c) => c, + Feature::Terminal => '▣', + Feature::Camera => { + if (tick / 15).is_multiple_of(4) { + '○' + } else { + '◉' + } + } + Feature::AcUnit => '▩', + Feature::Helipad => 'H', + Feature::Pipe => '╌', + Feature::Cable => '╌', + }; + } + let horizontal = mask & (MASK_E | MASK_W) != 0; + match terrain { + Terrain::Void => ' ', + Terrain::Water => pick(&g.water, tick / 6 + variant as u64), + Terrain::Sewage => '≈', + Terrain::Road => { + if variant.is_multiple_of(16) { + '·' + } else { + ' ' + } + } + Terrain::Sidewalk => '░', + Terrain::Alley => ' ', + Terrain::Plaza => { + if variant.is_multiple_of(3) { + '▫' + } else { + ' ' + } + } + Terrain::Floor => { + if variant.is_multiple_of(23) { + '·' + } else { + ' ' + } + } + Terrain::Wall => wall_set(kind)[(mask & 15) as usize], + Terrain::Door => '▯', + Terrain::Window => '▫', + Terrain::Rooftop => pick(&g.mass, variant as u64), + Terrain::Walkway => { + if horizontal { + '═' + } else { + '║' + } + } + Terrain::Bridge => { + if horizontal { + '╍' + } else { + '╏' + } + } + Terrain::Tunnel => { + if variant.is_multiple_of(19) { + '.' + } else { + ' ' + } + } + Terrain::Rail => { + if (tick / 20).is_multiple_of(2) { + '╬' + } else { + '╫' + } + } + Terrain::Stair => match connector { + Some(ConnDir { + up: true, + down: true, + .. + }) => '⇅', + Some(ConnDir { down: true, .. }) => '▼', + _ => '▲', + }, + Terrain::Lift => { + if (tick / 10).is_multiple_of(2) { + '≡' + } else { + '⇅' + } + } + Terrain::Ladder => match connector { + Some(ConnDir { + kind: ConnectorKind::Manhole, + .. + }) => '◍', + _ => '╫', + }, + Terrain::Grate => '▦', + } +} + +/// Legend rows for a stratum: glyph and label. +pub fn legend_entries(k: StratumKind) -> Vec<(char, &'static str)> { + let common = [ + ('▲', "stairs"), + ('≡', "lift"), + ('╫', "ladder"), + ('▣', "terminal"), + ('●', "lamp"), + ]; + let mut out: Vec<(char, &'static str)> = match k { + StratumKind::Undercity => vec![ + (' ', "tunnel"), + ('≈', "sewage"), + ('▦', "grate"), + ('╬', "rail"), + ('◍', "manhole"), + ('╌', "pipe"), + ('☰', "vent"), + ], + StratumKind::Street => vec![ + (' ', "road"), + ('░', "sidewalk"), + ('▫', "plaza/window"), + ('│', "wall"), + ('▯', "door"), + ('▪', "neon"), + ('▣', "holo ad"), + ('▬', "barrier"), + ('▤', "stall"), + ('◉', "camera"), + ('~', "puddle/water"), + ], + StratumKind::Skyways => vec![ + ('═', "walkway"), + ('╌', "cable"), + ('│', "wall"), + ('▯', "door"), + ], + StratumKind::Heights => vec![ + ('█', "rooftop"), + ('╍', "bridge"), + ('H', "helipad"), + ('▩', "ac unit"), + ], + StratumKind::Net => vec![ + ('◇', "node"), + ('◆', "server"), + ('⬡', "backbone"), + ('◈', "ice"), + ], + }; + out.extend(common); + out +} + +/// Base colours for a terrain within a theme: (fg, bg). +fn terrain_colors(terrain: Terrain, ctx: &RenderCtx) -> (Rgb, Rgb) { + let ts = ctx.style.theme_or_neutral(ctx.theme); + let bg0 = ctx.style.background; + match terrain { + Terrain::Void => (bg0, bg0), + Terrain::Water => (Rgb(50, 110, 170), Rgb(8, 22, 48)), + Terrain::Sewage => (Rgb(70, 150, 70), Rgb(10, 28, 12)), + Terrain::Road => (ts.road.scale(1.8), ts.road), + Terrain::Sidewalk => (ts.sidewalk, ts.road.scale(1.1)), + Terrain::Alley => (ts.road.scale(1.6), ts.road.scale(1.25)), + Terrain::Plaza => (ts.accent2.scale(0.5), ts.sidewalk.scale(0.85)), + Terrain::Floor => (ts.floor.scale(1.6), ts.floor), + Terrain::Wall => (ts.wall, bg0), + Terrain::Door => (ts.accent2, ts.floor), + Terrain::Window => (ts.accent.scale(0.75), bg0), + Terrain::Rooftop => (ts.roof, ts.roof.scale(0.4)), + Terrain::Walkway => (ts.accent.scale(0.85), Rgb(18, 22, 30)), + Terrain::Bridge => (ts.wall.scale(1.3), Rgb(18, 22, 30)), + Terrain::Tunnel => (Rgb(48, 58, 48), Rgb(13, 16, 14)), + Terrain::Rail => (Rgb(140, 125, 95), Rgb(13, 16, 14)), + Terrain::Stair | Terrain::Lift | Terrain::Ladder => (ts.accent2, ts.floor), + Terrain::Grate => (Rgb(100, 112, 100), Rgb(13, 16, 14)), + } +} + +/// Feature foreground colour, if the feature overrides it. +fn feature_color(f: Feature, tile: &Tile, ctx: &RenderCtx, tick: u64) -> Rgb { + let ts = ctx.style.theme_or_neutral(ctx.theme); + match f { + Feature::Neon(h) => { + ctx.style + .hue(h) + .scale(flicker(tick, tile.variant, tile.variant.is_multiple_of(5))) + } + Feature::HoloAd => ts + .accent2 + .scale(0.7 + 0.3 * pulse(tick + tile.variant as u64, 24)), + Feature::Lamp => Rgb(255, 240, 200), + Feature::Vent => Rgb(170, 175, 185), + Feature::Puddle => Rgb(80, 115, 165), + Feature::Trash => Rgb(115, 105, 85), + Feature::Barrier => Rgb(255, 60, 60), + Feature::Stall => ts.accent, + Feature::Sign(_) => ts.accent2, + Feature::Terminal => Rgb(0, 230, 210), + Feature::Camera => Rgb(255, 60, 70), + Feature::AcUnit => Rgb(125, 135, 145), + Feature::Helipad => Rgb(230, 230, 130), + Feature::Pipe => Rgb(125, 105, 85), + Feature::Cable => Rgb(85, 85, 95), + } +} + +/// The styled cell for a tile. Remembered tiles freeze at tick 0. +pub fn cell(tile: &Tile, ctx: &RenderCtx) -> Cell { + let tick = if ctx.vis == Visibility::Remembered { + 0 + } else { + ctx.tick + }; + let ch = glyph_for( + tile.terrain, + tile.feature, + ctx.connector, + ctx.kind, + tile.variant, + tick, + ctx.wall_mask, + ctx.style, + ); + let (mut fg, mut bg) = terrain_colors(tile.terrain, ctx); + if let Some(f) = tile.feature { + fg = feature_color(f, tile, ctx, tick); + } + let (dr, dg, db) = ctx.style.stratum(ctx.stratum).tint; + fg = fg.tint(dr, dg, db); + bg = bg.tint(dr, dg, db); + let emits = matches!( + tile.feature, + Some( + Feature::Neon(_) + | Feature::HoloAd + | Feature::Lamp + | Feature::Terminal + | Feature::Camera + ) + ); + let fg = if emits { fg } else { lit(fg, ctx.light) }; + let bg = lit(bg, ctx.light); + let bold = + tile.is_connector() || matches!(tile.feature, Some(Feature::Sign(_) | Feature::Neon(_))); + Cell { + ch, + fg, + bg, + bold, + dim: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::lighting::apply_vis; + use crate::render::style::StylePack; + use crate::render::{RenderCtx, Visibility}; + use citygen::{ + ALL_FEATURES, ALL_TERRAIN, Feature, Hue, Pos, StratumKind, Terrain, Theme, Tile, + }; + use unicode_width::UnicodeWidthChar; + + #[test] + fn style_glyphs_are_single_width() { + let s = StylePack::load().unwrap(); + for set in [ + &s.glyphs.holo, + &s.glyphs.rain, + &s.glyphs.water, + &s.glyphs.steam, + &s.glyphs.mass, + &s.glyphs.net, + ] { + for c in set { + assert_eq!(c.width(), Some(1), "{c:?}"); + } + } + for terrain in ALL_TERRAIN { + for v in [0u8, 77, 200] { + for tick in [0u64, 7, 20] { + for mask in 0..16u8 { + let c = glyph_for(terrain, None, None, None, v, tick, mask, &s); + assert_eq!(c.width(), Some(1), "{terrain:?} mask {mask}"); + } + } + } + } + for f in ALL_FEATURES { + for tick in [0u64, 4, 15] { + let c = glyph_for(Terrain::Floor, Some(f), None, None, 1, tick, 0, &s); + assert_eq!(c.width(), Some(1), "{f:?}"); + } + } + for k in StratumKind::PHYSICAL { + for (c, label) in legend_entries(k) { + assert_eq!(c.width(), Some(1), "{label}"); + } + } + } + + #[test] + fn cell_mapping_is_pure_and_lit() { + let s = StylePack::load().unwrap(); + let ctx = |light, tick| RenderCtx { + style: &s, + theme: Some(Theme::CorpCore), + stratum: StratumKind::Street, + light, + tick, + pos: Pos::new(1, 1), + vis: Visibility::Visible, + connector: None, + kind: None, + wall_mask: 0, + }; + let wall = Tile::new(Terrain::Wall); + assert_eq!(cell(&wall, &ctx(255, 0)), cell(&wall, &ctx(255, 0))); + assert!(cell(&wall, &ctx(255, 0)).fg.0 > cell(&wall, &ctx(0, 0)).fg.0); + let mut neon = Tile::new(Terrain::Wall); + neon.feature = Some(Feature::Neon(Hue::Magenta)); + neon.variant = 5; // broken sign: variant.is_multiple_of(5) + let on = cell(&neon, &ctx(200, 0)); + let off = cell(&neon, &ctx(200, 117)); + assert!(off.fg.0 < on.fg.0 / 2, "on {:?} off {:?}", on.fg, off.fg); + let water = Tile::new(Terrain::Water); + assert_ne!(cell(&water, &ctx(100, 0)).ch, cell(&water, &ctx(100, 6)).ch); + let unseen = apply_vis(cell(&wall, &ctx(255, 0)), Visibility::Unseen); + assert_eq!(unseen.ch, ' '); + let remembered = apply_vis(cell(&wall, &ctx(255, 0)), Visibility::Remembered); + assert!(remembered.fg.0 < cell(&wall, &ctx(255, 0)).fg.0); + assert_eq!( + glyph_for(Terrain::Wall, None, None, None, 0, 0, 0b0101, &s), + '│' + ); + assert_eq!( + glyph_for(Terrain::Wall, None, None, None, 0, 0, 0b0110, &s), + '┌' + ); + assert_eq!( + glyph_for(Terrain::Wall, None, None, None, 0, 0, 0b1111, &s), + '┼' + ); + assert!(connects(Terrain::Wall, Terrain::Door)); + assert!(!connects(Terrain::Wall, Terrain::Floor)); + assert!(connects(Terrain::Walkway, Terrain::Bridge)); + } +} diff --git a/flavor/style.ron b/flavor/style.ron new file mode 100644 index 0000000..f6938a1 --- /dev/null +++ b/flavor/style.ron @@ -0,0 +1,35 @@ +// Visual style pack: palettes per theme and stratum, neon hues, glyph sets. +// Every glyph must be exactly one terminal cell wide. +StylePack( + background: (10, 11, 16), + neutral: ThemeStyle(accent: (120, 140, 160), accent2: (200, 210, 220), wall: (90, 100, 115), floor: (28, 32, 40), road: (24, 26, 32), sidewalk: (46, 50, 60), roof: (70, 78, 90)), + themes: [ + (CorpCore, ThemeStyle(accent: (0, 229, 255), accent2: (220, 235, 245), wall: (110, 135, 160), floor: (26, 34, 46), road: (26, 29, 36), sidewalk: (52, 58, 70), roof: (80, 95, 115))), + (NightMarket, ThemeStyle(accent: (255, 0, 200), accent2: (255, 150, 40), wall: (130, 95, 80), floor: (40, 30, 26), road: (30, 26, 28), sidewalk: (60, 48, 44), roof: (95, 70, 60))), + (Megablocks, ThemeStyle(accent: (255, 190, 0), accent2: (210, 210, 200), wall: (120, 120, 125), floor: (34, 34, 36), road: (26, 26, 28), sidewalk: (56, 56, 58), roof: (85, 85, 90))), + (CombatZone, ThemeStyle(accent: (255, 40, 60), accent2: (255, 200, 60), wall: (110, 80, 70), floor: (36, 26, 24), road: (28, 22, 20), sidewalk: (54, 42, 38), roof: (80, 60, 55))), + (Docks, ThemeStyle(accent: (0, 200, 190), accent2: (190, 110, 60), wall: (90, 110, 110), floor: (26, 36, 38), road: (24, 30, 32), sidewalk: (46, 58, 60), roof: (70, 90, 92))), + (Industrial, ThemeStyle(accent: (230, 230, 0), accent2: (120, 230, 90), wall: (110, 115, 95), floor: (34, 36, 28), road: (28, 30, 24), sidewalk: (54, 58, 46), roof: (84, 88, 72))), + (NeonStrip, ThemeStyle(accent: (255, 80, 180), accent2: (60, 140, 255), wall: (120, 90, 140), floor: (36, 26, 44), road: (28, 22, 34), sidewalk: (58, 46, 68), roof: (90, 70, 105))), + (OldTown, ThemeStyle(accent: (255, 150, 60), accent2: (90, 200, 140), wall: (130, 115, 90), floor: (40, 36, 28), road: (30, 28, 24), sidewalk: (62, 56, 46), roof: (95, 85, 65))), + ], + strata: [ + (Undercity, StratumStyle(tint: (-8, 8, -6), fog: true, rain: 0)), + (Street, StratumStyle(tint: (0, 0, 0), fog: false, rain: 1)), + (Skyways, StratumStyle(tint: (0, 2, 8), fog: false, rain: 2)), + (Heights, StratumStyle(tint: (0, 6, 22), fog: false, rain: 3)), + (Net, StratumStyle(tint: (-10, 20, 0), fog: false, rain: 0)), + ], + hues: [ + (Cyan, (0, 255, 230)), (Magenta, (255, 0, 200)), (Pink, (255, 90, 190)), (Orange, (255, 140, 0)), + (Yellow, (255, 220, 0)), (Green, (90, 255, 130)), (Blue, (70, 130, 255)), (Red, (255, 40, 60)), (White, (240, 240, 255)), + ], + glyphs: GlyphSets( + holo: ['▣', '▤', '▥', '▦', '▧', '▨', '▩', '◫', 'ア', 'イ', 'ウ', 'エ', '¥', '$', '€', '§'], + rain: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠈', '⠐', '⠠', '╵', '╷'], + water: ['≈', '~', '∼'], + steam: ['°', '∘', '○'], + mass: ['█', '▓', '▒', '░'], + net: ['◇', '◆', '⬡', '⌗', '▪', '◈'], + ), +)