Wilder maps: rivers of water and lava across free maps (neutral bridges), natural caverns until about a quarter of every map is open

- caverns in free play and the campaign (mission pieces, fortresses, islands, sealed caves and dwarf caves stay untouched)
- caverns sometimes hold a pool of water or lava, veins in their walls, rarely a small treasure or a creature in chains to free
- rivers keep away from the start; test: open share, liquids and a hero path to the core
- standalone file in play/ rebuilt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-04 08:15:34 +01:00
1 parent 51814b8ac8
commit 2de0a1de8a
6 files changed
+198 -2

No files matched your search

+1
View File
@@ -504,6 +504,7 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game {
g.add_rival(side, x, y, diff);
}
g.place_dwarf_caves(d.seed);
g.add_wilds(d.seed, false);
g.init_research(Some(i));
g.init_fog();
let heart = d.gate || d.forts.iter().any(|f| f.heart);
+2 -1
View File
@@ -629,6 +629,7 @@ impl Game {
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
g.guard_treasures();
g.place_dwarf_caves(seed);
g.add_wilds(seed, true);
g.init_research(None);
g.init_fog();
g.msg("msg.welcome1", vec![]);
@@ -5133,7 +5134,7 @@ mod tests {
// rubies fill their own tiles
g.add_res(1200.0, 2);
g.update(0.01);
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.res == 2).map(|t| t.gold).sum();
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res == 2).map(|t| t.gold).sum();
assert_eq!(rubies, 1200);
assert!(g.map.t.iter().all(|t| t.gold <= TREASURY_TILE as i32 || t.vein().is_some()));
// spending empties tiles (randomly), the total always matches the gold counter
+1
View File
@@ -30,6 +30,7 @@ mod sound;
mod spells;
mod touch;
mod ui;
mod wilds;
mod xm;
use cloud::{Cloud, CloudEvent};
+1
View File
@@ -85,6 +85,7 @@ impl Game {
g.msg("msg.welcome1", vec![]);
g.msg("msg.welcome2", vec![]);
g.msg(if rivals == 0 { "msg.welcome_humans" } else if humans { "msg.welcome_both" } else { "msg.welcome_rivals" }, vec![rivals.to_string()]);
g.add_wilds(seed, true);
g.init_research(None);
g.init_fog();
g.place_dwarf_caves(seed);
+192
View File
@@ -0,0 +1,192 @@
// The wild underground: rivers of water and lava across free maps (with neutral bridges) and
// natural caverns until about a quarter of the map is open - some with a pool, veins along the
// walls, now and then a small treasure or a creature in chains waiting to be freed.
// Everything the map or the mission placed on purpose (core, portals, gate, fortresses,
// islands, sealed caves, dwarf caves, rival dungeons) is left alone.
use crate::game::*;
use crate::map::*;
const OPEN_SHARE: f32 = 0.25;
impl Game {
pub fn add_wilds(&mut self, seed: u32, rivers: bool) {
let mut rng = Rng(seed.wrapping_mul(0x2c1b_3c6d) ^ 0x297a_2d39 | 1);
for _ in 0..6 {
rng.next();
}
let (w, h) = (mw() as i32, mh() as i32);
let area = (w * h) as f32;
if rivers {
// one river per ~100x100 tiles, at least one; some of lava
let n = ((area / 10000.0).round() as i32).clamp(1, 4);
for k in 0..n {
// away from the start: vertical rivers in the right part, horizontal ones above or below the core
let vertical = rng.f() < 0.5;
let pos = if vertical { 0.42 + rng.f() * 0.4 } else if rng.f() < 0.5 { 0.12 + rng.f() * 0.18 } else { 0.7 + rng.f() * 0.18 };
let r = River { lava: rng.f() < 0.4, vertical, pos, width: 3 + (rng.f() < 0.5) as i32, bridges: 2 + (area > 15000.0) as u32 };
let bridges = self.map.carve_river(&r, seed.wrapping_add(k as u32 * 7919));
// free maps: the bridges belong to nobody (anybody may claim them)
for b in bridges {
self.map.t[b].owner = Owner::None;
}
}
}
// what must not be touched: owned ground, buildings, mission pieces and their surroundings
let n = self.map.t.len();
let mut keep = vec![false; n];
let mark = |keep: &mut Vec<bool>, x: i32, y: i32, r: i32| {
for yy in y - r..=y + r {
for xx in x - r..=x + r {
if inb(xx, yy) {
keep[idx(xx, yy)] = true;
}
}
}
};
for i in 0..n {
let t = &self.map.t[i];
let special = t.owner != Owner::None
|| matches!(t.kind, Kind::Core | Kind::Portal | Kind::Gate | Kind::Tower | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest | Kind::Bridge | Kind::LavaBridge);
if special {
let (x, y) = xy(i);
mark(&mut keep, x, y, 3);
}
}
for c in self.dwarf_caves.clone() {
mark(&mut keep, c.at.0, c.at.1, 7);
}
for e in self.ents.iter().filter(|e| e.alive && (e.chained || e.hero || e.dwarf() || e.rival())) {
mark(&mut keep, e.x as i32, e.y as i32, 4);
}
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
mark(&mut keep, cx, cy, 9);
let open = |m: &Map| m.t.iter().filter(|t| !t.solid() && t.kind != Kind::Rock).count() as f32;
let mut tries = 0;
while open(&self.map) < area * OPEN_SHARE && tries < 400 {
tries += 1;
let (x, y) = (rng.range(4, w - 5), rng.range(4, h - 5));
if keep[idx(x, y)] {
continue;
}
self.carve_cavern(x, y, &keep, &mut rng);
}
}
// a cavern: a few overlapping blobs, ragged edges
fn carve_cavern(&mut self, x: i32, y: i32, keep: &[bool], rng: &mut Rng) {
let (w, h) = (mw() as i32, mh() as i32);
let blobs = 2 + rng.range(0, 3);
let mut tiles: Vec<usize> = Vec::new();
let (mut bx, mut by) = (x, y);
for _ in 0..blobs {
let r = rng.range(2, 5);
for yy in by - r..=by + r {
for xx in bx - r..=bx + r {
if xx < 3 || yy < 3 || xx >= w - 3 || yy >= h - 3 {
continue;
}
let d = (((xx - bx).pow(2) + (yy - by).pow(2)) as f32).sqrt();
if d > r as f32 + 0.3 - rng.f() * 0.9 {
continue;
}
let i = idx(xx, yy);
let t = &self.map.t[i];
if keep[i] || !t.solid() || t.kind == Kind::Rock || t.kind == Kind::Steel {
continue;
}
tiles.push(i);
}
}
bx += rng.range(-r, r);
by += rng.range(-r, r);
}
if tiles.len() < 6 {
return;
}
for &i in &tiles {
let t = &mut self.map.t[i];
t.kind = Kind::Floor;
t.owner = Owner::None;
t.gold = 0;
t.marked = false;
t.reinforced = false;
}
// veins in the walls around
for &i in &tiles {
let (tx, ty) = xy(i);
for &(dx, dy) in &DIR4 {
let (nx, ny) = (tx + dx, ty + dy);
if !inb(nx, ny) || keep[idx(nx, ny)] {
continue;
}
let t = &mut self.map.t[idx(nx, ny)];
if t.kind == Kind::Earth && rng.f() < 0.07 {
let r = rng.f();
let (k, g) = if r < 0.06 { (Kind::Ruby, rng.range(500, 900)) } else if r < 0.3 { (Kind::Silver, rng.range(300, 600)) } else { (Kind::Gold, rng.range(400, 900)) };
t.kind = k;
t.gold = g;
t.hp = 6.0;
}
}
}
// the middle: sometimes a pool, rarely a treasure or a creature in chains
let (sx, sy) = tiles.iter().fold((0i64, 0i64), |a, &i| (a.0 + xy(i).0 as i64, a.1 + xy(i).1 as i64));
let (mx, my) = ((sx / tiles.len() as i64) as i32, (sy / tiles.len() as i64) as i32);
let inside = |m: &Map, x: i32, y: i32| inb(x, y) && m.t[idx(x, y)].kind == Kind::Floor && m.t[idx(x, y)].owner == Owner::None;
let roll = rng.f();
if tiles.len() > 30 && roll < 0.35 {
let kind = if rng.f() < 0.4 { Kind::Lava } else { Kind::Water };
for yy in my - 1..=my + 1 {
for xx in mx - 1..=mx + 1 {
// only where floor surrounds it, so the cavern stays passable around the pool
if inside(&self.map, xx, yy) && DIR8.iter().all(|&(dx, dy)| !self.map.get(xx + dx, yy + dy).map_or(true, |t| t.solid())) {
self.map.t[idx(xx, yy)].kind = kind;
}
}
}
} else if roll < 0.47 && inside(&self.map, mx, my) {
for &(dx, dy) in &[(0, 0), (1, 0)] {
if inside(&self.map, mx + dx, my + dy) {
let t = &mut self.map.t[idx(mx + dx, my + dy)];
t.kind = Kind::Treasury;
t.gold = rng.range(200, 450);
t.res = (rng.f() < 0.3) as u8 * 2;
}
}
} else if roll < 0.53 && inside(&self.map, mx, my) {
let kinds = [EK::Spider, EK::Kobold, EK::Ogre, EK::Bat, EK::Mage];
let kind = kinds[rng.range(0, kinds.len() as i32 - 1) as usize];
let level = rng.range(2, 4) as u32;
let id = self.spawn(kind, false, mx as f32 + 0.5, my as f32 + 0.5, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.chained = true;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn open_share(g: &Game) -> f32 {
g.map.t.iter().filter(|t| !t.solid() && t.kind != Kind::Rock).count() as f32 / g.map.t.len() as f32
}
#[test]
fn free_maps_have_rivers_and_caverns_and_the_heroes_find_a_way() {
for seed in 1..=8u32 {
for &(w, h) in &[(64usize, 64usize), (128, 128)] {
let g = Game::new(seed, w, h);
let share = open_share(&g);
assert!(share > 0.2, "seed {} {}x{}: only {:.0}% open", seed, w, h, share * 100.0);
assert!(g.map.t.iter().any(|t| t.liquid()), "rivers or pools");
let (gx, gy) = g.map.gate;
assert!(g.map.hero_path_to_core(idx(gx + 1, gy + 1)).is_some(), "seed {}: heroes reach the core", seed);
let (cx, cy) = g.map.core;
assert_eq!(g.map.t[idx(cx + 1, cy + 1)].kind, Kind::Core);
}
}
}
}