From b740b9d33446d9c4e07fb399b93f0f05222926de Mon Sep 17 00:00:00 2001 From: Jeffrey Wilcke Date: Mon, 12 Oct 2026 21:33:48 +0200 Subject: [PATCH] feat(net): cyberspace terrains, node features and their tileset Co-Authored-By: Claude Fable 5.1 --- crates/citygen/src/tile.rs | 95 ++++++++++++++++++- crates/citygen/src/world.rs | 9 ++ crates/cyberpunk/src/render/tileset.rs | 124 +++++++++++++++++++++++-- 3 files changed, 214 insertions(+), 14 deletions(-) diff --git a/crates/citygen/src/tile.rs b/crates/citygen/src/tile.rs index 9cc81b9..3c00ef2 100644 --- a/crates/citygen/src/tile.rs +++ b/crates/citygen/src/tile.rs @@ -1,6 +1,7 @@ //! Tiles: terrain, features and derived walkability. use crate::ids::{BuildingId, DistrictId}; +use crate::net::NodeKind; use serde::{Deserialize, Serialize}; /// Base terrain of a tile. Every tile has exactly one. @@ -26,9 +27,14 @@ pub enum Terrain { Lift, Ladder, Grate, + NetGrid, + NetLane, + NetWall, + NetFloor, + NetGate, } -pub const ALL_TERRAIN: [Terrain; 20] = [ +pub const ALL_TERRAIN: [Terrain; 25] = [ Terrain::Void, Terrain::Water, Terrain::Sewage, @@ -49,8 +55,53 @@ pub const ALL_TERRAIN: [Terrain; 20] = [ Terrain::Lift, Terrain::Ladder, 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. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Hue { @@ -83,9 +134,11 @@ pub enum Feature { Helipad, Pipe, 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::HoloAd, Feature::Lamp, @@ -101,6 +154,7 @@ pub const ALL_FEATURES: [Feature; 15] = [ Feature::Helipad, Feature::Pipe, Feature::Cable, + Feature::Node(NodeKind::Server), ]; /// One cell of a stratum. @@ -148,6 +202,10 @@ impl Tile { | Lift | Ladder | Grate + | NetGrid + | NetLane + | NetFloor + | NetGate ); let feature_ok = !matches!( self.feature, @@ -163,7 +221,10 @@ impl Tile { } 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. @@ -210,7 +271,31 @@ mod tests { assert!(!Tile::new(Terrain::Window).is_walkable()); assert!(Tile::new(Terrain::Plaza).is_outdoor_ground()); assert!(Tile::new(Terrain::Door).is_building_part()); - assert_eq!(ALL_TERRAIN.len(), 20); - assert_eq!(ALL_FEATURES.len(), 15); + assert_eq!(ALL_TERRAIN.len(), 25); + 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 + ); } } diff --git a/crates/citygen/src/world.rs b/crates/citygen/src/world.rs index 892032a..2c7c30c 100644 --- a/crates/citygen/src/world.rs +++ b/crates/citygen/src/world.rs @@ -25,6 +25,15 @@ impl StratumKind { 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 { match self { StratumKind::Undercity => "Undercity", diff --git a/crates/cyberpunk/src/render/tileset.rs b/crates/cyberpunk/src/render/tileset.rs index 2fbfd21..4c8d8b4 100644 --- a/crates/cyberpunk/src/render/tileset.rs +++ b/crates/cyberpunk/src/render/tileset.rs @@ -2,9 +2,10 @@ use crate::anim::effects::{flicker, pulse}; use crate::render::lighting::lit; +use crate::render::overlay::SIGNAL; use crate::render::style::StylePack; 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] = [ '─', '│', '─', '└', '│', '│', '┌', '├', '─', '┘', '─', '┴', '┐', '┤', '┬', '┼', @@ -21,13 +22,31 @@ 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). +/// Whether two adjacent terrains draw as one continuous structure (walls, walkways, lanes). 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); + let netwall = |t| matches!(t, NetWall | NetGate); + let lane = |t| matches!(t, NetLane | NetGate); (wallish(a) && wallish(b)) || (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) -> &'static [char; 16] { @@ -86,6 +105,7 @@ pub fn glyph_for( Feature::Helipad => 'H', Feature::Pipe => '╌', Feature::Cable => '╌', + Feature::Node(k) => node_glyph(k, style), }; } let horizontal = mask & (MASK_E | MASK_W) != 0; @@ -172,6 +192,24 @@ pub fn glyph_for( _ => '╫', }, 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"), ], StratumKind::Net => vec![ - ('◇', "node"), + ('·', "grid"), + ('═', "lane"), + ('┃', "construct wall"), + ('▯', "gate"), + ('◇', "terminal / port"), ('◆', "server"), ('⬡', "backbone"), + ('⌗', "data vault"), ('◈', "ice"), + ('▪', "sensor"), ], }; - out.extend(common); + if k != StratumKind::Net { + out.extend(common); + } out } /// 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 bg0 = ctx.style.background; 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::Stair | Terrain::Lift | Terrain::Ladder => (ts.accent2, ts.floor), 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::Pipe => Rgb(125, 105, 85), 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.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 { fg = feature_color(f, tile, ctx, tick); } @@ -316,12 +373,15 @@ pub fn cell(tile: &Tile, ctx: &RenderCtx) -> Cell { | Feature::Lamp | Feature::Terminal | Feature::Camera + | Feature::Node(_) ) ); 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(_))); + let jack = ctx.connector.is_some_and(|c| c.kind == ConnectorKind::Jack); + let bold = tile.is_connector() + || matches!(tile.feature, Some(Feature::Sign(_) | Feature::Neon(_))) + || (matches!(tile.feature, Some(Feature::Node(_))) && jack); Cell { ch, fg, @@ -337,7 +397,9 @@ mod tests { use crate::render::lighting::apply_vis; use crate::render::style::StylePack; 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; #[test] @@ -423,4 +485,48 @@ mod tests { assert!(!connects(Terrain::Wall, Terrain::Floor)); 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")); + } }