sandpypi/sim.py
Stan44 1a6ef4f3c4 changed imports.py
it is now settings.py
settings handles loading the json file for the particles
and imports, and potentially other things such as
actual settings that are saveable and more
2024-12-26 05:21:13 -06:00

605 lines
26 KiB
Python

#File Name: sim.py
#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 json, 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.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.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 = [0.0, 0.0] # Global wind vector (x, y)
def handle_phase_transitions(self, particle, x, y):
"""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 melting
if hasattr(particle, 'melt_temperature') and particle.melt_temperature is not None:
if particle.temperature >= particle.melt_temperature and particle.melt:
self.transform_particle(x, y, particle.melt)
# 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 solidification
if hasattr(particle, 'solidify_temperature') and particle.solidify_temperature is not None:
if particle.temperature <= particle.solidify_temperature and particle.solidify:
self.transform_particle(x, y, particle.solidify)
def handle_particle_interactions(self, dt):
"""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):
"""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):
"""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):
"""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):
"""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 temperature(self, dt):
"""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 > 1100:
# Transition to gas
particle.is_gas = True
particle.temperature = 1100
particle.velocity = [random.uniform(-1, 1), random.uniform(-1, 1)]
particle.temperature < 1100
particle.is_gas = False
def calculate_forces(self, particle, x, y):
"""Calculate net forces acting on a particle."""
fx, fy = 0.0, 0.0 # Initialize forces
# Apply wind force
fx += self.wind[0] * (1.0 if not particle.is_gas else 0.5)
fy += self.wind[1] * (1.0 if not particle.is_gas else 0.5)
# Apply drag force
drag = particle.viscosity * -1
fx += drag * particle.velocity[0]
fy += drag * particle.velocity[1]
# Check neighboring particles
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:
# Temperature effects
if hasattr(neighbor, 'temperature') and hasattr(particle, 'temperature'):
if neighbor.temperature > particle.temperature:
fy += (neighbor.temperature - particle.temperature) * 0.1
# Gas pressure effects
if hasattr(neighbor, 'is_gas') and hasattr(particle, 'is_gas'):
if neighbor.is_gas and not particle.is_gas:
fx += dx * 0.1
fy += dy * 0.1
return fx, fy
def ignite_particle(self, 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 # Adjust burn time as needed
def spread_fire(self):
"""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_temperature(self, dt):
"""Handle temperature changes and state transitions"""
for x, y in list(self.active_particles):
particle = self.particles[x][y]
if not particle:
continue
# 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)
# 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):
"""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 get_cell_key(self, x, y):
# 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):
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))
def remove_from_spatial_grid(self, x, y):
cell_key = self.get_cell_key(x, y)
if cell_key in self.spatial_grid:
self.spatial_grid[cell_key].discard((x, y))
def create_particle_circle(self, center_x, center_y):
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 create_particle(self, x, y):
"""Create a new particle with full property support"""
particle_type = self.current_particle_type.lower()
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
)
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))
def update_spatial_grid(self):
"""Update spatial grid for optimized collision detection"""
self.spatial_grid.clear()
for x, y in self.active_particles:
self.add_to_spatial_grid(self.particles[x][y], x, y)
def apply_gravity(self, dt):
"""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']:
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 handle_special_particles(self, particle, x, y):
"""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 apply_physics(self, dt):
"""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
# 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
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(-1, 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
particle.velocity[0] += (fx / particle.mass) * dt
particle.velocity[1] += (fy / particle.mass) * dt
if particle.liquid:
# Enhanced liquid spreading
spread_chance = 0.8
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):
"""Clear particles in a circle around the given point based on brush size"""
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
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)
def mix_liquids(self, liquid1, liquid2):
"""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 simulate_step(self, dt):
"""Run a single step of the simulation"""
# Update particle positions and physics
self.apply_gravity(dt)
self.apply_physics(dt)
# Handle state changes and interactions
self.handle_temperature(dt)
self.handle_particle_interactions(dt)
self.burning()
self.spread_fire()
# Update spatial grid
self.update_spatial_grid()