use bevy::prelude::*; use crate::{ sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day}, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::WorldGenerated, }; pub struct LightPlugin; impl Plugin for LightPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, create_structure_assets) .add_systems( Update, clear_structures.run_if(on_message::), ) .add_systems( FixedUpdate, ( update_light_map.in_set(SimSystems::Decide), burn_fuel.in_set(SimSystems::Act), ), ); } } pub const LIT_BRIGHTNESS: f32 = 0.5; pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2; const AMBIENT_STEPS: f32 = 16.0; const DAY_COLOR: Vec3 = Vec3::ONE; const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4); pub fn daylight(hour: f32) -> f32 { match hour { h if h < 5.0 => 0.0, h if h < 7.0 => (h - 5.0) / 2.0, h if h < 18.0 => 1.0, h if h < 20.0 => 1.0 - (h - 18.0) / 2.0, _ => 0.0, } } pub fn ambient_color(daylight: f32) -> Vec3 { NIGHT_COLOR.lerp(DAY_COLOR, daylight) } pub fn brightness(color: Vec3) -> f32 { (color.x + color.y + color.z) / 3.0 } pub fn dark_soon(tick: SimTick) -> bool { let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS); daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum StructureKind { Campfire, Torch, } impl StructureKind { pub fn wood_cost(self) -> u32 { match self { StructureKind::Campfire => 10, StructureKind::Torch => 2, } } pub fn fuel_ticks(self) -> u32 { let hours = match self { StructureKind::Campfire => 12, StructureKind::Torch => 8, }; hours * TICKS_PER_HOUR as u32 } pub fn radius(self) -> f32 { match self { StructureKind::Campfire => 7.0, StructureKind::Torch => 4.0, } } fn light_color(self) -> Vec3 { match self { StructureKind::Campfire => Vec3::new(1.1, 0.8, 0.5), StructureKind::Torch => Vec3::new(1.0, 0.8, 0.55), } } pub fn light_at(self, source: UVec2, tile: UVec2) -> Vec3 { let d = source.as_vec2().distance(tile.as_vec2()) / self.radius(); if d >= 1.0 { return Vec3::ZERO; } self.light_color() * (1.0 - d * d).powi(2) } } #[derive(Component, Debug, Clone, Copy, PartialEq)] #[require(Transform, Visibility)] pub struct Structure { pub kind: StructureKind, pub fuel: u32, } pub fn light_from(tile: UVec2, sources: impl IntoIterator) -> Vec3 { sources .into_iter() .map(|(pos, kind)| kind.light_at(pos, tile)) .sum() } #[derive(Resource, Default, Debug, Clone, PartialEq)] pub struct LightMap { size: UVec2, ambient_step: u32, ambient: Vec3, sources: Vec, } impl LightMap { pub fn color(&self, tile: UVec2) -> Vec3 { if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y { return Vec3::ONE; } (self.ambient + self.sources[(tile.y * self.size.x + tile.x) as usize]).min(Vec3::ONE) } pub fn is_lit(&self, tile: UVec2) -> bool { brightness(self.color(tile)) >= LIT_BRIGHTNESS } pub fn compute( size: UVec2, daylight: f32, sources: impl IntoIterator, ) -> Self { let ambient_step = (daylight * AMBIENT_STEPS).round() as u32; let mut light = vec![Vec3::ZERO; size.element_product() as usize]; for (pos, kind) in sources { let r = kind.radius().ceil() as i32; for dy in -r..=r { for dx in -r..=r { let tile = pos.as_ivec2() + IVec2::new(dx, dy); if tile.x < 0 || tile.y < 0 || tile.x >= size.x as i32 || tile.y >= size.y as i32 { continue; } let tile = tile.as_uvec2(); light[(tile.y * size.x + tile.x) as usize] += kind.light_at(pos, tile); } } } Self { size, ambient_step, ambient: ambient_color(ambient_step as f32 / AMBIENT_STEPS), sources: light, } } } fn update_light_map( tick: Res, map: Res, structures: Query<(&TilePos, &Structure)>, added: Query<(), Added>, mut removed: RemovedComponents, mut light: ResMut, ) { let day = daylight(hour_of_day(*tick)); let step = (day * AMBIENT_STEPS).round() as u32; let sources_changed = !added.is_empty() || removed.read().count() > 0; if !sources_changed && step == light.ambient_step && light.size == map.size { return; } *light = LightMap::compute( map.size, day, structures.iter().map(|(pos, s)| (pos.0, s.kind)), ); } fn burn_fuel(mut commands: Commands, mut structures: Query<(Entity, &mut Structure)>) { for (entity, mut structure) in &mut structures { structure.fuel = structure.fuel.saturating_sub(1); if structure.fuel == 0 { commands.entity(entity).despawn(); } } } fn clear_structures(mut commands: Commands, structures: Query>) { for entity in &structures { commands.entity(entity).despawn(); } } type Part = (Handle, Handle); #[derive(Resource, Default)] pub struct StructureAssets { stone: Part, log: Part, flame: Part, flame_core: Part, stick: Part, } const CAMPFIRE_STONES: usize = 7; fn flame_mesh(width: f32, height: f32) -> Mesh { Triangle2d::new( Vec2::new(-width / 2.0, -height / 2.0), Vec2::new(width / 2.0, -height / 2.0), Vec2::new(0.0, height / 2.0), ) .into() } fn create_structure_assets( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, ) { let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color)); commands.insert_resource(StructureAssets { stone: part( Circle::new(TILE_SIZE * 0.09).into(), Color::srgb_u8(120, 116, 110), ), log: part( Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.14).into(), Color::srgb_u8(110, 74, 42), ), flame: part( flame_mesh(TILE_SIZE * 0.36, TILE_SIZE * 0.5), Color::srgb_u8(240, 110, 30), ), flame_core: part( flame_mesh(TILE_SIZE * 0.18, TILE_SIZE * 0.28), Color::srgb_u8(255, 220, 90), ), stick: part( Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(), Color::srgb_u8(100, 70, 40), ), }); } fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle { ( Mesh2d(mesh.clone()), MeshMaterial2d(material.clone()), transform, ) } pub fn spawn_structure( commands: &mut Commands, assets: &StructureAssets, tile: UVec2, kind: StructureKind, ) { let mut entity = commands.spawn(( Name::new(format!("{kind:?}")), Structure { kind, fuel: kind.fuel_ticks(), }, TilePos(tile), Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)), )); let at = |x: f32, y: f32, z: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, z); match kind { StructureKind::Campfire => entity.with_children(|c| { for i in 0..CAMPFIRE_STONES { let angle = i as f32 / CAMPFIRE_STONES as f32 * std::f32::consts::TAU; let pos = Vec2::from_angle(angle) * 0.38; c.spawn(part_bundle(&assets.stone, at(pos.x, pos.y, 0.0))); } for angle in [0.6_f32, -0.6] { let log = at(0.0, -0.08, 0.01).with_rotation(Quat::from_rotation_z(angle)); c.spawn(part_bundle(&assets.log, log)); } c.spawn(part_bundle(&assets.flame, at(0.0, 0.08, 0.02))); c.spawn(part_bundle(&assets.flame_core, at(0.0, 0.03, 0.03))); }), StructureKind::Torch => entity.with_children(|c| { c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0))); let small = Vec3::splat(0.6); c.spawn(part_bundle( &assets.flame, at(0.0, 0.18, 0.01).with_scale(small), )); c.spawn(part_bundle( &assets.flame_core, at(0.0, 0.15, 0.02).with_scale(small), )); }), }; } #[cfg(test)] mod tests { use super::*; use crate::sim::TICKS_PER_HOUR; #[test] fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() { assert_eq!(daylight(12.0), 1.0); assert_eq!(daylight(23.0), 0.0); assert_eq!(daylight(2.0), 0.0); assert_eq!(daylight(19.0), 0.5); assert_eq!(daylight(6.0), 0.5); } // Building a fire takes time, so colonists start before it actually gets dark. #[test] fn darkness_is_anticipated_before_dusk() { let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR); assert!(!dark_soon(at(12))); assert!(dark_soon(at(18))); assert!(dark_soon(at(23))); assert!(!dark_soon(at(8))); } #[test] fn fires_light_their_surroundings_with_falloff() { let fire = UVec2::new(10, 10); let near = StructureKind::Campfire.light_at(fire, UVec2::new(11, 10)); let far = StructureKind::Campfire.light_at(fire, UVec2::new(15, 10)); assert!(brightness(near) > brightness(far)); assert_eq!( StructureKind::Campfire.light_at(fire, UVec2::new(17, 10)), Vec3::ZERO ); } #[test] fn light_map_combines_ambient_and_fires() { let fire = (UVec2::new(5, 5), StructureKind::Campfire); let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]); assert!(night.is_lit(UVec2::new(5, 6))); assert!(!night.is_lit(UVec2::new(18, 18))); let day = LightMap::compute(UVec2::splat(20), 1.0, []); assert!(day.is_lit(UVec2::new(18, 18))); } #[test] fn an_empty_light_map_counts_as_daylight() { assert!(LightMap::default().is_lit(UVec2::new(3, 3))); } #[test] fn structures_burn_out() { let mut app = App::new(); app.add_systems(Update, burn_fuel); let fire = app .world_mut() .spawn(Structure { kind: StructureKind::Torch, fuel: 3, }) .id(); for _ in 0..3 { app.update(); } assert!(app.world().get_entity(fire).is_err()); } }