diff --git a/.gitignore b/.gitignore index fb7e93c..f6007b0 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,6 @@ sandpypi.onefile-build/ sandpypi.exe sandpypi.7z unittest/ -.7z \ No newline at end of file +.7z +.zip +livenotes.txt \ No newline at end of file diff --git a/__init__.py b/__init__.py index 8b13789..82cda0b 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1,2 @@ - +import os +import sys diff --git a/particles.json b/particles.json index d1f82d7..454da41 100644 --- a/particles.json +++ b/particles.json @@ -5,11 +5,12 @@ "hardness": 0.5, "color": [255, 255, 0, 255], "velocity": 0.5, + "wind": 1, "mass": 0.5, "conductivity": 0, "heat_capacity": 1, "flamability": 0.8, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -79,7 +80,7 @@ "velocity": 0.0, "conductivity": 0, "heat_capacity": 0, - "color": [75, 75, 75, 255], + "color": [75, 75, 170, 255], "mass": 1, "flamability": 0.0, "temperature": 0, @@ -104,7 +105,7 @@ "color": [139, 69, 19, 255], "mass": 0.5, "flamability": 0, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -165,7 +166,7 @@ "color": [75, 75, 75, 255], "mass": 1, "flamability": 0, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -185,7 +186,7 @@ "color": [139, 69, 19, 255], "mass": 0.5, "flamability": 0, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -202,14 +203,14 @@ "velocity": 1.5, "conductivity": 0, "heat_capacity": 0, - "color": [128, 128, 128, 255], + "color": [128, 128, 128, 220], "mass": 1, "flamability": 0, "melt": "molten-Stone", "melt_temperature": 800, "solidify": "stone", "solidify_temperature": 799, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -253,9 +254,9 @@ "flamability": 0.8, "burning_temperature": 250, "burning_rate": 0.01, - "burning_color": [255, 0, 0, 255], + "burning_color": [255, 69, 19, 255], "burning": false, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -272,9 +273,9 @@ "velocity": 0.5, "conductivity": 0, "heat_capacity": 1, - "color": [139, 69, 19, 255], + "color": [255, 69, 19, 255], "mass": 0.5, - "flamability": 1, + "flamability": 0.8, "temperature": 251, "burning": true, "explosive": false, @@ -293,7 +294,7 @@ "velocity": 0.0, "conductivity": 0, "heat_capacity": 1, - "color": [25, 25, 25, 25], + "color": [255, 255, 255, 25], "mass": 0.0, "flamability": 0, "temperature": 0, @@ -313,7 +314,7 @@ "velocity": 0.5, "conductivity": 0, "heat_capacity": 1, - "color": [255, 45, 24, 255], + "color": [255, 45, 60, 255], "mass": 0.3, "flamability": 0, "temperature": 1400, @@ -340,7 +341,7 @@ "flamability": 0, "melt": "molten-rock", "melt_temperature": 600, - "temperature": 0, + "temperature": 20, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], @@ -438,19 +439,19 @@ "solid": true, "is_gas": false, "melt": "molten-glass", - "melt_temperature": 1000 + "melt_temperature": 600 }, - "flame": { - "name": "Flame", + "plasma": { + "name": "Plasma", "size": 1, "hardness": 0.0, "velocity": 0.0, "conductivity": 0, "heat_capacity": 1, - "color": [255, 100, 0, 255], + "color": [255, 100, 200, 255], "mass": 0.0, "flamability": 0, - "temperature": 1000, + "temperature": 3600, "explosive": false, "explosion_radius": 0, "explosion_color": [0, 0, 0], diff --git a/rendering.py b/rendering.py index 088aadc..80c9872 100644 --- a/rendering.py +++ b/rendering.py @@ -28,6 +28,7 @@ from settings import pygame, random, particle_properties, engine_settings class Rendering: def __init__(self, width, height): + #self.setup_gpu_rendering() self.screen = pygame.display.set_mode((width, height)) self.background = pygame.Surface((width, height)) self.background.fill((0, 0, 0)) @@ -39,7 +40,13 @@ class Rendering: self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) self.cached_fonts = { 'debug': pygame.font.SysFont(None, 24), - 'button': pygame.font.SysFont(None, 20) + 'button': pygame.font.SysFont(None, 20), + 'slider': pygame.font.SysFont(None, 20), + 'settings': pygame.font.SysFont(None, 20), + 'zoom': pygame.font.SysFont(None, 20), + 'brush_size': pygame.font.SysFont(None, 20), + 'zoom_window': pygame.font.SysFont(None, 20), + 'zoom_window_text': pygame.font.SysFont(None, 20) } # Pre-render static UI elements @@ -64,7 +71,7 @@ class Rendering: self.category_buttons = {} self.setup_category_menu() self.setup_static_ui() - + def setup_gpu_rendering(self): # Initialize OpenGL context @@ -124,7 +131,15 @@ class Rendering: color = particle_colors[particle.particle_type] else: color = (255, 255, 255) - + + if engine_settings['enable_glow']: + glow_color = (255, 255, 255) + glow_radius = 0.5 * particle_size + glow_surface = pygame.Surface((glow_radius * 2, glow_radius * 2), pygame.SRCALPHA) + pygame.draw.circle(glow_surface, glow_color, (glow_radius, glow_radius), glow_radius) + glow_surface.set_alpha(85) + self.particle_surface.blit(glow_surface, (x * particle_size - glow_radius, y * particle_size - glow_radius)) + if engine_settings['enable_gas_effect']: if particle.is_gas: # Enhanced gas visibility @@ -146,15 +161,6 @@ class Rendering: pygame.draw.rect(self.particle_surface, color, rect) - if engine_settings['enable_glow']: - glow_color = (255, 255, 255) - glow_radius = 0.5 * particle_size - glow_surface = pygame.Surface((glow_radius * 2, glow_radius * 2), pygame.SRCALPHA) - pygame.draw.circle(glow_surface, glow_color, (glow_radius, glow_radius), glow_radius) - glow_surface.set_alpha(85) - self.particle_surface.blit(glow_surface, (x * particle_size - glow_radius, y * particle_size - glow_radius)) - - self.screen.blit(self.background, (0, 0)) self.screen.blit(self.particle_surface, (0, 0)) @@ -277,42 +283,54 @@ class Rendering: self.screen.blit(temperature_surface, (0, 0)) - def draw_debug_overlay(self, fps, particles): # this is the function that draws the debug overlay - if engine_settings ['enable_fps']: - # Draw FPS - font = pygame.font.Font(None, 24) - fps_text = font.render(f"FPS: {fps:.2f}", True, (255, 255, 255)) - self.screen.blit(fps_text, (10, 10)) - - if engine_settings ['enable_debug']: - # Get mouse position and convert to grid coordinates - self.debug_surface.fill((0, 0, 0, 0)) - mouse_x, mouse_y = pygame.mouse.get_pos() - grid_x, grid_y = mouse_x // 3, mouse_y // 3 + def draw_debug_overlay(self, fps, sim): + if not engine_settings['enable_fps'] and not engine_settings['enable_debug']: + return - particle_info = self._get_particle_info(particles, grid_x, grid_y) - - # Draw debug information - font = pygame.font.SysFont(None, 24) - particle_count = sum(1 for row in particles for cell in row if cell is not None) + # Create static debug surface if not exists + if not hasattr(self, 'debug_surface'): + self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + + # Only update when values change significantly + mouse_x, mouse_y = pygame.mouse.get_pos() + grid_x, grid_y = mouse_x // 3, mouse_y // 3 - debug_info = [ - f"FPS: {int(fps)}", - f"Mouse: ({mouse_x}, {mouse_y})", - f"Grid: ({grid_x}, {grid_y})", - f"Particle: {particle_info}", - #f"Active Particle Count: {sim.Simulation.get_active_particle_count(sim.Simulation)}", - f"Particle Count: {particle_count}" - ] - + current_info = ( + int(fps), + int(sim.track_tps()), + mouse_x // 10, # Reduced update frequency + mouse_y // 10, + sim.particle_count + ) + + if not hasattr(self, '_last_debug_info') or current_info != self._last_debug_info: + self._last_debug_info = current_info + self.debug_surface.fill((0, 0, 0, 0)) + + font = self.cached_fonts['debug'] y_offset = 10 - - for info in debug_info: - debug_text = font.render(info, True, (255, 255, 255)) - self.screen.blit(debug_text, (10, y_offset)) + + if engine_settings['enable_fps']: + fps_surf = font.render(f"FPS: {fps:.1f} | TPS: {sim.track_tps():.1f}", True, (255, 255, 255)) + self.debug_surface.blit(fps_surf, (10, y_offset)) y_offset += 25 - self.screen.blit(self.debug_surface, (10, 10)) + + if engine_settings['enable_debug']: + debug_lines = [ + f"Mouse: ({mouse_x}, {mouse_y})", + f"Grid: ({grid_x}, {grid_y})", + f"Particles: {sim.particle_count}" + ] + + for line in debug_lines: + text_surf = font.render(line, True, (255, 255, 255)) + self.debug_surface.blit(text_surf, (10, y_offset)) + y_offset += 25 + + # Single blit of cached surface + self.screen.blit(self.debug_surface, (0, 0)) + def draw_buttons(self): # this is the function that draws the buttons self.buttons = {} @@ -344,7 +362,7 @@ class Rendering: button_surface = pygame.Surface((80, 25)) color = self.particle_properties[particle_type].get('color', (255, 255, 255)) button_surface.fill(color) - font = pygame.font.SysFont(None, 20) + font = self.cached_fonts.get('button') label = font.render(particle_type, True, (0, 0, 0)) button_surface.blit(label, (5, 5)) self.button_surfaces[particle_type] = button_surface @@ -358,7 +376,7 @@ class Rendering: if 'clear' not in self.button_surfaces: clear_surface = pygame.Surface((80, 25)) clear_surface.fill((255, 0, 0)) - font = pygame.font.SysFont(None, 24) + font = self.cached_fonts.get('button') label = font.render("Clear", True, (255, 255, 255)) clear_surface.blit(label, (5, 5)) self.button_surfaces['clear'] = clear_surface @@ -370,7 +388,7 @@ class Rendering: if 'settings' not in self.button_surfaces: settings_surface = pygame.Surface((80, 25)) settings_surface.fill((255, 255, 255)) - font = pygame.font.SysFont(None, 24) + font = self.cached_fonts.get('button') label = font.render("Settings", True, (0, 0, 0)) settings_surface.blit(label, (5, 5)) self.button_surfaces['settings'] = settings_surface @@ -393,7 +411,7 @@ class Rendering: pygame.draw.rect(self.screen, (255, 255, 255), (500, 10, 100, 20)) pygame.draw.rect(self.screen, (0, 0, 0), (500, 10, 100, 20), 2) pygame.draw.rect(self.screen, (255, 0, 0), (500 + brush_size, 10, 100 - brush_size * 2, 20)) - label = pygame.font.SysFont(None, 24).render(f"Brush Size: {brush_size}", True, (255, 255, 255)) + label = self.cached_fonts.get('brush_size').render(f"Brush Size: {brush_size}", True, (255, 255, 255)) self.screen.blit(label, (500, 10)) def draw_settings_menu(self): @@ -401,7 +419,7 @@ class Rendering: settings_surface.fill((50, 50, 50)) y_offset = 10 - font = pygame.font.SysFont(None, 24) + font = self.cached_fonts.get('settings') for setting, value in engine_settings.items(): # Create toggle button @@ -416,15 +434,17 @@ class Rendering: return settings_surface + def clear_screen(self, sim): # this is the function that clears the screen # Store current particle type current_type = sim.current_particle_type - + self.particle_count = 0 # Reset simulation grid while preserving particle type sim.particles = [[None for _ in range(sim.height)] for _ in range(sim.width)] sim.active_particles.clear() sim.current_particle_type = current_type - + sim.reset_particle_count() # Clear display surfaces self.background.fill((0, 0, 0)) self.particle_surface.fill((0, 0, 0, 0)) + diff --git a/sandpypi.py b/sandpypi.py index 935e051..664ffdc 100644 --- a/sandpypi.py +++ b/sandpypi.py @@ -13,11 +13,147 @@ It handles user input events such as mouse clicks, mouse wheel scrolling, and ke It also updates the simulation, draws the particles, buttons, and other UI elements, and manages the settings menu. The main loop runs at a target frame rate of 60 FPS, with the actual frame rate displayed in the debug overlay. """ +import cProfile +import pstats from settings import pygame, engine_settings from rendering import Rendering +""" +This is for the future physics engine until i figure out a better method used for testing right now. +#import os +#import sys +#sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +""" from sim import Simulation -def main(): # Main function to run the program +def update_simulation(sim, dt, engine_settings): + """Update simulation state""" + sim.simulate_step(dt, engine_settings) + +def render_frame(rendering, sim, mouse_pos): + """Render all visual elements""" + # Draw particles + rendering.draw_particles(sim.particles, sim.active_particles, sim.particle_size, rendering.particle_colors) + + # Draw UI elements + rendering.draw_buttons() + rendering.draw_brush_size_slider(sim.brush_size) + + # Draw brush cursor + mouse_x, mouse_y = mouse_pos + rendering.render_brush_cursor(mouse_x, mouse_y, sim.brush_size * sim.particle_size) + +def handle_input(event, sim, rendering, settings_visible, zoom_active, zoom_locked, zoom_pos): + """Handle all input events""" + if event.type == pygame.MOUSEBUTTONDOWN: + return handle_mouse_down(event, sim, rendering, settings_visible, zoom_active) + elif event.type == pygame.MOUSEBUTTONUP: + return handle_mouse_up(event) + elif event.type == pygame.KEYDOWN: + return handle_key_press(event, rendering, sim) + return None + +def handle_mouse_down(event, sim, rendering, settings_visible, zoom_active): + """Handle mouse button down events""" + mouse_pos = pygame.mouse.get_pos() + in_settings_area = False + + if settings_visible: + settings_rect = pygame.Rect(rendering.width - 320, 100, 300, 400) + in_settings_area = settings_rect.collidepoint(mouse_pos) + + if event.button == 4: # Mouse wheel up + sim.brush_size = min(sim.brush_size + 1, sim.max_brush_size) + elif event.button == 5: # Mouse wheel down + sim.brush_size = max(sim.brush_size - 1, 1) + elif event.button == 1: # Left click + return handle_left_click(mouse_pos, sim, rendering, settings_visible, in_settings_area, zoom_active) + elif event.button == 3: # Right click + return {'mouse_down_right': True} + elif event.button == 2: # Middle click + return {'mouse_down_middle': True} + return {} + +def handle_left_click(mouse_pos, sim, rendering, settings_visible, in_settings_area, zoom_active): + """Handle left click interactions""" + result = {'mouse_down_left': False, 'settings_visible': settings_visible, 'over_button': False} + + if rendering.settings_button.collidepoint(mouse_pos): + result['settings_visible'] = not settings_visible + result['over_button'] = True + return result + + if zoom_active: + result['zoom_locked'] = not result.get('zoom_locked', False) + if result['zoom_locked']: + result['zoom_pos'] = mouse_pos + return result + + if settings_visible and in_settings_area: + handle_settings_click(mouse_pos, sim) + result['over_button'] = True + return result + + if not in_settings_area: + if handle_ui_click(mouse_pos, sim, rendering): + result['over_button'] = True + else: + result['mouse_down_left'] = True + + return result + +def handle_settings_click(mouse_pos, sim): + """Handle clicks in settings menu""" + settings_menu_y = 100 + relative_y = mouse_pos[1] - settings_menu_y + setting_index = relative_y // 30 + if 0 <= setting_index < len(engine_settings): + setting_name = list(engine_settings.keys())[setting_index] + engine_settings[setting_name] = not engine_settings[setting_name] + +def handle_ui_click(mouse_pos, sim, rendering): + """Handle clicks on UI elements""" + for category, button in rendering.category_buttons.items(): + if button.collidepoint(mouse_pos): + rendering.current_category = category + return True + + for particle_type, button in rendering.buttons.items(): + if button.collidepoint(mouse_pos): + sim.current_particle_type = particle_type + return True + + if rendering.clear_screen_button.collidepoint(mouse_pos): + rendering.clear_screen(sim) + return True + + return False + +def handle_mouse_up(event): + """Handle mouse button up events""" + if event.button == 1: + return {'mouse_down_left': False} + elif event.button == 3: + return {'mouse_down_right': False} + elif event.button == 2: + return {'mouse_down_middle': False} + return {} + +def handle_key_press(event, rendering, sim): + """Handle keyboard press events""" + if event.key == pygame.K_ESCAPE: + print("Escape button pressed") + print(f"Exiting Program {__file__}") + return {'running': False} + elif event.key == pygame.K_SPACE: + engine_settings['pause_sim'] = not engine_settings['pause_sim'] + elif event.key == pygame.K_c: + rendering.clear_screen(sim) + sim.reset_particle_count() + elif event.key == pygame.K_z: + return {'zoom_active': True, 'zoom_locked': False, 'zoom_pos': pygame.mouse.get_pos()} + return {} + +def main(): pygame.init() clock = pygame.time.Clock() width = 1024 @@ -25,36 +161,42 @@ def main(): # Main function to run the program screen = pygame.display.set_mode((width, height), pygame.HWSURFACE | pygame.DOUBLEBUF) sim = Simulation(width, height) rendering = Rendering(width, height) + + # State variables mouse_down_left = False mouse_down_right = False mouse_down_middle = False - mouse_down_wheel_up = False - mouse_down_wheel_down = False over_button = False zoom_active = False zoom_locked = False zoom_pos = None settings_visible = False - settings_menu_y = 100 - running = True + running = True while running: - fps = clock.get_fps() # Get the current frame rate - dt = clock.tick(300) / 1000 # sets the target frame rate to 60 FPS + # Clear screen at start of frame + screen.fill((0, 0, 0)) + + fps = clock.get_fps() + dt = clock.tick(1000) / 1000 keys = pygame.key.get_pressed() + mouse_pos = pygame.mouse.get_pos() zoom_active = keys[pygame.K_z] - # Handle events for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + continue + if event.type == pygame.MOUSEBUTTONDOWN: mouse_pos = pygame.mouse.get_pos() - # Check if clicking in settings area when menu is visible in_settings_area = False + if settings_visible: settings_rect = pygame.Rect(rendering.width - 320, 100, 300, 400) in_settings_area = settings_rect.collidepoint(mouse_pos) - + if event.button == 4: # Mouse wheel up sim.brush_size = min(sim.brush_size + 1, sim.max_brush_size) elif event.button == 5: # Mouse wheel down @@ -62,19 +204,19 @@ def main(): # Main function to run the program elif event.button == 1: # Left click over_button = False - # Check settings button first + # Check settings button first if rendering.settings_button.collidepoint(mouse_pos): settings_visible = not settings_visible over_button = True - if zoom_active: + elif zoom_active: zoom_locked = not zoom_locked if zoom_locked: zoom_pos = mouse_pos # Handle settings menu interactions elif settings_visible and in_settings_area: - relative_y = mouse_pos[1] - settings_menu_y + relative_y = mouse_pos[1] - 100 # settings_menu_y setting_index = relative_y // 30 if 0 <= setting_index < len(engine_settings): setting_name = list(engine_settings.keys())[setting_index] @@ -82,7 +224,6 @@ def main(): # Main function to run the program over_button = True elif not in_settings_area: - # Check category buttons for category, button in rendering.category_buttons.items(): if button.collidepoint(mouse_pos): @@ -101,6 +242,7 @@ def main(): # Main function to run the program # Check clear screen button if rendering.clear_screen_button.collidepoint(mouse_pos): rendering.clear_screen(sim) + particle_count = 0 over_button = True if not over_button and not in_settings_area: @@ -108,87 +250,85 @@ def main(): # Main function to run the program elif event.button == 3: # Right click mouse_down_right = True - elif event.button == 2: # Middle click mouse_down_middle = True - elif event.type == pygame.MOUSEBUTTONUP and event.button == 1: - mouse_down_left = False - elif event.type == pygame.MOUSEBUTTONUP and event.button == 3: - mouse_down_right = False - elif event.type == pygame.MOUSEBUTTONUP and event.button == 2: - mouse_down_middle = False + elif event.type == pygame.MOUSEBUTTONUP: + if event.button == 1: + mouse_down_left = False + elif event.button == 3: + mouse_down_right = False + elif event.button == 2: + mouse_down_middle = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: - print("Escape button pressed") - print(f"Exiting Program {__file__}") running = False - elif event.key == pygame.K_SPACE: - print(f"Pause button pressed but not functional 'Space'") - pass - + engine_settings['pause_sim'] = not engine_settings['pause_sim'] elif event.key == pygame.K_c: rendering.clear_screen(sim) - elif event.key == pygame.K_z: - zoom_active = not zoom_active - if zoom_active: - zoom_locked = False - zoom_pos = pygame.mouse.get_pos() + zoom_active = True + zoom_locked = False + zoom_pos = pygame.mouse.get_pos() - elif event.type == pygame.QUIT: - running = False + # Update simulation if not paused + if not engine_settings['pause_sim']: + sim.simulate_step(dt, engine_settings) + # Handle continuous mouse input if mouse_down_left and not over_button: - x, y = pygame.mouse.get_pos() - # Check if current particle type is wind or air + x, y = mouse_pos if sim.current_particle_type not in ['wind', 'air']: sim.create_particle_circle(x, y) else: - # Handle wind/air differently sim.add_wind_zone(x, y) if mouse_down_right: - x, y = pygame.mouse.get_pos() + x, y = mouse_pos sim.clear_particles_circle(x, y) if mouse_down_middle: - x, y = pygame.mouse.get_pos() + x, y = mouse_pos sim.create_particle(x, y) + # Draw everything in correct order + rendering.draw_particles(sim.particles, sim.active_particles, sim.particle_size, rendering.particle_colors) + rendering.draw_buttons() + rendering.draw_brush_size_slider(sim.brush_size) + rendering.render_brush_cursor(mouse_pos[0], mouse_pos[1], sim.brush_size * sim.particle_size) + + # Handle zoom window if zoom_active or zoom_locked: - mouse_pos = zoom_pos if zoom_locked else pygame.mouse.get_pos() - zoom_surface = rendering.draw_zoom_window(sim.particles, sim.particle_size, rendering.particle_colors, mouse_pos) - # Position zoom window - zoom_x = 10 if mouse_pos[0] > width/2 else width - 110 - zoom_y = 10 if mouse_pos[1] > height/2 else height - 110 + current_zoom_pos = zoom_pos if zoom_locked else mouse_pos + zoom_surface = rendering.draw_zoom_window(sim.particles, sim.particle_size, + rendering.particle_colors, current_zoom_pos) + zoom_x = 10 if current_zoom_pos[0] > width/2 else width - 110 + zoom_y = 10 if current_zoom_pos[1] > height/2 else height - 110 screen.blit(zoom_surface, (zoom_x, zoom_y)) - # Draw Settings + # Draw settings and debug overlay last if settings_visible: settings_menu = rendering.draw_settings_menu() rendering.screen.blit(settings_menu, (rendering.width - 320, 100)) - rendering.draw_debug_overlay(fps, sim.particles) - pygame.display.flip() - - # Update and draw particles - sim.simulate_step(dt=0.016) - # Draw particles - rendering.draw_particles(sim.particles, sim.active_particles, sim.particle_size, rendering.particle_colors) - # Draw buttons - rendering.draw_buttons() - # Draw brush size slider - rendering.draw_brush_size_slider(sim.brush_size) - # Get current mouse position - mouse_x, mouse_y = pygame.mouse.get_pos() - # Draw brush cursor at mouse position - rendering.render_brush_cursor(mouse_x, mouse_y, sim.brush_size * sim.particle_size) - + rendering.draw_debug_overlay(fps, sim) + pygame.display.flip() pygame.quit() + if __name__ == "__main__": + # Profile the application + profiler = cProfile.Profile() + profiler.enable() + main() + + profiler.disable() + # Write profiling results to file + with open('profile_results.log', 'w') as f: + stats = pstats.Stats(profiler, stream=f) + stats.sort_stats('cumulative') + stats.print_stats() diff --git a/settings.py b/settings.py index b3f2bb9..50ce540 100644 --- a/settings.py +++ b/settings.py @@ -16,9 +16,11 @@ The `particle_properties` variable is initialized by calling `load_particle_prop import pygame import json import random +import time import numpy as np engine_settings = { + 'pause_sim': True, 'enable_cursor': True, 'enable_glow': False, 'enable_gas_effect': True, @@ -27,6 +29,7 @@ engine_settings = { 'enable_WVisuals': False, 'enable_PVisuals': False, 'enable_TempVisuals': False, + 'outerwall': True, # 'settings': True/False } diff --git a/sim.py b/sim.py index 0dd3681..e400744 100644 --- a/sim.py +++ b/sim.py @@ -19,7 +19,7 @@ Key Components: """ #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, particle_properties +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: @@ -84,7 +84,8 @@ class Simulation: self.width = width self.height = height self.particle_size = 3 - self.particles = [[None for _ in range(height)] for _ in range(width)] + 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 = {} @@ -96,6 +97,9 @@ class 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 @@ -162,25 +166,29 @@ class Simulation: 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) + # 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""" - self.update_spatial_grid() for x, y in list(self.active_particles): particle = self.particles[x][y] if not particle: @@ -252,22 +260,6 @@ class Simulation: self.active_particles.add((new_x, new_y)) self.active_particles.discard((x, y)) - - def 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 > 100: - # Transition to gas - particle.is_gas = True - particle.temperature = 100 - particle.velocity = [random.uniform(-1, 1), random.uniform(-1, 1)] - particle.temperature < 100 - particle.is_gas = False - - def add_wind_zone(self, x, y): # Instead of creating particles, store wind zone data wind_zone = { @@ -316,6 +308,53 @@ class Simulation: 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""" @@ -393,22 +432,13 @@ class Simulation: 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) + 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)]: @@ -438,9 +468,11 @@ class Simulation: 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) @@ -451,10 +483,11 @@ class Simulation: 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. @@ -462,10 +495,16 @@ class Simulation: 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) + 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() @@ -484,7 +523,7 @@ class Simulation: continue # Handle granular materials (sand, dirt) - if particle.particle_type in ['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: @@ -526,7 +565,7 @@ class Simulation: particle.position = (new_x, new_y) - def apply_physics(self, dt): # this is where we apply physics. + def apply_physics(self, dt, engine_settings): # this is where we apply physics. """Handle all physics effects""" new_active_particles = set() @@ -535,6 +574,27 @@ class Simulation: 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)) @@ -607,8 +667,9 @@ class Simulation: continue else: # Regular particle physics - particle.velocity[0] += (fx / particle.mass) * dt - particle.velocity[1] += (fy / particle.mass) * dt + mass = max(particle.mass, 0.001) + particle.velocity[0] += (fx / mass) * dt + particle.velocity[1] += (fy / mass) * dt if particle.liquid: # Enhanced liquid spreading @@ -642,6 +703,8 @@ class Simulation: 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 @@ -653,6 +716,10 @@ class Simulation: 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 @@ -669,64 +736,6 @@ class Simulation: liquid2.color = self.calculate_color(liquid2.temperature) - def get_particle_count(self): - """Returns the number of particles in the simulation for the Debug display and for performance analysis.""" - count = 0 - for x in range(self.width): - for y in range(self.height): - if self.particles[x][y] is not None: - count += 1 - return count - - - 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 _wake_neighbors(self, x, y): for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: @@ -735,9 +744,30 @@ class Simulation: 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): + + def simulate_step(self, dt, engine_settings): """Run a single step of the simulation""" + active_list = list(self.active_particles) batch_size = 1000 @@ -751,11 +781,10 @@ class Simulation: # Update particle positions and physics self.apply_gravity(dt) - self.apply_physics(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() - + self.spread_fire() \ No newline at end of file