diff --git a/.gitignore b/.gitignore index 50b6338..fc9a223 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/node_modules \ No newline at end of file +/node_modules +out.png \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 619060f..86b52e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.5.0" @@ -71,6 +77,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytemuck" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b236fc92302c97ed75b38da1f4917b5cdda4984745740f153a5d3059e48d725e" + [[package]] name = "byteorder" version = "1.5.0" @@ -104,6 +116,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + [[package]] name = "crypto-common" version = "0.1.6" @@ -130,6 +176,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "equivalent" version = "1.0.1" @@ -155,9 +207,11 @@ dependencies = [ "fastrand", "futures-util", "hecs", + "image", "indexmap", "lazy_static", "noise-functions", + "rayon", "seahash", "serde", "serde_json", @@ -173,6 +227,25 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "fdeflate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9bfee30e4dedf0ab8b422f03af778d9612b63f502710fc500a334ebe2de645" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -293,6 +366,18 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +[[package]] +name = "image" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" +dependencies = [ + "bytemuck", + "byteorder", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -350,6 +435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" dependencies = [ "adler", + "simd-adler32", ] [[package]] @@ -438,6 +524,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "png" +version = "0.17.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -492,13 +591,33 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags", + "bitflags 2.5.0", ] [[package]] @@ -576,6 +695,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" diff --git a/Cargo.toml b/Cargo.toml index c909bdc..dffb455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,6 @@ name = "ewo3" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] hecs = { version = "0.10", features = ["column-serialize"] } euclid = { version = "0.22", features = ["serde"] } @@ -20,4 +18,10 @@ slab = "0.4" lazy_static = "1" seahash = "4" noise-functions = "0.2" -indexmap = "2" \ No newline at end of file +indexmap = "2" +image = { version = "0.25", default-features = false, features = ["png"] } +rayon = "1" + +[[bin]] +name = "worldgen" +path = "src/worldgen_test.rs" \ No newline at end of file diff --git a/src/coordtest.py b/src/coordtest.py new file mode 100644 index 0000000..3921204 --- /dev/null +++ b/src/coordtest.py @@ -0,0 +1,29 @@ +from collections import namedtuple +import math + +Coord = namedtuple("Coord", ["q", "r"]) + +def naivec2i(c, rad): + q = c.q + rad + r = c.r + rad + coords_above = 0 + for rprime in range(r): + coords_above += 2*rad+1 - abs(rad-rprime) + q_start = -c.r if r < rad else c.r + return coords_above + (q - q_start) + +def fastc2i(c, rad): + q = c.q + rad + r = c.r + rad + fh = min(r, rad) + coords_above = fh*(rad+1) + fh*(fh-1)//2 + if fh < r: + d = r - fh + coords_above += d*(2*rad+1) - d*(d-1)//2 + q_start = r if r < rad else -rad + print(coords_above, q, q_start, q) + return coords_above + (c.q - q_start) + +print(naivec2i(Coord(-512, 1), 512)) +q, r = 0, -2 +print(naivec2i(Coord(q, r), 2), fastc2i(Coord(q, r), 2)) \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 092146c..0e1d5cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,6 @@ use hecs::{CommandBuffer, Entity, World}; -use euclid::{Point3D, Point2D, Vector2D}; use futures_util::{stream::TryStreamExt, SinkExt, StreamExt}; use indexmap::IndexMap; -use noise_functions::Sample3; use tokio::net::{TcpListener, TcpStream}; use tokio_tungstenite::tungstenite::protocol::Message; use tokio::sync::{mpsc, Mutex}; @@ -11,29 +9,10 @@ use std::{collections::{hash_map::Entry, HashMap, HashSet, VecDeque}, convert::T use slab::Slab; use serde::{Serialize, Deserialize}; -struct AxialWorldSpace; -struct CubicWorldSpace; -type Coord = Point2D; -type CubicCoord = Point3D; -type CoordVec = Vector2D; +pub mod worldgen; +pub mod map; -fn to_cubic(p0: Coord) -> CubicCoord { - CubicCoord::new(p0.x, p0.y, -p0.x - p0.y) -} - -fn hex_distance(p0: Coord, p1: Coord) -> i64 { - let ax_dist = p0 - p1; - (ax_dist.x.abs() + ax_dist.y.abs() + (ax_dist.x + ax_dist.y).abs()) / 2 -} - -fn on_axis(p: CoordVec) -> bool { - let p = to_cubic(Coord::origin() + p); - let mut zero_ax = 0; - if p.x == 0 { zero_ax += 1 } - if p.y == 0 { zero_ax += 1 } - if p.z == 0 { zero_ax += 1 } - zero_ax >= 1 -} +use map::*; async fn handle_connection(raw_stream: TcpStream, addr: SocketAddr, mut frames_rx: mpsc::Receiver, inputs_tx: mpsc::Sender) -> Result<()> { let ws_stream = tokio_tungstenite::accept_async(raw_stream).await.context("websocket handshake failure")?; @@ -105,7 +84,8 @@ struct Client { struct GameState { world: World, clients: Slab, - ticks: u64 + ticks: u64, + map: worldgen::GeneratedWorld } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -255,57 +235,7 @@ impl Inventory { } const VIEW: i64 = 15; -const WALL: i64 = 128; const RANDOM_DESPAWN_INV_RATE: u64 = 4000; -const DIRECTIONS: &[CoordVec] = &[CoordVec::new(0, -1), CoordVec::new(1, -1), CoordVec::new(-1, 0), CoordVec::new(1, 0), CoordVec::new(0, 1), CoordVec::new(-1, 1)]; - -#[derive(Debug, Clone, PartialEq, Eq)] -enum BaseTerrain { - Empty, - Occupied, - VeryOccupied -} - -impl BaseTerrain { - fn can_enter(&self) -> bool { - *self == BaseTerrain::Empty - } - - fn symbol(&self) -> Option { - match *self { - Self::Empty => None, - Self::Occupied => Some('#'), - Self::VeryOccupied => Some('█') - } - } -} - -const NOISE_SCALE: f32 = 0.05; - -fn get_base_terrain(pos: Coord) -> BaseTerrain { - let distance = hex_distance(pos, Coord::origin()); - if distance >= (WALL + 12) { - return BaseTerrain::VeryOccupied - } - if distance >= WALL { - return BaseTerrain::Occupied - } - let pos = to_cubic(pos); - let noise = noise_functions::CellDistance.ridged(2, 1.00, 0.20).seed(406).sample3([pos.x as f32 * NOISE_SCALE, pos.y as f32 * NOISE_SCALE, pos.z as f32 * NOISE_SCALE]); - if noise >= 0.3 { - return BaseTerrain::VeryOccupied - } - if noise >= 0.2 { - return BaseTerrain::Occupied - } - return BaseTerrain::Empty -} - -fn sample_range(range: i64) -> CoordVec { - let q = fastrand::i64(-range..=range); - let r = fastrand::i64((-range).max(-q-range)..=range.min(-q+range)); - CoordVec::new(q, r) -} struct EnemySpec { symbol: char, @@ -335,10 +265,6 @@ impl EnemySpec { } } -fn count_hexes(x: i64) -> i64 { - x*(x+1)*3+1 -} - fn rng_from_hash(x: H) -> fastrand::Rng { let mut h = seahash::SeaHasher::new(); x.hash(&mut h); @@ -425,7 +351,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } newpos = pos + sample_range(*spawn_range.end()); } - if !occupied && get_base_terrain(newpos).can_enter() && hex_distance(newpos, pos) >= *spawn_range.start() { + if !occupied && state.map.get_terrain(newpos).entry_cost().is_some() && hex_distance(newpos, pos) >= *spawn_range.start() { let mut spec = EnemySpec::random(); spec.drops.push((Item::Bone, StochasticNumber::Triangle { min: 0.7 * spec.initial_health / 40.0, max: 1.3 * spec.initial_health / 40.0, mode: spec.initial_health / 40.0 })); if spec.ranged { @@ -558,7 +484,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } let target = position + next_movement; - if get_base_terrain(target).can_enter() && target != position { + if state.map.get_terrain(target).entry_cost().is_some() && target != position { buffer.insert_one(client.entity, MovingInto(target)); } } @@ -568,6 +494,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let mut despawn_buffer = HashSet::new(); // This might lead to a duping glitch, which would at least be funny. + // TODO: Players should drop items on disconnect. let kill = |buffer: &mut CommandBuffer, despawn_buffer: &mut HashSet, state: &GameState, entity: Entity, killer: Option, position: Option| { let position = position.unwrap_or_else(|| state.world.get::<&Position>(entity).unwrap().head()); despawn_buffer.insert(entity); @@ -618,7 +545,8 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } } - if get_base_terrain(*target_pos).can_enter() { + if let Some(entry_cost) = state.map.get_terrain(*target_pos).entry_cost() { + move_cost += entry_cost as f32; let entry = match positions.entry(*target_pos) { Entry::Occupied(o) => { let target_entity = *o.get(); @@ -696,28 +624,24 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let mut nearby = vec![]; if let Ok(pos) = state.world.get::<&Position>(client.entity) { let pos = pos.head(); - for q in -VIEW..=VIEW { - for r in (-VIEW).max(-q - VIEW)..= VIEW.min(-q+VIEW) { - let offset = CoordVec::new(q, r); - let pos = pos + offset; - if let Some(symbol) = get_base_terrain(pos).symbol() { - nearby.push((q, r, symbol, 1.0)); - } else { - if let Some(entity) = positions.get(&pos) { - let render = state.world.get::<&Render>(*entity)?; - let health = if let Ok(h) = state.world.get::<&Health>(*entity) { - h.pct() - } else { 1.0 }; - nearby.push((q, r, render.0, health)); - } else if let Some(entity) = terrain_positions.get(&pos) { - let render = state.world.get::<&Render>(*entity)?; - nearby.push((q, r, render.0, 1.0)); - } else { - let mut rng = rng_from_hash(pos); - let bg = if rng.usize(0..10) == 0 { ',' } else { '.' }; - nearby.push((q, r, bg, rng.f32() * 0.1 + 0.9)) - } - } + for offset in hex_circle(VIEW) { + let pos = pos + offset; + let mut rng = rng_from_hash(pos); + + if let Some(entity) = positions.get(&pos) { + let render = state.world.get::<&Render>(*entity)?; + let health = if let Ok(h) = state.world.get::<&Health>(*entity) { + h.pct() + } else { 1.0 }; + nearby.push((offset.x, offset.y, render.0, health)); + } else if let Some(entity) = terrain_positions.get(&pos) { + let render = state.world.get::<&Render>(*entity)?; + nearby.push((offset.x, offset.y, render.0, 1.0)); + } else if let Some(terrain) = state.map.get_terrain(pos).symbol() { + nearby.push((offset.x, offset.y, terrain, rng.f32() * 0.1 + 0.9)); + } else { + let bg = if rng.usize(0..10) == 0 { ',' } else { '.' }; + nearby.push((offset.x, offset.y, bg, rng.f32() * 0.1 + 0.9)) } } let health = state.world.get::<&Health>(client.entity)?.0; @@ -756,8 +680,8 @@ fn random_identifier() -> char { fn add_new_player(state: &mut GameState) -> Result { let pos = loop { - let pos = Coord::origin() + sample_range(WALL - 10); - if get_base_terrain(pos).can_enter() { + let pos = Coord::origin() + sample_range(state.map.radius() - 10); + if state.map.get_terrain(pos).entry_cost().is_some() { break pos; } }; @@ -786,7 +710,8 @@ async fn main() -> Result<()> { let state = Arc::new(Mutex::new(GameState { world: World::new(), clients: Slab::new(), - ticks: 0 + ticks: 0, + map: worldgen::generate_world() })); let try_socket = TcpListener::bind(&addr).await; diff --git a/src/map.rs b/src/map.rs new file mode 100644 index 0000000..b9699cd --- /dev/null +++ b/src/map.rs @@ -0,0 +1,55 @@ +use euclid::{Point3D, Point2D, Vector2D}; + +pub struct AxialWorldSpace; +pub struct CubicWorldSpace; +pub type Coord = Point2D; +pub type CubicCoord = Point3D; +pub type CoordVec = Vector2D; + +pub fn to_cubic(p0: Coord) -> CubicCoord { + CubicCoord::new(p0.x, p0.y, -p0.x - p0.y) +} + +pub fn hex_distance(p0: Coord, p1: Coord) -> i64 { + let ax_dist = p0 - p1; + (ax_dist.x.abs() + ax_dist.y.abs() + (ax_dist.x + ax_dist.y).abs()) / 2 +} + +pub fn on_axis(p: CoordVec) -> bool { + let p = to_cubic(Coord::origin() + p); + let mut zero_ax = 0; + if p.x == 0 { zero_ax += 1 } + if p.y == 0 { zero_ax += 1 } + if p.z == 0 { zero_ax += 1 } + zero_ax >= 1 +} + +pub fn rotate_60(p0: CoordVec) -> CoordVec { + let s = -p0.x - p0.y; + CoordVec::new(s, p0.x) +} + +pub const DIRECTIONS: &[CoordVec] = &[CoordVec::new(0, -1), CoordVec::new(1, -1), CoordVec::new(-1, 0), CoordVec::new(1, 0), CoordVec::new(0, 1), CoordVec::new(-1, 1)]; + +pub fn sample_range(range: i64) -> CoordVec { + let q = fastrand::i64(-range..=range); + let r = fastrand::i64((-range).max(-q-range)..=range.min(-q+range)); + CoordVec::new(q, r) +} + +pub fn hex_circle(range: i64) -> impl Iterator { + (-range..=range).flat_map(move |q| { + ((-range).max(-q - range)..= range.min(-q+range)).map(move |r| { + CoordVec::new(q, r) + }) + }) +} + +pub fn hex_range(range: i64) -> impl Iterator { + (0..=range).flat_map(|x| hex_circle(x).map(move |c| (x, c))) +} + + +pub fn count_hexes(x: i64) -> i64 { + x*(x+1)*3+1 +} \ No newline at end of file diff --git a/src/worldgen.rs b/src/worldgen.rs new file mode 100644 index 0000000..461fa58 --- /dev/null +++ b/src/worldgen.rs @@ -0,0 +1,446 @@ +use std::{cmp::Ordering, collections::{hash_map::Entry, BinaryHeap, HashMap, HashSet}, hash::{Hash, Hasher}, ops::{Index, IndexMut}}; + +use noise_functions::Sample3; +use serde::{Deserialize, Serialize}; + +use crate::map::*; + +pub const WORLD_RADIUS: i64 = 1024; +const NOISE_SCALE: f32 = 0.001; +const HEIGHT_EXPONENT: f32 = 0.3; +const WATER_SOURCES: usize = 40; + +pub fn height_baseline(pos: Coord) -> f32 { + let w_frac = (hex_distance(pos, Coord::origin()) as f32 / WORLD_RADIUS as f32).powf(3.0); + let pos = to_cubic(pos); + let noise = + noise_functions::OpenSimplex2s.ridged(6, 0.7, 1.55).seed(406).sample3([10.0 + pos.x as f32 * NOISE_SCALE, pos.y as f32 * NOISE_SCALE, pos.z as f32 * NOISE_SCALE]); + let range = 1.0 - 2.0 * (w_frac - 0.5).powf(2.0); + noise * range - w_frac +} + +fn percentilize f32>(raw: &mut Map, postprocess: F) { + let mut xs: Vec<(usize, f32)> = raw.data.iter().copied().enumerate().collect(); + xs.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + for (j, (i, _x)) in xs.into_iter().enumerate() { + let percentile = j as f32 / raw.data.len() as f32; + raw.data[i] = postprocess(percentile); + } +} + +pub fn generate_heights() -> Map { + let mut raw = Map::::from_fn(height_baseline, WORLD_RADIUS); + percentilize(&mut raw, |x| { + let s = 1.0 - (1.0 - x).powf(HEIGHT_EXPONENT); + s * 2.0 - 1.0 + }); + raw +} + +struct CoordsIndexIterator { + radius: i64, + index: usize, + r: i64, + q: i64, + max: usize +} + +impl Iterator for CoordsIndexIterator { + type Item = (Coord, usize); + fn next(&mut self) -> Option { + if self.index == self.max { + return None; + } + let result = (Coord::new(self.q, self.r), self.index); + self.index += 1; + self.q += 1; + if self.r < 0 && self.q == self.radius + 1 { + self.r += 1; + self.q = -self.radius - self.r; + } + if self.r >= 0 && self.q + self.r == self.radius + 1 { + self.r += 1; + self.q = -self.radius; + } + Some(result) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Map { + pub data: Vec, + pub radius: i64 +} + +impl Map { + pub fn new(radius: i64, fill: T) -> Map where T: Clone { + let size = count_hexes(radius) as usize; + Map { + data: vec![fill; size], + radius + } + } + + pub fn from_fn S>(mut f: F, radius: i64) -> Map { + let size = count_hexes(radius) as usize; + Map { + radius, + data: Vec::from_iter(CoordsIndexIterator { + radius, + index: 0, + max: size, + r: -radius, + q: 0 + }.map(|(c, _i)| f(c))) + } + } + + pub fn map S>(mut f: F, other: &Self) -> Map { + Map::::from_fn(|c| f(&other[c]), other.radius) + } + + fn coord_to_index(&self, c: Coord) -> usize { + let r = c.y + self.radius; + let fh = r.min(self.radius); + let mut coords_above = fh*(self.radius+1) + fh*(fh-1)/2; + if fh < r { + let d = r - fh; + coords_above += d*(2*self.radius+1) - d*(d-1)/2; + } + let q_start = if r < self.radius { -r } else { -self.radius }; + (coords_above + (c.x - q_start)) as usize + } + + fn in_range(&self, coord: Coord) -> bool { + hex_distance(coord, Coord::origin()) <= self.radius + } + + fn iter_coords(&self) -> impl Iterator { + CoordsIndexIterator { + radius: self.radius, + index: 0, + max: self.data.len(), + r: -self.radius, + q: 0 + } + } + + pub fn iter(&self) -> impl Iterator { + self.iter_coords().map(|(c, i)| (c, &self.data[i])) + } +} + +impl Index for Map { + type Output = T; + fn index(&self, index: Coord) -> &Self::Output { + //println!("{:?}", index); + &self.data[self.coord_to_index(index)] + } +} + +impl IndexMut for Map { + fn index_mut(&mut self, index: Coord) -> &mut Self::Output { + let i = self.coord_to_index(index); + &mut self.data[i] + } +} + +pub fn generate_contours(field: &Map, interval: f32) -> Vec<(Coord, f32, f32, CoordVec)> { + let mut v = vec![]; + // Starting at the origin, we want to detect contour lines in any of the six directions. + // Go in one of the perpendicular directions to generate base directions then scan until the edge is reached starting from any of those points. + for scan_direction in DIRECTIONS { + // "perpendicular" doesn't really work in axial coordinates, but this apparently does so whatever + let slit_direction = rotate_60(*scan_direction); + for x in 0..field.radius { + let base = Coord::zero() + slit_direction * x; + let mut last: Option = None; + for y in 0..field.radius { + let point = base + *scan_direction * y; + if hex_distance(point, Coord::zero()) <= field.radius { + let sample = field[point] / interval; + if let Some(last) = last { + if last.trunc() != sample.trunc() { + v.push((point, last, sample, *scan_direction)); + } + } + last = Some(sample); + } + } + } + } + v +} + +#[derive(Clone, Copy, Debug)] +struct PointWrapper(f32, T); + +impl PartialEq for PointWrapper { + fn eq(&self, other: &Self) -> bool { + assert!(self.0.is_finite() && other.0.is_finite()); + self.0 == other.0 && self.1 == other.1 + } +} + +impl Eq for PointWrapper {} + +fn hash_thing(thing: &T) -> u64 { + let mut hasher = seahash::SeaHasher::new(); + thing.hash(&mut hasher); + hasher.finish() +} + +// we only need a consistent ordering, even if it's not semantically meaningful +// reverse order so that we can pop the smallest element first +impl Ord for PointWrapper { + fn cmp(&self, other: &Self) -> Ordering { + other.0.partial_cmp(&self.0).unwrap().then_with(|| hash_thing(&self.1).cmp(&hash_thing(&other.1))) + } +} + +impl PartialOrd for PointWrapper { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +pub fn generate_separated_high_points(n: usize, sep: i64, map: &Map) -> Vec { + let mut points = vec![]; + let mut priority = BinaryHeap::with_capacity(map.data.len()); + for (coord, height) in map.iter() { + priority.push(PointWrapper(-*height, coord)); + } + while points.len() < n { + let next = priority.pop().unwrap(); + if points.iter().all(|x| hex_distance(*x, next.1) > sep) { + points.push(next.1) + } + } + points +} + +fn astar f32, G: FnMut(C) -> I, I: Iterator, H: FnMut(C) -> bool>(start: C, mut is_end: H, mut heuristic: F, mut get_neighbors: G) -> Vec { + let mut frontier = BinaryHeap::new(); + frontier.push(PointWrapper(0.0, start)); + let mut came_from: HashMap> = HashMap::new(); + came_from.insert(start, PointWrapper(0.0, start)); + + let mut end = None; + while let Some(PointWrapper(_est_cost, current)) = frontier.pop() { + if is_end(current) { + end = Some(current); + break + } + + let cost = came_from[¤t].0; + for (neighbour, next_cost) in get_neighbors(current) { + let new_cost = cost + next_cost; + match came_from.entry(neighbour.clone()) { + Entry::Occupied(mut o) => { + let PointWrapper(old_cost, _old_parent) = o.get(); + if new_cost < *old_cost { + o.insert(PointWrapper(new_cost, current.clone())); + frontier.push(PointWrapper(new_cost + heuristic(neighbour), neighbour)); + } + }, + Entry::Vacant(v) => { + v.insert(PointWrapper(new_cost, current.clone())); + frontier.push(PointWrapper(new_cost + heuristic(neighbour), neighbour)); + } + } + } + } + + let mut end = end.unwrap(); + let mut path = vec![]; + while let Some(next) = came_from.get(&end) { + path.push(next.1); + end = came_from.get(&end).unwrap().1; + if end == start { + break; + } + } + path.reverse(); + path +} + +pub fn distance_map>(radius: i64, sources: I) -> Map { + let radius_f = radius as f32; + let mut distances = Map::::new(radius, radius_f); + let mut queue = BinaryHeap::new(); + for source in sources { + queue.push(PointWrapper(0.0, source)); + } + while let Some(PointWrapper(dist, coord)) = queue.pop() { + if distances[coord] < radius_f { + continue; + } + if dist < radius_f { + distances[coord] = dist; + } + for offset in DIRECTIONS { + let neighbor = coord + *offset; + let new_distance = dist + 1.0; + if distances.in_range(neighbor) && new_distance < distances[neighbor] { + queue.push(PointWrapper(new_distance, neighbor)); + } + } + } + distances +} + +pub fn compute_humidity(distances: Map, heightmap: &Map) -> Map { + let mut humidity = distances; + percentilize(&mut humidity, |x| (1.0 - x).powf(0.3)); + for (coord, h) in heightmap.iter() { + humidity[coord] -= *h * 0.6; + } + percentilize(&mut humidity, |x| x.powf(1.0)); + humidity +} + +const SEA_LEVEL: f32 = -0.8; +const EROSION: f32 = 0.05; + +pub fn simulate_water(heightmap: &mut Map) -> Map { + let mut watermap = Map::::new(heightmap.radius, 0.0); + + let sources = generate_separated_high_points(WATER_SOURCES, WORLD_RADIUS / 10, &heightmap); + let sinks = heightmap.iter_coords().filter(|(c, _)| heightmap[*c] <= SEA_LEVEL).map(|(c, _)| c).collect::>(); + + for source in sources.iter() { + let heightmap_ = &*heightmap; + let watermap_ = &watermap; + let get_neighbours = |c: Coord| { + DIRECTIONS.iter().flat_map(move |offset| { + let neighbor = c + *offset; + if heightmap_.in_range(neighbor) { + let factor = if watermap_[neighbor] > 0.0 { 0.1 } else { 1.0 }; + Some((neighbor, factor * (heightmap_[neighbor] - heightmap_[c] + 0.00).max(0.0))) + } else { + None + } + }) + }; + let heuristic = |c: Coord| { + (heightmap[c] * 0.00).max(0.0) + }; + let path = astar(*source, |c| sinks.contains(&c), heuristic, get_neighbours); + + for point in path { + let water_range_raw = watermap[point] * 1.0 - heightmap[point]; + let water_range = water_range_raw.ceil() as i64; + for (_, nearby) in hex_range(water_range) { + if !watermap.in_range(point + nearby) { + continue; + } + watermap[point + nearby] += 0.5; + watermap[point + nearby] = watermap[point + nearby].min(3.0); + } + + let erosion_range_raw = water_range_raw * 2.0 + 2.0; + let erosion_range = erosion_range_raw.ceil() as i64; + for (this_range, nearby) in hex_range(erosion_range) { + if !watermap.in_range(point + nearby) { + continue; + } + if this_range > 0 { + heightmap[point + nearby] -= EROSION * watermap[point] / (this_range as f32) / erosion_range_raw.max(1.0); + heightmap[point + nearby] = heightmap[point + nearby].max(SEA_LEVEL); + } + } + } + } + + for sink in sinks { + watermap[sink] = 10.0; + } + + watermap +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TerrainType { + Empty, + Contour, + ShallowWater, + DeepWater, + Wall +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedWorld { + heightmap: Map, + terrain: Map, + humidity: Map +} + +pub fn generate_world() -> GeneratedWorld { + let mut heightmap = generate_heights(); + let mut terrain = Map::::new(heightmap.radius, TerrainType::Empty); + + let water = simulate_water(&mut heightmap); + + let contours = generate_contours(&heightmap, 0.15); + + for (point, _, _, _) in contours { + terrain[point] = TerrainType::Contour; + } + + for (point, water) in water.iter() { + if *water > 1.0 { + terrain[point] = TerrainType::DeepWater; + } else if *water > 0.0 { + terrain[point] = TerrainType::ShallowWater; + } + } + + let distances = distance_map( + WORLD_RADIUS, + water.iter().filter_map(|(c, i)| if *i > 0.0 { Some(c) } else { None })); + let humidity = compute_humidity(distances, &heightmap); + + GeneratedWorld { + heightmap, + terrain, + humidity + } +} + +impl TerrainType { + pub fn entry_cost(&self) -> Option { + match *self { + Self::Empty => Some(0), + Self::Wall => None, + Self::ShallowWater => Some(10), + Self::DeepWater => None, + Self::Contour => Some(1) + } + } + + pub fn symbol(&self) -> Option { + match *self { + Self::Empty => None, + Self::Wall => Some('#'), + Self::ShallowWater => Some('~'), + Self::DeepWater => Some('≈'), + Self::Contour => Some('/') + } + } +} + +impl GeneratedWorld { + pub fn get_terrain(&self, pos: Coord) -> TerrainType { + let distance = hex_distance(pos, Coord::origin()); + + if distance >= self.heightmap.radius { + return TerrainType::Wall + } + + self.terrain[pos].clone() + } + + pub fn radius(&self) -> i64 { + self.heightmap.radius + } +} \ No newline at end of file diff --git a/src/worldgen_test.rs b/src/worldgen_test.rs new file mode 100644 index 0000000..67a1af9 --- /dev/null +++ b/src/worldgen_test.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use image::{ImageBuffer, Rgb}; +use std::collections::{HashMap, HashSet}; + +mod worldgen; +mod map; + +use worldgen::*; +use map::*; + +fn main() -> Result<()> { + let mut heightmap = generate_heights(); + + println!("hydro..."); + let water = simulate_water(&mut heightmap); + + println!("contours..."); + let contours = generate_contours(&heightmap, 0.15); + let mut contour_points = HashMap::new(); + + for (point, x1, x2, _) in contours { + let steepness = x1 - x2; + let entry = contour_points.entry(point).or_default(); + *entry = std::cmp::max(*entry, (steepness * 4000.0).abs() as u8); + } + + println!("humidity..."); + let water_distances = distance_map( + WORLD_RADIUS, + water.iter().filter_map(|(c, i)| if *i > 0.0 { Some(c) } else { None })); + let humidity = compute_humidity(water_distances, &heightmap); + + println!("rendering..."); + let mut image = ImageBuffer::from_pixel((WORLD_RADIUS * 2 + 1) as u32, (WORLD_RADIUS * 2 + 1) as u32, Rgb::from([0u8, 0, 0])); + + for (position, value) in heightmap.iter() { + let col = position.x + (position.y - (position.y & 1)) / 2 + WORLD_RADIUS; + let row = position.y + WORLD_RADIUS; + //let contour = contour_points.get(&position).copied().unwrap_or_default(); + let contour = (255.0 * humidity[position]) as u8; + let water = water[position]; + image.put_pixel(col as u32, row as u32, Rgb::from([contour, 0, (water.min(1.0) * 255.0) as u8])); + } + + image.save("./out.png")?; + + Ok(()) +} \ No newline at end of file