feat(net): cyberspace terrains, node features and their tileset

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jeffrey Wilcke
2026-10-12 21:33:48 +02:00
parent 292e502807
commit b740b9d334
3 changed files with 214 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
//! Tiles: terrain, features and derived walkability. //! Tiles: terrain, features and derived walkability.
use crate::ids::{BuildingId, DistrictId}; use crate::ids::{BuildingId, DistrictId};
use crate::net::NodeKind;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Base terrain of a tile. Every tile has exactly one. /// Base terrain of a tile. Every tile has exactly one.
@@ -26,9 +27,14 @@ pub enum Terrain {
Lift, Lift,
Ladder, Ladder,
Grate, Grate,
NetGrid,
NetLane,
NetWall,
NetFloor,
NetGate,
} }
pub const ALL_TERRAIN: [Terrain; 20] = [ pub const ALL_TERRAIN: [Terrain; 25] = [
Terrain::Void, Terrain::Void,
Terrain::Water, Terrain::Water,
Terrain::Sewage, Terrain::Sewage,
@@ -49,8 +55,53 @@ pub const ALL_TERRAIN: [Terrain; 20] = [
Terrain::Lift, Terrain::Lift,
Terrain::Ladder, Terrain::Ladder,
Terrain::Grate, Terrain::Grate,
Terrain::NetGrid,
Terrain::NetLane,
Terrain::NetWall,
Terrain::NetFloor,
Terrain::NetGate,
]; ];
impl Terrain {
/// Human label for the inspector and legend.
pub fn label(self) -> &'static str {
use Terrain::*;
match self {
Void => "void",
Water => "water",
Sewage => "sewage",
Road => "road",
Sidewalk => "sidewalk",
Alley => "alley",
Plaza => "plaza",
Floor => "floor",
Wall => "wall",
Door => "door",
Window => "window",
Rooftop => "rooftop",
Walkway => "walkway",
Bridge => "bridge",
Tunnel => "tunnel",
Rail => "rail",
Stair => "stair",
Lift => "lift",
Ladder => "ladder",
Grate => "grate",
NetGrid => "grid",
NetLane => "lane",
NetWall => "construct wall",
NetFloor => "construct floor",
NetGate => "gate",
}
}
/// Cyberspace-only terrain.
pub fn is_net(self) -> bool {
use Terrain::*;
matches!(self, NetGrid | NetLane | NetWall | NetFloor | NetGate)
}
}
/// Named neon colours; the renderer maps them to RGB. /// Named neon colours; the renderer maps them to RGB.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Hue { pub enum Hue {
@@ -83,9 +134,11 @@ pub enum Feature {
Helipad, Helipad,
Pipe, Pipe,
Cable, Cable,
/// A Net node standing on this cyberspace tile.
Node(NodeKind),
} }
pub const ALL_FEATURES: [Feature; 15] = [ pub const ALL_FEATURES: [Feature; 16] = [
Feature::Neon(Hue::Cyan), Feature::Neon(Hue::Cyan),
Feature::HoloAd, Feature::HoloAd,
Feature::Lamp, Feature::Lamp,
@@ -101,6 +154,7 @@ pub const ALL_FEATURES: [Feature; 15] = [
Feature::Helipad, Feature::Helipad,
Feature::Pipe, Feature::Pipe,
Feature::Cable, Feature::Cable,
Feature::Node(NodeKind::Server),
]; ];
/// One cell of a stratum. /// One cell of a stratum.
@@ -148,6 +202,10 @@ impl Tile {
| Lift | Lift
| Ladder | Ladder
| Grate | Grate
| NetGrid
| NetLane
| NetFloor
| NetGate
); );
let feature_ok = !matches!( let feature_ok = !matches!(
self.feature, self.feature,
@@ -163,7 +221,10 @@ impl Tile {
} }
pub fn blocks_sight(&self) -> bool { pub fn blocks_sight(&self) -> bool {
matches!(self.terrain, Terrain::Wall | Terrain::Door) matches!(
self.terrain,
Terrain::Wall | Terrain::Door | Terrain::NetWall
)
} }
/// Outdoor ground at street level: roads, sidewalks, alleys, plazas. /// Outdoor ground at street level: roads, sidewalks, alleys, plazas.
@@ -210,7 +271,31 @@ mod tests {
assert!(!Tile::new(Terrain::Window).is_walkable()); assert!(!Tile::new(Terrain::Window).is_walkable());
assert!(Tile::new(Terrain::Plaza).is_outdoor_ground()); assert!(Tile::new(Terrain::Plaza).is_outdoor_ground());
assert!(Tile::new(Terrain::Door).is_building_part()); assert!(Tile::new(Terrain::Door).is_building_part());
assert_eq!(ALL_TERRAIN.len(), 20); assert_eq!(ALL_TERRAIN.len(), 25);
assert_eq!(ALL_FEATURES.len(), 15); assert_eq!(ALL_FEATURES.len(), 16);
}
#[test]
fn net_tiles_walk_and_block_like_the_spec_says() {
assert!(Tile::new(Terrain::NetGrid).is_walkable());
assert!(Tile::new(Terrain::NetLane).is_walkable());
assert!(Tile::new(Terrain::NetFloor).is_walkable());
assert!(Tile::new(Terrain::NetGate).is_walkable());
assert!(!Tile::new(Terrain::NetWall).is_walkable());
assert!(Tile::new(Terrain::NetWall).blocks_sight());
assert!(!Tile::new(Terrain::NetGate).blocks_sight());
let mut t = Tile::new(Terrain::NetGrid);
t.feature = Some(Feature::Node(crate::net::NodeKind::Ice));
assert!(t.is_walkable(), "nodes never block walking in this spec");
assert_eq!(Terrain::NetWall.label(), "construct wall");
assert_eq!(Terrain::Road.label(), "road");
assert!(Terrain::NetLane.is_net() && !Terrain::Road.is_net());
assert_eq!(ALL_TERRAIN.len(), 25);
assert_eq!(ALL_FEATURES.len(), 16);
assert_eq!(crate::world::StratumKind::ALL.len(), 5);
assert_eq!(
crate::world::StratumKind::ALL[4],
crate::world::StratumKind::Net
);
} }
} }

View File

@@ -25,6 +25,15 @@ impl StratumKind {
StratumKind::Heights, StratumKind::Heights,
]; ];
/// Every stratum a finished city has, bottom first, Net last.
pub const ALL: [StratumKind; 5] = [
StratumKind::Undercity,
StratumKind::Street,
StratumKind::Skyways,
StratumKind::Heights,
StratumKind::Net,
];
pub fn label(self) -> &'static str { pub fn label(self) -> &'static str {
match self { match self {
StratumKind::Undercity => "Undercity", StratumKind::Undercity => "Undercity",

View File

@@ -2,9 +2,10 @@
use crate::anim::effects::{flicker, pulse}; use crate::anim::effects::{flicker, pulse};
use crate::render::lighting::lit; use crate::render::lighting::lit;
use crate::render::overlay::SIGNAL;
use crate::render::style::StylePack; use crate::render::style::StylePack;
use crate::render::{Cell, ConnDir, RenderCtx, Rgb, Visibility}; use crate::render::{Cell, ConnDir, RenderCtx, Rgb, Visibility};
use citygen::{BuildingKind, ConnectorKind, Feature, StratumKind, Terrain, Tile}; use citygen::{BuildingKind, ConnectorKind, Feature, NodeKind, StratumKind, Terrain, Tile};
const LIGHT: [char; 16] = [ const LIGHT: [char; 16] = [
'─', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼', '─', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼',
@@ -21,13 +22,31 @@ pub const MASK_E: u8 = 2;
pub const MASK_S: u8 = 4; pub const MASK_S: u8 = 4;
pub const MASK_W: u8 = 8; pub const MASK_W: u8 = 8;
/// Whether two adjacent terrains draw as one continuous structure (walls, walkways). /// Whether two adjacent terrains draw as one continuous structure (walls, walkways, lanes).
pub fn connects(a: Terrain, b: Terrain) -> bool { pub fn connects(a: Terrain, b: Terrain) -> bool {
use Terrain::*; use Terrain::*;
let wallish = |t| matches!(t, Wall | Door | Window); let wallish = |t| matches!(t, Wall | Door | Window);
let spanish = |t| matches!(t, Walkway | Bridge); let spanish = |t| matches!(t, Walkway | Bridge);
let netwall = |t| matches!(t, NetWall | NetGate);
let lane = |t| matches!(t, NetLane | NetGate);
(wallish(a) && wallish(b)) (wallish(a) && wallish(b))
|| (spanish(a) && (spanish(b) || matches!(b, Door | Floor | Stair | Lift | Ladder))) || (spanish(a) && (spanish(b) || matches!(b, Door | Floor | Stair | Lift | Ladder)))
|| (netwall(a) && netwall(b))
|| (lane(a) && lane(b))
}
/// Glyph for a Net node kind from the style's `net` set.
pub fn node_glyph(kind: NodeKind, style: &StylePack) -> char {
let g = &style.glyphs.net;
let i = match kind {
NodeKind::Terminal => 0,
NodeKind::Server => 1,
NodeKind::Backbone => 2,
NodeKind::DataVault => 3,
NodeKind::Ice => 5,
_ => 4,
};
g[i % g.len()]
} }
fn wall_set(kind: Option<BuildingKind>) -> &'static [char; 16] { fn wall_set(kind: Option<BuildingKind>) -> &'static [char; 16] {
@@ -86,6 +105,7 @@ pub fn glyph_for(
Feature::Helipad => 'H', Feature::Helipad => 'H',
Feature::Pipe => '╌', Feature::Pipe => '╌',
Feature::Cable => '╌', Feature::Cable => '╌',
Feature::Node(k) => node_glyph(k, style),
}; };
} }
let horizontal = mask & (MASK_E | MASK_W) != 0; let horizontal = mask & (MASK_E | MASK_W) != 0;
@@ -172,6 +192,24 @@ pub fn glyph_for(
_ => '╫', _ => '╫',
}, },
Terrain::Grate => '▦', Terrain::Grate => '▦',
Terrain::NetGrid => {
if variant & 1 == 1 {
'·'
} else {
' '
}
}
Terrain::NetLane => DOUBLE[(mask & 15) as usize],
Terrain::NetWall => {
let set = if kind.is_some_and(|k| k.is_corp()) {
&DOUBLE
} else {
&HEAVY
};
set[(mask & 15) as usize]
}
Terrain::NetFloor => ' ',
Terrain::NetGate => '▯',
} }
} }
@@ -220,18 +258,26 @@ pub fn legend_entries(k: StratumKind) -> Vec<(char, &'static str)> {
('▩', "ac unit"), ('▩', "ac unit"),
], ],
StratumKind::Net => vec![ StratumKind::Net => vec![
('◇', "node"), ('·', "grid"),
('═', "lane"),
('┃', "construct wall"),
('▯', "gate"),
('◇', "terminal / port"),
('◆', "server"), ('◆', "server"),
('⬡', "backbone"), ('⬡', "backbone"),
('⌗', "data vault"),
('◈', "ice"), ('◈', "ice"),
('▪', "sensor"),
], ],
}; };
if k != StratumKind::Net {
out.extend(common); out.extend(common);
}
out out
} }
/// Base colours for a terrain within a theme: (fg, bg). /// Base colours for a terrain within a theme: (fg, bg).
fn terrain_colors(terrain: Terrain, ctx: &RenderCtx) -> (Rgb, Rgb) { fn terrain_colors(terrain: Terrain, tile: &Tile, ctx: &RenderCtx, tick: u64) -> (Rgb, Rgb) {
let ts = ctx.style.theme_or_neutral(ctx.theme); let ts = ctx.style.theme_or_neutral(ctx.theme);
let bg0 = ctx.style.background; let bg0 = ctx.style.background;
match terrain { match terrain {
@@ -253,6 +299,14 @@ fn terrain_colors(terrain: Terrain, ctx: &RenderCtx) -> (Rgb, Rgb) {
Terrain::Rail => (Rgb(140, 125, 95), 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::Stair | Terrain::Lift | Terrain::Ladder => (ts.accent2, ts.floor),
Terrain::Grate => (Rgb(100, 112, 100), Rgb(13, 16, 14)), Terrain::Grate => (Rgb(100, 112, 100), Rgb(13, 16, 14)),
Terrain::NetGrid => (ts.accent.mix(SIGNAL, 0.5).scale(0.45), Rgb(3, 6, 10)),
Terrain::NetLane => (
SIGNAL.scale(0.6 + 0.4 * pulse(tick + tile.variant as u64, 20)),
Rgb(3, 6, 10),
),
Terrain::NetWall => (SIGNAL, Rgb(3, 6, 10)),
Terrain::NetFloor => (Rgb(10, 40, 44), Rgb(6, 18, 22)),
Terrain::NetGate => (Rgb(200, 255, 245), Rgb(6, 18, 22)),
} }
} }
@@ -281,6 +335,9 @@ fn feature_color(f: Feature, tile: &Tile, ctx: &RenderCtx, tick: u64) -> Rgb {
Feature::Helipad => Rgb(230, 230, 130), Feature::Helipad => Rgb(230, 230, 130),
Feature::Pipe => Rgb(125, 105, 85), Feature::Pipe => Rgb(125, 105, 85),
Feature::Cable => Rgb(85, 85, 95), Feature::Cable => Rgb(85, 85, 95),
Feature::Node(_) => {
Rgb(240, 255, 250).scale(0.7 + 0.3 * pulse(tick + tile.variant as u64, 30))
}
} }
} }
@@ -301,7 +358,7 @@ pub fn cell(tile: &Tile, ctx: &RenderCtx) -> Cell {
ctx.wall_mask, ctx.wall_mask,
ctx.style, ctx.style,
); );
let (mut fg, mut bg) = terrain_colors(tile.terrain, ctx); let (mut fg, mut bg) = terrain_colors(tile.terrain, tile, ctx, tick);
if let Some(f) = tile.feature { if let Some(f) = tile.feature {
fg = feature_color(f, tile, ctx, tick); fg = feature_color(f, tile, ctx, tick);
} }
@@ -316,12 +373,15 @@ pub fn cell(tile: &Tile, ctx: &RenderCtx) -> Cell {
| Feature::Lamp | Feature::Lamp
| Feature::Terminal | Feature::Terminal
| Feature::Camera | Feature::Camera
| Feature::Node(_)
) )
); );
let fg = if emits { fg } else { lit(fg, ctx.light) }; let fg = if emits { fg } else { lit(fg, ctx.light) };
let bg = lit(bg, ctx.light); let bg = lit(bg, ctx.light);
let bold = let jack = ctx.connector.is_some_and(|c| c.kind == ConnectorKind::Jack);
tile.is_connector() || matches!(tile.feature, Some(Feature::Sign(_) | Feature::Neon(_))); let bold = tile.is_connector()
|| matches!(tile.feature, Some(Feature::Sign(_) | Feature::Neon(_)))
|| (matches!(tile.feature, Some(Feature::Node(_))) && jack);
Cell { Cell {
ch, ch,
fg, fg,
@@ -337,7 +397,9 @@ mod tests {
use crate::render::lighting::apply_vis; use crate::render::lighting::apply_vis;
use crate::render::style::StylePack; use crate::render::style::StylePack;
use crate::render::{RenderCtx, Visibility}; use crate::render::{RenderCtx, Visibility};
use citygen::{ALL_FEATURES, ALL_TERRAIN, Feature, Hue, StratumKind, Terrain, Theme, Tile}; use citygen::{
ALL_FEATURES, ALL_TERRAIN, Feature, Hue, NodeKind, StratumKind, Terrain, Theme, Tile,
};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
#[test] #[test]
@@ -423,4 +485,48 @@ mod tests {
assert!(!connects(Terrain::Wall, Terrain::Floor)); assert!(!connects(Terrain::Wall, Terrain::Floor));
assert!(connects(Terrain::Walkway, Terrain::Bridge)); assert!(connects(Terrain::Walkway, Terrain::Bridge));
} }
#[test]
fn net_tiles_render_in_the_signal_palette() {
let s = StylePack::load().unwrap();
let ctx = |stratum| RenderCtx {
style: &s,
theme: Some(Theme::CorpCore),
stratum,
light: 255,
tick: 0,
vis: Visibility::Visible,
connector: None,
kind: None,
wall_mask: MASK_E | MASK_W,
};
let wall = cell(&Tile::new(Terrain::NetWall), &ctx(StratumKind::Net));
assert_eq!(wall.ch, '━');
assert!(wall.fg.2 > wall.fg.0, "wall is cyan-ish: {:?}", wall.fg);
let lane = cell(&Tile::new(Terrain::NetLane), &ctx(StratumKind::Net));
assert_eq!(lane.ch, '═');
let mut grid = Tile::new(Terrain::NetGrid);
grid.variant = 1;
assert_eq!(cell(&grid, &ctx(StratumKind::Net)).ch, '·');
grid.variant = 2;
assert_eq!(cell(&grid, &ctx(StratumKind::Net)).ch, ' ');
assert_eq!(
cell(&Tile::new(Terrain::NetGate), &ctx(StratumKind::Net)).ch,
'▯'
);
let mut node = Tile::new(Terrain::NetGrid);
node.feature = Some(Feature::Node(NodeKind::Server));
assert_eq!(
cell(&node, &ctx(StratumKind::Net)).ch,
node_glyph(NodeKind::Server, &s)
);
assert_eq!(node_glyph(NodeKind::Server, &s), '◆');
assert_eq!(node_glyph(NodeKind::Ice, &s), '◈');
assert!(connects(Terrain::NetWall, Terrain::NetGate));
assert!(connects(Terrain::NetLane, Terrain::NetGate));
assert!(!connects(Terrain::NetLane, Terrain::NetWall));
let legend = legend_entries(StratumKind::Net);
assert!(legend.iter().any(|(_, l)| *l == "construct wall"));
assert!(!legend.iter().any(|(_, l)| *l == "stairs"));
}
} }