src/structures.rs 14.4 K · 466 lines · raw · history

1 use bevy::prelude::*;
2 use serde::{Deserialize, Serialize};
3
4 use crate::{
5 items::ItemKind,
6 sim::{SimSystems, TICKS_PER_HOUR},
7 world::{TILE_SIZE, TilePos, WorldMap},
8 worldgen::WorldGenerated,
9 };
10
11 pub struct StructuresPlugin;
12
13 impl Plugin for StructuresPlugin {
14 fn build(&self, app: &mut App) {
15 app.add_systems(Startup, create_structure_assets)
16 .add_systems(
17 Update,
18 (
19 clear_structures.run_if(on_message::<WorldGenerated>),
20 join_walls,
21 ),
22 )
23 .add_systems(FixedUpdate, burn_fuel.in_set(SimSystems::Act));
24 }
25 }
26
27 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28 pub enum StructureKind {
29 Campfire,
30 Torch,
31 Bed,
32 Wall,
33 Door,
34 Workbench,
35 Statue,
36 }
37
38 impl StructureKind {
39 pub fn cost(self) -> (ItemKind, u32) {
40 match self {
41 StructureKind::Campfire => (ItemKind::Wood, 10),
42 StructureKind::Torch => (ItemKind::Wood, 2),
43 StructureKind::Bed => (ItemKind::Wood, 15),
44 StructureKind::Wall => (ItemKind::Stone, 2),
45 StructureKind::Door => (ItemKind::Wood, 5),
46 StructureKind::Workbench => (ItemKind::Wood, 20),
47 StructureKind::Statue => (ItemKind::Gold, 5),
48 }
49 }
50
51 pub fn fuel_ticks(self) -> Option<u32> {
52 let hours = match self {
53 StructureKind::Campfire => 12,
54 StructureKind::Torch => 8,
55 _ => return None,
56 };
57 Some(hours * TICKS_PER_HOUR as u32)
58 }
59
60 pub fn light(self) -> Option<(f32, Vec3)> {
61 match self {
62 StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))),
63 StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))),
64 _ => None,
65 }
66 }
67
68 pub fn heat(self) -> f32 {
69 match self {
70 StructureKind::Campfire => 20.0,
71 StructureKind::Torch => 4.0,
72 _ => 0.0,
73 }
74 }
75
76 pub fn radius(self) -> f32 {
77 self.light().map_or(0.0, |(radius, _)| radius)
78 }
79
80 pub fn name(self) -> &'static str {
81 match self {
82 StructureKind::Campfire => "campfire",
83 StructureKind::Torch => "torch",
84 StructureKind::Bed => "bed",
85 StructureKind::Wall => "wall",
86 StructureKind::Door => "door",
87 StructureKind::Workbench => "workbench",
88 StructureKind::Statue => "statue",
89 }
90 }
91 }
92
93 #[derive(Component, Debug, Clone, Copy, PartialEq)]
94 #[require(Transform, Visibility)]
95 pub struct Structure {
96 pub kind: StructureKind,
97 }
98
99 #[derive(Component, Debug, Clone, Copy, PartialEq)]
100 pub struct Fuel(pub u32);
101
102 fn burn_fuel(mut commands: Commands, mut fuel: Query<(Entity, &mut Fuel)>) {
103 for (entity, mut fuel) in &mut fuel {
104 fuel.0 = fuel.0.saturating_sub(1);
105 if fuel.0 == 0 {
106 commands.entity(entity).despawn();
107 }
108 }
109 }
110
111 fn clear_structures(mut commands: Commands, structures: Query<Entity, With<Structure>>) {
112 for entity in &structures {
113 commands.entity(entity).despawn();
114 }
115 }
116
117 type Part = (Handle<Mesh>, Handle<ColorMaterial>);
118
119 #[derive(Resource, Default)]
120 pub struct StructureAssets {
121 stone: Part,
122 log: Part,
123 flame: Part,
124 flame_core: Part,
125 stick: Part,
126 bed_frame: Part,
127 blanket: Part,
128 pillow: Part,
129 wall: Part,
130 wall_top: Part,
131 wall_arm_h: Part,
132 wall_arm_v: Part,
133 wall_corner: Part,
134 door: Part,
135 door_knob: Part,
136 bench: Part,
137 bench_leg: Part,
138 anvil: Part,
139 plinth: Part,
140 gold: Part,
141 }
142
143 const CAMPFIRE_STONES: usize = 7;
144
145 fn flame_mesh(width: f32, height: f32) -> Mesh {
146 Triangle2d::new(
147 Vec2::new(-width / 2.0, -height / 2.0),
148 Vec2::new(width / 2.0, -height / 2.0),
149 Vec2::new(0.0, height / 2.0),
150 )
151 .into()
152 }
153
154 fn create_structure_assets(
155 mut commands: Commands,
156 mut meshes: ResMut<Assets<Mesh>>,
157 mut materials: ResMut<Assets<ColorMaterial>>,
158 ) {
159 let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
160 commands.insert_resource(StructureAssets {
161 stone: part(
162 Circle::new(TILE_SIZE * 0.09).into(),
163 Color::srgb_u8(120, 116, 110),
164 ),
165 log: part(
166 Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.14).into(),
167 Color::srgb_u8(110, 74, 42),
168 ),
169 flame: part(
170 flame_mesh(TILE_SIZE * 0.36, TILE_SIZE * 0.5),
171 Color::srgb_u8(240, 110, 30),
172 ),
173 flame_core: part(
174 flame_mesh(TILE_SIZE * 0.18, TILE_SIZE * 0.28),
175 Color::srgb_u8(255, 220, 90),
176 ),
177 stick: part(
178 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
179 Color::srgb_u8(100, 70, 40),
180 ),
181 bed_frame: part(
182 Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.92).into(),
183 Color::srgb_u8(120, 82, 48),
184 ),
185 blanket: part(
186 Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.55).into(),
187 Color::srgb_u8(70, 110, 160),
188 ),
189 pillow: part(
190 Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.18).into(),
191 Color::srgb_u8(230, 228, 220),
192 ),
193 wall: part(
194 Rectangle::new(TILE_SIZE, TILE_SIZE).into(),
195 Color::srgb_u8(84, 82, 80),
196 ),
197 wall_top: part(
198 Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.8).into(),
199 Color::srgb_u8(132, 128, 122),
200 ),
201 wall_arm_h: part(
202 Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.12).into(),
203 Color::srgb_u8(132, 128, 122),
204 ),
205 wall_arm_v: part(
206 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.8).into(),
207 Color::srgb_u8(132, 128, 122),
208 ),
209 wall_corner: part(
210 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(),
211 Color::srgb_u8(132, 128, 122),
212 ),
213 door: part(
214 Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.9).into(),
215 Color::srgb_u8(140, 96, 54),
216 ),
217 door_knob: part(
218 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(),
219 Color::srgb_u8(60, 44, 30),
220 ),
221 bench: part(
222 Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.45).into(),
223 Color::srgb_u8(150, 104, 60),
224 ),
225 bench_leg: part(
226 Rectangle::new(TILE_SIZE * 0.1, TILE_SIZE * 0.35).into(),
227 Color::srgb_u8(96, 66, 38),
228 ),
229 anvil: part(
230 Rectangle::new(TILE_SIZE * 0.32, TILE_SIZE * 0.18).into(),
231 Color::srgb_u8(70, 74, 82),
232 ),
233 plinth: part(
234 Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.22).into(),
235 Color::srgb_u8(150, 146, 140),
236 ),
237 gold: part(
238 Triangle2d::new(
239 Vec2::new(-TILE_SIZE * 0.2, -TILE_SIZE * 0.25),
240 Vec2::new(TILE_SIZE * 0.2, -TILE_SIZE * 0.25),
241 Vec2::new(0.0, TILE_SIZE * 0.35),
242 )
243 .into(),
244 Color::srgb_u8(236, 196, 60),
245 ),
246 });
247 }
248
249 const WALL_INSET: f32 = 0.1;
250
251 #[derive(Debug, Clone, Copy, PartialEq)]
252 pub enum WallJoint {
253 Arm(IVec2),
254 Corner(IVec2),
255 }
256
257 impl WallJoint {
258 fn offset(self) -> Vec2 {
259 let edge = 0.5 - WALL_INSET / 2.0;
260 match self {
261 WallJoint::Arm(d) | WallJoint::Corner(d) => d.as_vec2() * edge,
262 }
263 }
264 }
265
266 pub fn wall_joints(solid: impl Fn(IVec2) -> bool) -> Vec<WallJoint> {
267 let sides = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y];
268 let mut joints: Vec<WallJoint> = sides
269 .into_iter()
270 .filter(|&d| solid(d))
271 .map(WallJoint::Arm)
272 .collect();
273 for d in [
274 IVec2::ONE,
275 IVec2::new(1, -1),
276 IVec2::new(-1, 1),
277 IVec2::NEG_ONE,
278 ] {
279 if solid(IVec2::new(d.x, 0)) && solid(IVec2::new(0, d.y)) && solid(d) {
280 joints.push(WallJoint::Corner(d));
281 }
282 }
283 joints
284 }
285
286 #[derive(Component)]
287 struct WallJointPiece;
288
289 fn join_walls(
290 mut commands: Commands,
291 assets: Res<StructureAssets>,
292 added: Query<&Structure, Added<Structure>>,
293 mut removed: RemovedComponents<Structure>,
294 structures: Query<(Entity, &TilePos, &Structure)>,
295 pieces: Query<Entity, With<WallJointPiece>>,
296 mut last: Local<std::collections::HashSet<IVec2>>,
297 ) {
298 if added.is_empty() && removed.read().count() == 0 {
299 return;
300 }
301 let solid: std::collections::HashSet<IVec2> = structures
302 .iter()
303 .filter(|(_, _, s)| matches!(s.kind, StructureKind::Wall | StructureKind::Door))
304 .map(|(_, pos, _)| pos.0.as_ivec2())
305 .collect();
306 let new_wall = added
307 .iter()
308 .any(|s| matches!(s.kind, StructureKind::Wall | StructureKind::Door));
309 if solid == *last && !new_wall {
310 return;
311 }
312 *last = solid.clone();
313 for piece in &pieces {
314 commands.entity(piece).despawn();
315 }
316 for (entity, pos, structure) in &structures {
317 if structure.kind != StructureKind::Wall {
318 continue;
319 }
320 let tile = pos.0.as_ivec2();
321 let joints = wall_joints(|d| solid.contains(&(tile + d)));
322 commands.entity(entity).with_children(|c| {
323 for joint in joints {
324 let part = match joint {
325 WallJoint::Arm(d) if d.x != 0 => &assets.wall_arm_v,
326 WallJoint::Arm(_) => &assets.wall_arm_h,
327 WallJoint::Corner(_) => &assets.wall_corner,
328 };
329 let at = (joint.offset() * TILE_SIZE).extend(0.01);
330 c.spawn((
331 WallJointPiece,
332 part_bundle(part, Transform::from_translation(at)),
333 ));
334 }
335 });
336 }
337 }
338
339 fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
340 (
341 Mesh2d(mesh.clone()),
342 MeshMaterial2d(material.clone()),
343 transform,
344 )
345 }
346
347 pub fn spawn_structure(
348 commands: &mut Commands,
349 assets: &StructureAssets,
350 tile: UVec2,
351 kind: StructureKind,
352 ) -> Entity {
353 let mut entity = commands.spawn((
354 Name::new(format!("{kind:?}")),
355 Structure { kind },
356 TilePos(tile),
357 Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
358 ));
359 if let Some(fuel) = kind.fuel_ticks() {
360 entity.insert(Fuel(fuel));
361 }
362 let at = |x: f32, y: f32, z: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, z);
363 match kind {
364 StructureKind::Campfire => entity.with_children(|c| {
365 for i in 0..CAMPFIRE_STONES {
366 let angle = i as f32 / CAMPFIRE_STONES as f32 * std::f32::consts::TAU;
367 let pos = Vec2::from_angle(angle) * 0.38;
368 c.spawn(part_bundle(&assets.stone, at(pos.x, pos.y, 0.0)));
369 }
370 for angle in [0.6_f32, -0.6] {
371 let log = at(0.0, -0.08, 0.01).with_rotation(Quat::from_rotation_z(angle));
372 c.spawn(part_bundle(&assets.log, log));
373 }
374 c.spawn(part_bundle(&assets.flame, at(0.0, 0.08, 0.02)));
375 c.spawn(part_bundle(&assets.flame_core, at(0.0, 0.03, 0.03)));
376 }),
377 StructureKind::Bed => entity.with_children(|c| {
378 c.spawn(part_bundle(&assets.bed_frame, at(0.0, 0.0, 0.0)));
379 c.spawn(part_bundle(&assets.blanket, at(0.0, -0.13, 0.01)));
380 c.spawn(part_bundle(&assets.pillow, at(0.0, 0.3, 0.01)));
381 }),
382 StructureKind::Wall => entity.with_children(|c| {
383 c.spawn(part_bundle(&assets.wall, at(0.0, 0.0, 0.0)));
384 c.spawn(part_bundle(&assets.wall_top, at(0.0, 0.0, 0.01)));
385 }),
386 StructureKind::Door => entity.with_children(|c| {
387 c.spawn(part_bundle(&assets.door, at(0.0, 0.0, 0.0)));
388 c.spawn(part_bundle(&assets.door_knob, at(0.25, 0.0, 0.01)));
389 }),
390 StructureKind::Workbench => entity.with_children(|c| {
391 for x in [-0.35, 0.35] {
392 c.spawn(part_bundle(&assets.bench_leg, at(x, -0.25, 0.0)));
393 }
394 c.spawn(part_bundle(&assets.bench, at(0.0, 0.05, 0.01)));
395 c.spawn(part_bundle(&assets.anvil, at(0.15, 0.2, 0.02)));
396 }),
397 StructureKind::Statue => entity.with_children(|c| {
398 c.spawn(part_bundle(&assets.plinth, at(0.0, -0.35, 0.0)));
399 c.spawn(part_bundle(&assets.gold, at(0.0, 0.05, 0.01)));
400 }),
401 StructureKind::Torch => entity.with_children(|c| {
402 c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
403 let small = Vec3::splat(0.6);
404 c.spawn(part_bundle(
405 &assets.flame,
406 at(0.0, 0.18, 0.01).with_scale(small),
407 ));
408 c.spawn(part_bundle(
409 &assets.flame_core,
410 at(0.0, 0.15, 0.02).with_scale(small),
411 ));
412 }),
413 };
414 entity.id()
415 }
416
417 #[cfg(test)]
418 mod tests {
419 use super::*;
420
421 #[test]
422 fn fires_burn_out() {
423 let mut app = App::new();
424 app.add_systems(Update, burn_fuel);
425 let fire = app
426 .world_mut()
427 .spawn((
428 Structure {
429 kind: StructureKind::Torch,
430 },
431 Fuel(3),
432 ))
433 .id();
434 for _ in 0..3 {
435 app.update();
436 }
437 assert!(app.world().get_entity(fire).is_err());
438 }
439
440 #[test]
441 fn beds_are_permanent() {
442 assert_eq!(StructureKind::Bed.fuel_ticks(), None);
443 assert!(StructureKind::Campfire.fuel_ticks().is_some());
444 }
445
446 // Walls draw a dark tile with a lighter top; the top reaches into every neighbouring wall or
447 // door (and fills the corner where a thick block of wall meets), so a wall reads as one solid
448 // structure outlined only on its outside.
449 #[test]
450 fn wall_tops_join_their_neighbours() {
451 let solid = |tiles: &[(i32, i32)]| {
452 let tiles: Vec<IVec2> = tiles.iter().map(|&(x, y)| IVec2::new(x, y)).collect();
453 move |d: IVec2| tiles.contains(&d)
454 };
455 assert!(wall_joints(solid(&[])).is_empty());
456 let straight = wall_joints(solid(&[(1, 0), (-1, 0)]));
457 assert_eq!(
458 straight,
459 vec![WallJoint::Arm(IVec2::X), WallJoint::Arm(IVec2::NEG_X)]
460 );
461 let corner = wall_joints(solid(&[(1, 0), (0, 1)]));
462 assert_eq!(corner.len(), 2);
463 let block = wall_joints(solid(&[(1, 0), (0, 1), (1, 1)]));
464 assert!(block.contains(&WallJoint::Corner(IVec2::ONE)));
465 }
466 }