From df71026ef1f7c234fff87e2863a09951e4fa7cf8 Mon Sep 17 00:00:00 2001 From: osmarks Date: Tue, 24 Feb 2026 11:44:34 +0000 Subject: [PATCH] current prototype --- Cargo.lock | 1 + Cargo.toml | 5 +- src/App.svelte | 4 +- src/main.rs | 317 ++++++++++++++++++++++++++++++++---------------- src/map.rs | 110 +++++++++++++++++ src/plant.rs | 96 +++++++++++++++ src/worldgen.rs | 118 +----------------- static/app.js | 2 +- 8 files changed, 433 insertions(+), 220 deletions(-) create mode 100644 src/plant.rs diff --git a/Cargo.lock b/Cargo.lock index f45fbc5..0b3576a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ dependencies = [ "serde", "serde_json", "slab", + "smallvec", "tokio", "tokio-macros 0.2.6", "tokio-tungstenite", diff --git a/Cargo.toml b/Cargo.toml index 42de582..c30c20b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,11 @@ indexmap = "2" image = { version = "0.25", default-features = false, features = ["png"] } rayon = "1" bincode = { version = "2.0.0-rc.3", features = ["serde"] } +smallvec = "1" [[bin]] name = "worldgen" path = "src/worldgen_test.rs" -[target.x86_64-unknown-linux-gnu] -rustflags = ["-Ctarget-cpu=native"] \ No newline at end of file +[profile.release] +debug = true \ No newline at end of file diff --git a/src/App.svelte b/src/App.svelte index a348c90..76cf3b1 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,7 +4,7 @@ \:global(html) scrollbar-color: black lightgray - + \:global(body) font-family: "Fira Sans", "Noto Sans", "Segoe UI", Verdana, sans-serif font-weight: 300 @@ -98,7 +98,7 @@ let ws const connect = () => { - ws = new WebSocket(window.location.protocol === "https:" ? "wss://ewo.osmarks.net/" : "ws://localhost:8080/") + ws = new WebSocket(window.location.protocol === "https:" ? "wss://ewo.osmarks.net/" : "ws://localhost:8011/") ws.addEventListener("message", ev => { const data = JSON.parse(ev.data) diff --git a/src/main.rs b/src/main.rs index 132f99f..6913fe2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,18 @@ -use hecs::{CommandBuffer, Entity, World}; +use hecs::{CommandBuffer, Entity, With, World}; use futures_util::{stream::TryStreamExt, SinkExt, StreamExt}; use indexmap::IndexMap; +use smallvec::{smallvec, SmallVec}; use tokio::net::{TcpListener, TcpStream}; use tokio_tungstenite::tungstenite::protocol::Message; use tokio::sync::{mpsc, Mutex}; use anyhow::{Result, Context, anyhow}; -use std::{collections::{hash_map::Entry, HashMap, HashSet, VecDeque}, convert::TryFrom, hash::{Hash, Hasher}, net::SocketAddr, sync::Arc, time::Duration}; +use std::{convert::TryFrom, hash::{Hash, Hasher}, net::SocketAddr, ops::DerefMut, sync::Arc, time::Duration}; use slab::Slab; use serde::{Serialize, Deserialize}; pub mod worldgen; pub mod map; +pub mod plant; use map::*; @@ -85,7 +87,8 @@ struct GameState { world: World, clients: Slab, ticks: u64, - map: worldgen::GeneratedWorld + map: worldgen::GeneratedWorld, + positions: PositionIndex } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -112,19 +115,79 @@ impl Item { } } +struct PositionIndex { + particles: Map>, + entities: Map>, + terrain: Map> +} + +impl PositionIndex { + fn new(radius: i32) -> Self { + Self { + particles: Map::new(radius, None), + entities: Map::new(radius, None), + terrain: Map::new(radius, None) + } + } +} + #[derive(Debug, Clone)] struct PlayerCharacter; +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +enum MapLayer { + Particles, + Entities, + Terrain +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct Position(VecDeque); +struct Position { + layer: MapLayer, + coords: SmallVec<[Coord; 2]> +} impl Position { fn head(&self) -> Coord { - *self.0.front().unwrap() + self.coords[0] } - fn single_tile(c: Coord) -> Self { - Self(VecDeque::from([c])) + fn single_tile(c: Coord, layer: MapLayer) -> Self { + Self { + layer, + coords: smallvec![c] + } + } + + fn iter_coords(&self) -> impl Iterator + '_ { + self.coords.iter().copied() + } + + fn record_for(&mut self, index: &mut PositionIndex, entity: Option) { + let target_layer = match self.layer { + MapLayer::Particles => &mut index.particles, + MapLayer::Entities => &mut index.entities, + MapLayer::Terrain => &mut index.terrain, + }; + for coord in self.coords.iter() { + target_layer[*coord] = entity; + } + } + + // return value is whether it is now dead/positionless + fn remove_coord(&mut self, coord: Coord, index: &mut PositionIndex, entity: Entity) -> bool { + self.record_for(index, None); + self.coords.retain(|x| *x != coord); + self.record_for(index, Some(entity)); + self.coords.len() > 0 + } + + fn move_into(&mut self, coord: Coord, index: &mut PositionIndex, entity: Entity) -> Coord { + self.record_for(index, None); + let fst = self.coords.remove(0); + self.coords.push(coord); + self.record_for(index, Some(entity)); + fst } } @@ -141,6 +204,9 @@ impl Health { } } +#[derive(Debug, Clone)] +struct ShrinkOnDeath; + #[derive(Debug, Clone)] struct Render(char); @@ -165,15 +231,9 @@ struct Enemy; #[derive(Debug, Clone)] struct MoveCost(StochasticNumber); -#[derive(Debug, Clone)] -struct Collidable; - #[derive(Debug, Clone)] struct Velocity(CoordVec); -#[derive(Debug, Clone)] -struct Terrain; - #[derive(Debug, Clone)] struct Obstruction { entry_multiplier: f32, exit_multiplier: f32 } @@ -203,6 +263,15 @@ struct DespawnOnImpact; #[derive(Debug, Clone)] struct Inventory(indexmap::IndexMap); +#[derive(Debug, Clone)] +struct Plant(plant::Genome); + +#[derive(Debug, Clone)] +struct NewlyAdded; // ugly hack to work around ECS deficiencies + +#[derive(Debug, Clone)] +struct BlocksEnemySpawn; + impl Inventory { fn add(&mut self, item: Item, qty: u64) { *self.0.entry(item).or_default() += qty; @@ -253,7 +322,7 @@ struct EnemySpec { } impl EnemySpec { - // Numbers ported from original EWO. Fudge constants added elsewhere. + // Numbers ported from original EWO. Fudge constants added elsewhere. fn random() -> EnemySpec { match fastrand::usize(0..650) { 0..=99 => EnemySpec { symbol: 'I', min_damage: 10.0, damage_range: 5.0, initial_health: 50.0, move_delay: 70, attack_cooldown: 10, ranged: false, drops: vec![], movement: 1 }, // IBIS @@ -275,21 +344,10 @@ fn rng_from_hash(x: H) -> fastrand::Rng { fastrand::Rng::with_seed(h.finish()) } -fn consume_energy_if_available(e: &mut Option<&mut Energy>, cost: f32) -> bool { +fn consume_energy_if_available>(e: &mut Option, cost: f32) -> bool { e.is_none() || e.as_mut().unwrap().try_consume(cost) } -// Box-Muller transform -fn normal() -> f32 { - let u = fastrand::f32(); - let v = fastrand::f32(); - (v * std::f32::consts::TAU).cos() * (-2.0 * u.ln()).sqrt() -} - -fn normal_scaled(mu: f32, sigma: f32) -> f32 { - normal() * sigma + mu -} - fn triangle_distribution(min: f32, max: f32, mode: f32) -> f32 { let sample = fastrand::f32(); let threshold = (mode - min) / (max - min); @@ -324,23 +382,15 @@ impl StochasticNumber { } async fn game_tick(state: &mut GameState) -> Result<()> { - let mut terrain_positions = HashMap::new(); - let mut positions = HashMap::new(); - - for (entity, pos) in state.world.query_mut::>() { - for subpos in pos.0.iter() { - positions.insert(*subpos, entity); - } - } - - for (entity, pos) in state.world.query_mut::>() { - for subpos in pos.0.iter() { - terrain_positions.insert(*subpos, entity); - } - } - let mut buffer = hecs::CommandBuffer::new(); + for (entity, position) in state.world.query_mut::>() { + position.record_for(&mut state.positions, Some(entity)); + buffer.remove_one::(entity); + } + + buffer.run_on(&mut state.world); + // Spawn enemies for (_entity, (pos, EnemyTarget { spawn_range, spawn_density, spawn_rate_inv, .. })) in state.world.query::<(&Position, &EnemyTarget)>().iter() { let pos = pos.head(); @@ -349,9 +399,11 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let mut newpos = pos + sample_range(*spawn_range.end()); let mut occupied = false; for _ in 0..(c as f32 / spawn_density * 0.005).ceil() as usize { - if positions.contains_key(&newpos) { - occupied = true; - break; + if let Some(entity) = state.positions.entities[newpos] { + if state.world.get::<&BlocksEnemySpawn>(entity).is_ok() { + occupied = true; + break; + } } newpos = pos + sample_range(*spawn_range.end()); } @@ -364,29 +416,31 @@ async fn game_tick(state: &mut GameState) -> Result<()> { Health(spec.initial_health, spec.initial_health), Enemy, RangedAttack { damage: StochasticNumber::triangle_from_min_range(spec.min_damage, spec.damage_range), energy: spec.attack_cooldown as f32, range: 4 }, - Position::single_tile(newpos), + Position::single_tile(newpos, MapLayer::Entities), MoveCost(StochasticNumber::Triangle { min: 0.0, max: 2.0 * spec.move_delay as f32 / 3.0, mode: spec.move_delay as f32 / 3.0 }), - Collidable, DespawnRandomly(RANDOM_DESPAWN_INV_RATE), Energy { regeneration_rate: 1.0, current: 0.0, burst: 0.0 }, Drops(spec.drops), - Jump(spec.movement) - )); + Jump(spec.movement), + NewlyAdded, + BlocksEnemySpawn + )) } else { buffer.spawn(( Render(spec.symbol), Health(spec.initial_health, spec.initial_health), Enemy, Attack { damage: StochasticNumber::triangle_from_min_range(spec.min_damage, spec.damage_range), energy: spec.attack_cooldown as f32 }, - Position::single_tile(newpos), + Position::single_tile(newpos, MapLayer::Entities), MoveCost(StochasticNumber::Triangle { min: 0.0, max: 2.0 * spec.move_delay as f32 / 3.0, mode: spec.move_delay as f32 / 3.0 }), - Collidable, DespawnRandomly(RANDOM_DESPAWN_INV_RATE), Energy { regeneration_rate: 1.0, current: 0.0, burst: 0.0 }, Drops(spec.drops), - Jump(spec.movement) - )); - } + Jump(spec.movement), + NewlyAdded, + BlocksEnemySpawn + )) + }; } } } @@ -396,7 +450,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let pos = pos.head(); for direction in DIRECTIONS.iter() { - if let Some(target) = positions.get(&(pos + *direction)) { + if let Some(target) = &state.positions.entities[pos + *direction] { if let Ok(_) = state.world.get::<&EnemyTarget>(*target) { buffer.insert_one(entity, MovingInto(pos + *direction)); continue; @@ -434,8 +488,10 @@ async fn game_tick(state: &mut GameState) -> Result<()> { Enemy, Attack { damage: ranged_attack.damage, energy: 0.0 }, Velocity(atk_dir), - Position::single_tile(pos), - DespawnOnTick(state.ticks.wrapping_add(ranged_attack.range)) + Position::single_tile(pos, MapLayer::Particles), + DespawnOnTick(state.ticks.wrapping_add(ranged_attack.range)), + DespawnOnImpact, + NewlyAdded )); } } else { @@ -483,13 +539,14 @@ async fn game_tick(state: &mut GameState) -> Result<()> { Input::DownRight => next_movement = CoordVec::new(0, 1), Input::DownLeft => next_movement = CoordVec::new(-1, 1), Input::Dig => { - if terrain_positions.get(&position).is_none() && energy.try_consume(5.0) { + // Dig a hole + if state.positions.terrain[position].is_none() && energy.try_consume(5.0) { buffer.spawn(( - Terrain, Render('_'), Obstruction { entry_multiplier: 5.0, exit_multiplier: 5.0 }, DespawnOnTick(state.ticks.wrapping_add(StochasticNumber::triangle_from_min_range(5000.0, 5000.0).sample().round() as u64)), - Position::single_tile(position) + Position::single_tile(position, MapLayer::Terrain), + NewlyAdded )); inventory.add(Item::Dirt, StochasticNumber::triangle_from_min_range(1.0, 3.0).sample_rounded()); } @@ -498,7 +555,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { Err(e) => return Err(e.into()) } } - + let target = position + next_movement; if state.map.get_terrain(target).entry_cost().is_some() && target != position { buffer.insert_one(client.entity, MovingInto(target)); @@ -507,13 +564,15 @@ async fn game_tick(state: &mut GameState) -> Result<()> { buffer.run_on(&mut state.world); - let mut despawn_buffer = HashSet::new(); + let mut despawn_buffer = Vec::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); + // The final position argument is in some sense redundant but exists to satisfy dynamic borrow checking. + let kill = |buffer: &mut CommandBuffer, despawn_buffer: &mut Vec<(Entity, Position)>, state: &GameState, entity: Entity, killer: Option| { + let position = (*state.world.get::<&Position>(entity).unwrap()).clone(); + let position_head = position.head(); + despawn_buffer.push((entity, position)); buffer.despawn(entity); let mut materialized_drops = Inventory::empty(); if let Ok(drops) = state.world.get::<&Drops>(entity) { @@ -534,22 +593,25 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } else { false }; if !killer_consumed_items && !materialized_drops.is_empty() { buffer.spawn(( - Position::single_tile(position), + Position::single_tile(position_head, MapLayer::Entities), Render('☒'), - materialized_drops + materialized_drops, + NewlyAdded, + Health(10.0, 10.0) )); } }; + let mut about_to_move = Vec::new(); // Process motion and attacks - for (entity, (current_pos, MovingInto(target_pos), damage, mut energy, move_cost, despawn_on_impact)) in state.world.query::<(&mut Position, &MovingInto, Option<&mut Attack>, Option<&mut Energy>, Option<&MoveCost>, Option<&DespawnOnImpact>)>().iter() { + for (entity, (current_pos, MovingInto(target_pos), damage, mut energy, move_cost, despawn_on_impact)) in state.world.query::<(&Position, &MovingInto, Option<&mut Attack>, Option<&mut Energy>, Option<&MoveCost>, Option<&DespawnOnImpact>)>().iter() { let mut move_cost = move_cost.map(|x| x.0.sample()).unwrap_or(0.0); move_cost *= (hex_distance(*target_pos, current_pos.head()) as f32).powf(0.5); - - for tile in current_pos.0.iter() { + + for tile in current_pos.iter_coords() { // TODO: perhaps large enemies should not be exponentially more vulnerable to environmental hazards - if let Some(current_terrain) = terrain_positions.get(tile) { + if let Some(current_terrain) = &state.positions.terrain[tile] { if let Ok(obstruction) = state.world.get::<&Obstruction>(*current_terrain) { move_cost *= obstruction.exit_multiplier; } @@ -557,7 +619,7 @@ async fn game_tick(state: &mut GameState) -> Result<()> { } // TODO: attacks into obstructions are still cheap; is this desirable? - if let Some(target_terrain) = terrain_positions.get(target_pos) { + if let Some(target_terrain) = &state.positions.terrain[*target_pos] { if let Ok(obstruction) = state.world.get::<&Obstruction>(*target_terrain) { move_cost *= obstruction.entry_multiplier; } @@ -565,9 +627,9 @@ async fn game_tick(state: &mut GameState) -> Result<()> { 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(); + let can_move = match &state.positions.entities[*target_pos] { + Some(target_entity) => { + let target_entity = target_entity.clone(); if let Ok(mut x) = state.world.get::<&mut Health>(target_entity) { match damage { Some(Attack { damage, energy: energy_cost }) => { @@ -578,32 +640,50 @@ async fn game_tick(state: &mut GameState) -> Result<()> { _ => () } if despawn_on_impact.is_some() { - kill(&mut buffer, &mut despawn_buffer, &state, entity, Some(target_entity), Some(*target_pos)); + kill(&mut buffer, &mut despawn_buffer, &state, entity, Some(target_entity)); } if x.0 < 0.0 { - kill(&mut buffer, &mut despawn_buffer, &state, target_entity, Some(entity), Some(*target_pos)); - Some(Entry::Occupied(o)) + // TODO: this may be totally broken + if state.world.get::<&ShrinkOnDeath>(target_entity).is_ok() { + let mut positions = state.world.get::<&mut Position>(target_entity).unwrap(); + + if positions.remove_coord(*target_pos, &mut state.positions, target_entity) { + std::mem::drop(positions); + kill(&mut buffer, &mut despawn_buffer, &state, target_entity, Some(entity)); + } else { + x.0 = x.1; // reset health + } + } else { + kill(&mut buffer, &mut despawn_buffer, &state, target_entity, Some(entity)); + } + true // murdered to death; space is now open } else { - None + false // still alive; cannot move there } } else { - None // no "on pickup" exists; emulated with health 0 + false // if no health, cannot be destroyed } }, - Entry::Vacant(v) => Some(Entry::Vacant(v)) + None => true // empty, can move }; - if let Some(entry) = entry { - // TODO: perhaps this should be applied to attacks too? - if consume_energy_if_available(&mut energy, move_cost) { - *entry.or_insert(entity) = entity; - positions.remove(¤t_pos.0.pop_back().unwrap()); - current_pos.0.push_front(*target_pos); - } + if can_move { + about_to_move.push((entity, *target_pos, move_cost)); } } buffer.remove_one::(entity); } + for (entity, target_pos, move_cost) in about_to_move.drain(..) { + // TODO: perhaps this should be applied to attacks too? + let mut energy = state.world.get::<&mut Energy>(entity).ok(); + let mut current_pos = state.world.get::<&mut Position>(entity).unwrap() ; + if consume_energy_if_available(&mut energy, move_cost) { + state.positions.entities[target_pos] = Some(entity.clone()); + let tail_pos = current_pos.move_into(target_pos, &mut state.positions, entity); + state.positions.entities[tail_pos] = None; + } + } + buffer.run_on(&mut state.world); for (_entity, energy) in state.world.query_mut::<&mut Energy>() { @@ -613,27 +693,32 @@ async fn game_tick(state: &mut GameState) -> Result<()> { // Process transient entities for (entity, tick) in state.world.query::<&DespawnOnTick>().iter() { if state.ticks == tick.0 { - kill(&mut buffer, &mut despawn_buffer, &state, entity, None, None); + kill(&mut buffer, &mut despawn_buffer, &state, entity, None); } } for (entity, DespawnRandomly(inv_rate)) in state.world.query::<&DespawnRandomly>().iter() { if fastrand::u64(0..*inv_rate) == 0 { - kill(&mut buffer, &mut despawn_buffer, &state, entity, None, None); + kill(&mut buffer, &mut despawn_buffer, &state, entity, None); } } buffer.run_on(&mut state.world); - let mut delete = vec![]; - for (position, entity) in positions.iter() { - if despawn_buffer.contains(entity) { - delete.push(*position); + for (entity, position) in despawn_buffer.drain(..) { + for coord in position.iter_coords() { + // TODO: fix + if state.positions.particles[coord] == Some(entity) { + state.positions.particles[coord] = None; + } + if state.positions.entities[coord] == Some(entity) { + state.positions.entities[coord] = None; + } + if state.positions.terrain[coord] == Some(entity) { + state.positions.terrain[coord] = None; + } } } - for position in delete { - positions.remove(&position); - } // Send views to clients // TODO: terrain layer below others @@ -646,13 +731,13 @@ async fn game_tick(state: &mut GameState) -> Result<()> { let pos = pos + offset; let mut rng = rng_from_hash(pos); - if let Some(entity) = positions.get(&pos) { + if let Some(entity) = &state.positions.particles[pos].or(state.positions.entities[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) { + } else if let Some(entity) = &state.positions.terrain[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() { @@ -704,10 +789,9 @@ fn add_new_player(state: &mut GameState) -> Result { } }; Ok(state.world.spawn(( - Position::single_tile(pos), + Position::single_tile(pos, MapLayer::Entities), PlayerCharacter, Render(random_identifier()), - Collidable, Attack { damage: StochasticNumber::Triangle { min: 20.0, max: 60.0, mode: 20.0 }, energy: 5.0 }, Health(128.0, 128.0), EnemyTarget { @@ -717,7 +801,9 @@ fn add_new_player(state: &mut GameState) -> Result { aggression_range: 5 }, Energy { current: 0.0, regeneration_rate: 1.0, burst: 5.0 }, - Inventory::empty() + Inventory::empty(), + NewlyAdded, + BlocksEnemySpawn ))) } @@ -728,7 +814,7 @@ async fn load_world() -> Result { #[tokio::main] async fn main() -> Result<()> { - let addr = std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:8080".to_string()); + let addr = std::env::args().nth(1).unwrap_or_else(|| "0.0.0.0:8011".to_string()); let world = match load_world().await { Ok(world) => world, @@ -744,9 +830,27 @@ async fn main() -> Result<()> { world: World::new(), clients: Slab::new(), ticks: 0, + positions: PositionIndex::new(world.radius), map: world })); + { + let mut state = state.lock().await; + let count = count_hexes(state.map.radius() / 5); + let mut batch = Vec::with_capacity(count as usize); + for (distance, offset) in hex_range(state.map.radius() / 5) { + batch.push(( + Position::single_tile(Coord::origin() + offset * 5, MapLayer::Entities), + Render('+'), + Health(100.0, 100.0), + //ShrinkOnDeath, + Plant(plant::Genome::random()), + NewlyAdded + )); + } + state.world.spawn_batch(batch); + } + let try_socket = TcpListener::bind(&addr).await; let listener = try_socket.expect("Failed to bind"); println!("Listening on: {}", addr); @@ -786,9 +890,16 @@ async fn main() -> Result<()> { println!("conn result {:?}", handle_connection(stream, addr, frames_rx, inputs_tx).await); let mut state = state_.lock().await; state.clients.remove(id); + let mut pos = match state.world.get::<&Position>(entity) { + Err(_) => return, + Ok(p) => { + (*p).clone() + } + }; + pos.record_for(&mut state.positions, None); let _ = state.world.despawn(entity); }); } Ok(()) -} \ No newline at end of file +} diff --git a/src/map.rs b/src/map.rs index 3fff4de..1ee5441 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,4 +1,6 @@ use euclid::{Point3D, Point2D, Vector2D}; +use serde::{Deserialize, Serialize}; +use std::ops::{Index, IndexMut}; pub struct AxialWorldSpace; pub struct CubicWorldSpace; @@ -56,4 +58,112 @@ pub fn hex_range(range: i32) -> impl Iterator { pub fn count_hexes(x: i32) -> i32 { x*(x+1)*3+1 +} + +struct CoordsIndexIterator { + radius: i32, + index: usize, + r: i32, + q: i32, + 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: i32 +} + +impl Map { + pub fn new(radius: i32, 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: i32) -> 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) + } + + pub 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 + } + + pub fn in_range(&self, coord: Coord) -> bool { + hex_distance(coord, Coord::origin()) <= self.radius + } + + pub 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] + } } \ No newline at end of file diff --git a/src/plant.rs b/src/plant.rs new file mode 100644 index 0000000..b902d4c --- /dev/null +++ b/src/plant.rs @@ -0,0 +1,96 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CropType { + Grass, + EucalyptusTree, + BushTomato, + GoldenWattleTree +} + +#[derive(Debug, Clone, Copy)] +pub struct Genome { + crop_type: CropType, + // polygenic traits; parameterized as N(0,1) + growth_rate: f32, + nitrogen_fixation_rate: f32, + optimal_water_level: f32, + optimal_temperature: f32, + reproduction_rate: f32, + temperature_tolerance: f32, + water_tolerance: f32, + max_size: f32 + // TODO color trait +} + +fn sigmoid(x: f32) -> f32 { + 1.0 / (1.0 + (-x).exp()) +} + +// Box-Muller transform +fn normal() -> f32 { + let u = fastrand::f32(); + let v = fastrand::f32(); + (v * std::f32::consts::TAU).cos() * (-2.0 * u.ln()).sqrt() +} + +fn normal_scaled(mu: f32, sigma: f32) -> f32 { + normal() * sigma + mu +} + +impl Genome { + pub fn effective_growth_rate(&self, water: f32, temperature: f32) -> f32 { + let water_diff = (water - self.optimal_water_level).abs(); + let temperature_diff = (temperature - self.optimal_temperature).abs(); + 1.5f32.powf(self.growth_rate) + - self.reproduction_rate * 0.1 // faster reproduction trades off slightly against growth + - self.nitrogen_fixation_rate.max(0.0) * 0.16 // same for nitrogen fixation + - (water_diff - sigmoid(self.water_tolerance)).max(0.0) // penalize plants when far from optimal environmental range + - (temperature_diff - sigmoid(self.temperature_tolerance)).max(0.0) // same for temperature + - self.water_tolerance * 0.2 + - self.temperature_tolerance * 0.2 + } + + pub fn random() -> Genome { + let crop_type = match fastrand::usize(0..4) { + 0 => CropType::Grass, + 1 => CropType::EucalyptusTree, + 2 => CropType::BushTomato, + 3 => CropType::GoldenWattleTree, + _ => unreachable!() + }; + + let (nitrogen_fixation_rate, optimal_water_level, optimal_temperature, max_size) = match crop_type { + CropType::Grass => (-10.0, 0.0, 0.0, 0.0), + CropType::EucalyptusTree => (-10.0, 2.0, 1.0, 5.0), + CropType::BushTomato => (-10.0, -1.0, 1.5, 1.0), + CropType::GoldenWattleTree => (2.0, 1.5, 1.0, 3.0), + + }; + + Genome { + crop_type: crop_type, + growth_rate: normal(), + nitrogen_fixation_rate, + optimal_water_level, + optimal_temperature, + reproduction_rate: normal(), + temperature_tolerance: normal(), + water_tolerance: normal(), + max_size + } + } + + pub fn hybridize(&self, other: &Genome) -> Option { + if self.crop_type != other.crop_type { return None } + Some(Genome { + crop_type: self.crop_type, + growth_rate: (self.growth_rate + other.growth_rate) / 2.0 + normal_scaled(0.0, 0.1), + nitrogen_fixation_rate: (self.nitrogen_fixation_rate + other.nitrogen_fixation_rate) / 2.0 + normal_scaled(0.0, 0.03), + optimal_water_level: (self.optimal_water_level + other.optimal_water_level) / 2.0 + normal_scaled(0.0, 0.03), + optimal_temperature: (self.optimal_temperature + other.optimal_temperature) / 2.0 + normal_scaled(0.0, 0.03), + reproduction_rate: (self.reproduction_rate + other.reproduction_rate) / 2.0 + normal_scaled(0.0, 0.5), + temperature_tolerance: (self.temperature_tolerance + other.temperature_tolerance) / 2.0 + normal_scaled(0.0, 0.2), + water_tolerance: (self.water_tolerance + other.water_tolerance) / 2.0 + normal_scaled(0.0, 0.2), + max_size: (self.max_size + other.max_size) / 2.0 + normal_scaled(0.0, 0.3) + }) + } +} diff --git a/src/worldgen.rs b/src/worldgen.rs index 55808d8..18ce10d 100644 --- a/src/worldgen.rs +++ b/src/worldgen.rs @@ -1,4 +1,4 @@ -use std::{cmp::Ordering, collections::{hash_map::Entry, BinaryHeap, HashMap, HashSet, VecDeque}, hash::{Hash, Hasher}, ops::{Index, IndexMut}}; +use std::{cmp::Ordering, collections::{hash_map::Entry, BinaryHeap, HashMap, HashSet, VecDeque}, hash::{Hash, Hasher}}; use noise_functions::Sample3; use serde::{Deserialize, Serialize}; @@ -48,114 +48,6 @@ pub fn generate_heights() -> Map { raw } -struct CoordsIndexIterator { - radius: i32, - index: usize, - r: i32, - q: i32, - 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: i32 -} - -impl Map { - pub fn new(radius: i32, 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: i32) -> 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. @@ -482,8 +374,8 @@ fn smooth(map: &Map, radius: i32) -> Map { const BASE_TEMPERATURE: f32 = 30.0; // degrees const HEIGHT_SCALE: f32 = 1e3; // unrealistic but makes world more interesting; m -const SEA_LEVEL_AIR_PRESSURE: f32 = 1013.0; // hPa -const PRESSURE_DROP_PER_METER: f32 = 0.001; // hPa m^-1 +//const SEA_LEVEL_AIR_PRESSURE: f32 = 1013.0; // hPa +//const PRESSURE_DROP_PER_METER: f32 = 0.001; // hPa m^-1 const AIR_SPECIFIC_HEAT_CAPACITY: f32 = 1012.0; // J kg^-1 K^-1 const EARTH_GRAVITY: f32 = 9.81; // m s^-2 @@ -577,7 +469,8 @@ pub struct GeneratedWorld { salt: Map, atmo_humidity: Map, temperature: Map, - soil_nutrients: Map + soil_nutrients: Map, + pub radius: i32 } pub fn generate_world() -> GeneratedWorld { @@ -609,6 +502,7 @@ pub fn generate_world() -> GeneratedWorld { let soil_nutrients = soil_nutrients(&groundwater); GeneratedWorld { + radius: heightmap.radius, heightmap, terrain, groundwater, diff --git a/static/app.js b/static/app.js index 8d55b63..a239920 100644 --- a/static/app.js +++ b/static/app.js @@ -804,7 +804,7 @@ let inventory = []; let ws; const connect = () => { - ws = new WebSocket(window.location.protocol === "https:" ? "wss://ewo.osmarks.net/" : "ws://localhost:8080/"); + ws = new WebSocket(window.location.protocol === "https:" ? "wss://ewo.osmarks.net/" : "ws://localhost:8011/"); ws.addEventListener("message", (ev) => { const data = JSON.parse(ev.data); if (data.Display) {