use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ items::ItemKind, sim::{SimSystems, TICKS_PER_HOUR}, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::WorldGenerated, }; pub struct StructuresPlugin; impl Plugin for StructuresPlugin { fn build(&self, app: &mut App) { app.add_systems(Startup, create_structure_assets) .add_systems( Update, ( clear_structures.run_if(on_message::), join_walls, ), ) .add_systems(FixedUpdate, burn_fuel.in_set(SimSystems::Act)); } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum StructureKind { Campfire, Torch, Bed, Wall, Door, Workbench, Statue, } impl StructureKind { pub fn cost(self) -> (ItemKind, u32) { match self { StructureKind::Campfire => (ItemKind::Wood, 10), StructureKind::Torch => (ItemKind::Wood, 2), StructureKind::Bed => (ItemKind::Wood, 15), StructureKind::Wall => (ItemKind::Stone, 2), StructureKind::Door => (ItemKind::Wood, 5), StructureKind::Workbench => (ItemKind::Wood, 20), StructureKind::Statue => (ItemKind::Gold, 5), } } pub fn fuel_ticks(self) -> Option { let hours = match self { StructureKind::Campfire => 12, StructureKind::Torch => 8, _ => return None, }; Some(hours * TICKS_PER_HOUR as u32) } pub fn light(self) -> Option<(f32, Vec3)> { match self { StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))), StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))), _ => None, } } pub fn heat(self) -> f32 { match self { StructureKind::Campfire => 20.0, StructureKind::Torch => 4.0, _ => 0.0, } } pub fn radius(self) -> f32 { self.light().map_or(0.0, |(radius, _)| radius) } pub fn name(self) -> &'static str { match self { StructureKind::Campfire => "campfire", StructureKind::Torch => "torch", StructureKind::Bed => "bed", StructureKind::Wall => "wall", StructureKind::Door => "door", StructureKind::Workbench => "workbench", StructureKind::Statue => "statue", } } } #[derive(Component, Debug, Clone, Copy, PartialEq)] #[require(Transform, Visibility)] pub struct Structure { pub kind: StructureKind, } #[derive(Component, Debug, Clone, Copy, PartialEq)] pub struct Fuel(pub u32); fn burn_fuel(mut commands: Commands, mut fuel: Query<(Entity, &mut Fuel)>) { for (entity, mut fuel) in &mut fuel { fuel.0 = fuel.0.saturating_sub(1); if fuel.0 == 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, bed_frame: Part, blanket: Part, pillow: Part, wall: Part, wall_top: Part, wall_arm_h: Part, wall_arm_v: Part, wall_corner: Part, door: Part, door_knob: Part, bench: Part, bench_leg: Part, anvil: Part, plinth: Part, gold: 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), ), bed_frame: part( Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.92).into(), Color::srgb_u8(120, 82, 48), ), blanket: part( Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.55).into(), Color::srgb_u8(70, 110, 160), ), pillow: part( Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.18).into(), Color::srgb_u8(230, 228, 220), ), wall: part( Rectangle::new(TILE_SIZE, TILE_SIZE).into(), Color::srgb_u8(84, 82, 80), ), wall_top: part( Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.8).into(), Color::srgb_u8(132, 128, 122), ), wall_arm_h: part( Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.12).into(), Color::srgb_u8(132, 128, 122), ), wall_arm_v: part( Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.8).into(), Color::srgb_u8(132, 128, 122), ), wall_corner: part( Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(), Color::srgb_u8(132, 128, 122), ), door: part( Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.9).into(), Color::srgb_u8(140, 96, 54), ), door_knob: part( Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(), Color::srgb_u8(60, 44, 30), ), bench: part( Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.45).into(), Color::srgb_u8(150, 104, 60), ), bench_leg: part( Rectangle::new(TILE_SIZE * 0.1, TILE_SIZE * 0.35).into(), Color::srgb_u8(96, 66, 38), ), anvil: part( Rectangle::new(TILE_SIZE * 0.32, TILE_SIZE * 0.18).into(), Color::srgb_u8(70, 74, 82), ), plinth: part( Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.22).into(), Color::srgb_u8(150, 146, 140), ), gold: part( Triangle2d::new( Vec2::new(-TILE_SIZE * 0.2, -TILE_SIZE * 0.25), Vec2::new(TILE_SIZE * 0.2, -TILE_SIZE * 0.25), Vec2::new(0.0, TILE_SIZE * 0.35), ) .into(), Color::srgb_u8(236, 196, 60), ), }); } const WALL_INSET: f32 = 0.1; #[derive(Debug, Clone, Copy, PartialEq)] pub enum WallJoint { Arm(IVec2), Corner(IVec2), } impl WallJoint { fn offset(self) -> Vec2 { let edge = 0.5 - WALL_INSET / 2.0; match self { WallJoint::Arm(d) | WallJoint::Corner(d) => d.as_vec2() * edge, } } } pub fn wall_joints(solid: impl Fn(IVec2) -> bool) -> Vec { let sides = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]; let mut joints: Vec = sides .into_iter() .filter(|&d| solid(d)) .map(WallJoint::Arm) .collect(); for d in [ IVec2::ONE, IVec2::new(1, -1), IVec2::new(-1, 1), IVec2::NEG_ONE, ] { if solid(IVec2::new(d.x, 0)) && solid(IVec2::new(0, d.y)) && solid(d) { joints.push(WallJoint::Corner(d)); } } joints } #[derive(Component)] struct WallJointPiece; fn join_walls( mut commands: Commands, assets: Res, added: Query<&Structure, Added>, mut removed: RemovedComponents, structures: Query<(Entity, &TilePos, &Structure)>, pieces: Query>, mut last: Local>, ) { if added.is_empty() && removed.read().count() == 0 { return; } let solid: std::collections::HashSet = structures .iter() .filter(|(_, _, s)| matches!(s.kind, StructureKind::Wall | StructureKind::Door)) .map(|(_, pos, _)| pos.0.as_ivec2()) .collect(); let new_wall = added .iter() .any(|s| matches!(s.kind, StructureKind::Wall | StructureKind::Door)); if solid == *last && !new_wall { return; } *last = solid.clone(); for piece in &pieces { commands.entity(piece).despawn(); } for (entity, pos, structure) in &structures { if structure.kind != StructureKind::Wall { continue; } let tile = pos.0.as_ivec2(); let joints = wall_joints(|d| solid.contains(&(tile + d))); commands.entity(entity).with_children(|c| { for joint in joints { let part = match joint { WallJoint::Arm(d) if d.x != 0 => &assets.wall_arm_v, WallJoint::Arm(_) => &assets.wall_arm_h, WallJoint::Corner(_) => &assets.wall_corner, }; let at = (joint.offset() * TILE_SIZE).extend(0.01); c.spawn(( WallJointPiece, part_bundle(part, Transform::from_translation(at)), )); } }); } } 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, ) -> Entity { let mut entity = commands.spawn(( Name::new(format!("{kind:?}")), Structure { kind }, TilePos(tile), Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)), )); if let Some(fuel) = kind.fuel_ticks() { entity.insert(Fuel(fuel)); } 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::Bed => entity.with_children(|c| { c.spawn(part_bundle(&assets.bed_frame, at(0.0, 0.0, 0.0))); c.spawn(part_bundle(&assets.blanket, at(0.0, -0.13, 0.01))); c.spawn(part_bundle(&assets.pillow, at(0.0, 0.3, 0.01))); }), StructureKind::Wall => entity.with_children(|c| { c.spawn(part_bundle(&assets.wall, at(0.0, 0.0, 0.0))); c.spawn(part_bundle(&assets.wall_top, at(0.0, 0.0, 0.01))); }), StructureKind::Door => entity.with_children(|c| { c.spawn(part_bundle(&assets.door, at(0.0, 0.0, 0.0))); c.spawn(part_bundle(&assets.door_knob, at(0.25, 0.0, 0.01))); }), StructureKind::Workbench => entity.with_children(|c| { for x in [-0.35, 0.35] { c.spawn(part_bundle(&assets.bench_leg, at(x, -0.25, 0.0))); } c.spawn(part_bundle(&assets.bench, at(0.0, 0.05, 0.01))); c.spawn(part_bundle(&assets.anvil, at(0.15, 0.2, 0.02))); }), StructureKind::Statue => entity.with_children(|c| { c.spawn(part_bundle(&assets.plinth, at(0.0, -0.35, 0.0))); c.spawn(part_bundle(&assets.gold, at(0.0, 0.05, 0.01))); }), 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), )); }), }; entity.id() } #[cfg(test)] mod tests { use super::*; #[test] fn fires_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()); } #[test] fn beds_are_permanent() { assert_eq!(StructureKind::Bed.fuel_ticks(), None); assert!(StructureKind::Campfire.fuel_ticks().is_some()); } // Walls draw a dark tile with a lighter top; the top reaches into every neighbouring wall or // door (and fills the corner where a thick block of wall meets), so a wall reads as one solid // structure outlined only on its outside. #[test] fn wall_tops_join_their_neighbours() { let solid = |tiles: &[(i32, i32)]| { let tiles: Vec = tiles.iter().map(|&(x, y)| IVec2::new(x, y)).collect(); move |d: IVec2| tiles.contains(&d) }; assert!(wall_joints(solid(&[])).is_empty()); let straight = wall_joints(solid(&[(1, 0), (-1, 0)])); assert_eq!( straight, vec![WallJoint::Arm(IVec2::X), WallJoint::Arm(IVec2::NEG_X)] ); let corner = wall_joints(solid(&[(1, 0), (0, 1)])); assert_eq!(corner.len(), 2); let block = wall_joints(solid(&[(1, 0), (0, 1), (1, 1)])); assert!(block.contains(&WallJoint::Corner(IVec2::ONE))); } }