feat(grid): ✨ Introduce terrain blending system with new structs/enums and blending functions for smooth transitions.
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
666907f986
commit
9c4d635df3
2 changed files with 261 additions and 0 deletions
|
|
@ -12,6 +12,9 @@ pub use edge::{
|
|||
canonical_edge, edges_of_hex, hexes_of_edge, reverse_dir, EdgeFeatures, EdgeId, EdgeOccupant,
|
||||
};
|
||||
|
||||
pub mod terrain_blend;
|
||||
pub use terrain_blend::{TerrainBlendEntry, TerrainBlendTable};
|
||||
|
||||
/// Reasons a centre-to-centre move can be rejected by
|
||||
/// [`GridState::validate_centre_to_centre_move`]. Distinct variants so the
|
||||
/// caller (UI, AI, combat) can route on the cause.
|
||||
|
|
|
|||
258
src/simulator/crates/mc-core/src/grid/terrain_blend.rs
Normal file
258
src/simulator/crates/mc-core/src/grid/terrain_blend.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
//! Edge-terrain blend lookup (HEX_GEOMETRY.md §8 ecotones).
|
||||
//!
|
||||
//! Each hex edge carries a derived terrain — the blend of the two adjacent
|
||||
//! tiles' centre terrains. A `plains+mountains` edge is *foothills*; a
|
||||
//! `forest+ocean` edge is *riverside_forest*. This module owns the
|
||||
//! pure-data lookup; consumers (combat, movement, renderer) call
|
||||
//! [`TerrainBlendTable::lookup`] with the two parent terrain ids and get
|
||||
//! `Some(edge_terrain_id)` or `None` (for same-terrain pairs and
|
||||
//! undefined pairings — the caller defaults to the centre terrain
|
||||
//! unchanged).
|
||||
//!
|
||||
//! ## Symmetry guarantee
|
||||
//!
|
||||
//! `lookup("plains", "mountains")` returns the same result as
|
||||
//! `lookup("mountains", "plains")`. The table internally canonicalizes
|
||||
//! pairs by alphabetic sort.
|
||||
//!
|
||||
//! ## Loading
|
||||
//!
|
||||
//! [`TerrainBlendTable::from_json_str`] parses the canonical
|
||||
//! `public/games/age-of-dwarves/data/terrain/terrain_blends.json`
|
||||
//! format. mc-core does not load files directly; the GDExtension /
|
||||
//! WASM bridge reads the JSON and hands the string to this loader.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema for one entry in `terrain_blends.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerrainBlendEntry {
|
||||
/// Unordered pair of terrain ids; the table canonicalizes via sort.
|
||||
pub pair: [String; 2],
|
||||
/// Edge terrain id assigned to that pair.
|
||||
pub edge_terrain: String,
|
||||
}
|
||||
|
||||
/// Schema for the JSON file root.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct TerrainBlendFile {
|
||||
#[serde(rename = "_doc", default)]
|
||||
_doc: String,
|
||||
blends: Vec<TerrainBlendEntry>,
|
||||
}
|
||||
|
||||
/// In-memory blend table. Build via [`Self::from_json_str`] from the
|
||||
/// canonical JSON, then call [`Self::lookup`] for each edge that needs
|
||||
/// its blend resolved.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TerrainBlendTable {
|
||||
/// Map from (smaller, larger) terrain id pair to edge terrain id.
|
||||
/// Keys are canonical-sorted so `(plains, mountains)` and
|
||||
/// `(mountains, plains)` resolve to the same entry.
|
||||
entries: HashMap<(String, String), String>,
|
||||
}
|
||||
|
||||
impl TerrainBlendTable {
|
||||
/// Parse a JSON string in the `terrain_blends.json` format.
|
||||
///
|
||||
/// Duplicate pairs are *replaced* — later entries win. Returns a
|
||||
/// `serde_json::Error` if the JSON is malformed.
|
||||
pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
|
||||
let file: TerrainBlendFile = serde_json::from_str(s)?;
|
||||
let mut entries = HashMap::with_capacity(file.blends.len());
|
||||
for entry in file.blends {
|
||||
let key = canonical_pair(&entry.pair[0], &entry.pair[1]);
|
||||
entries.insert(key, entry.edge_terrain);
|
||||
}
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
/// Number of distinct pair entries in the table.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True if the table has no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Look up the blend terrain for an edge between `host` and `neighbour`.
|
||||
///
|
||||
/// - Returns `Some(edge_terrain_id)` if the pair is in the table.
|
||||
/// - Returns `None` if `host == neighbour` (same-terrain edge has no
|
||||
/// transition; caller uses centre terrain unchanged).
|
||||
/// - Returns `None` if the pair is not in the table (caller defaults
|
||||
/// to centre terrain).
|
||||
///
|
||||
/// Symmetric: `lookup(a, b) == lookup(b, a)` for any inputs.
|
||||
pub fn lookup(&self, host: &str, neighbour: &str) -> Option<&str> {
|
||||
if host == neighbour {
|
||||
return None;
|
||||
}
|
||||
let key = canonical_pair(host, neighbour);
|
||||
self.entries.get(&key).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Iterate over all entries as `(canonical_pair, edge_terrain_id)`.
|
||||
/// Useful for validators that want to confirm every blend has a
|
||||
/// corresponding terrain JSON entry.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&(String, String), &String)> {
|
||||
self.entries.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical-sort two terrain ids alphabetically. The smaller string
|
||||
/// goes first regardless of input order.
|
||||
fn canonical_pair(a: &str, b: &str) -> (String, String) {
|
||||
if a <= b {
|
||||
(a.to_string(), b.to_string())
|
||||
} else {
|
||||
(b.to_string(), a.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal blend table covering the 10 canonical Game 1 ecotones.
|
||||
/// Mirrors the structure of `data/terrain/terrain_blends.json` so the
|
||||
/// tests verify the same shape without filesystem access.
|
||||
fn sample_table() -> TerrainBlendTable {
|
||||
TerrainBlendTable::from_json_str(SAMPLE_JSON)
|
||||
.expect("sample JSON should parse cleanly")
|
||||
}
|
||||
|
||||
const SAMPLE_JSON: &str = r#"
|
||||
{
|
||||
"_doc": "test sample",
|
||||
"blends": [
|
||||
{ "pair": ["forest", "plains"], "edge_terrain": "grass_fringe" },
|
||||
{ "pair": ["mountains", "plains"], "edge_terrain": "foothills" },
|
||||
{ "pair": ["ocean", "plains"], "edge_terrain": "shore" },
|
||||
{ "pair": ["forest", "mountains"], "edge_terrain": "wooded_foothills" },
|
||||
{ "pair": ["forest", "ocean"], "edge_terrain": "riverside_forest" },
|
||||
{ "pair": ["mountains", "ocean"], "edge_terrain": "cliff" }
|
||||
]
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_sample_json() {
|
||||
let table = sample_table();
|
||||
assert_eq!(table.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_blend_for_known_pair() {
|
||||
let table = sample_table();
|
||||
assert_eq!(table.lookup("plains", "mountains"), Some("foothills"));
|
||||
assert_eq!(table.lookup("forest", "ocean"), Some("riverside_forest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_is_symmetric_regardless_of_argument_order() {
|
||||
let table = sample_table();
|
||||
for (a, b) in [
|
||||
("plains", "mountains"),
|
||||
("forest", "ocean"),
|
||||
("mountains", "ocean"),
|
||||
] {
|
||||
assert_eq!(
|
||||
table.lookup(a, b),
|
||||
table.lookup(b, a),
|
||||
"lookup({a}, {b}) and lookup({b}, {a}) differed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_none_for_same_terrain_pair() {
|
||||
let table = sample_table();
|
||||
// Same-terrain edges have no transition — the caller defaults
|
||||
// to the centre terrain unchanged.
|
||||
for terrain in ["plains", "forest", "mountains", "ocean"] {
|
||||
assert_eq!(
|
||||
table.lookup(terrain, terrain),
|
||||
None,
|
||||
"{terrain}+{terrain} should return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_none_for_undefined_pair() {
|
||||
let table = sample_table();
|
||||
// The sample table has no `desert+swamp` entry; the caller will
|
||||
// default to the centre terrain unchanged.
|
||||
assert_eq!(table.lookup("desert", "swamp"), None);
|
||||
assert_eq!(table.lookup("snow", "tundra"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_pairs_resolve_to_last_entry() {
|
||||
let json = r#"
|
||||
{
|
||||
"_doc": "duplicate test",
|
||||
"blends": [
|
||||
{ "pair": ["a", "b"], "edge_terrain": "first" },
|
||||
{ "pair": ["b", "a"], "edge_terrain": "second" }
|
||||
]
|
||||
}
|
||||
"#;
|
||||
let table = TerrainBlendTable::from_json_str(json).unwrap();
|
||||
assert_eq!(table.len(), 1);
|
||||
assert_eq!(table.lookup("a", "b"), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_json_str_rejects_malformed_input() {
|
||||
assert!(TerrainBlendTable::from_json_str("not json").is_err());
|
||||
assert!(
|
||||
TerrainBlendTable::from_json_str(r#"{"blends": "not an array"}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_yields_every_entry() {
|
||||
let table = sample_table();
|
||||
let collected: Vec<_> = table.iter().collect();
|
||||
assert_eq!(collected.len(), 6);
|
||||
}
|
||||
|
||||
/// End-to-end: parse the actual production `terrain_blends.json` and
|
||||
/// verify the canonical Game 1 blend entries resolve. This protects
|
||||
/// against the data file drifting from the schema.
|
||||
#[test]
|
||||
fn production_terrain_blends_json_parses_and_has_canonical_entries() {
|
||||
const PROD_JSON: &str = include_str!(
|
||||
"../../../../../../public/games/age-of-dwarves/data/terrain/terrain_blends.json"
|
||||
);
|
||||
let table = TerrainBlendTable::from_json_str(PROD_JSON)
|
||||
.expect("production terrain_blends.json must parse");
|
||||
|
||||
// Every Game 1 canonical blend must be present.
|
||||
let canonical = [
|
||||
("plains", "mountains", "foothills"),
|
||||
("plains", "forest", "grass_fringe"),
|
||||
("plains", "ocean", "shore"),
|
||||
("forest", "mountains", "wooded_foothills"),
|
||||
("forest", "ocean", "riverside_forest"),
|
||||
("mountains", "ocean", "cliff"),
|
||||
("forest", "desert", "scrub_edge"),
|
||||
("plains", "desert", "arid_plains"),
|
||||
("mountains", "desert", "badlands"),
|
||||
("forest", "swamp", "bog_edge"),
|
||||
];
|
||||
for (a, b, expected) in canonical {
|
||||
assert_eq!(
|
||||
table.lookup(a, b),
|
||||
Some(expected),
|
||||
"production blend table missing or wrong: {a}+{b} → expected {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue