There appears to be some differences between master and my local machines so this didn't work as expected. revert Updated sim.py updated sim.py with performance patch from the dev branch.
790 lines
34 KiB
Python
790 lines
34 KiB
Python
"""
|
|
#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() |