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