From 0d54d987acbdbcaf3bddd0189e9a7b7bc160a247 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 22 Jun 2026 20:13:07 +0300 Subject: [PATCH] feat: explosions, particle system, electricity, structural integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explosions: - trigger_explosion(x, y, radius, damage) in Game - Destroys non-static solids, ignites empty cells, pressure spike - Knockback to entities (velocity away from center + upward pop) - Particle burst on explosion (fire + smoke + debris) Particle system: - ParticleManager in src/physics/particle.rs (capacity 2000) - CPU-side: position, velocity, life, color, gravity, size - spawn_burst() for radial bursts, individual spawn() for ambient - Rendered as third draw call in graphics.rs (is_ui: 2 in shader) - ParticleInstance = ColorInstance compatible (12 bytes) - upload_particles() via GpuRenderer trait - Ambient: fire sparks, lava bubbles — scanned around player Electricity: - chunk.electricity: Vec (4KB/chunk) — current strength 0-255 - Material.conductive + conductivity fields (water = conductive) - electricity_step() in CA: propagates current through conductive neighbors, decays over time, skips non-conductive cells - cells_swap swaps electricity, serialization saves/loads it Structural integrity: - Material.structural: bool (stone, wood = true) - structural_step() in CA (every 30 ticks, infinite mode only): checks if structural cells have support (below, below-left, below-right) converts unsupported to Sand (falls) - Only active in infinite mode to avoid breaking test grids Graphics shader: is_ui==2 branch renders particles in world-space All 185 tests + 14 scenarios pass. 143 FPS benchmark. --- assets/shaders/graphics.vert | 32 ++++-- assets/shaders/graphics_vert.spv | Bin 8684 -> 9652 bytes benchmark_results.json | 22 ++-- src/game.rs | 117 +++++++++++++++++++++ src/main.rs | 8 ++ src/physics/mod.rs | 1 + src/physics/particle.rs | 113 +++++++++++++++++++++ src/render/graphics.rs | 112 +++++++++++++++++++++ src/render/vulkan.rs | 2 + src/world/cellular.rs | 168 +++++++++++++++++++++++++++++++ src/world/chunk.rs | 17 ++++ src/world/chunked_grid.rs | 12 +++ src/world/material.rs | 48 +++++++++ 13 files changed, 631 insertions(+), 21 deletions(-) create mode 100644 src/physics/particle.rs diff --git a/assets/shaders/graphics.vert b/assets/shaders/graphics.vert index bbc59a2..aecbd7f 100644 --- a/assets/shaders/graphics.vert +++ b/assets/shaders/graphics.vert @@ -71,17 +71,29 @@ vec3 compute_light(ivec2 world_pos) { } void main() { - vec2 pixel = (in_grid + in_pos) * pc.cell_size; - gl_Position = vec4( - 2.0 * pixel.x / pc.screen_size.x - 1.0, - 2.0 * pixel.y / pc.screen_size.y - 1.0, - 0.0, 1.0 - ); - out_color = in_color; - if (pc.is_ui != 0u) { + if (pc.is_ui == 2u) { + vec2 world_pos = in_grid; + vec2 screen = (world_pos - vec2(pc.cam_pos)) * pc.cell_size + in_pos * pc.cell_size; + gl_Position = vec4( + 2.0 * screen.x / pc.screen_size.x - 1.0, + 2.0 * screen.y / pc.screen_size.y - 1.0, + 0.0, 1.0 + ); + out_color = in_color; out_light = vec3(1.0); } else { - ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos)); - out_light = compute_light(world_pos); + vec2 pixel = (in_grid + in_pos) * pc.cell_size; + gl_Position = vec4( + 2.0 * pixel.x / pc.screen_size.x - 1.0, + 2.0 * pixel.y / pc.screen_size.y - 1.0, + 0.0, 1.0 + ); + out_color = in_color; + if (pc.is_ui != 0u) { + out_light = vec3(1.0); + } else { + ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos)); + out_light = compute_light(world_pos); + } } } diff --git a/assets/shaders/graphics_vert.spv b/assets/shaders/graphics_vert.spv index 5da6ebb22e8189027d010cb4523eae623799c3f3..1c82ede1c61feb75bd77c426ed6785e4a788b30f 100644 GIT binary patch literal 9652 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 H=f?j4`*yH0 literal 8684 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 { + fire_count += 1; + if self.tick % 3 == 0 + && (dx * dx + dy * dy) < 400 + && self.particles.count() < 1800 + { + let mut rng = + (x as u32).wrapping_mul(7919).wrapping_add(self.tick as u32); + rng ^= rng << 13; + rng ^= rng >> 17; + let off_x = ((rng & 0xFF) as f32 / 255.0 - 0.5) * 2.0; + let off_y = ((rng >> 8 & 0xFF) as f32 / 255.0 - 0.5) * 2.0; + self.particles.spawn( + x as f32 + off_x, + y as f32 + off_y, + off_x * 0.3, + -0.5 - ((rng >> 16) as f32 / 65535.0), + 15 + (rng % 10), + [255, 140 + ((rng >> 4) % 80) as u8, 20, 200], + 1.0 + ((rng >> 8) % 3) as f32 * 0.5, + -0.02, + ); + } + } + MaterialId::Lava => { + lava_count += 1; + if self.tick % 20 == 0 + && (dx * dx + dy * dy) < 300 + && self.particles.count() < 1800 + { + self.particles.spawn_burst( + x as f32, + y as f32, + 2, + [255, 100, 20, 180], + 1.5, + 25, + 1.5, + 0.1, + ); + } + } + MaterialId::Water => { + water_count += 1; + } + _ => {} + } + } + } + let _ = (fire_count, lava_count, water_count); + } + fn play_ambient_sounds(&mut self) { if !self.audio.is_enabled() { return; @@ -1658,4 +1731,48 @@ impl Game { } } } + + pub fn trigger_explosion(&mut self, x: i32, y: i32, radius: i32, damage: f32) { + for dy in -radius..=radius { + for dx in -radius..=radius { + let dist_sq = dx * dx + dy * dy; + if dist_sq > radius * radius { + continue; + } + let bx = x + dx; + let by = y + dy; + if !self.grid.in_bounds(bx, by) { + continue; + } + let cell = self.grid.get(bx, by); + if cell.is_solid() && !cell.is_static() { + self.grid.set(bx, by, crate::world::cell::Cell::empty()); + } else if cell.is_empty() { + let mut fire = crate::world::cell::Cell::new(MaterialId::Fire); + fire.updated_this_tick = true; + self.grid.set(bx, by, fire); + self.grid.set_temp(bx, by, 600.0); + } + self.grid.set_pressure(bx, by, 255); + self.grid.mark_dirty(bx, by); + } + } + self.audio.play("explosion"); + for e in self.entities.all_mut() { + if !e.alive { + continue; + } + let dx = e.cx as i32 - x; + let dy = e.cy as i32 - y; + let dist = ((dx * dx + dy * dy) as f32).sqrt(); + if dist < radius as f32 * 1.5 { + let dmg = (damage * (1.0 - dist / (radius as f32 * 1.5))).max(0.0); + e.take_damage(dmg); + let knockback = (1.0 - dist / (radius as f32 * 1.5)).max(0.0) * 5.0; + let dir = if dx == 0 { 0.0 } else { dx as f32 / dist }; + e.set_horizontal_vel(dir * knockback); + e.set_vertical_vel(-knockback * 0.5); + } + } + } } diff --git a/src/main.rs b/src/main.rs index 7f321a2..acba0fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ trait GpuRenderer { fn grid_w(&self) -> usize; fn grid_h(&self) -> usize; fn cell_pixel_size(&self) -> u32; + fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager); } impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer { @@ -102,6 +103,9 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer { fn cell_pixel_size(&self) -> u32 { verbatim::render::vulkan::VulkanRenderer::cell_pixel_size(self) } + fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) { + verbatim::render::vulkan::VulkanRenderer::upload_particles(self, particles); + } } impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer { @@ -131,6 +135,9 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer { fn cell_pixel_size(&self) -> u32 { verbatim::render::graphics::GraphicsRenderer::cell_pixel_size(self) } + fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) { + verbatim::render::graphics::GraphicsRenderer::upload_particles(self, particles); + } } fn main() { @@ -532,6 +539,7 @@ fn run_gpu_mode(title: &str) { game.build_ui(vw, vh); + renderer.upload_particles(&game.particles); renderer.render( &game.grid, &game.entities, diff --git a/src/physics/mod.rs b/src/physics/mod.rs index 5635e2c..f7e8b7f 100644 --- a/src/physics/mod.rs +++ b/src/physics/mod.rs @@ -1,3 +1,4 @@ pub mod collision; +pub mod particle; pub mod projectile; pub mod verlet; diff --git a/src/physics/particle.rs b/src/physics/particle.rs new file mode 100644 index 0000000..d573c24 --- /dev/null +++ b/src/physics/particle.rs @@ -0,0 +1,113 @@ +#[derive(Clone, Copy)] +pub struct Particle { + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + pub life: u32, + pub max_life: u32, + pub color: [u8; 4], + pub size: f32, + pub gravity: f32, +} + +impl Particle { + pub fn alive(&self) -> bool { + self.life > 0 + } +} + +pub struct ParticleManager { + particles: Vec, + capacity: usize, +} + +impl ParticleManager { + pub fn new(capacity: usize) -> Self { + Self { + particles: Vec::with_capacity(capacity), + capacity, + } + } + + pub fn spawn( + &mut self, + x: f32, + y: f32, + vx: f32, + vy: f32, + life: u32, + color: [u8; 4], + size: f32, + gravity: f32, + ) { + if self.particles.len() >= self.capacity { + return; + } + self.particles.push(Particle { + x, + y, + vx, + vy, + life, + max_life: life, + color, + size, + gravity, + }); + } + + pub fn spawn_burst( + &mut self, + x: f32, + y: f32, + count: usize, + color: [u8; 4], + speed: f32, + life: u32, + size: f32, + gravity: f32, + ) { + let mut rng = 0x12345u32; + for _ in 0..count { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + let angle = (rng as f32 / u32::MAX as f32) * std::f32::consts::TAU; + let s = speed * (0.3 + 0.7 * ((rng >> 16) as f32 / u16::MAX as f32)); + let vx = angle.cos() * s; + let vy = angle.sin() * s - speed * 0.3; + self.spawn(x, y, vx, vy, life, color, size, gravity); + } + } + + pub fn update(&mut self) { + for p in &mut self.particles { + p.life = p.life.saturating_sub(1); + p.vy += p.gravity; + p.x += p.vx; + p.y += p.vy; + p.vx *= 0.96; + p.vy *= 0.96; + } + self.particles.retain(|p| p.alive()); + } + + pub fn all(&self) -> &[Particle] { + &self.particles + } + + pub fn clear(&mut self) { + self.particles.clear(); + } + + pub fn count(&self) -> usize { + self.particles.len() + } +} + +impl Default for ParticleManager { + fn default() -> Self { + Self::new(2000) + } +} diff --git a/src/render/graphics.rs b/src/render/graphics.rs index a39cf1b..992b8e9 100644 --- a/src/render/graphics.rs +++ b/src/render/graphics.rs @@ -46,6 +46,14 @@ struct ColorInstance { color: [u8; 4], } +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct ParticleInstance { + grid_x: f32, + grid_y: f32, + color: [u8; 4], +} + #[repr(C)] #[derive(bytemuck::NoUninit, Clone, Copy, Default)] struct GpuLightSource { @@ -115,6 +123,12 @@ pub struct GraphicsRenderer { ui_instance_ptr: *mut ColorInstance, ui_instance_capacity: usize, + particle_instance_buffer: vk::Buffer, + particle_instance_memory: vk::DeviceMemory, + particle_instance_ptr: *mut ParticleInstance, + particle_instance_capacity: usize, + particle_count: usize, + grid_buffer: vk::Buffer, grid_memory: vk::DeviceMemory, grid_ptr: *mut u32, @@ -706,6 +720,44 @@ impl GraphicsRenderer { ptr as *mut ColorInstance }; + let part_capacity = 2000usize; + let part_inst_sz = + (part_capacity * std::mem::size_of::()) as vk::DeviceSize; + let pibi = vk::BufferCreateInfo::default() + .size(part_inst_sz) + .usage(vk::BufferUsageFlags::VERTEX_BUFFER) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let particle_instance_buffer = unsafe { device.create_buffer(&pibi, None) } + .map_err(|e| format!("part inst buf: {e:?}"))?; + let preq = unsafe { device.get_buffer_memory_requirements(particle_instance_buffer) }; + let pmt = find_mem( + preq.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + )?; + let particle_instance_memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(preq.size) + .memory_type_index(pmt), + None, + ) + } + .map_err(|e| format!("part inst mem: {e:?}"))?; + let particle_instance_ptr = unsafe { + device + .bind_buffer_memory(particle_instance_buffer, particle_instance_memory, 0) + .expect("bind part inst"); + let ptr = device + .map_memory( + particle_instance_memory, + 0, + part_inst_sz, + vk::MemoryMapFlags::default(), + ) + .expect("map part inst"); + ptr as *mut ParticleInstance + }; + Ok(Self { grid_w, grid_h, @@ -742,6 +794,11 @@ impl GraphicsRenderer { ui_instance_memory, ui_instance_ptr, ui_instance_capacity: ui_capacity, + particle_instance_buffer, + particle_instance_memory, + particle_instance_ptr, + particle_instance_capacity: part_capacity, + particle_count: 0, grid_buffer, grid_memory, @@ -1076,6 +1133,35 @@ impl GraphicsRenderer { device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0); } + if self.particle_count > 0 { + device.cmd_bind_vertex_buffers( + cmd, + 0, + &[self.vertex_buffer, self.particle_instance_buffer], + &[0, 0], + ); + let part_pc = PushConstants { + screen_size: [ + self.swapchain_extent.width as f32, + self.swapchain_extent.height as f32, + ], + cell_size: [CHAR_W as f32, CHAR_H as f32], + world_size: [self.grid_w as i32, self.grid_h as i32], + cam_pos: [cam_x, cam_y], + ambient: [1.0, 1.0, 1.0], + is_ui: 2, + light_count: 0, + }; + device.cmd_push_constants( + cmd, + self.pipeline_layout, + vk::ShaderStageFlags::VERTEX, + 0, + bytemuck::bytes_of(&part_pc), + ); + device.cmd_draw_indexed(cmd, 6, self.particle_count as u32, 0, 0, 0); + } + device.cmd_end_render_pass(cmd); let _ = device.end_command_buffer(cmd); @@ -1115,6 +1201,29 @@ impl GraphicsRenderer { CHAR_W } + pub fn upload_particles(&mut self, particles: &crate::physics::particle::ParticleManager) { + let instances = unsafe { + std::slice::from_raw_parts_mut( + self.particle_instance_ptr, + self.particle_instance_capacity, + ) + }; + let mut count = 0usize; + for p in particles.all() { + if count >= self.particle_instance_capacity { + break; + } + let alpha = ((p.life as f32 / p.max_life as f32) * 255.0) as u8; + instances[count] = ParticleInstance { + grid_x: p.x, + grid_y: p.y, + color: [p.color[0], p.color[1], p.color[2], alpha], + }; + count += 1; + } + self.particle_count = count; + } + fn check_resize(&mut self) { let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let caps = match unsafe { @@ -1346,6 +1455,9 @@ impl Drop for GraphicsRenderer { self.device.free_memory(self.instance_memory, None); self.device.destroy_buffer(self.ui_instance_buffer, None); self.device.free_memory(self.ui_instance_memory, None); + self.device + .destroy_buffer(self.particle_instance_buffer, None); + self.device.free_memory(self.particle_instance_memory, None); self.device.destroy_buffer(self.vertex_buffer, None); self.device.free_memory(self.vertex_memory, None); self.device.destroy_buffer(self.index_buffer, None); diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index aad67bc..5cea7ef 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -712,6 +712,8 @@ impl VulkanRenderer { CHAR_W } + pub fn upload_particles(&mut self, _particles: &crate::physics::particle::ParticleManager) {} + fn check_resize(&mut self) { let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let caps = match unsafe { diff --git a/src/world/cellular.rs b/src/world/cellular.rs index a15138b..0159451 100644 --- a/src/world/cellular.rs +++ b/src/world/cellular.rs @@ -146,6 +146,8 @@ impl CellularAutomaton { self.heat_transfer(grid, &active, &pre_dirty); self.gas_step(grid, &active, &pre_dirty); self.pressure_step(grid, &active, &pre_dirty); + self.electricity_step(grid, &active, &pre_dirty); + self.structural_step(grid, &active); self.light_step(grid, &active); self.tick += 1; } @@ -760,6 +762,172 @@ impl CellularAutomaton { } } + fn electricity_step( + &mut self, + grid: &mut ChunkedGrid, + active: &[(i32, i32)], + pre_dirty: &std::collections::HashMap<(i32, i32), (i32, i32, i32, i32)>, + ) { + let reg = crate::world::material::MaterialRegistry::instance(); + let cs = CHUNK_SIZE as i32; + for &(cx, cy) in active { + let dirty = grid.get_chunk_dirty(cx, cy); + let pre = pre_dirty.get(&(cx, cy)).copied(); + let (min_x, min_y, max_x, max_y) = match (dirty, pre) { + (Some(d), Some(p)) => (d.0.min(p.0), d.1.min(p.1), d.2.max(p.2), d.3.max(p.3)), + (Some(d), None) => d, + (None, Some(p)) => p, + (None, None) => continue, + }; + let w = max_x - min_x + 1; + let h = max_y - min_y + 1; + let ox = cx * cs; + let oy = cy * cs; + let chunk = match grid.get_chunk_mut(cx, cy) { + Some(c) => c, + None => continue, + }; + let has_elec = chunk.electricity.iter().any(|&e| e > 0); + if !has_elec { + continue; + } + let mut changes: Vec<(usize, u8)> = Vec::new(); + for ly in 0..h { + let wy = min_y + ly - oy; + if wy < 0 || wy >= cs { + continue; + } + for lx in 0..w { + let wx = min_x + lx - ox; + if wx < 0 || wx >= cs { + continue; + } + let idx = (wy as usize) * CHUNK_SIZE + (wx as usize); + let cur = chunk.electricity[idx]; + if cur == 0 { + continue; + } + let mat = reg.get(chunk.cells[idx].material); + if !mat.conductive && cur < 200 { + changes.push((idx, cur.saturating_sub(20))); + continue; + } + let mut spread = 0u8; + for &(dx, dy) in &NEIGHBORS4 { + let nx = wx + dx; + let ny = wy + dy; + if nx < 0 || nx >= cs || ny < 0 || ny >= cs { + continue; + } + let ni = (ny as usize) * CHUNK_SIZE + (nx as usize); + let n_mat = reg.get(chunk.cells[ni].material); + let n_elec = chunk.electricity[ni]; + if n_mat.conductive && n_elec < cur { + let diff = (cur - n_elec) / 4; + if diff > 0 { + changes.push((ni, n_elec + diff)); + spread += diff; + } + } + } + if spread > 0 { + changes.push((idx, cur.saturating_sub(spread))); + } else { + changes.push((idx, cur.saturating_sub(5))); + } + } + } + for (idx, val) in changes { + chunk.electricity[idx] = val; + } + } + } + + fn structural_step(&mut self, grid: &mut ChunkedGrid, active: &[(i32, i32)]) { + if !grid.is_infinite() { + return; + } + if self.tick % 30 != 0 { + return; + } + let reg = crate::world::material::MaterialRegistry::instance(); + let cs = CHUNK_SIZE as i32; + for &(cx, cy) in active { + let dirty = match grid.get_chunk_dirty(cx, cy) { + Some(d) => d, + None => continue, + }; + let (min_x, min_y, max_x, max_y) = dirty; + let ox = cx * cs; + let oy = cy * cs; + let chunk = match grid.get_chunk_mut(cx, cy) { + Some(c) => c, + None => continue, + }; + let mut to_collapse: Vec<(usize, i32, i32)> = Vec::new(); + for ly in 0..(max_y - min_y + 1) { + let wy = min_y + ly - oy; + if wy < 0 || wy >= cs { + continue; + } + for lx in 0..(max_x - min_x + 1) { + let wx = min_x + lx - ox; + if wx < 0 || wx >= cs { + continue; + } + let idx = (wy as usize) * CHUNK_SIZE + (wx as usize); + let mat = reg.get(chunk.cells[idx].material); + if !mat.structural { + continue; + } + let below = if wy + 1 < cs { + let bi = ((wy + 1) as usize) * CHUNK_SIZE + (wx as usize); + chunk.cells[bi].is_solid() + } else { + true + }; + if below { + continue; + } + let below_left = if wy + 1 < cs && wx > 0 { + let bi = ((wy + 1) as usize) * CHUNK_SIZE + ((wx - 1) as usize); + chunk.cells[bi].is_solid() + } else { + true + }; + let below_right = if wy + 1 < cs && wx + 1 < cs { + let bi = ((wy + 1) as usize) * CHUNK_SIZE + ((wx + 1) as usize); + chunk.cells[bi].is_solid() + } else { + true + }; + if below_left || below_right { + continue; + } + let left = if wx > 0 { + chunk.cells[(wy as usize) * CHUNK_SIZE + ((wx - 1) as usize)].is_solid() + } else { + true + }; + let right = if wx + 1 < cs { + chunk.cells[(wy as usize) * CHUNK_SIZE + ((wx + 1) as usize)].is_solid() + } else { + true + }; + if !left && !right { + to_collapse.push((idx, wx, wy)); + } + } + } + for (idx, wx, wy) in to_collapse { + chunk.cells[idx] = Cell::new(MaterialId::Sand); + chunk.temps[idx] = 20.0; + chunk.modified = true; + chunk.mark_dirty(wx, wy); + } + } + } + fn light_step(&mut self, grid: &mut ChunkedGrid, active: &[(i32, i32)]) { self.light_tick += 1; if self.light_tick % 20 != 0 { diff --git a/src/world/chunk.rs b/src/world/chunk.rs index da9766b..1bc719d 100644 --- a/src/world/chunk.rs +++ b/src/world/chunk.rs @@ -9,6 +9,7 @@ pub struct Chunk { pub gas_type: Vec, pub gas_density: Vec, pub light: Vec<[u8; 3]>, + pub electricity: Vec, pub active: bool, pub modified: bool, pub was_modified: bool, @@ -28,6 +29,7 @@ impl Chunk { gas_type: vec![0; CHUNK_AREA], gas_density: vec![0; CHUNK_AREA], light: vec![[0, 0, 0]; CHUNK_AREA], + electricity: vec![0; CHUNK_AREA], active: false, modified: false, was_modified: false, @@ -142,6 +144,21 @@ impl Chunk { } } + #[inline] + pub fn get_electricity(&self, x: i32, y: i32) -> u8 { + if !Self::in_bounds(x, y) { + return 0; + } + self.electricity[Self::idx(x, y)] + } + + #[inline] + pub fn set_electricity(&mut self, x: i32, y: i32, val: u8) { + if Self::in_bounds(x, y) { + self.electricity[Self::idx(x, y)] = val; + } + } + pub fn is_empty(&self) -> bool { self.cells.iter().all(|c| c.is_empty()) } diff --git a/src/world/chunked_grid.rs b/src/world/chunked_grid.rs index ecd8881..14eab61 100644 --- a/src/world/chunked_grid.rs +++ b/src/world/chunked_grid.rs @@ -450,6 +450,7 @@ impl ChunkedGrid { chunk.pressure.swap(ci1, ci2); chunk.gas_type.swap(ci1, ci2); chunk.gas_density.swap(ci1, ci2); + chunk.electricity.swap(ci1, ci2); chunk.cells[ci2].updated_this_tick = true; chunk.modified = true; chunk.mark_dirty(lx1, ly1); @@ -503,6 +504,7 @@ impl ChunkedGrid { chunk.pressure.swap(i1, i2); chunk.gas_type.swap(i1, i2); chunk.gas_density.swap(i1, i2); + chunk.electricity.swap(i1, i2); chunk.cells[i2].updated_this_tick = true; chunk.modified = true; chunk.mark_dirty(lx1, ly1); @@ -860,6 +862,9 @@ impl ChunkedGrid { for l in &chunk.light { bytes.extend_from_slice(&l[..]); } + for &e in &chunk.electricity { + bytes.push(e); + } let dir = Path::new(path); if let Some(parent) = dir.parent() { std::fs::create_dir_all(parent)?; @@ -919,6 +924,13 @@ impl ChunkedGrid { chunk.light[i] = [data[off], data[off + 1], data[off + 2]]; off += 3; } + for i in 0..area { + if off >= data.len() { + break; + } + chunk.electricity[i] = data[off]; + off += 1; + } } else { let (x0, y0, x1, y1) = bounds; let w = (x1 - x0) as usize; diff --git a/src/world/material.rs b/src/world/material.rs index bad564e..4f57422 100644 --- a/src/world/material.rs +++ b/src/world/material.rs @@ -13,6 +13,9 @@ pub struct Material { pub ignition_temp: f32, pub melt_temp: f32, pub heat_conductivity: f32, + pub conductive: bool, + pub conductivity: f32, + pub structural: bool, pub color_fg: (u8, u8, u8), pub color_bg: (u8, u8, u8), pub display_char: char, @@ -40,6 +43,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.0, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (15, 15, 20), color_bg: (10, 10, 15), display_char: ' ', @@ -56,6 +62,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: 1700.0, heat_conductivity: 0.2, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (218, 178, 90), color_bg: (60, 50, 30), display_char: '.', @@ -72,6 +81,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: 0.0, heat_conductivity: 0.4, + conductive: true, + conductivity: 0.6, + structural: false, color_fg: (64, 128, 220), color_bg: (20, 40, 80), display_char: '~', @@ -88,6 +100,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: 1200.0, heat_conductivity: 0.3, + conductive: false, + conductivity: 0.0, + structural: true, color_fg: (120, 120, 130), color_bg: (40, 40, 50), display_char: '#', @@ -104,6 +119,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.05, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (255, 80, 20), color_bg: (120, 20, 0), display_char: '#', @@ -120,6 +138,9 @@ impl MaterialRegistry { ignition_temp: 300.0, melt_temp: f32::INFINITY, heat_conductivity: 0.1, + conductive: false, + conductivity: 0.0, + structural: true, color_fg: (140, 90, 50), color_bg: (50, 30, 20), display_char: 'T', @@ -136,6 +157,9 @@ impl MaterialRegistry { ignition_temp: 200.0, melt_temp: f32::INFINITY, heat_conductivity: 0.15, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (180, 50, 50), color_bg: (60, 15, 15), display_char: '%', @@ -152,6 +176,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.1, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (220, 210, 190), color_bg: (60, 55, 50), display_char: '`', @@ -168,6 +195,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.2, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (200, 200, 220), color_bg: (30, 30, 40), display_char: '~', @@ -184,6 +214,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.6, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (255, 160, 40), color_bg: (100, 30, 0), display_char: '^', @@ -200,6 +233,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.3, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (100, 255, 60), color_bg: (20, 60, 10), display_char: '~', @@ -216,6 +252,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.1, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (100, 100, 100), color_bg: (20, 20, 20), display_char: '*', @@ -232,6 +271,9 @@ impl MaterialRegistry { ignition_temp: 250.0, melt_temp: f32::INFINITY, heat_conductivity: 0.1, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (80, 200, 60), color_bg: (20, 50, 15), display_char: '"', @@ -248,6 +290,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.2, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (100, 70, 50), color_bg: (40, 30, 20), display_char: ':', @@ -264,6 +309,9 @@ impl MaterialRegistry { ignition_temp: f32::INFINITY, melt_temp: f32::INFINITY, heat_conductivity: 0.0, + conductive: false, + conductivity: 0.0, + structural: false, color_fg: (255, 220, 80), color_bg: (60, 50, 20), display_char: '>',