use crate::color::{parse_color, RgbColor};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use flatland_protocol::{NpcView, ResourceNodeState, ResourceNodeView, TerrainKindView};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MapPresentation {
pub glyph: String,
pub color: RgbColor,
}
impl MapPresentation {
fn new(glyph: impl Into<String>, color: RgbColor) -> Self {
Self {
glyph: glyph.into(),
color,
}
}
}
#[derive(Debug, Default, Deserialize)]
struct DefaultsYaml {
#[serde(default)]
resource: Option<GlyphColorYaml>,
#[serde(default)]
resource_harvesting: Option<GlyphColorYaml>,
#[serde(default)]
resource_cooldown: Option<GlyphColorYaml>,
#[serde(default)]
npc_wildlife: Option<GlyphColorYaml>,
#[serde(default)]
npc_friendly: Option<GlyphColorYaml>,
#[serde(default)]
player: Option<GlyphColorYaml>,
#[serde(default)]
corpse: Option<GlyphColorYaml>,
#[serde(default)]
loot: Option<GlyphColorYaml>,
#[serde(default)]
chest: Option<GlyphColorYaml>,
#[serde(default)]
chest_locked: Option<GlyphColorYaml>,
#[serde(default)]
door_closed: Option<GlyphColorYaml>,
#[serde(default)]
door_open: Option<GlyphColorYaml>,
#[serde(default)]
wall: Option<GlyphColorYaml>,
#[serde(default)]
well_center: Option<GlyphColorYaml>,
#[serde(default)]
quest_board: Option<GlyphColorYaml>,
}
#[derive(Debug, Deserialize)]
struct GlyphColorYaml {
glyph: String,
color: String,
}
#[derive(Debug, Deserialize)]
struct TerrainKindsFile {
terrain_kinds: HashMap<String, GlyphColorYaml>,
}
#[derive(Debug, Deserialize)]
struct ItemGlyphsFile {
items: Vec<ItemGlyphEntry>,
}
#[derive(Debug, Deserialize)]
struct ItemGlyphEntry {
template_id: String,
#[serde(default)]
glyph: Option<String>,
#[serde(default)]
color: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MapGlyphsFile {
#[serde(default)]
defaults: DefaultsYaml,
#[serde(default)]
npcs: HashMap<String, GlyphColorYaml>,
#[serde(default)]
entities: HashMap<String, GlyphColorYaml>,
}
#[derive(Debug)]
struct Catalog {
terrain: HashMap<String, MapPresentation>,
defaults: DefaultsBlock,
items: HashMap<String, MapPresentation>,
npcs: HashMap<String, MapPresentation>,
entities: HashMap<String, MapPresentation>,
}
#[derive(Debug)]
struct DefaultsBlock {
resource: MapPresentation,
resource_harvesting: MapPresentation,
resource_cooldown: MapPresentation,
npc_wildlife: MapPresentation,
npc_friendly: MapPresentation,
player: MapPresentation,
corpse: MapPresentation,
loot: MapPresentation,
chest: MapPresentation,
chest_locked: MapPresentation,
door_closed: MapPresentation,
door_open: MapPresentation,
wall: MapPresentation,
well_center: MapPresentation,
quest_board: MapPresentation,
}
fn find_assets_path(relative: &str) -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for ancestor in manifest.ancestors() {
let candidate = ancestor.join(relative);
if candidate.is_file() {
return candidate;
}
}
PathBuf::from(relative)
}
fn find_assets_dir(relative: &str) -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for ancestor in manifest.ancestors() {
let candidate = ancestor.join(relative);
if candidate.is_dir() {
return candidate;
}
}
PathBuf::from(relative)
}
fn parse_entry(def: &GlyphColorYaml) -> Option<MapPresentation> {
if def.glyph.is_empty() {
return None;
}
let color = parse_color(&def.color)?;
Some(MapPresentation::new(def.glyph.clone(), color))
}
fn parse_map(defs: HashMap<String, GlyphColorYaml>) -> HashMap<String, MapPresentation> {
defs.into_iter()
.filter_map(|(k, v)| parse_entry(&v).map(|p| (k, p)))
.collect()
}
fn builtin_defaults() -> DefaultsBlock {
DefaultsBlock {
resource: MapPresentation::new("?", RgbColor::DARK_GRAY),
resource_harvesting: MapPresentation::new("%", RgbColor::YELLOW),
resource_cooldown: MapPresentation::new("·", RgbColor::DARK_GRAY),
npc_wildlife: MapPresentation::new("N", RgbColor::YELLOW),
npc_friendly: MapPresentation::new("N", RgbColor::CYAN),
player: MapPresentation::new("@", RgbColor::GREEN),
corpse: MapPresentation::new("x", RgbColor::DARK_GRAY),
loot: MapPresentation::new("*", RgbColor::YELLOW),
chest: MapPresentation::new("■", RgbColor::rgb(139, 90, 43)),
chest_locked: MapPresentation::new("▣", RgbColor::rgb(160, 82, 45)),
door_closed: MapPresentation::new("D", RgbColor::RED),
door_open: MapPresentation::new("d", RgbColor::LIGHT_RED),
wall: MapPresentation::new("+", RgbColor::MAGENTA),
well_center: MapPresentation::new("O", RgbColor::CYAN),
quest_board: MapPresentation::new("!", RgbColor::rgb(0xe8, 0xc5, 0x47)),
}
}
fn merge_default(yaml: Option<GlyphColorYaml>, fallback: &MapPresentation) -> MapPresentation {
yaml.and_then(|d| parse_entry(&d))
.unwrap_or_else(|| fallback.clone())
}
fn builtin_terrain(kind: TerrainKindView) -> MapPresentation {
match kind {
TerrainKindView::Grass => MapPresentation::new(".", RgbColor::rgb(0x5a, 0x73, 0x56)),
TerrainKindView::Hill => MapPresentation::new("^", RgbColor::rgb(0x9a, 0x92, 0x68)),
TerrainKindView::Trail => MapPresentation::new(":", RgbColor::rgb(0xc4, 0xb8, 0x90)),
TerrainKindView::Rock => MapPresentation::new("#", RgbColor::rgb(0x5a, 0x50, 0x48)),
TerrainKindView::Bog => MapPresentation::new(",", RgbColor::MAGENTA),
TerrainKindView::ShallowWater => MapPresentation::new("~", RgbColor::CYAN),
TerrainKindView::DeepWater => MapPresentation::new("~", RgbColor::BLUE),
}
}
fn terrain_key(kind: TerrainKindView) -> &'static str {
match kind {
TerrainKindView::Grass => "grass",
TerrainKindView::Hill => "hill",
TerrainKindView::Trail => "trail",
TerrainKindView::Rock => "rock",
TerrainKindView::Bog => "bog",
TerrainKindView::ShallowWater => "shallow_water",
TerrainKindView::DeepWater => "deep_water",
}
}
fn elevation_palette(elevation: f32) -> RgbColor {
let bucket = elevation.round().clamp(-3.0, 8.0) as i32;
match bucket {
b if b <= -1 => RgbColor::rgb(0x3d, 0x58, 0x6a), 0 => RgbColor::rgb(0x4a, 0x6b, 0x42), 1 => RgbColor::rgb(0x62, 0x7a, 0x48), 2 => RgbColor::rgb(0x7a, 0x86, 0x4c), 3 => RgbColor::rgb(0x96, 0x8a, 0x58), 4 => RgbColor::rgb(0xae, 0x8c, 0x62), 5 => RgbColor::rgb(0xc8, 0xa4, 0x72), 6 => RgbColor::rgb(0xde, 0xc0, 0x90), _ => RgbColor::rgb(0xec, 0xea, 0xf4), }
}
fn darken_rgb(color: RgbColor, factor: f32) -> RgbColor {
RgbColor::rgb(
(color.r as f32 * factor) as u8,
(color.g as f32 * factor) as u8,
(color.b as f32 * factor) as u8,
)
}
fn lighten_rgb(color: RgbColor, factor: f32) -> RgbColor {
RgbColor::rgb(
((color.r as f32 * factor).min(255.0)) as u8,
((color.g as f32 * factor).min(255.0)) as u8,
((color.b as f32 * factor).min(255.0)) as u8,
)
}
fn load_item_presentations() -> HashMap<String, MapPresentation> {
let path = find_assets_dir("assets/items");
let Ok(entries) = std::fs::read_dir(&path) else {
return HashMap::new();
};
let fallback = RgbColor::rgb(0x8a, 0x8a, 0x8a);
let mut out = HashMap::new();
let mut files: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.collect();
files.sort();
for file in files {
let Ok(raw) = std::fs::read_to_string(&file) else {
continue;
};
let Ok(root) = serde_yaml::from_str::<ItemGlyphsFile>(&raw) else {
continue;
};
for item in root.items {
let Some(glyph) = item.glyph else {
continue;
};
let color = item
.color
.as_deref()
.and_then(parse_color)
.unwrap_or(fallback);
out.insert(item.template_id, MapPresentation::new(glyph, color));
}
}
out
}
fn load_catalog() -> Catalog {
let mut terrain = HashMap::new();
if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/terrain-kinds.yaml")) {
if let Ok(file) = serde_yaml::from_str::<TerrainKindsFile>(&raw) {
terrain = parse_map(file.terrain_kinds);
}
}
let mut defaults = builtin_defaults();
let items = load_item_presentations();
let mut npcs = HashMap::new();
let mut entities = HashMap::new();
if let Ok(raw) = std::fs::read_to_string(find_assets_path("assets/world/map-glyphs.yaml")) {
if let Ok(file) = serde_yaml::from_str::<MapGlyphsFile>(&raw) {
let base = builtin_defaults();
defaults = DefaultsBlock {
resource: merge_default(file.defaults.resource, &base.resource),
resource_harvesting: merge_default(
file.defaults.resource_harvesting,
&base.resource_harvesting,
),
resource_cooldown: merge_default(
file.defaults.resource_cooldown,
&base.resource_cooldown,
),
npc_wildlife: merge_default(file.defaults.npc_wildlife, &base.npc_wildlife),
npc_friendly: merge_default(file.defaults.npc_friendly, &base.npc_friendly),
player: merge_default(file.defaults.player, &base.player),
corpse: merge_default(file.defaults.corpse, &base.corpse),
loot: merge_default(file.defaults.loot, &base.loot),
chest: merge_default(file.defaults.chest, &base.chest),
chest_locked: merge_default(file.defaults.chest_locked, &base.chest_locked),
door_closed: merge_default(file.defaults.door_closed, &base.door_closed),
door_open: merge_default(file.defaults.door_open, &base.door_open),
wall: merge_default(file.defaults.wall, &base.wall),
well_center: merge_default(file.defaults.well_center, &base.well_center),
quest_board: merge_default(file.defaults.quest_board, &base.quest_board),
};
npcs = parse_map(file.npcs);
entities = parse_map(file.entities);
}
}
Catalog {
terrain,
defaults,
items,
npcs,
entities,
}
}
fn catalog_cell() -> &'static RwLock<Catalog> {
static CELL: OnceLock<RwLock<Catalog>> = OnceLock::new();
CELL.get_or_init(|| RwLock::new(load_catalog()))
}
fn catalog() -> std::sync::RwLockReadGuard<'static, Catalog> {
catalog_cell()
.read()
.expect("map presentation catalog lock")
}
pub fn reload_map_presentation_catalog() {
*catalog_cell()
.write()
.expect("map presentation catalog lock") = load_catalog();
}
pub fn maybe_reload_for_content_rev(content_rev: u64) {
static LAST_REV: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let prev = LAST_REV.load(std::sync::atomic::Ordering::Relaxed);
if content_rev > 0 && content_rev != prev {
LAST_REV.store(content_rev, std::sync::atomic::Ordering::Relaxed);
reload_map_presentation_catalog();
}
}
fn npc_glyph_lookup(c: &Catalog, npc: &NpcView) -> Option<MapPresentation> {
let id_key = npc.id.to_ascii_lowercase();
if let Some(p) = c.npcs.get(&id_key) {
return Some(p.clone());
}
if let Some(prefix) = id_key.split('_').next() {
if let Some(p) = c.npcs.get(prefix) {
return Some(p.clone());
}
}
let label_key = npc.label.to_ascii_lowercase();
c.npcs.get(&label_key).cloned()
}
pub fn terrain_for(kind: TerrainKindView) -> MapPresentation {
catalog()
.terrain
.get(terrain_key(kind))
.cloned()
.unwrap_or_else(|| builtin_terrain(kind))
}
pub fn terrain_for_elevation(kind: TerrainKindView, elevation: f32) -> MapPresentation {
match kind {
TerrainKindView::ShallowWater | TerrainKindView::DeepWater | TerrainKindView::Bog => {
return terrain_for(kind);
}
TerrainKindView::Trail => {
let color = lighten_rgb(elevation_palette(elevation), 1.12);
return MapPresentation::new(":", color);
}
TerrainKindView::Rock => {
let color = darken_rgb(elevation_palette(elevation), 0.62);
let glyph = if elevation >= 3.5 { "▓" } else { "#" };
return MapPresentation::new(glyph, color);
}
_ => {}
}
let base = terrain_for(kind);
let color = elevation_palette(elevation);
if elevation.abs() < 0.25 {
return MapPresentation::new(base.glyph, color);
}
let bucket = elevation.round().clamp(-9.0, 9.0) as i32;
let ch = match bucket {
b if b >= 6 => '▲',
b if b >= 4 => '▲',
b if b >= 2 => '^',
b if b >= 1 => '▴',
b if b <= -1 => '▾',
_ => base.glyph.chars().next().unwrap_or('.'),
};
MapPresentation::new(ch.to_string(), color)
}
pub fn terrain_for_zone(
kind: TerrainKindView,
elevation: f32,
glyph_override: Option<&str>,
color_override: Option<&str>,
) -> MapPresentation {
let mut pres = terrain_for_elevation(kind, elevation);
if let Some(g) = glyph_override.map(str::trim).filter(|s| !s.is_empty()) {
let ch = g.chars().next().unwrap_or('.');
pres.glyph = ch.to_string();
}
if let Some(raw) = color_override.map(str::trim).filter(|s| !s.is_empty()) {
if let Some(color) = parse_color(raw) {
pres.color = color;
}
}
pres
}
pub fn resource_for(node: &ResourceNodeView) -> MapPresentation {
let c = catalog();
match node.state {
ResourceNodeState::Harvesting => c.defaults.resource_harvesting.clone(),
ResourceNodeState::Cooldown => c.defaults.resource_cooldown.clone(),
ResourceNodeState::Available => c
.items
.get(&node.item_template)
.cloned()
.unwrap_or_else(|| c.defaults.resource.clone()),
}
}
pub fn npc_for(npc: &NpcView) -> MapPresentation {
let c = catalog();
if let Some(p) = npc_glyph_lookup(&c, npc) {
return p;
}
if npc.entity_id.is_some() {
return c.defaults.npc_wildlife.clone();
}
c.defaults.npc_friendly.clone()
}
pub fn player_presentation() -> MapPresentation {
catalog().defaults.player.clone()
}
pub fn corpse_presentation() -> MapPresentation {
catalog().defaults.corpse.clone()
}
pub fn loot_presentation() -> MapPresentation {
catalog().defaults.loot.clone()
}
pub fn chest_presentation(locked: bool) -> MapPresentation {
let c = catalog();
if locked {
c.defaults.chest_locked.clone()
} else {
c.defaults.chest.clone()
}
}
pub fn door_presentation(open: bool) -> MapPresentation {
let c = catalog();
if open {
c.defaults.door_open.clone()
} else {
c.defaults.door_closed.clone()
}
}
pub fn wall_presentation() -> MapPresentation {
catalog().defaults.wall.clone()
}
pub fn well_center_presentation() -> MapPresentation {
catalog().defaults.well_center.clone()
}
pub fn quest_board_presentation() -> MapPresentation {
catalog().defaults.quest_board.clone()
}
pub fn shallow_water_presentation() -> MapPresentation {
terrain_for(TerrainKindView::ShallowWater)
}
pub fn entity_fallback(label: &str) -> MapPresentation {
let c = catalog();
let initial = label
.chars()
.next()
.map(|ch| ch.to_ascii_uppercase().to_string())
.unwrap_or_else(|| "?".into());
if let Some(mut p) = c.entities.get("wildlife_other").cloned() {
p.glyph = initial.clone();
return p;
}
MapPresentation::new(initial, RgbColor::YELLOW)
}
#[derive(Debug, Clone)]
pub struct MapLegendEntry {
pub presentation: MapPresentation,
pub label: String,
}
fn legend_push(out: &mut Vec<MapLegendEntry>, pres: &MapPresentation, label: impl Into<String>) {
out.push(MapLegendEntry {
presentation: pres.clone(),
label: label.into(),
});
}
pub fn map_legend_entries() -> Vec<MapLegendEntry> {
let c = catalog();
let mut out = Vec::new();
legend_push(&mut out, &c.defaults.player, "you");
legend_push(&mut out, &c.defaults.loot, "ground loot");
legend_push(&mut out, &c.defaults.corpse, "carcass");
legend_push(&mut out, &c.defaults.door_closed, "door (closed)");
legend_push(&mut out, &c.defaults.door_open, "door (open)");
legend_push(&mut out, &c.defaults.wall, "wall");
legend_push(&mut out, &c.defaults.well_center, "well");
legend_push(&mut out, &c.defaults.resource_harvesting, "harvesting node");
legend_push(&mut out, &c.defaults.resource_cooldown, "node cooldown");
let mut terrain: Vec<_> = c.terrain.iter().collect();
terrain.sort_by_key(|(k, _)| *k);
if terrain.is_empty() {
for kind in [
TerrainKindView::Grass,
TerrainKindView::Hill,
TerrainKindView::Trail,
TerrainKindView::Rock,
TerrainKindView::Bog,
TerrainKindView::ShallowWater,
TerrainKindView::DeepWater,
] {
let pres = terrain_for(kind);
legend_push(&mut out, &pres, terrain_key(kind).replace('_', " "));
}
} else {
for (kind, pres) in terrain {
legend_push(&mut out, pres, kind.replace('_', " "));
}
}
let mut npcs: Vec<_> = c.npcs.iter().collect();
npcs.sort_by_key(|(k, _)| *k);
for (id, pres) in npcs {
legend_push(&mut out, pres, format!("{id}"));
}
let mut items: Vec<_> = c.items.iter().collect();
items.sort_by_key(|(k, _)| *k);
for (id, pres) in items {
legend_push(&mut out, pres, id.replace('_', " "));
}
out
}
pub fn format_map_legend_plain() -> String {
let mut out = String::from("Map legend\n");
for entry in map_legend_entries() {
out.push_str(&format!(
" {} {}\n",
entry.presentation.glyph, entry.label
));
}
out.push_str(" [red cell] T1 combat target\n");
out.push_str(" [blue cell] T2 combat target\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_named_and_hex_colors() {
assert_eq!(parse_color("cyan"), Some(RgbColor::CYAN));
assert_eq!(parse_color("#0af"), Some(RgbColor::rgb(0x00, 0xaa, 0xff)));
}
#[test]
fn terrain_and_resource_catalog_load() {
let oak = resource_for(&ResourceNodeView {
id: "t".into(),
label: "Oak".into(),
x: 0.0,
y: 0.0,
z: 0.0,
item_template: "oak_log".into(),
state: ResourceNodeState::Available,
blocking: false,
blocking_radius_m: 0.8,
tile_id: None,
sprite_mode: None,
presentation_state: None,
});
assert_eq!(oak.glyph, "?");
let rabbit = npc_for(&NpcView {
id: "r1".into(),
label: "Rabbit".into(),
role: "critter".into(),
x: 0.0,
y: 0.0,
building_id: None,
entity_id: Some(2),
life_state: None,
hp_pct: None,
can_trade: false,
tile_id: None,
behavior_state: None,
sprite_mode: None,
presentation_state: None,
});
assert_eq!(rabbit.glyph, "N");
let jack = npc_for(&NpcView {
id: "jack_wanderer".into(),
label: "Jack".into(),
role: "villager".into(),
x: 0.0,
y: 0.0,
building_id: None,
entity_id: None,
life_state: None,
hp_pct: None,
can_trade: false,
tile_id: None,
behavior_state: None,
sprite_mode: None,
presentation_state: None,
});
assert_eq!(jack.glyph, "N");
assert_eq!(jack.color, parse_color("cyan").expect("cyan"));
}
#[test]
fn trail_and_rock_use_distinct_glyphs_with_elevation_tint() {
let trail = terrain_for_elevation(TerrainKindView::Trail, 2.0);
assert_eq!(trail.glyph, ":");
let rock = terrain_for_elevation(TerrainKindView::Rock, 4.0);
assert_eq!(rock.glyph, "▓");
let low = terrain_for_elevation(TerrainKindView::Grass, 0.0);
let high = terrain_for_elevation(TerrainKindView::Grass, 4.0);
assert_ne!(low.color, high.color);
}
#[test]
fn terrain_for_zone_applies_glyph_and_color_overrides() {
let base = terrain_for_elevation(TerrainKindView::Grass, 0.0);
let custom = terrain_for_zone(TerrainKindView::Grass, 0.0, Some("%"), Some("blue"));
assert_eq!(custom.glyph, "%");
assert_eq!(custom.color, parse_color("blue").expect("blue"));
assert_ne!(custom.glyph, base.glyph);
assert_ne!(custom.color, base.color);
}
}