feat(city): style pack, tileset and lighting
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
//! Application state, the screen trait and the fixed-rate frame loop.
|
//! Application state, the screen trait and the fixed-rate frame loop.
|
||||||
|
|
||||||
|
use crate::render::style::StylePack;
|
||||||
use crate::term::{TerminalGuard, install_panic_hook};
|
use crate::term::{TerminalGuard, install_panic_hook};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use citygen::{City, GenParams};
|
use citygen::{City, GenParams};
|
||||||
@@ -19,6 +20,7 @@ pub const FRAME: Duration = Duration::from_micros(33_333);
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub params: GenParams,
|
pub params: GenParams,
|
||||||
pub city: Option<City>,
|
pub city: Option<City>,
|
||||||
|
pub style: StylePack,
|
||||||
pub tick: u64,
|
pub tick: u64,
|
||||||
status: Option<(String, u64)>,
|
status: Option<(String, u64)>,
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ impl AppState {
|
|||||||
AppState {
|
AppState {
|
||||||
params: GenParams::default(),
|
params: GenParams::default(),
|
||||||
city: None,
|
city: None,
|
||||||
|
style: StylePack::load().expect("embedded style pack is valid"),
|
||||||
tick: 0,
|
tick: 0,
|
||||||
status: None,
|
status: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
mod anim;
|
mod anim;
|
||||||
mod app;
|
mod app;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod render;
|
||||||
mod save;
|
mod save;
|
||||||
mod screens;
|
mod screens;
|
||||||
mod term;
|
mod term;
|
||||||
|
|||||||
51
crates/city/src/render/lighting.rs
Normal file
51
crates/city/src/render/lighting.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
crates/city/src/render/mod.rs
Normal file
112
crates/city/src/render/mod.rs
Normal file
@@ -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<Theme>,
|
||||||
|
pub stratum: StratumKind,
|
||||||
|
pub light: u8,
|
||||||
|
pub tick: u64,
|
||||||
|
pub pos: Pos,
|
||||||
|
pub vis: Visibility,
|
||||||
|
pub connector: Option<ConnDir>,
|
||||||
|
pub kind: Option<BuildingKind>,
|
||||||
|
/// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
122
crates/city/src/render/style.rs
Normal file
122
crates/city/src/render/style.rs
Normal file
@@ -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<char>,
|
||||||
|
pub rain: Vec<char>,
|
||||||
|
pub water: Vec<char>,
|
||||||
|
pub steam: Vec<char>,
|
||||||
|
pub mass: Vec<char>,
|
||||||
|
pub net: Vec<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<StylePack> {
|
||||||
|
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<Theme>) -> &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);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
crates/city/src/render/tileset.rs
Normal file
429
crates/city/src/render/tileset.rs
Normal file
@@ -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<BuildingKind>) -> &'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<Feature>,
|
||||||
|
connector: Option<ConnDir>,
|
||||||
|
kind: Option<BuildingKind>,
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
35
flavor/style.ron
Normal file
35
flavor/style.ron
Normal file
@@ -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: ['◇', '◆', '⬡', '⌗', '▪', '◈'],
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user