""" #File Name: sim.py Particle-based Physics Simulation System ====================================== This module implements a 2D particle simulation with physics, interactions, and state changes. Key Components: -------------- 1. Particle Class - Handles individual particle properties and behaviors - Supports multiple particle types (solid, liquid, gas) - Manages temperature and state transitions 2. Simulation Class - Core simulation engine - Manages particle creation, movement and interactions - Handles physics calculations and spatial partitioning """ #Load the imports. Pygame is what makes this even work and so simple may consider other engines for performance depends on learning curve. from settings import random, time, particle_properties # Load particle properties from json so we know what particles we got and how they should be simulated. class Particle: def __init__(self, position, velocity, mass, particle_type, properties, temperature=20): self.position = position # (x, y) self.velocity = velocity # (vx, vy) self.mass = mass self.particle_type = particle_type # Core properties self.size = properties.get("size", 1) self.hardness = properties.get("hardness", 0.5) self.color = properties.get("color", [255, 255, 255, 255]) self.temperature = properties.get("temperature", temperature) # Physics properties self.conductivity = properties.get("conductivity", 0) self.heat_capacity = properties.get("heat_capacity", 1) self.flamability = properties.get("flamability", 0.0) self.friction = properties.get("friction", 0.5) self.viscosity = properties.get("viscosity", 1.0) self.pressure = properties.get("pressure", 0) # State properties self.liquid = properties.get("liquid", False) self.solid = properties.get("solid", True) self.is_gas = properties.get("is_gas", False) # Temperature transition properties self.melt = properties.get("melt", None) self.melt_temperature = properties.get("melt_temperature", None) self.solidify = properties.get("solidify", None) self.solidify_temperature = properties.get("solidify_temperature", None) self.evaporate = properties.get("evaporate", None) self.evaporate_temperature = properties.get("evaporate_temperature", None) self.freeze = properties.get("freeze", None) self.freeze_temperature = properties.get("freeze_temperature", None) # Special properties self.explosive = properties.get("explosive", False) self.explosion_radius = properties.get("explosion_radius", 0) self.explosion_color = properties.get("explosion_color", [0, 0, 0]) @classmethod def from_type(cls, position, particle_type, properties): default_velocity = [0, 0] default_mass = properties.get("mass", 1.0) return cls(position, default_velocity, default_mass, particle_type, properties) class Simulation: # the main class of the simulation. def __init__(self, width, height, x=0, y=0): self.dormant_particles = set() self.particle_movement_counter = {} self.DORMANT_THRESHOLD = 10 self.x = x self.y = y self.new_x = 0 self.new_y = 0 self.width = width self.height = height self.particle_size = 3 self.particles = [[None for _ in range(height)] for _ in range(width)] self.particle_count = 0 self.active_particles = set() self.cell_size = 32 self.spatial_grid = {} self.brush_size = 1 self.max_brush_size = 20 self.particle_properties = particle_properties self.current_particle_type = 'sand' self.gravity = 9.8 # m/s^2, adjustable based on the scale of simulation self.wind_zones = [] self.wind = [0.0, 0.0] # Global wind vector (x, y) def reset_particle_count(self): self.particle_count = 0 def get_cell_key(self, x, y): # this is where we get the cell key. # Convert coordinates to grid cell cell_x = x // self.cell_size cell_y = y // self.cell_size return (cell_x, cell_y) def add_to_spatial_grid(self, particle, x, y): # this is where we add to the spatial grid. cell_key = self.get_cell_key(x, y) if cell_key not in self.spatial_grid: self.spatial_grid[cell_key] = set() self.spatial_grid[cell_key].add((x, y)) return cell_key def remove_from_spatial_grid(self, x, y): # this is where we remove from the spatial grid. cell_key = self.get_cell_key(x, y) if cell_key in self.spatial_grid: self.spatial_grid[cell_key].discard((x, y)) return cell_key def update_spatial_grid(self): # this is where we update the spatial grid. """Update spatial grid for optimized collision detection""" if len(self.active_particles) > 100: self.spatial_grid = {} cell_lists = {} for x, y in self.active_particles: cell_key = (x // self.cell_size, y // self.cell_size) if cell_key not in cell_lists: cell_lists[cell_key] = [] cell_lists[cell_key].append((x, y)) self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} def _check_dormant_state(self, x, y, particle): key = (x, y) if particle.particle_type == 'wall': self.dormant_particles.add(key) return True if not hasattr(particle, 'last_position'): particle.last_position = (x, y) self.particle_movement_counter[key] = 0 return False if particle.last_position == (x, y): self.particle_movement_counter[key] = self.particle_movement_counter.get(key, 0) + 1 if self.particle_movement_counter[key] >= self.DORMANT_THRESHOLD: self.dormant_particles.add(key) return True else: particle.last_position = (x, y) self.particle_movement_counter[key] = 0 self.dormant_particles.discard(key) return False def handle_phase_transitions(self, particle, x, y): # this is where we handle all the phase transitions. """Handle all phase transitions for a particle""" # Check evaporation if hasattr(particle, 'evaporate_temperature') and particle.evaporate_temperature is not None: if particle.temperature >= particle.evaporate_temperature and particle.evaporate: self.transform_particle(x, y, particle.evaporate) # Check freezing if hasattr(particle, 'freeze_temperature') and particle.freeze_temperature is not None: if particle.temperature <= particle.freeze_temperature and particle.freeze: self.transform_particle(x, y, particle.freeze) # Check for melting with proper attribute validation if (hasattr(particle, 'melt') and hasattr(particle, 'melt_temperature') and particle.melt_temperature is not None): if particle.temperature >= particle.melt_temperature: new_type = particle.melt if new_type in self.particle_properties: self.transform_particle(x, y, new_type) # Check for solidification with proper attribute validation if (hasattr(particle, 'solidify') and hasattr(particle, 'solidify_temperature') and particle.solidify_temperature is not None): if particle.temperature <= particle.solidify_temperature: new_type = particle.solidify if new_type in self.particle_properties: self.transform_particle(x, y, new_type) def handle_particle_interactions(self, dt): # this is where we handle all the particle interactions. """Handle interactions between different particle types""" for x, y in list(self.active_particles): particle = self.particles[x][y] if not particle: continue # Check neighboring particles for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)]: nx, ny = x + dx, y + dy if 0 <= nx < self.width and 0 <= ny < self.height: neighbor = self.particles[nx][ny] if neighbor: self.process_interaction(particle, neighbor, x, y, nx, ny) def process_interaction(self, particle1, particle2, x1, y1, x2, y2): # this function is part 2 of handle_particle_interactions. """Process specific interactions between two particles""" # Water + Sand = Mud if (particle1.particle_type == 'water' and particle2.particle_type == 'sand' or particle2.particle_type == 'water' and particle1.particle_type == 'sand'): self.create_mud(x1, y1) self.particles[x2][y2] = None self.active_particles.discard((x2, y2)) # Lava/Fire effects if particle1.particle_type in ['lava', 'fire', 'flame'] or particle2.particle_type in ['lava', 'fire', 'flame']: target = particle2 if particle1.particle_type in ['lava', 'fire', 'flame'] else particle1 target_x, target_y = (x2, y2) if particle1.particle_type in ['lava', 'fire', 'flame'] else (x1, y1) # Water to Steam if target.particle_type == 'water': self.transform_particle(target_x, target_y, 'steam') # Wood to Fire elif target.particle_type == 'wood': if random.random() < 0.3: # 30% chance to ignite self.transform_particle(target_x, target_y, 'fire') def create_mud(self, x, y): # this is where we create the mud. probably should be moved to handle_particle_interactions or process_interaction. """Create mud particle from water and sand interaction""" if 'mud' in self.particle_properties: properties = self.particle_properties['mud'] new_particle = Particle.from_type((x, y), 'mud', properties) self.particles[x][y] = new_particle self.active_particles.add((x, y)) def transform_particle(self, x, y, new_type): # this is where we transform the particle. """Transform a particle into a different type""" if new_type in self.particle_properties: properties = self.particle_properties[new_type] new_particle = Particle.from_type((x, y), new_type, properties) self.particles[x][y] = new_particle self.active_particles.add((x, y)) def handle_gas_movement(self, particle, x, y): # this is where we handle the gas movement. this function sucks. wip """Handle gas particle movement""" if particle.is_gas: dx = random.uniform(-1, 1) dy = random.uniform(-2, 0) # Bias upward movement new_x = int(x + dx) new_y = int(y + dy) if 0 <= new_x < self.width and 0 <= new_y < self.height: if self.particles[new_x][new_y] is None: self.particles[x][y] = None self.particles[new_x][new_y] = particle self.active_particles.add((new_x, new_y)) self.active_particles.discard((x, y)) def add_wind_zone(self, x, y): # Instead of creating particles, store wind zone data wind_zone = { 'x': x, 'y': y, 'radius': 50, 'strength': 2.0, 'direction': [1, 0] } self.wind_zones.append(wind_zone) def calculate_forces(self, particle, x, y): # this is where we calculate the forces. """Calculate net forces acting on a particle.""" fx, fy = 0.0, 0.0 # Initialize forces # Check wind zones for zone in self.wind_zones: dx = x - zone['x'] dy = y - zone['y'] distance = (dx*dx + dy*dy) ** 0.5 if distance <= zone['radius']: fx += zone['direction'][0] * zone['strength'] * (1 - distance/zone['radius']) fy += zone['direction'][1] * zone['strength'] * (1 - distance/zone['radius']) # Apply wind force if particle.is_gas: fx += self.wind[0] * 0.5 fy += self.wind[1] * 0.5 else: fx += self.wind[0] fy += self.wind[1] # Apply drag force drag = particle.viscosity * -1 fx += drag * particle.velocity[0] fy += drag * particle.velocity[1] # Check neighboring particles neighbors = self._get_quick_neighbors(x, y) for nx, ny in neighbors: if 0 <= nx < self.width and 0 <= ny < self.height: neighbor = self.particles[nx][ny] if neighbor: self._apply_neighbor_forces(particle, neighbor, fx, fy) return fx, fy def _process_particle_batch(self, batch, dt): updates = [] new_active = set() # Filter out dormant particles from the batch active_batch = [pos for pos in batch if pos not in self.dormant_particles] for x, y in active_batch: particle = self.particles[x][y] if not particle: continue if particle.particle_type == 'wall': new_active.add((x, y)) continue # Check if particle should become dormant if self._check_dormant_state(x, y, particle): new_active.add((x, y)) continue # physics calculations fx, fy = self.calculate_forces(particle, x, y) # Use max() to ensure mass is never zero mass = max(particle.mass, 0.001) particle.velocity[0] += (fx / mass) * dt particle.velocity[1] += (fy / mass) * dt new_x = int(x + particle.velocity[0] * dt) new_y = int(y + particle.velocity[1] * dt) if 0 <= new_x < self.width and 0 <= new_y < self.height: if self.particles[new_x][new_y] is None: updates.append((x, y, new_x, new_y, particle)) new_active.add((new_x, new_y)) # Wake up neighboring dormant particles self._wake_neighbors(new_x, new_y) else: new_active.add((x, y)) # Apply updates and return new active set for old_x, old_y, new_x, new_y, particle in updates: self.particles[old_x][old_y] = None self.particles[new_x][new_y] = particle particle.position = (new_x, new_y) return new_active def _get_quick_neighbors(self, x, y): """Quick neighbor lookup without full spatial grid""" return [(x+dx, y+dy) for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]] def _apply_neighbor_forces(self, particle, neighbor, fx, fy): """Optimized neighbor force calculation""" if hasattr(neighbor, 'temperature') and hasattr(particle, 'temperature'): temp_diff = neighbor.temperature - particle.temperature fy += temp_diff * 0.05 def ignite_particle(self, particle): # this is where we ignite the particle. """Handle ignition and burning of flammable particles.""" if hasattr(particle, 'flamability') and particle.flamability > 0.5: if hasattr(particle, 'temperature') and particle.temperature > 150: particle.type = 'fire' particle.temperature += 200 # Add burning effect for wood if particle.type == 'wood': particle.burning = True particle.burn_time = 100 # burn time def spread_fire(self): # this is where we spread the fire. """Spread fire to neighboring particles.""" for x, y in list(self.active_particles): particle = self.particles[x][y] if particle and (particle.particle_type == 'fire' or getattr(particle, 'burning', False)): # Check all neighboring cells including diagonals for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: nx, ny = x + dx, y + dy if 0 <= nx < self.width and 0 <= ny < self.height: neighbor = self.particles[nx][ny] if neighbor and hasattr(neighbor, 'flamability'): if neighbor.particle_type == 'wood': # Higher chance to ignite wood if random.random() < 0.3: # 30% chance to spread self.ignite_particle(neighbor) elif neighbor.flamability > 0: if random.random() < 0.1: # 10% chance for other materials self.ignite_particle(neighbor) def handle_special_particles(self, particle, x, y): # this is where we handle special particles. """Handle special particle behaviors""" if particle.particle_type in ['fire', 'flame', 'smoke']: if random.random() < 0.6: # % chance self.particles[x][y] = None self.active_particles.discard((x, y)) if particle.particle_type in ['fire', 'flame', 'lava']: # Create smoke above with proper physics if random.random() < 0.65 and y > 0: # % chance for smoke properties = self.particle_properties['smoke'] new_smoke = Particle.from_type((x, y-1), 'smoke', properties) if self.particles[x][y-1] is None: self.particles[x][y-1] = new_smoke self.active_particles.add((x, y-1)) else: # Handle collision with water if particle.particle_type == 'water': self.particles[x][y-1] = None self.active_particles.discard((x, y-1)) self.particles[x][y] = None self.active_particles.discard((x, y)) self.particles[x][y] = Particle.from_type((x, y), 'water', self.particle_properties['water']) self.active_particles.add((x, y)) def handle_temperature(self, dt): # this is where we handle the temperature. """Handle temperature changes and state transitions""" for x, y in list(self.active_particles): particle = self.particles[x][y] if not particle: continue if particle.temperature > 1700: # Transition to gas particle.is_gas = True particle.temperature = 1700 particle.velocity = [random.uniform(-1, 1), random.uniform(-1, 1)] particle.temperature < 1400 particle.is_gas = False # Temperature spread to neighbors for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: nx, ny = x + dx, y + dy if 0 <= nx < self.width and 0 <= ny < self.height: neighbor = self.particles[nx][ny] if (neighbor and hasattr(neighbor, 'temperature') and neighbor.temperature is not None): temp_diff = particle.temperature - neighbor.temperature heat_transfer = temp_diff * 0.1 * dt particle.temperature -= heat_transfer neighbor.temperature += heat_transfer def burning(self): # this is where we handle the burning. """Handle burning of particles.""" for x, y in list(self.active_particles): particle = self.particles[x][y] if particle and hasattr(particle, 'burning') and particle.burning: particle.temperature += 10 if particle.temperature > 1000: self.particles[x][y] = None self.active_particles.remove((x, y)) self.spatial_grid.pop((x, y), None) def create_particle(self, x, y): # this is where we create the particle. """Create a new particle with full property support""" particle_type = self.current_particle_type.lower() # Check if the particle is within the grid boundaries if particle_type in self.particle_properties: grid_x = x // self.particle_size grid_y = y // self.particle_size if 0 <= grid_x < self.width and 0 <= grid_y < self.height: properties = self.particle_properties[particle_type] position = (grid_x, grid_y) new_particle = Particle( position=position, velocity=[0, 0], mass=properties.get('mass', 1.0), particle_type=particle_type, properties=properties ) # Add to the grid if 0 <= grid_x < len(self.particles) and 0 <= grid_y < len(self.particles[0]): self.particles[grid_x][grid_y] = new_particle self.active_particles.add((grid_x, grid_y)) self.particle_count += 1 def create_particle_circle(self, center_x, center_y): # this is where we create the particle circle. brush_size = int(self.brush_size) for dx in range(-brush_size, brush_size + 1): for dy in range(-brush_size, brush_size + 1): if dx*dx + dy*dy <= brush_size*brush_size: # Circle check self.create_particle(center_x + dx * self.particle_size, center_y + dy * self.particle_size) def get_particle_state(self, x, y): # this is where we get the particle state. """Get the state of a particle at a given position""" particle = self.particles[x][y] if particle: return particle.particle_type return None def apply_gravity(self, dt): # this is where we apply gravity. """Handle only gravity and basic particle movement""" self.spatial_grid.clear() for x, y in list(self.active_particles): particle = self.particles[x][y] if not particle or particle.particle_type == 'wall': continue # Apply gravity new_y = y + 1 new_x = x # Check boundaries if not (0 <= new_x < self.width and 0 <= new_y < self.height): continue # Handle granular materials (sand, dirt) if particle.particle_type in ['sand', 'dirt', 'snow', 'ice']: if self.particles[x][new_y] is None: new_x, new_y = x, y + 1 else: # Try diagonal movement with randomization diagonal_dirs = [(-1, 1), (1, 1)] random.shuffle(diagonal_dirs) for dx, dy in diagonal_dirs: test_x = x + dx test_y = y + dy if (0 <= test_x < self.width and 0 <= test_y < self.height and self.particles[test_x][test_y] is None): if random.random() < 0.8: # 80% chance to move diagonally new_x = test_x new_y = test_y break # Handle liquid movement (water, lava) elif particle.liquid: if self.particles[x][new_y] is None: new_x = x new_y = y + 1 else: spread_directions = [(-1, 0), (1, 0)] random.shuffle(spread_directions) for dx, _ in spread_directions: test_x = x + dx if (0 <= test_x < self.width and self.particles[test_x][y] is None): new_x = test_x new_y = y break # Move particle if destination is empty if self.particles[new_x][new_y] is None: self.particles[x][y] = None self.particles[new_x][new_y] = particle self.active_particles.add((new_x, new_y)) self.active_particles.discard((x, y)) particle.position = (new_x, new_y) def apply_physics(self, dt, engine_settings): # this is where we apply physics. """Handle all physics effects""" new_active_particles = set() for x, y in list(self.active_particles): particle = self.particles[x][y] if not particle: continue # Handle boundaries based on settings if engine_settings['outerwall']: if x <= 0 or x >= self.width-1 or y <= 0 or y >= self.height-1: # Create wall particle at boundary if none exists if self.particles[x][y] is None: properties = self.particle_properties['wall'] wall = Particle.from_type((x, y), 'wall', properties) self.particles[x][y] = wall new_active_particles.add((x, y)) self.particle_count += 1 # Track new wall particle continue else: # Delete particles that go out of bounds if x <= 0 or x >= self.width-1 or y <= 0 or y >= self.height-1: if self.particles[x][y] is not None: self.particles[x][y] = None self.active_particles.discard((x, y)) self.particle_count -= 1 continue # Skip wall physics - walls are immutable if particle.particle_type == 'wall': new_active_particles.add((x, y)) continue # Handle dissipating particles if particle.particle_type in ['fire', 'flame']: # Clear current position first self.particles[x][y] = None self.active_particles.discard((x, y)) # Handle fire movement dx = random.uniform(-0.5, 0.5) dy = random.uniform(-1.5, -0.5) # Upward drift new_x = int(x + dx) new_y = int(y + dy) if 0 <= new_x < self.width and 0 <= new_y < self.height: if self.particles[new_x][new_y] is None: self.particles[new_x][new_y] = particle new_active_particles.add((new_x, new_y)) # Generate smoke above the new fire position if random.random() < 0.25 and new_y > 0: properties = self.particle_properties['smoke'] new_smoke = Particle( position=(new_x, new_y-1), velocity=[random.uniform(-0.5, 0.5), -1], mass=properties.get('mass', 0.1), particle_type='smoke', properties=properties ) if self.particles[new_x][new_y-1] is None: self.particles[new_x][new_y-1] = new_smoke new_active_particles.add((new_x, new_y-1)) # Dissipation chance if random.random() < 0.02: continue continue # Air handling - particles can pass through should implement proper air instead of a particle if particle.particle_type == 'air': continue # Handle phase transitions self.handle_phase_transitions(particle, x, y) # Calculate forces fx, fy = self.calculate_forces(particle, x, y) # handle gas particles if particle.is_gas: # Gas-specific movement dx = random.uniform(2, -1) dy = random.uniform(-2, 0) # Bias upward new_x = int(x + dx) new_y = int(y + dy) self.particles[x][y] = None self.active_particles.discard((x, y)) if 0 <= new_x < self.width and 0 <= new_y < self.height: if self.particles[new_x][new_y] is None: self.particles[x][y] = None self.particles[new_x][new_y] = particle new_active_particles.add((new_x, new_y)) self.active_particles.discard((x, y)) continue else: # Regular particle physics mass = max(particle.mass, 0.001) particle.velocity[0] += (fx / mass) * dt particle.velocity[1] += (fy / mass) * dt if particle.liquid: # Enhanced liquid spreading spread_chance = 0.5 if random.random() < spread_chance: dx = random.choice([-1, 1]) if (0 <= x + dx < self.width and self.particles[x + dx][y] is None): new_x = x + dx new_y = y self.particles[x][y] = None self.particles[new_x][new_y] = particle new_active_particles.add((new_x, new_y)) continue # Update position for non-liquid particles new_x = int(x + particle.velocity[0] * dt) new_y = int(y + particle.velocity[1] * dt) if 0 <= new_x < self.width and 0 <= new_y < self.height: if self.particles[new_x][new_y] is None: self.particles[x][y] = None self.particles[new_x][new_y] = particle else: new_active_particles.add((x, y)) self.active_particles = new_active_particles def clear_particles_circle(self, center_x, center_y): # this is for the brush tool """Clear particles in a circle around the given point based on brush size""" brush_size = int(self.brush_size) particles_cleared = 0 # Track how many particles we clear for dx in range(-brush_size, brush_size + 1): for dy in range(-brush_size, brush_size + 1): if dx*dx + dy*dy <= brush_size*brush_size: # Circle check grid_x = (center_x + dx * self.particle_size) // self.particle_size grid_y = (center_y + dy * self.particle_size) // self.particle_size if 0 <= grid_x < self.width and 0 <= grid_y < self.height: if self.particles[grid_x][grid_y]: self.particles[grid_x][grid_y] = None self.active_particles.discard((grid_x, grid_y)) self.remove_from_spatial_grid(grid_x, grid_y) particles_cleared += 1 self.particle_count = max(0, self.particle_count - particles_cleared) # Update count, ensure it doesn't go negative def mix_liquids(self, liquid1, liquid2): # this is for the mix tool """Handle liquid mixing interactions""" if liquid1.temperature != liquid2.temperature: avg_temp = (liquid1.temperature + liquid2.temperature) / 2 liquid1.temperature = avg_temp liquid2.temperature = avg_temp liquid1.density = self.calculate_density(liquid1.temperature) liquid2.density = self.calculate_density(liquid2.temperature) liquid1.viscosity = self.calculate_viscosity(liquid1.temperature) liquid2.viscosity = self.calculate_viscosity(liquid2.temperature) liquid1.color = self.calculate_color(liquid1.temperature) liquid2.color = self.calculate_color(liquid2.temperature) def _wake_neighbors(self, x, y): for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: nx, ny = x + dx, y + dy key = (nx, ny) if key in self.dormant_particles: self.dormant_particles.discard(key) self.particle_movement_counter[key] = 0 def track_tps(self): """Track Ticks Per Second for simulation performance monitoring""" if not hasattr(self, '_tps_counter'): self._tps_counter = 0 self._tps_timer = time.time() self._current_tps = 0 self._tps_counter += 1 current_time = time.time() elapsed = current_time - self._tps_timer # Update TPS count every second if elapsed >= 1.0: self._current_tps = self._tps_counter / elapsed self._tps_counter = 0 self._tps_timer = current_time return self._current_tps def simulate_step(self, dt, engine_settings): """Run a single step of the simulation""" active_list = list(self.active_particles) batch_size = 1000 for i in range(0, len(active_list), batch_size): batch = active_list[i:i + batch_size] self._process_particle_batch(batch, dt) # Update spatial grid only when needed if len(self.active_particles) > 100: self.update_spatial_grid() # Update particle positions and physics self.apply_gravity(dt) self.apply_physics(dt, engine_settings) # Handle state changes and interactions self.handle_temperature(dt) self.handle_particle_interactions(dt) self.burning() self.spread_fire()