diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e27d317 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13.1"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Run linters + run: | + black . --check + isort . --check +# flake8 . + + - name: Run tests + run: | + pytest tests/ --cov=src --cov-report=xml + +# Deployment job - uncomment and configure when PyPI uploads are needed +# This job will build and upload your package to PyPI when merging to main +# +# deploy: +# needs: test +# runs-on: ubuntu-latest +# if: github.event_name == 'push' && github.ref == 'refs/heads/main' +# +# steps: +# - uses: actions/checkout@v3 +# +# - name: Set up Python +# uses: actions/setup-python@v3 +# with: +# python-version: "3.8" +# +# - name: Build and publish +# env: +# TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} +# TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} +# run: | +# pip install build twine +# python -m build +# twine upload dist/* diff --git a/.gitignore b/.gitignore index f6007b0..d7ba603 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ -# ---> Python -# Byte-compiled / optimized / DLL files + +# Python +__pycache__/ +.mypy_cache/ *.py[cod] *$py.class -__pycache__/ -# C extensions *.so - -# Distribution / packaging .Python build/ develop-eggs/ @@ -20,158 +18,36 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ +# Virtual Environment venv/ +env/ ENV/ -env.bak/ -venv.bak/ +.venv/ +.*_venv/ +.env -# Spyder project settings -.spyderproject -.spyproject +# IDE +.idea/ +.vscode/ +*.swp +*.swo -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Custom Ignores -current_stations.html -forecast_data.json -openapi.json -__pycache__/sim.cpython-312.pyc -__pycache__/sim.cpython-312.pyc -__pycache__/rendering.cpython-312.pyc -sandpypi.dist/ -sandpypi.build/ -sandpypi.onefile-build/ -sandpypi.exe -sandpypi.7z +# Testing +.coverage +htmlcov/ unittest/ -.7z -.zip -livenotes.txt \ No newline at end of file +.pytest_cache/ + +# Distribution +dist/ +build/ +*.exe + +# Logs +logs/ +*.log +livenotes.txt diff --git a/.pre-commit-config.yaml.disabled b/.pre-commit-config.yaml.disabled new file mode 100644 index 0000000..7d9c87a --- /dev/null +++ b/.pre-commit-config.yaml.disabled @@ -0,0 +1,30 @@ +#repos: +#- repo: https://github.com/psf/black +# rev: 23.12.1 +# hooks: +# - id: black +# language_version: python3 +# args: ["--line-length", "79"] + +#- repo: https://github.com/pycqa/isort +# rev: 5.13.2 +# hooks: +# - id: isort +# args: ["--profile", "black", "--filter-files"] + +#- repo: https://github.com/pre-commit/pre-commit-hooks +# rev: v4.5.0 +# hooks: +# - id: trailing-whitespace +# - id: end-of-file-fixer +# - id: check-yaml +# - id: check-added-large-files + +#- repo: local +# hooks: +# - id: pytest +# name: pytest +# entry: pytest +# language: system +# pass_filenames: false +# always_run: false diff --git a/.vscode/settings.json b/.vscode/settings.json index 461b413..0d3d120 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,7 @@ { + "python.linting.pylintArgs": [ + "--disable=E1101", + "--ignored-modules=pygame" + ], "cody.agentic.context.experimentalShell": true } diff --git a/DONOTRUNDOESNOTWORKTHISWAY.main.py b/DONOTRUNDOESNOTWORKTHISWAY.main.py deleted file mode 100644 index 0fe95ab..0000000 --- a/DONOTRUNDOESNOTWORKTHISWAY.main.py +++ /dev/null @@ -1,16 +0,0 @@ -from src.config.settings import cProfile, pstats -from src.sandpypi import main - -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/__init__.py b/__init__.py new file mode 100644 index 0000000..be9872a --- /dev/null +++ b/__init__.py @@ -0,0 +1,33 @@ +""" +Sandpypi - A falling sand simulation package in Python. + +This package provides modules for particle physics simulation and rendering: +- config.settings: Configuration and particle properties +- physics.sim: Physics engine and particle behavior +- rendering.rendering: Display and visualization components +""" + +from src.config.settings import ( + cProfile, + engine_settings, + particle_properties, + pstats, + time, +) +from src.physics.sim import Simulation +from src.rendering.rendering import Rendering + +__all__ = [ + "cProfile", + "pstats", + "time", + "engine_settings", + "particle_properties", + "Simulation", + "Rendering", +] +# src\rendering\rendering.py" +# src\physics\sim.py +# src\config\settings.py +# src\physics\particle.py +# src\debug\debugger_system.py diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..849a12e --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,22 @@ +""" Configuration file for Sphinx documentation""" + +PROJECT = "sandpypi" +COPYRIGHT = "2024, stan44" +AUTHOR = "stan44" +VERSION = "0.1.0" +RELEASE = "0.1.0" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx_rtd_theme", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +HTML_THEME = "sphinx_rtd_theme" +html_static_path = ["_static"] +# html_logo = "_static/logo.png" # Add your project's logo diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..f7f4fee --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,29 @@ +Welcome to Sandpypi Documentation +=============================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + guides/getting_started + guides/user_guide + architecture/overview + api/modules + +Development Standards +------------------ + +This project follows these development standards: + +* Black code formatting (88 characters line length) +* Type hints for Python functions +* Google style docstrings +* Particle simulation physics +* Optimized rendering with Pygame + +Quick Links +---------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..f9cdb33 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +namespace_packages = True +explicit_package_bases = True +python_version = 3.13 diff --git a/plansandideas/Coding optimization plan.md b/plansandideas/Coding optimization plan.md new file mode 100644 index 0000000..5252fa4 --- /dev/null +++ b/plansandideas/Coding optimization plan.md @@ -0,0 +1,46 @@ +targeted performance improvements while maintaining core plan: + +1. Spatial Grid Optimizations: + +```python +def update_spatial_grid(self): + # Use direct array operations instead of dict + # Pre-allocate grid arrays for particle positions + # Batch update active cells +``` + +2. Force Calculations: + +```python +def calculate_forces(self, particle, x, y): + # Vectorize neighbor force calculations + # Cache common force values + # Use direct array math instead of loops +``` + +3. Temperature System: + +```python +def handle_temperature(self, dt): + # Batch temperature updates + # Use array operations for heat transfer + # Pre-calculate temperature thresholds +``` + +4. Particle Creation: + +```python +def create_particle_circle(self): + # Pre-calculate circle bounds + # Batch particle creation + # Use array operations for position checks +``` + +5. Phase Transitions: + +```python +def handle_phase_transitions(self): + # Group similar transitions + # Cache property lookups + # Batch state changes +``` diff --git a/physics/REORGANIZATION.md b/plansandideas/REORGANIZATION.md similarity index 100% rename from physics/REORGANIZATION.md rename to plansandideas/REORGANIZATION.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a391f2c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sandpypi" +version = "0.1.0" +authors = [ + {name = "Stan44", email = "stan44@example.com"}, +] +description = "Falling Sand Simulation in Python" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "numpy>=1.21.0", + "pygame>=2.1.0" +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "black>=22.0", + "flake8>=4.0", + "isort>=5.0", + "mypy>=0.9", + "pylint>=2.10", + "sphinx>=4.0", + "sphinx-rtd-theme>=1.0" +] + +[tool.black] +line-length = 79 +target-version = ['py38'] + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 79 +force_grid_wrap = 0 +include_trailing_comma = true +use_parentheses = true +ensure_newline_before_comments = true + +[tool.flake8] +max-line-length = 79 + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +check_untyped_defs = true +strict_optional = true + +[tool.pylint] +max-line-length = 79 +ignored-modules = ["pygame"] +disable = ["E1101"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..357b045 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ + +-r requirements.txt +pytest>=7.0 +pytest-cov>=4.0 +black>=22.0 +isort>=5.0 +mypy>=0.9 diff --git a/src/sandpypi.py b/sandpypi.py similarity index 65% rename from src/sandpypi.py rename to sandpypi.py index 0563a24..39184cd 100644 --- a/src/sandpypi.py +++ b/sandpypi.py @@ -3,32 +3,40 @@ # Sandpypi by Stanton. # Project name is a placeholder. # This has been a multimonth or year project i have time blindness sorta. -# This is my most functional system for falling sand in python yet i took some things i learned in JS. +# This is my most functional system for falling sand in python yet i took +# some things i learned in JS. # This needs further optimizations to core performance sections. The main function to run the Sandpypi program. -This function initializes the Pygame environment, creates the simulation and rendering objects, and enters the main event loop. -It handles user input events such as mouse clicks, mouse wheel scrolling, and keyboard presses. -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 (this fps varies on my mood and the testing), with the actual frame rate displayed in the debug overlay. +This function initializes the Pygame environment, creates the simulation and +rendering objects, and enters the main event loop. +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 +(this fps varies on my mood and the testing), with the actual frame rate +displayed in the debug overlay. """ # Import Require files for the Engine. -from config.settings import pygame, engine_settings, cProfile, pstats, time +import pygame -from rendering.rendering import Rendering -from physics.sim import Simulation +from src.config.settings import cProfile, engine_settings, pstats, time +from src.physics.sim import Simulation +from src.rendering.rendering import Rendering - -def handle_input(event, sim, rendering, settings_visible, zoom_active, zoom_locked, zoom_pos): +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_down( + event, sim, rendering, settings_visible, zoom_active + ) + if event.type == pygame.MOUSEBUTTONUP: return handle_mouse_up(event) - elif event.type == pygame.KEYDOWN: + if event.type == pygame.KEYDOWN: return handle_key_press(event, rendering, sim) return None @@ -37,7 +45,7 @@ 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) @@ -47,44 +55,57 @@ def handle_mouse_down(event, sim, rendering, settings_visible, zoom_active): 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) + 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} + return {"mouse_down_right": True} elif event.button == 2: # Middle click - return {'mouse_down_middle': True} + return {"mouse_down_middle": True} return {} -def handle_left_click(mouse_pos, sim, rendering, settings_visible, in_settings_area, zoom_active): +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} - + 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 + 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 + 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 + handle_settings_click(mouse_pos) + result["over_button"] = True return result if not in_settings_area: if handle_ui_click(mouse_pos, sim, rendering): - result['over_button'] = True + result["over_button"] = True else: - result['mouse_down_left'] = True - + result["mouse_down_left"] = True + return result -def handle_settings_click(mouse_pos, sim): +def handle_settings_click(mouse_pos): """Handle clicks in settings menu""" settings_menu_y = 100 relative_y = mouse_pos[1] - settings_menu_y @@ -100,27 +121,27 @@ def handle_ui_click(mouse_pos, sim, rendering): 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} + return {"mouse_down_left": False} elif event.button == 3: - return {'mouse_down_right': False} + return {"mouse_down_right": False} elif event.button == 2: - return {'mouse_down_middle': False} + return {"mouse_down_middle": False} return {} @@ -129,26 +150,33 @@ def handle_key_press(event, rendering, sim): if event.key == pygame.K_ESCAPE: print("Escape button pressed") print(f"Exiting Program {__file__}") - return {'running': False} + return {"running": False} elif event.key == pygame.K_SPACE: - engine_settings['pause_sim'] = not engine_settings['pause_sim'] + 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 { + "zoom_active": True, + "zoom_locked": False, + "zoom_pos": pygame.mouse.get_pos(), + } return {} def main(): + """Main function to run the simulation""" pygame.init() clock = pygame.time.Clock() width = 1024 height = 768 - screen = pygame.display.set_mode((width, height), pygame.HWSURFACE | pygame.DOUBLEBUF) + 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 @@ -159,11 +187,12 @@ def main(): zoom_pos = None settings_visible = False running = True + last_particle_time = 0 while running: # Clear screen at start of frame screen.fill((0, 0, 0)) - + fps = clock.get_fps() dt = clock.tick(60) / 1000 keys = pygame.key.get_pressed() @@ -179,13 +208,17 @@ def main(): if event.type == pygame.MOUSEBUTTONDOWN: mouse_pos = pygame.mouse.get_pos() in_settings_area = False - + if settings_visible: - settings_rect = pygame.Rect(rendering.width - 320, 100, 300, 400) + 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) + 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 @@ -206,35 +239,47 @@ def main(): 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] - engine_settings[setting_name] = not engine_settings[setting_name] + setting_name = list(engine_settings.keys())[ + setting_index + ] + engine_settings[ + setting_name + ] = not engine_settings[setting_name] over_button = True elif not in_settings_area: # Check category buttons - for category, button in rendering.category_buttons.items(): + for ( + category, + button, + ) in rendering.category_buttons.items(): if button.collidepoint(mouse_pos): rendering.current_category = category over_button = True break - + # Check particle buttons if not over_button: - for particle_type, button in rendering.buttons.items(): + for ( + particle_type, + button, + ) in rendering.buttons.items(): if button.collidepoint(mouse_pos): sim.current_particle_type = particle_type over_button = True break - + # Check clear screen button - if rendering.clear_screen_button.collidepoint(mouse_pos): + if rendering.clear_screen_button.collidepoint( + mouse_pos + ): rendering.clear_screen(sim) - particle_count = 0 + # particle_count = 0 over_button = True if not over_button and not in_settings_area: mouse_down_left = True - + elif event.button == 3: # Right click mouse_down_right = True elif event.button == 2: # Middle click @@ -252,7 +297,9 @@ def main(): if event.key == pygame.K_ESCAPE: running = False elif event.key == pygame.K_SPACE: - engine_settings['pause_sim'] = not engine_settings['pause_sim'] + 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: @@ -261,21 +308,18 @@ def main(): zoom_pos = pygame.mouse.get_pos() # Update simulation if not paused - if not engine_settings['pause_sim']: + 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 = mouse_pos - if sim.current_particle_type not in ['wind', 'air']: - current_time = time.time() - if not hasattr(main, 'last_particle_time'): - main.last_particle_time = 0 - - # Limit particle creation to every 16ms (approximately 60 FPS) - if current_time - main.last_particle_time >= 0.016: - sim.create_particle_circle(x, y) - main.last_particle_time = current_time + current_time = time.time() + if sim.current_particle_type not in ["wind", "air"]: + # Limit particle creation to every 16ms (approximately 60 FPS) + if current_time - last_particle_time >= 0.016: + sim.create_particle_circle(x, y) + last_particle_time = current_time if mouse_down_right: x, y = mouse_pos @@ -288,40 +332,49 @@ def main(): # Handle zoom window if zoom_active or zoom_locked: 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 = 80 if current_zoom_pos[0] > width/2 else width - 110 - zoom_y = 80 if current_zoom_pos[1] > height/2 else height - 110 + zoom_surface = rendering.draw_zoom_window( + sim.particles, + sim.particle_size, + rendering.particle_colors, + current_zoom_pos, + ) + zoom_x = 80 if current_zoom_pos[0] > width / 2 else width - 110 + zoom_y = 80 if current_zoom_pos[1] > height / 2 else height - 110 screen.blit(zoom_surface, (zoom_x, zoom_y)) # Draw everything in correct order - rendering.draw_particles(sim.particles, sim.active_particles, sim.particle_size, rendering.particle_colors) + 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) - - + rendering.render_brush_cursor( + mouse_pos[0], mouse_pos[1], sim.brush_size * sim.particle_size + ) # 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) 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: + with open("profile_results.log", "w", encoding="utf-8") as f: stats = pstats.Stats(profiler, stream=f) - stats.sort_stats('cumulative') + stats.sort_stats("cumulative") stats.print_stats() diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100644 index 0000000..ee585b2 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Rich TUI linting interface for Sandpypi.""" +import logging +import subprocess +import time +from datetime import datetime +from pathlib import Path + +from rich.console import Console +from rich.layout import Layout +from rich.live import Live +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn + + +class LinterTUI: + """Main classs for the linting TUI.""" + + def __init__(self): + self.console = Console() + self.layout = Layout() + self.layout.split_column( + Layout(name="output", ratio=8), Layout(name="progress", ratio=2) + ) + self.setup_logging() + + def setup_logging(self): + """Logging""" + self.log_dir = Path("logs") + self.log_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_file = f"lint_{timestamp}.log" + logging.basicConfig( + filename=self.log_dir / self.log_file, + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + def get_target_path(self): + """Get thee target path for linting.""" + paths = input( + "\nEnter paths to lint (comma-separated NO SPACES)\ + or press Enter for default [src,tests]: " + ).strip() + target_paths = paths.split(",") if paths else ["src", "tests"] + + # Convert to absolute paths and include subdirectories + abs_paths = [] + for path in target_paths: + base_path = Path(path).absolute() + if base_path.is_dir(): + abs_paths.extend(str(p) for p in base_path.rglob("*.py")) + else: + abs_paths.append(str(base_path)) + + return abs_paths + + def run_linters(self, selected_linters, paths): + """Run the linters on the selected paths.""" + progress = Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + expand=True, + ) + + self.layout["progress"].update(progress) + + with Live(self.layout, refresh_per_second=5, screen=True): + task = progress.add_task( + "[cyan]Linting...", total=len(selected_linters) + ) + + for linter in selected_linters: + progress.update(task, description=f"[cyan]Running {linter}...") + + cmd = [linter] + paths + with subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + bufsize=1, + ) as process: + current_output = [] + linter_name = linter.split()[0] + while True: + output = process.stdout.readline() + if output == "" and process.poll() is not None: + break + if output: + current_output.append(output.strip()) + # Log each line of output + logging.info("Running %s on %s", linter, paths) + logging.info("Output: %s", output.strip()) + + self.layout["output"].update( + Panel( + "\n".join(current_output[-20:]), + title=f"[bold green]{linter}\ + {linter_name}", + border_style="green", + ) + ) + time.sleep(0.1) + + # Log any errors + if not current_output: + stderr_output = process.stderr.read() + output_text = stderr_output or "No output" + self.layout["output"].update( + Panel( + output_text, + title=f"[bold green]{linter} Output", + border_style="green", + ) + ) + if stderr_output: + logging.error("Errors: %s", stderr_output) + + progress.advance(task) + + final_message = Panel( + "[bold green]✨ Linting Complete! ✨\n" + "[cyan]Check the logs for detailed results.\n" + f"[green]Logs saved to: {self.log_dir / self.log_file}\n" + "[yellow]Press Enter to exit...", + title="[bold blue]Sandpypi Linter", + border_style="green", + padding=(2, 4), + ) + + self.layout["output"].update(final_message) + self.layout["progress"].update( + Panel("[bold green]100% Complete!", border_style="green") + ) + + input() + + +def main(): + """Runner for the Linting TUI.""" + tui = LinterTUI() + linters = ["black", "isort", "mypy", "flake8", "pylint"] + paths = tui.get_target_path() + tui.run_linters(linters, paths) + + +if __name__ == "__main__": + main() +# scripts\run_tests.py +# !/usr/bin/env python3 diff --git a/scripts/setup_dev.py b/scripts/setup_dev.py new file mode 100644 index 0000000..e8479ab --- /dev/null +++ b/scripts/setup_dev.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Development environment setup script.""" +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path + + +def check_python_version(): + """Ensure Python version meets minimum requirements.""" + min_version = (3, 8) + current = sys.version_info[:2] + if current < min_version: + raise SystemError( + f"Python {min_version[0]}.{min_version[1]} or higher required" + ) + + +def clean_existing_venv(): + """Remove existing virtual environment if present.""" + venv_path = Path(".venv") + if venv_path.exists(): + shutil.rmtree(venv_path) + + +def initialize_environment(): + """Create and initialize development environment.""" + # Allow configurable venv path + venv_path = os.getenv("VENV_PATH", ".sandpypi_venv") + + # Create and activate virtual environment + subprocess.run([sys.executable, "-m", "venv", venv_path], check=True) + + # Get the correct paths based on OS + is_windows = platform.system() == "Windows" + scripts_path = "Scripts" if is_windows else "bin" + + # Setup commands using virtual environment paths + pip_cmd = os.path.join(venv_path, scripts_path, "pip") + + # Update pip and install dependencies + subprocess.run([pip_cmd, "install", "--upgrade", "pip"], check=True) + subprocess.run([pip_cmd, "install", "pre-commit"], check=True) + subprocess.run([pip_cmd, "install", "-e", ".[dev]"], check=True) + # Install and configure pre-commit hooks + precommit_cmd = os.path.join(venv_path, scripts_path, "pre-commit") + subprocess.run([precommit_cmd, "install"], check=True) + subprocess.run( + [precommit_cmd, "install", "--hook-type", "pre-push"], check=True + ) + + +def setup_docs(): + """Initialize documentation structure.""" + docs_path = Path("docs") + if not docs_path.exists(): + subprocess.run( + [ + os.path.join( + ".venv", + "Scripts" if platform.system() == "Windows" else "bin", + "sphinx-quickstart", + ), + "docs", + "--sep", + "--project=Sandpypi", + "--author=Stan44", + ], + check=True, + ) + + +def main(): + """Execute the complete development environment setup.""" + try: + check_python_version() + clean_existing_venv() + # setup_git_hooks() + initialize_environment() + setup_docs() + + print("\n✅ Development environment setup complete!") + print("\nNext steps:") + print("1. Activate your virtual environment:") + activate_cmd = ( + "source .venv/Scripts/activate" + if platform.system() == "Windows" + else "source .venv/bin/activate" + ) + print(activate_cmd) + print("2. Run tests: pytest") + print("3. Build docs: cd docs && make html") + + except subprocess.CalledProcessError as e: + print(f"\n❌ Setup failed: {e}") + sys.exit(1) + except (OSError, IOError) as e: + print(f"\n❌ File system error: {e}") + sys.exit(1) + except ImportError as e: + print(f"\n❌ Dependency error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2ebf57e --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +"""setup.py""" + +from setuptools import find_packages, setup # type: ignore + +setup( + name="sandpypi", + packages=find_packages(), +) diff --git a/src/__init__.py b/src/__init__.py index e69de29..c4624fa 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1,14 @@ +""" +Sandpypi - A falling sand simulation package in Python. + +This package provides modules for particle physics simulation and rendering: +- config.settings: Configuration and particle properties +- physics.sim: Physics engine and particle behavior +- rendering.rendering: Display and visualization components +""" + +# src\rendering\rendering.py +# src\physics\sim.py +# src\config\settings.py +# src\physics\particle.py +# src\debug\debugger_system.py diff --git a/src/config/py.typed b/src/config/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/config/settings.py b/src/config/settings.py index 3e7a043..b857951 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -3,57 +3,65 @@ Global settings and imports for the project. -This module defines various settings for the game engine, such as enabling or disabling the cursor, glow effect, gas effect, debug mode, and FPS display. It also provides a function to load particle properties from a JSON file. +This module defines various settings for the game engine, such as +enabling or disabling the cursor, glow effect, gas effect, debug mode, +and FPS display. +It also provides a function to load particle properties from a JSON file. -The `engine_settings` dictionary contains the configurable settings for the game engine. These settings can be used to customize the behavior of the game. +The `engine_settings` dictionary contains the configurable settings +for the game engine. These settings can be used to +customize the behavior of the game. -The `load_particle_properties()` function attempts to load particle properties from a 'particles.json' file. If the file is not found or the JSON data is invalid, it returns an empty dictionary. +The `load_particle_properties()` function attempts to load particle properties +from a 'particles.json' file. If the file is not found or the +JSON data is invalid, it returns an empty dictionary. -The `particle_properties` variable is initialized by calling `load_particle_properties()` when the module is imported. +The `particle_properties` variable is initialized by calling +`load_particle_properties()` when the module is imported. """ + import cProfile -import pstats -import sys -import os -import pygame import json +import os +import pstats import random +import sys import time + import numpy as np +import pygame engine_settings = { - 'pause_sim': True, - 'enable_cursor': True, - 'enable_glow': False, - 'enable_gas_effect': True, - 'enable_debug': True, - 'enable_fps': True, - 'enable_WVisuals': False, - 'enable_PVisuals': False, - 'enable_TempVisuals': False, - 'outerwall': False, + "pause_sim": True, + "enable_cursor": True, + "enable_glow": False, + "enable_gas_effect": True, + "enable_debug": True, + "enable_fps": True, + "enable_WVisuals": False, + "enable_PVisuals": False, + "enable_TempVisuals": False, + "outerwall": False, # 'settings': True/False } + + def load_particle_properties(): + """Loads particle properties from a JSON file.""" particle_data = {} - base_path = os.path.join(os.path.dirname(__file__), '..') - core_path = os.path.join(base_path, 'part', 'coreparts') - mods_path = os.path.join(base_path, 'part', 'mods') + base_path = os.path.join(os.path.dirname(__file__), "..") + core_path = os.path.join(base_path, "part", "coreparts") + mods_path = os.path.join(base_path, "part", "mods") def load_json_files(directory): for root, _, files in os.walk(directory): for file in files: - if file.endswith('.json'): + if file.endswith(".json"): file_path = os.path.join(root, file) try: - with open(file_path, 'r') as f: + with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) - """# Debug: Print specific temperature-related properties - for particle_name, props in data.items(): - if 'temperature' in props: - print(f"Loaded {particle_name} with temp: {props['temperature']}") - if 'melt_temperature' in props: - print(f"Loaded {particle_name} with melt_temp: {props['melt_temperature']}")""" + particle_data.update(data) except (FileNotFoundError, json.JSONDecodeError) as e: print(f"Error loading {file}: {e}") @@ -65,16 +73,21 @@ def load_particle_properties(): else: print(f"Directory not found: {path}") - """# Final verification of critical properties - print("\nVerifying critical properties:") - for name, props in particle_data.items(): - if 'lava' in name.lower(): - print(f"Lava properties: {props}")""" - return particle_data + # Load particle properties once when module is imported particle_properties = load_particle_properties() -__all__ = ['pygame', 'np', 'random', 'time', 'engine_settings', 'particle_properties', 'cProfile', 'pstats', 'sys', 'os'] - +__all__ = [ + "pygame", + "np", + "random", + "time", + "engine_settings", + "particle_properties", + "cProfile", + "pstats", + "sys", + "os", +] diff --git a/src/debug/debugger_system.py b/src/debug/debugger_system.py index 2cadd65..dd5513a 100644 --- a/src/debug/debugger_system.py +++ b/src/debug/debugger_system.py @@ -1,7 +1,17 @@ -from config.settings import engine_settings, pygame +"""# DebuggerSystem.py""" + import time +import psutil + +from src.config.settings import engine_settings, pygame # typing: ignore + +process = psutil.Process() + + class DebuggerSystem: + """DebuggerSystem class for tracking and displaying performance metrics.""" + def __init__(self): self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) self.debug_font = pygame.font.SysFont(None, 24) @@ -10,9 +20,15 @@ class DebuggerSystem: self.current_fps = 0 self.debug_text = [] self.performance_metrics = { - 'particle_updates': 0, - 'physics_time': 0, - 'render_time': 0 + "particle_updates": 0, + "physics_time": 0, + "render_time": 0, + "spatial_grid_updates": 0, + "active_particles_near_mouse": 0, + "render_regions": 0, + "memory_usage": 0, + "cpu_usage": 0, + "mouse_movement_delta": 0, } def track_fps(self): @@ -20,39 +36,69 @@ class DebuggerSystem: self.fps_counter += 1 current_time = time.time() elapsed = current_time - self.fps_timer - + if elapsed >= 1.0: self.current_fps = self.fps_counter / elapsed self.fps_counter = 0 self.fps_timer = current_time return self.current_fps - def update_performance_metrics(self, sim): - """Track simulation performance metrics""" - self.performance_metrics['particle_updates'] = len(sim.active_particles) - return self.performance_metrics + def track_spatial_activity(self, sim, mouse_pos): + """Track spatial grid updates near mouse position""" + x, y = mouse_pos + cell_key = sim.get_cell_key(x, y) + radius = 3 # Track 3x3 grid around mouse + active_count = 0 + + for dx in range(-radius, radius + 1): + for dy in range(-radius, radius + 1): + check_key = (cell_key[0] + dx, cell_key[1] + dy) + if check_key in sim.spatial_grid: + active_count += len(sim.spatial_grid[check_key]) + + self.performance_metrics["active_particles_near_mouse"] = active_count + self.performance_metrics["spatial_grid_updates"] += 1 + + def track_render_performance(self, rendering): + """Track rendering system metrics""" + self.performance_metrics["render_regions"] = len( + rendering.active_particles + ) + + def update_system_metrics(self): + """Track system resource usage""" + self.performance_metrics["memory_usage"] = ( + process.memory_info().rss / 1024 / 1024 + ) # MB + self.performance_metrics["cpu_usage"] = process.cpu_percent() def draw_debug_overlay(self, screen, sim): - if not engine_settings['enable_fps'] and not engine_settings['enable_debug']: + """Draws the debug overlay on the screen.""" + if ( + not engine_settings["enable_fps"] + and not engine_settings["enable_debug"] + ): return self.debug_surface.fill((0, 0, 0, 128)) y_offset = 10 - if engine_settings['enable_fps']: - fps_text = f"FPS: {self.current_fps:.1f} | TPS: {sim.track_tps():.1f}" + if engine_settings["enable_fps"]: + fps_text = ( + f"FPS: {self.current_fps:.1f} | TPS: {sim.track_tps():.1f}" + ) fps_surf = self.debug_font.render(fps_text, True, (255, 255, 255)) self.debug_surface.blit(fps_surf, (10, y_offset)) y_offset += 25 - if engine_settings['enable_debug']: + if engine_settings["enable_debug"]: debug_info = [ f"Active Particles: {len(sim.active_particles)}", f"Total Particles: {sim.particle_count}", f"Current Type: {sim.current_particle_type}", - f"Brush Size: {sim.brush_size}" + f"Brush Size: {sim.brush_size}", ] - + for line in debug_info: text_surf = self.debug_font.render(line, True, (255, 255, 255)) self.debug_surface.blit(text_surf, (10, y_offset)) @@ -62,5 +108,5 @@ class DebuggerSystem: def log_error(self, error_msg): """Log errors for debugging""" - with open('debug_log.txt', 'a') as f: + with open("debug_log.txt", "a", encoding="utf-8") as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {error_msg}\n") diff --git a/src/physics/particle.py b/src/physics/particle.py index 7228a90..2916208 100644 --- a/src/physics/particle.py +++ b/src/physics/particle.py @@ -1,13 +1,27 @@ +"""Load particle properties from json so we know what particles +we got and how they should be simulated. +""" + -# Load particle properties from json so we know what particles we got and how they should be simulated. class Particle: - def __init__(self, simulation, position, velocity, mass, particle_type, properties, temperature=20): + """Set up a particle with the given properties.""" + + def __init__( + self, + simulation, + 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 self.sim = simulation - + # Core properties self.size = properties.get("size", 1) self.hardness = properties.get("hardness", 0.5) @@ -22,22 +36,26 @@ class Particle: 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.solidify_temperature = properties.get( + "solidify_temperature", None + ) self.evaporate = properties.get("evaporate", None) - self.evaporate_temperature = properties.get("evaporate_temperature", 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) @@ -49,7 +67,9 @@ class Particle: self.pressure_resistance = properties.get("pressure_resistance", 0) self.pressure_tolerance = properties.get("pressure_tolerance", 0) self.pressure_threshold = properties.get("pressure_threshold", 0) - self.pressure_threshold_duration = properties.get("pressure_threshold_duration", 0) + self.pressure_threshold_duration = properties.get( + "pressure_threshold_duration", 0 + ) # Burning properties self.burning = properties.get("burning", False) @@ -62,8 +82,14 @@ class Particle: @classmethod def from_type(cls, simulation, position, particle_type, properties): + """Pre-initialize a particle with default values based on its type.""" default_velocity = [0, 0] default_mass = properties.get("mass", 1.0) - return cls(simulation, position, default_velocity, default_mass, particle_type, properties) - - + return cls( + simulation, + position, + default_velocity, + default_mass, + particle_type, + properties, + ) diff --git a/src/physics/py.typed b/src/physics/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/physics/sim.py b/src/physics/sim.py index ec91442..6a3085c 100644 --- a/src/physics/sim.py +++ b/src/physics/sim.py @@ -3,7 +3,8 @@ Particle-based Physics Simulation System ====================================== -This module implements a 2D particle simulation with physics, interactions, and state changes. +This module implements a 2D particle simulation with physics, interactions, +and state changes. Key Components: -------------- @@ -18,86 +19,21 @@ Key Components: - Manages temperature and state transitions """ -#Load the imports. -from config.settings import np, time, particle_properties -from physics.particle import Particle - - -# Load particle properties from json so we know what particles we got and how they should be simulated. -class Particle: - def __init__(self, simulation, 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 - self.sim = simulation - - # 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) - self.durability = properties.get("durability", 100.0) - - # 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]) - self.explosion_force = properties.get("explosion_force", 0) - self.explosion_duration = properties.get("explosion_duration", 0) - - # Pressure properties - self.pressure_resistance = properties.get("pressure_resistance", 0) - self.pressure_tolerance = properties.get("pressure_tolerance", 0) - self.pressure_threshold = properties.get("pressure_threshold", 0) - self.pressure_threshold_duration = properties.get("pressure_threshold_duration", 0) - - # Burning properties - self.burning = properties.get("burning", False) - self.burn_temperature = properties.get("burn_temperature", 0) - self.burn_duration = properties.get("burn_duration", 0) - self.burn_color = properties.get("burn_color", [255, 0, 0]) - self.burn_rate = properties.get("burn_rate", 0) - self.burn_intensity = properties.get("burn_intensity", 0) - self.burn_rate_multiplier = properties.get("burn_rate_multiplier", 1.0) - - @classmethod - def from_type(cls, simulation, position, particle_type, properties): - default_velocity = [0, 0] - default_mass = properties.get("mass", 1.0) - return cls(simulation, position, default_velocity, default_mass, particle_type, properties) +# Load the imports. +from ..config.settings import np, particle_properties, time +from ..physics.particle import Particle class Simulation: -# the main class of the 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.DORMANT_THRESHOLD = 10 + self._tps_counter = 0 + self._tps_timer = time.time() + self._current_tps = 0 self.x = x self.y = y self.new_x = 0 @@ -105,8 +41,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.particle_count = 0 + 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 = {} @@ -114,171 +50,196 @@ class Simulation: self.max_brush_size = 20 self.particle_properties = particle_properties self.particle_types = list(self.particle_properties.keys()) - self.current_particle_type = self.particle_types[0] if self.particle_types else 'sand' - self.gravity = 9.8 # m/s^2, adjustable based on the scale of simulation + self.current_particle_type = ( + self.particle_types[0] if self.particle_types else "sand" + ) + self.gravity = 9.8 # m/s^2 self.wind_zones = [] self.wind = [0.0, 0.0] # Global wind vector (x, y) - def reset_particle_count(self): """Reset and recalculate accurate particle count""" - active_count = np.sum([1 for x, y in self.active_particles if self.particles[x][y] is not None]) + active_count = np.sum( + [ + 1 + for x, y in self.active_particles + if self.particles[x][y] is not None + ] + ) self.particle_count = int(active_count) - def get_accurate_particle_count(self): """Get current accurate particle count using numpy""" - particle_mask = np.array([[self.particles[x][y] is not None - for y in range(self.height)] - for x in range(self.width)]) + particle_mask = np.array( + [ + [self.particles[x][y] is not None for y in range(self.height)] + for x in range(self.width) + ] + ) return np.sum(particle_mask) - - def get_cell_key(self, x, y): # this is where we get the cell key. - # Convert coordinates to grid cell + 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): # this is where we add to the spatial grid. + def add_to_spatial_grid(self, 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. + 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 _get_neighbors_from_grid(self, x, y): """Get neighbors using spatial grid""" cell_key = self.get_cell_key(x, y) neighbors = [] - # Check current and adjacent cells for dx in [-1, 0, 1]: for dy in [-1, 0, 1]: check_key = (cell_key[0] + dx, cell_key[1] + dy) if check_key in self.spatial_grid: neighbors.extend(self.spatial_grid[check_key]) - - return neighbors + return neighbors def update_spatial_grid(self): """Enhanced spatial grid update""" if len(self.active_particles) > 100: self.spatial_grid = {} cell_lists = {} - + # Track temperature-sensitive particles temp_sensitive_cells = set() - + 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)) - + # Mark cells with temperature-sensitive particles particle = self.particles[x][y] - if hasattr(particle, 'solidify_temperature') or hasattr(particle, 'melt_temperature'): + if hasattr(particle, "solidify_temperature") or hasattr( + particle, "melt_temperature" + ): temp_sensitive_cells.add(cell_key) - + self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} return temp_sensitive_cells - - def handle_phase_transitions(self, particle, x, y): # this is where we handle all the phase transitions. + def handle_phase_transitions(self, particle, x, y): """Handle all phase transitions for a particle""" - state_changed = False - - if particle.particle_type == 'lava': - if particle.temperature < particle.solidify_temperature: - state_changed = self.transform_particle(x, y, particle.solidify) - return state_changed + # state_changed = False # Check evaporation - if hasattr(particle, 'evaporate_temperature') and particle.evaporate_temperature is not None: - if particle.temperature >= particle.evaporate_temperature and particle.evaporate: + 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) - + 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 ( + 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 ( + 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) return new_type - - if particle.particle_type == 'steam': - # Steam should condense when it cools + + if particle.particle_type == "steam": + # Steam should condense when it cools if particle.temperature <= particle.solidify_temperature: self.transform_particle(x, y, new_type) return new_type - # Check durability property from JSON - if (hasattr(particle, 'durability') and hasattr(particle, 'brk') - and particle.brk is not None): + # Check durability property from JSON + if ( + hasattr(particle, "durability") + and hasattr(particle, "brk") + and particle.brk is not None + ): if particle.durability <= 0: self.transform_particle(x, y, particle.broken) return new_type - def handle_particle_damage(self, particle, x, y): """Handle damage calculations for particles with durability""" - if not hasattr(particle, 'durability'): + if not hasattr(particle, "durability"): return - + # Pressure damage fx, fy = self.calculate_forces(particle, x, y) - pressure = (fx*fx + fy*fy)**0.5 # Calculate magnitude of force vector - - if hasattr(particle, 'pressure_threshold') and pressure > particle.pressure_threshold: + pressure = ( + fx * fx + fy * fy + ) ** 0.5 # Calculate magnitude of force vector + + if ( + hasattr(particle, "pressure_threshold") + and pressure > particle.pressure_threshold + ): particle.durability -= 0.1 - + # Impact damage neighbors = self._get_neighbors_from_grid(x, y) for nx, ny in neighbors: neighbor = self.particles[nx][ny] if neighbor and neighbor.velocity[1] > 5.0: particle.durability -= 0.2 - + # Heat damage if particle.temperature > 900: particle.durability -= 1 - + # Check if particle should break - if particle.durability <= 0 and hasattr(particle, 'broken'): + if particle.durability <= 0 and hasattr(particle, "broken"): self.transform_particle(x, y, particle.broken) return - - def handle_particle_interactions(self, dt): # this is where we handle all the particle interactions. + def handle_particle_interactions(self): """Handle interactions between different particle types""" for x, y in list(self.active_particles): particle = self.particles[x][y] - + if not particle: continue @@ -286,80 +247,118 @@ class Simulation: self.handle_particle_damage(particle, x, y) # Check neighboring particles - for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)]: + 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) + 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. + def process_interaction(self, particle1, particle2, x1, y1, x2, y2): """Process specific interactions between two particles""" # Water + Sand = Wet Sand - 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, 'wsand') # Pass wsand type + 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, "wsand") # Pass wsand type self.particles[x2][y2] = None self.active_particles.discard((x2, y2)) - + # Water + Dirt = Mud - if (particle1.particle_type == 'water' and particle2.particle_type == 'dirt' or - particle2.particle_type == 'water' and particle1.particle_type == 'dirt'): - self.create_mud(x1, y1, 'mud') # Pass mud type + if ( + particle1.particle_type == "water" + and particle2.particle_type == "dirt" + or particle2.particle_type == "water" + and particle1.particle_type == "dirt" + ): + self.create_mud(x1, y1, "mud") # Pass mud type 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) - + 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') - + if target.particle_type == "water": + self.transform_particle(target_x, target_y, "steam") + # Wood to Fire - elif target.particle_type == 'wood': + elif target.particle_type == "wood": if np.random.random() < 0.3: # 30% chance to ignite - self.transform_particle(target_x, target_y, 'fire') + self.transform_particle(target_x, target_y, "fire") # Add plasma effects - if particle1.particle_type == 'plasma' or particle2.particle_type == 'plasma': - target = particle2 if particle1.particle_type == 'plasma' else particle1 - target_x, target_y = (x2, y2) if particle1.particle_type == 'plasma' else (x1, y1) - + if ( + particle1.particle_type == "plasma" + or particle2.particle_type == "plasma" + ): + target = ( + particle2 if particle1.particle_type == "plasma" else particle1 + ) + target_x, target_y = ( + (x2, y2) if particle1.particle_type == "plasma" else (x1, y1) + ) + # Transfer high temperature to target - if hasattr(target, 'temperature'): + if hasattr(target, "temperature"): target.temperature += 100 # Rapid temperature increase - def create_mud(self, x, y, mud_type): """Create either wet sand or mud based on the specified type""" if mud_type in self.particle_properties: properties = self.particle_properties[mud_type] - new_particle = Particle.from_type(self, (x, y), mud_type, properties) + new_particle = Particle.from_type( + self, (x, y), mud_type, 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(self, (x, y), new_type, properties) + new_particle = Particle.from_type( + self, (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 + def handle_gas_movement(self, particle, x, y): """Handle gas particle movement""" if particle.is_gas: dx = np.random.uniform(-1, 1) dy = np.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 @@ -367,45 +366,46 @@ class Simulation: 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) + """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): """Calculate net forces acting on a particle.""" # Initialize forces as numpy array for vectorized operations forces = np.zeros(2, dtype=np.float32) # [fx, fy] - + # Vectorized wind zone calculations - if self.wind_zones: - positions = np.array([[zone['x'], zone['y']] for zone in self.wind_zones]) - directions = np.array([zone['direction'] for zone in self.wind_zones]) - strengths = np.array([zone['strength'] for zone in self.wind_zones]) - radii = np.array([zone['radius'] for zone in self.wind_zones]) - - # Calculate distances vectorized - dx = x - positions[:, 0] - dy = y - positions[:, 1] - distances = np.sqrt(dx**2 + dy**2) - - # Apply wind zone forces where distance <= radius - mask = distances <= radii - scale_factors = np.where(mask, (1 - distances/radii) * strengths[:, np.newaxis], 0) - forces += np.sum(directions * scale_factors[:, np.newaxis], axis=0) + # if self.wind_zones: + # positions = np.array([[zone['x'], zone['y']] for zone + # in self.wind_zones]) + # directions = np.array([zone['direction'] for zone + # in self.wind_zones]) + # strengths = np.array([zone['strength'] for zone + # in self.wind_zones]) + # radii = np.array([zone['radius'] for zone in self.wind_zones]) + + # Calculate distances vectorized + # dx = x - positions[:, 0] + # dy = y - positions[:, 1] + # distances = np.sqrt(dx**2 + dy**2) + + # Apply wind zone forces where distance <= radius + # mask = distances <= radii + # scale_factors = np.where(mask, (1 - distances/radii) * + # strengths[:, np.newaxis], 0) + # forces += np.sum(directions * scale_factors[:, np.newaxis], axis=0) # Apply global wind with vectorized operation - wind_factor = 0.5 if particle.is_gas else 1.0 - forces += np.array(self.wind) * wind_factor + # wind_factor = 0.5 if particle.is_gas else 1.0 + # forces += np.array(self.wind) * wind_factor # Apply drag force vectorized drag = particle.viscosity * -1 @@ -415,95 +415,72 @@ class Simulation: neighbors = np.array(self._get_neighbors_from_grid(x, y)) if len(neighbors): valid_mask = ( - (neighbors[:, 0] >= 0) & - (neighbors[:, 0] < self.width) & - (neighbors[:, 1] >= 0) & - (neighbors[:, 1] < self.height) + (neighbors[:, 0] >= 0) + & (neighbors[:, 0] < self.width) + & (neighbors[:, 1] >= 0) + & (neighbors[:, 1] < self.height) ) neighbors = neighbors[valid_mask] - + for nx, ny in neighbors: if (nx, ny) != (x, y): neighbor = self.particles[nx][ny] if neighbor: - self._apply_neighbor_forces(particle, neighbor, forces[0], forces[1]) + self._apply_neighbor_forces( + neighbor, forces[0], forces[1] + ) return forces[0], forces[1] - 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)]] + 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): + def _apply_neighbor_forces(self, particle, neighbor, fy): """Optimized neighbor force calculation""" - if hasattr(neighbor, 'temperature') and hasattr(particle, 'temperature'): + 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. + 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' + 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': + if particle.type == "wood": particle.burning = True - particle.burn_time = 100 # burn time - + 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)): + 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': + if neighbor and hasattr(neighbor, "flamability"): + if neighbor.particle_type == "wood": # Higher chance to ignite wood - if np.random.random() < 0.3: # 30% chance to spread + if np.random.random() < 0.3: # 30% chance self.ignite_particle(neighbor) elif neighbor.flamability > 0: - if np.random.random() < 0.1: # 10% chance for other materials + if np.random.random() < 0.1: # 10% chance 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 np.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 np.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. + def handle_temperature(self, dt): """Handle temperature changes and state transitions""" for x, y in list(self.active_particles): particle = self.particles[x][y] @@ -512,41 +489,44 @@ class Simulation: if not particle: continue - if particle.temperature > 30000: # Transition to gas particle.is_gas = True particle.temperature = 30000 - particle.velocity = [np.random.uniform(-1, 1), np.random.uniform(-1, 1)] - particle.temperature < 30000 - particle.is_gas = False + particle.velocity = [ + np.random.uniform(-1, 1), + np.random.uniform(-1, 1), + ] + if particle.temperature <= 30000: + 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): + if ( + neighbor + and hasattr(neighbor, "temperature") + and neighbor.temperature is not None + ): temp_diff = particle.temperature - neighbor.temperature - heat_transfer = temp_diff * 0.3 * dt + heat_transfer = temp_diff * 0.2 * 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: + 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) + self.spatial_grid.pop((x, y), None) - - def create_particle(self, x, y): # this is where we create the particle. + 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 @@ -554,178 +534,129 @@ class Simulation: grid_x = x // self.particle_size grid_y = y // self.particle_size - if (0 <= grid_x < self.width and - 0 <= grid_y < self.height and - self.particles[grid_x][grid_y] is None): - + if ( + 0 <= grid_x < self.width + and 0 <= grid_y < self.height + and self.particles[grid_x][grid_y] is None + ): properties = self.particle_properties[particle_type] position = (grid_x, grid_y) new_particle = Particle( simulation=self, position=position, velocity=[0, 0], - mass=properties.get('mass', 1.0), + mass=properties.get("mass", 1.0), particle_type=particle_type, - properties=properties + properties=properties, ) - + 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. + def create_particle_circle(self, center_x, center_y): + """ "Create a circular pattern""" 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) - + 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. + def get_particle_state(self, x, y): """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): - """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 in ['wall', 'stone', 'wood']: - 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: - # Define diagonal directions first - diagonal_dirs = [(-1, 1), (1, 1)] - diagonal_dirs = np.array(diagonal_dirs) - # Randomize movement directions - diagonal_dirs = np.random.permutation(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 np.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 = np.array([(-1, 0), (1, 0)]) - np.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): - """Handle all physics effects""" + """Handle all physics and particle movement effects""" new_active_particles = set() - updates = [] + # updates = [] + self.spatial_grid.clear() 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': + # Skip immutable particles + if particle.particle_type in ["wall", "stone", "wood"]: new_active_particles.add((x, y)) continue - # Handle dissipating particles - if particle.particle_type in ['fire', 'flame']: - # Clear current position first + # Handle dissipating particles (fire/flame) + if particle.particle_type in ["fire", "flame"]: self.particles[x][y] = None self.active_particles.discard((x, y)) - - # Handle fire movement using numpy random + dx = np.random.uniform(-0.5, 0.5) - dy = np.random.uniform(-1.5, -0.5) # Upward drift - new_x = int(x + dx) - new_y = int(y + dy) - + dy = np.random.uniform(-1.5, -0.5) + new_x, new_y = int(x + dx), 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 with numpy random + + # Generate smoke if np.random.random() < 0.25 and new_y > 0: - properties = self.particle_properties['smoke'] + properties = self.particle_properties["smoke"] new_smoke = Particle( simulation=self, - position=(new_x, new_y-1), + position=(new_x, new_y - 1), velocity=[np.random.uniform(-0.5, 0.5), -1], - mass=properties.get('mass', 0.1), - particle_type='smoke', - properties=properties + 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 using numpy random + 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)) if np.random.random() < 0.02: continue - continue - # Air handling - if particle.particle_type == 'air': + # Skip air particles + if particle.particle_type == "air": continue # Handle phase transitions self.handle_phase_transitions(particle, x, y) - # Calculate forces + # Calculate forces and initial position fx, fy = self.calculate_forces(particle, x, y) + new_x, new_y = x, y + 1 - # Handle gas particles - if particle.is_gas: - # Gas movement with numpy random + # Handle different particle types + if particle.particle_type in ["sand", "dirt", "snow", "ice"]: + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[x][new_y] is None: + new_x, new_y = x, y + 1 + else: + diagonal_dirs = np.random.permutation( + [(-1, 1), (1, 1)] + ) + for dx, dy in diagonal_dirs: + test_x, test_y = x + dx, y + dy + if ( + 0 <= test_x < self.width + and 0 <= test_y < self.height + and self.particles[test_x][test_y] is None + ): + if np.random.random() < 0.8: + new_x, new_y = test_x, test_y + break + + elif particle.is_gas: dx = np.random.uniform(2, -1) - dy = np.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)) - + dy = np.random.uniform(-2, 0) + new_x, new_y = int(x + dx), 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 @@ -733,6 +664,7 @@ class Simulation: 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) @@ -740,81 +672,80 @@ class Simulation: particle.velocity[1] += (fy / mass) * dt if particle.liquid: - # Enhanced liquid spreading with numpy random - if np.random.random() < 0.5: - dx = np.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 - - new_x = int(x + particle.velocity[0] * dt) - new_y = int(y + particle.velocity[1] * dt) - - # Update position + new_y = min(y + 1, self.height - 1) + if 0 <= x < self.width and 0 <= y < self.height: + self.remove_from_spatial_grid(x, y) + if self.particles[x][new_y] is None: + new_x, new_y = x, y + 1 + else: + spread_directions = np.random.permutation( + [(-1, 0), (1, 0)] + ) + 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, new_y = test_x, y + break + else: + new_x = int(x + particle.velocity[0] * dt) + new_y = int(y + particle.velocity[1] * dt) + + # Update particle position if valid 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)) + 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)) + particle.position = (new_x, new_y) self._wake_neighbors(new_x, new_y) else: new_active_particles.add((x, y)) - - # Apply updates - 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) - + # Handle boundaries - if engine_settings['outerwall']: - if x <= 0 or x >= self.width-1 or y <= 0 or y >= self.height-1: - 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 - continue - else: - 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 + if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1: + if self.particles[x][y] is not None: + # Remove particle and update counts + self.particles[x][y] = None + self.active_particles.discard((x, y)) + self.remove_from_spatial_grid(x, y) + self.particle_count -= 1 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""" + def clear_particles_circle(self, center_x, center_y): + """Clear particles in a circle around the given point based on brush""" 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 ( + 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 + self.particle_count = max(0, self.particle_count - particles_cleared) - - def mix_liquids(self, liquid1, liquid2): # this is for the mix tool - """Handle liquid mixing interactions""" + """ + ###Future functionality### + 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 @@ -825,7 +756,7 @@ class Simulation: 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]: @@ -836,26 +767,24 @@ class Simulation: 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'): + 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 + return self._current_tps def simulate_step(self, dt, engine_settings): """Run simulation step with spatial grid updates""" @@ -863,12 +792,12 @@ class Simulation: self.update_spatial_grid() # Update particle positions and physics - self.apply_gravity(dt) + # self.apply_gravity() self.apply_physics(dt, engine_settings) - + # Handle state changes and interactions self.handle_temperature(dt) - - self.handle_particle_interactions(dt) + + self.handle_particle_interactions() self.burning() - self.spread_fire() \ No newline at end of file + self.spread_fire() diff --git a/src/physics/tests/base.py b/src/physics/tests/base.py deleted file mode 100644 index 10acb26..0000000 --- a/src/physics/tests/base.py +++ /dev/null @@ -1,72 +0,0 @@ - -from config.settings import np, time, engine_settings -from src.physics.particle import Particle, particle_properties - - - -class SimulationBase: - def __init__(self, width: int, height: int, x: int = 0, y: int = 0): - self.dormant_particles = set() - self.particle_movement_counter = {} - self.DORMANT_THRESHOLD = 10 - self.width = width - self.height = height - self.x = x - self.y = y - self.new_x = 0 - self.new_y = 0 - - self.particle_size = 3 - self.particles = [[None for _ in range(height)] for _ in range(width)] - print(f"Base init - particles type: {type(self.particles)}") - print(f"After initialization - particles type: {type(self.particles)}") - self.active_particles = set() - self.particle_count = 0 - self.brush_size = 1 - self.max_brush_size = 20 - self.particle_properties = particle_properties - self.particle_types = list(self.particle_properties.keys()) - self.current_particle_type = self.particle_types[0] if self.particle_types else 'sand' - self.gravity = 9.8 - self.wind_zones = [] - self.wind = [0.0, 0.0] - - - def create_particle(self, x, y): # this is where we create the particle. - if engine_settings ["enable_debug"]: - print(f"Before particle creation - particles type: {type(self.particles)}") - - """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 and - self.particles[grid_x][grid_y] is None): - - 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 - ) - - 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): - 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 - self.create_particle(grid_x, grid_y) \ No newline at end of file diff --git a/src/physics/tests/forces.py b/src/physics/tests/forces.py deleted file mode 100644 index da72034..0000000 --- a/src/physics/tests/forces.py +++ /dev/null @@ -1,296 +0,0 @@ -""" -Physics force calculations and movement systems for Sandpypi -Handles particle movement, forces, and physics calculations -""" -from config.settings import np -from src.physics.particle import Particle - -class ForceSystem: - def __init__(self, simulation): - self.sim = simulation - self.wind_zones = [] - - - def calculate_forces(self, particle, x, y): - """Calculate net forces acting on a particle.""" - # Initialize forces as numpy array for vectorized operations - forces = np.zeros(2, dtype=np.float32) # [fx, fy] - - # Vectorized wind zone calculations - if self.wind_zones: - positions = np.array([[zone['x'], zone['y']] for zone in self.wind_zones]) - directions = np.array([zone['direction'] for zone in self.wind_zones]) - strengths = np.array([zone['strength'] for zone in self.wind_zones]) - radii = np.array([zone['radius'] for zone in self.wind_zones]) - - # Calculate distances vectorized - dx = x - positions[:, 0] - dy = y - positions[:, 1] - distances = np.sqrt(dx**2 + dy**2) - - # Apply wind zone forces where distance <= radius - mask = distances <= radii - scale_factors = np.where(mask, (1 - distances/radii) * strengths[:, np.newaxis], 0) - forces += np.sum(directions * scale_factors[:, np.newaxis], axis=0) - - # Apply global wind with vectorized operation - wind_factor = 0.5 if particle.is_gas else 1.0 - forces += np.array(self.wind) * wind_factor - - # Apply drag force vectorized - drag = particle.viscosity * -1 - forces += drag * np.array(particle.velocity) - - # Neighbor forces using numpy arrays - neighbors = np.array(self._get_neighbors_from_grid(x, y)) - if len(neighbors): - valid_mask = ( - (neighbors[:, 0] >= 0) & - (neighbors[:, 0] < self.width) & - (neighbors[:, 1] >= 0) & - (neighbors[:, 1] < self.height) - ) - neighbors = neighbors[valid_mask] - - for nx, ny in neighbors: - if (nx, ny) != (x, y): - neighbor = self.sim.particles[nx][ny] - if neighbor: - self._apply_neighbor_forces(particle, neighbor, forces[0], forces[1]) - - return forces[0], forces[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 apply_gravity(self, dt): - """Handle only gravity and basic particle movement""" - self.sim.spatial_grid.spatial_grid.clear() - - for x, y in list(self.sim.active_particles): - particle = self.sim.particles[x][y] - if not particle or particle.particle_type in ['wall', 'stone', 'wood']: - 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.sim.particles[x][new_y] is None: - new_x, new_y = x, y + 1 - else: - # Define diagonal directions first - diagonal_dirs = [(-1, 1), (1, 1)] - diagonal_dirs = np.array(diagonal_dirs) - # Randomize movement directions - diagonal_dirs = np.random.permutation(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.sim.particles[test_x][test_y] is None): - if np.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.sim.particles[x][new_y] is None: - new_x = x - new_y = y + 1 - else: - spread_directions = np.array([(-1, 0), (1, 0)]) - np.random.shuffle(spread_directions) - for dx, _ in spread_directions: - test_x = x + dx - if (0 <= test_x < self.width and - self.sim.particles[test_x][y] is None): - new_x = test_x - new_y = y - break - - # Move particle if destination is empty - if self.sim.particles[new_x][new_y] is None: - self.sim.particles[x][y] = None - self.sim.particles[new_x][new_y] = particle - self.sim.particles.add((new_x, new_y)) - self.sim.particles.discard((x, y)) - particle.position = (new_x, new_y) - - - def apply_physics(self, dt, engine_settings): - """Handle all physics effects""" - new_active_particles = set() - updates = [] - - for x, y in list(self.sim.active_particles): - particle = self.sim.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.sim.particles[x][y] = None - self.sim.particles.discard((x, y)) - - # Handle fire movement using numpy random - dx = np.random.uniform(-0.5, 0.5) - dy = np.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.sim.particles[new_x][new_y] is None: - self.sim.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - - # Generate smoke above with numpy random - if np.random.random() < 0.25 and new_y > 0: - properties = self.sim.particle_properties['smoke'] - new_smoke = Particle( - position=(new_x, new_y-1), - velocity=[np.random.uniform(-0.5, 0.5), -1], - mass=properties.get('mass', 0.1), - particle_type='smoke', - properties=properties - ) - if self.sim.particles[new_x][new_y-1] is None: - self.sim.particles[new_x][new_y-1] = new_smoke - new_active_particles.add((new_x, new_y-1)) - - # Dissipation chance using numpy random - if np.random.random() < 0.02: - continue - continue - - # Air handling - 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 movement with numpy random - dx = np.random.uniform(2, -1) - dy = np.random.uniform(-2, 0) # Bias upward - new_x = int(x + dx) - new_y = int(y + dy) - - self.sim.particles[x][y] = None - self.sim.particles.discard((x, y)) - - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.sim.particles[new_x][new_y] is None: - self.sim.particles[x][y] = None - self.sim.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - self.sim.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 with numpy random - if np.random.random() < 0.5: - dx = np.random.choice([-1, 1]) - if (0 <= x + dx < self.width and - self.sim.particles[x + dx][y] is None): - new_x = x + dx - new_y = y - self.sim.particles[x][y] = None - self.sim.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - continue - - new_x = int(x + particle.velocity[0] * dt) - new_y = int(y + particle.velocity[1] * dt) - - # Update position - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.sim.particles[new_x][new_y] is None: - updates.append((x, y, new_x, new_y, particle)) - new_active_particles.add((new_x, new_y)) - self._wake_neighbors(new_x, new_y) - else: - new_active_particles.add((x, y)) - - # Apply updates - for old_x, old_y, new_x, new_y, particle in updates: - self.sim.particles[old_x][old_y] = None - self.sim.particles[new_x][new_y] = particle - particle.position = (new_x, new_y) - - # Handle boundaries - if engine_settings['outerwall']: - if x <= 0 or x >= self.width-1 or y <= 0 or y >= self.height-1: - if self.sim.particles[x][y] is None: - properties = self.sim.particle_properties['wall'] - wall = Particle.from_type((x, y), 'wall', properties) - self.sim.particles[x][y] = wall - new_active_particles.add((x, y)) - self.particle_count += 1 - continue - else: - if x <= 0 or x >= self.width-1 or y <= 0 or y >= self.height-1: - if self.sim.particles[x][y] is not None: - self.sim.particles[x][y] = None - self.sim.particles.discard((x, y)) - self.sim.particle_count -= 1 - continue - - self.sim.particles = new_active_particles - - - 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 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 = np.random.uniform(-1, 1) - dy = np.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.sim.particles[new_x][new_y] is None: - self.sim.particles[x][y] = None - self.sim.particles[new_x][new_y] = particle - self.sim.particles.add((new_x, new_y)) - self.sim.particles.discard((x, y)) diff --git a/src/physics/tests/grid.py b/src/physics/tests/grid.py deleted file mode 100644 index c3cf42b..0000000 --- a/src/physics/tests/grid.py +++ /dev/null @@ -1,87 +0,0 @@ - -""" -Spatial grid management systems for Sandpypi -Handles particle positioning and neighbor calculations -""" -from physics.base import SimulationBase - - -class SpatialGrid: - def __init__(self, simulation): - self.sim = simulation - self.width = simulation.width - self.height = simulation.height - self.cell_size = 32 - self.spatial_grid = {} - - - def update_spatial_grid(self): - """Enhanced spatial grid update""" - if len(self.sim.active_particles) > 100: - self.spatial_grid = {} - cell_lists = {} - - # Track temperature-sensitive particles - temp_sensitive_cells = set() - - for x, y in self.sim.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)) - - # Mark cells with temperature-sensitive particles - particle = self.sim.particles[x][y] - if hasattr(particle, 'solidify_temperature') or hasattr(particle, 'melt_temperature'): - temp_sensitive_cells.add(cell_key) - - self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} - return temp_sensitive_cells - - - 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 _get_neighbors_from_grid(self, x, y): - """Get neighbors using spatial grid""" - cell_key = self.get_cell_key(x, y) - neighbors = [] - - # Check current and adjacent cells - for dx in [-1, 0, 1]: - for dy in [-1, 0, 1]: - check_key = (cell_key[0] + dx, cell_key[1] + dy) - if check_key in self.spatial_grid: - neighbors.extend(self.spatial_grid[check_key]) - - return neighbors - - - 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 _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 - diff --git a/src/physics/tests/interactions.py b/src/physics/tests/interactions.py deleted file mode 100644 index 02dc089..0000000 --- a/src/physics/tests/interactions.py +++ /dev/null @@ -1,118 +0,0 @@ - -""" -Particle interaction handling systems for Sandpypi -Manages how different particle types interact with each other -""" -from config.settings import np -from src.physics.particle import Particle -from physics.base import SimulationBase - - -class ParticleInteractions: - def __init__(self, simulation): - self.sim = simulation - - 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.sim.active_particles): - particle = self.sim.particles[x][y] - - if not particle: - continue - - # Handle damage for any particle with durability - self.handle_particle_damage(particle, x, y) - - # 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 = Wet Sand - 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, 'wsand') # Pass wsand type - self.particles[x2][y2] = None - self.active_particles.discard((x2, y2)) - - # Water + Dirt = Mud - if (particle1.particle_type == 'water' and particle2.particle_type == 'dirt' or - particle2.particle_type == 'water' and particle1.particle_type == 'dirt'): - self.create_mud(x1, y1, 'mud') # Pass mud type - 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 np.random.random() < 0.3: # 30% chance to ignite - self.transform_particle(target_x, target_y, 'fire') - - - def mix_liquids(self, liquid1, liquid2): # not implemented - """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 create_mud(self, x, y, mud_type): - """Create either wet sand or mud based on the specified type""" - if mud_type in self.particle_properties: - properties = self.particle_properties[mud_type] - new_particle = Particle.from_type((x, y), mud_type, properties) - self.particles[x][y] = new_particle - self.active_particles.add((x, y)) - - def handle_particle_damage(self, particle, x, y): - """Handle damage calculations for particles with durability""" - if not hasattr(particle, 'durability'): - return - - # Pressure damage - fx, fy = self.calculate_forces(particle, x, y) - pressure = (fx*fx + fy*fy)**0.5 # Calculate magnitude of force vector - - if hasattr(particle, 'pressure_threshold') and pressure > particle.pressure_threshold: - particle.durability -= 0.1 - - # Impact damage - neighbors = self._get_neighbors_from_grid(x, y) - for nx, ny in neighbors: - neighbor = self.particles[nx][ny] - if neighbor and neighbor.velocity[1] > 5.0: - particle.durability -= 0.2 - - # Heat damage - if particle.temperature > 900: - particle.durability -= 1 - - # Check if particle should break - if particle.durability <= 0 and hasattr(particle, 'broken'): - self.transform_particle(x, y, particle.broken) - return - - - 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)]] diff --git a/src/physics/tests/simulation.py b/src/physics/tests/simulation.py deleted file mode 100644 index e3c4145..0000000 --- a/src/physics/tests/simulation.py +++ /dev/null @@ -1,73 +0,0 @@ - -from physics.base import SimulationBase, time, np -from physics.grid import SpatialGrid as sg -from physics.forces import ForceSystem as fs -from physics.temperature import TemperatureSystem as ts -from physics.interactions import ParticleInteractions as pi -from src.physics.particle import Particle, particle_properties -from physics.sim import Simulation - -class Simulation(SimulationBase): - def __init__(self, width, height, x=0, y=0): - print(f"Simulation init - before base init - particles type: {type(getattr(self, 'particles', None))}") - - SimulationBase.__init__(self, width, height, x, y) - - print(f"After base init - particles type: {type(self.particles)}") - self.dormant_particles = set() - self.particle_movement_counter = {} - self.DORMANT_THRESHOLD = 10 - self.sim = Simulation(width, height) - self.spatial_grid = sg(self) - self.force_system = fs(self) - self.temperature_system = ts(self) - self.particle_interactions = pi(self) - - def simulate_step(self, dt, engine_settings): - """Run simulation step with spatial grid updates""" - self.spatial_grid.update_spatial_grid() - - # Update particle positions and physics - self.force_system.apply_gravity(dt) - self.sim.Simulation.apply_physics(dt, engine_settings) - - # Handle state changes and interactions - self.temperature_system.handle_temperature(dt) - self.particle_interactions.handle_particle_interactions(dt) - self.temperature_system.burning() - self.temperature_system.spread_fire() - - - 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 reset_particle_count(self): - """Reset and recalculate accurate particle count""" - active_count = np.sum([1 for x, y in self.active_particles if self.particles[x][y] is not None]) - self.particle_count = int(active_count) - - - def get_accurate_particle_count(self): - """Get current accurate particle count using numpy""" - particle_mask = np.array([[self.particles[x][y] is not None - for y in range(self.height)] - for x in range(self.width)]) - return np.sum(particle_mask) - diff --git a/src/physics/tests/temperature.py b/src/physics/tests/temperature.py deleted file mode 100644 index 44c398e..0000000 --- a/src/physics/tests/temperature.py +++ /dev/null @@ -1,162 +0,0 @@ - -""" -Temperature and state management systems for Sandpypi -Handles temperature changes, phase transitions, and burning mechanics -""" -from config.settings import np - - - -class TemperatureSystem: - def __init__(self, simulation): - self.sim = simulation - - def handle_temperature(self, dt): # this is where we handle the temperature. - """Handle temperature changes and state transitions""" - for x, y in list(self.sim.active_particles): - particle = self.sim.particles[x][y] - if particle and particle.is_gas: - particle.temperature -= 0.5 * dt - if not particle: - continue - - - if particle.temperature > 1700: - # Transition to gas - particle.is_gas = True - particle.temperature = 1700 - particle.velocity = [np.random.uniform(-1, 1), np.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.sim.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 handle_phase_transitions(self, particle, x, y): # this is where we handle all the phase transitions. - """Handle all phase transitions for a particle""" - state_changed = False - - if particle.particle_type == 'lava': - if particle.temperature < particle.solidify_temperature: - state_changed = self.transform_particle(x, y, particle.solidify) - return state_changed - - # 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) - - 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.sim.particle: - 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.sim.particle_properties: - self.transform_particle(x, y, new_type) - return new_type - - if particle.particle_type == 'steam': - # Steam should condense when it cools - if particle.temperature <= particle.solidify_temperature: - self.transform_particle(x, y, new_type) - return new_type - - # Check durability property from JSON - if (hasattr(particle, 'durability') and hasattr(particle, 'brk') - and particle.brk is not None): - if particle.durability <= 0: - self.transform_particle(x, y, particle.broken) - return new_type - - - def burning(self): # this is where we handle the burning. - """Handle burning of particles.""" - for x, y in list(self.sim.active_particles): - particle = self.sim.particles[x][y] - if particle and hasattr(particle, 'burning') and particle.burning: - particle.temperature += 10 - if particle.temperature > 1000: - self.sim.particles[x][y] = None - self.sim.active_particles.remove((x, y)) - self.spatial_grid.pop((x, y), None) - - - def spread_fire(self): # this is where we spread the fire. - """Spread fire to neighboring particles.""" - for x, y in list(self.sim.active_particles): - particle = self.sim.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.sim.particles[nx][ny] - if neighbor and hasattr(neighbor, 'flamability'): - if neighbor.particle_type == 'wood': - # Higher chance to ignite wood - if np.random.random() < 0.3: # 30% chance to spread - self.ignite_particle(neighbor) - elif neighbor.flamability > 0: - if np.random.random() < 0.1: # 10% chance for other materials - self.ignite_particle(neighbor) - - - 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 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 np.random.random() < 0.6: # % chance - self.sim.particles[x][y] = None - self.sim.active_particles.discard((x, y)) - - if particle.particle_type in ['fire', 'flame', 'lava']: - # Create smoke above with proper physics - if np.random.random() < 0.65 and y > 0: # % chance for smoke - properties = self.sim.particle_properties['smoke'] - new_smoke = self.sim.Particle.from_type((x, y-1), 'smoke', properties) - if self.sim.particles[x][y-1] is None: - self.sim.particles[x][y-1] = new_smoke - self.sim.active_particles.add((x, y-1)) - - else: - # Handle collision with water - if particle.particle_type == 'water': - self.sim.particles[x][y-1] = None - self.sim.active_particles.discard((x, y-1)) - self.sim.particles[x][y] = None - self.sim.active_particles.discard((x, y)) - self.sim.particles[x][y] = self.sim.Particle.from_type((x, y), 'water', self.sim.particle_properties['water']) - self.sim.active_particles.add((x, y)) \ No newline at end of file diff --git a/src/rendering/py.typed b/src/rendering/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/rendering/rendering.py b/src/rendering/rendering.py index 05f7b89..34346af 100644 --- a/src/rendering/rendering.py +++ b/src/rendering/rendering.py @@ -2,95 +2,136 @@ #File Name: rendering.py Rendering class for the particle simulation. -This class is responsible for rendering the particles, UI elements, and debug information on the screen. It handles the setup of the display, pre-rendering of static UI elements, and the drawing of particles, buttons, and debug overlays. +This class is responsible for rendering the particles, UI elements, and debug +information on the screen. +It handles the setup of the display, pre-rendering +of static UI elements, +and the drawing of particles, buttons, and debug overlays. -The `draw_particles` function is the main method for rendering the particles on the screen. It takes the particle data, active particles, particle size, and particle colors as input, and renders the particles on the `particle_surface`. The `particle_surface` is then blitted onto the main screen. +The `draw_particles` function is the main method for rendering the particles +on the screen. It takes the particle data, active particles, particle size, +and particle colors as input, +and renders the particles on the `particle_surface`. The `particle_surface` is +then blitted onto the main screen. -The `draw_zoom_window` function is used to render a zoomed-in view of the particles around the mouse cursor. It creates a separate surface for the zoomed-in view and returns it. +The `draw_zoom_window` function is used to render a zoomed-in view of the +particles around the mouse cursor. +It creates a separate surface for the zoomed-in view and returns it. -The `draw_debug_overlay` function is responsible for rendering the debug information, such as FPS, mouse position, and particle information, on the screen. +The `draw_debug_overlay` responsible for rendering the debug information, +such as FPS, mouse position, and particle information, on the screen. -The `draw_buttons` function handles the rendering of the category buttons, particle buttons, and other UI elements like the clear screen and settings buttons. +The `draw_buttons` function handles the rendering of the category buttons, +particle buttons, and other UI elements +like the clear screen and settings buttons. -The `render_brush_cursor` function is used to draw a visual indicator for the current brush size. +The `render_brush_cursor` draw a visual indicator for the current brush size. -The `draw_brush_size_slider` function renders a slider for adjusting the brush size. +The `draw_brush_size_slider` renders a slider for adjusting the brush size. -The `draw_settings_menu` function creates a settings menu surface that can be displayed on the screen. +The `draw_settings_menu` settings menu that can be displayed on the screen. -The `clear_screen` function is used to reset the simulation grid and clear the display surfaces. +The `clear_screen` reset the simulation grid and clear the display surfaces. +The `setup_gpu_rendering` placeholder for GPU-based rendering setup. """ - -from config.settings import pygame, random, particle_properties, engine_settings +from ..config.settings import ( + engine_settings, + particle_properties, + pygame, + random, +) class Rendering: + """Main rendering system""" def __init__(self, width, height): - #self.setup_gpu_rendering() + # self.setup_gpu_rendering() self.screen = pygame.display.set_mode((width, height)) self.background = pygame.Surface((width, height)) self.background.fill((0, 0, 0)) self.width = width self.height = height + self.particle_count = 0 self.particle_colors = {} self.particle_properties = particle_properties - self.particle_surface = pygame.Surface((width, height), pygame.SRCALPHA) - self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + self.particle_surface = pygame.Surface( + (width, height), pygame.SRCALPHA + ) + self.zoom_window_surface = pygame.Surface((200, 200), pygame.SRCALPHA) + # self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + self.particle_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + self.settings_menu_surface = pygame.Surface( + (300, 300), pygame.SRCALPHA + ) + self.brush_cursor_surface = pygame.Surface((10, 10), pygame.SRCALPHA) + self._last_debug_info = None + self.buttons = {} + self.button_height = 30 + self.button_width = 100 + self.clear_screen_button = pygame.Rect(10, 10, 100, 30) + self.settings_button = pygame.Rect(10, 50, 100, 30) self.cached_fonts = { - 'debug': pygame.font.SysFont(None, 24), - '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) + "debug": pygame.font.SysFont(None, 24), + "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 self.button_surfaces = {} # Initialize categories for name, properties in particle_properties.items(): - if 'color' in properties: - self.particle_colors[name.lower()] = properties['color'] - self.categories = {'Solids': [], 'Liquids': [], 'Gases': [], 'Special': []} + if "color" in properties: + self.particle_colors[name.lower()] = properties["color"] + self.categories = { + "Solids": [], + "Liquids": [], + "Gases": [], + "Special": [], + } for particle_name, properties in self.particle_properties.items(): - if properties.get('is_gas'): - self.categories['Gases'].append(particle_name) - elif properties.get('liquid'): - self.categories['Liquids'].append(particle_name) - elif properties.get('solid'): - self.categories['Solids'].append(particle_name) + if properties.get("is_gas"): + self.categories["Gases"].append(particle_name) + elif properties.get("liquid"): + self.categories["Liquids"].append(particle_name) + elif properties.get("solid"): + self.categories["Solids"].append(particle_name) else: - self.categories['Special'].append(particle_name) - - self.current_category = 'Solids' + self.categories["Special"].append(particle_name) + + self.current_category = "Solids" self.category_buttons = {} self.setup_category_menu() self.setup_static_ui() - def setup_gpu_rendering(self): - # Initialize OpenGL context + """Initialize OpenGL context""" pygame.display.gl_set_attribute(pygame.GL_ACCELERATED_VISUAL, 1) - self.screen = pygame.display.set_mode((self.width, self.height), - pygame.OPENGL | pygame.DOUBLEBUF) - + self.screen = pygame.display.set_mode( + (self.width, self.height), pygame.OPENGL | pygame.DOUBLEBUF + ) def setup_static_ui(self): + """Setup static UI elements""" for category in self.categories: surf = pygame.Surface((80, 25)) surf.fill((150, 150, 150)) - text = self.cached_fonts['button'].render(category, True, (0, 0, 0)) + text = self.cached_fonts["button"].render( + category, True, (0, 0, 0) + ) surf.blit(text, (5, 5)) self.button_surfaces[category] = surf - def setup_category_menu(self): - # Category buttons at the top + """Setup category menu""" x_offset = self.width - 350 y_offset = 10 for category in self.categories: @@ -98,20 +139,25 @@ class Rendering: self.category_buttons[category] = button_rect x_offset += 90 - def load_buttons(self): + """Load buttons""" x_offset = 10 y_offset = 10 for particle_type, properties in self.particle_properties.items(): - if 'color' in properties: - button_rect = pygame.Rect(x_offset, y_offset, self.button_width, self.button_height) + if "color" in properties: + button_rect = pygame.Rect( + x_offset, y_offset, self.button_width, self.button_height + ) self.buttons[particle_type.lower()] = button_rect - x_offset += self.button_width + 10 # Add spacing between buttons + x_offset += ( + self.button_width + 10 + ) # Add spacing between buttons - - def draw_particles(self, particles, active_particles, particle_size, particle_colors): # this is the function that draws the particles - #self.particle_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) + def draw_particles( + self, particles, active_particles, particle_size, particle_colors + ): + """Draw particles""" self.particle_surface.fill((0, 0, 0, 0)) particle_batches = {} @@ -123,24 +169,41 @@ class Rendering: p_type = particle.particle_type if p_type not in particle_batches: particle_batches[p_type] = [] - - rect = pygame.Rect(x * particle_size, y * particle_size, - particle_size, particle_size) + + rect = pygame.Rect( + x * particle_size, + y * particle_size, + particle_size, + particle_size, + ) particle_batches[p_type].append(rect) if particle.particle_type in particle_colors: color = particle_colors[particle.particle_type] else: color = (255, 255, 255) - - if engine_settings['enable_glow']: + + 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 = 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']: + 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 alpha = random.randint(128, 200) @@ -149,26 +212,33 @@ class Rendering: color.append(alpha) else: color[3] = alpha - + # Add subtle movement effect offset_x = random.randint(-1, 1) offset_y = random.randint(-1, 1) - rect = (x * particle_size + offset_x, y * particle_size + offset_y, - particle_size, particle_size) + rect = ( + x * particle_size + offset_x, + y * particle_size + offset_y, + particle_size, + particle_size, + ) else: - rect = (x * particle_size, y * particle_size, - particle_size, particle_size) - - pygame.draw.rect(self.particle_surface, color, rect) + rect = ( + x * particle_size, + y * particle_size, + particle_size, + particle_size, + ) + pygame.draw.rect(self.particle_surface, color, rect) self.screen.blit(self.background, (0, 0)) self.screen.blit(self.particle_surface, (0, 0)) """#Potentially for future for p_type, rects in particle_batches.items(): color = particle_colors.get(p_type, (255, 255, 255)) - rect = (x * particle_size, y * particle_size, - particle_size, particle_size) + rect = (x * particle_size, y * particle_size, + particle_size, particle_size) if len(rects) > 1: pygame.draw.rect(self.particle_surface, color, rect) else: @@ -177,163 +247,238 @@ class Rendering: self.screen.blit(self.background, (0, 0)) self.screen.blit(self.particle_surface, (0, 0))""" - def draw_zoom_window(self, particles, particle_size, particle_colors, mouse_pos, zoom_factor=4): # this is the function that draws the zoom window - print(f"Drawing zoom window.") + def draw_zoom_window( + self, + particles, + particle_size, + particle_colors, + mouse_pos, + zoom_factor=4, + ): + """Draw zoom window""" + print(f"Drawing zoom window.{mouse_pos}") zoom_size = 100 # Size of zoom window zoom_surface = pygame.Surface((zoom_size, zoom_size)) zoom_surface.fill((0, 0, 0)) - + # Get area around mouse to zoom mouse_x, mouse_y = mouse_pos - view_x = mouse_x - zoom_size/(2*zoom_factor) - view_y = mouse_y - zoom_size/(2*zoom_factor) + view_x = mouse_x - zoom_size / (2 * zoom_factor) + view_y = mouse_y - zoom_size / (2 * zoom_factor) print(f"Viewing area: {view_x}, {view_y}") # Draw zoomed particles - for x in range(int(view_x), int(view_x + zoom_size/zoom_factor)): - for y in range(int(view_y), int(view_y + zoom_size/zoom_factor)): + for x in range(int(view_x), int(view_x + zoom_size / zoom_factor)): + for y in range(int(view_y), int(view_y + zoom_size / zoom_factor)): if 0 <= x < len(particles) and 0 <= y < len(particles[0]): particle = particles[x][y] if particle: - color = particle_colors.get(particle.particle_type, (255, 255, 255)) - rect = ((x - view_x) * zoom_factor, + color = particle_colors.get( + particle.particle_type, (255, 255, 255) + ) + rect = ( + (x - view_x) * zoom_factor, (y - view_y) * zoom_factor, - particle_size * zoom_factor, - particle_size * zoom_factor) + particle_size * zoom_factor, + particle_size * zoom_factor, + ) pygame.draw.rect(zoom_surface, color, rect) print(f"Drawing zoom window at {mouse_pos}{zoom_surface}") return zoom_surface - def _get_particle_info(self, particles, x, y): if 0 <= x < len(particles) and 0 <= y < len(particles[0]): particle = particles[x][y] if particle: - attrs = ['temperature', 'liquid', 'is_gas', 'solid', 'mass', 'velocity', 'friction'] + attrs = [ + "temperature", + "liquid", + "is_gas", + "solid", + "mass", + "velocity", + "friction", + ] info = [f"Type: {particle.particle_type}"] - info.extend(f"{attr}: {getattr(particle, attr)}" for attr in attrs - if hasattr(particle, attr)) + info.extend( + f"{attr}: {getattr(particle, attr)}" + for attr in attrs + if hasattr(particle, attr) + ) return " | ".join(info) return "None" - + """ def draw_wind_overlay(self, wind_zones): - wind_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) - + wind_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + for zone in wind_zones: # Draw wind direction arrows - x, y = zone['x'], zone['y'] - radius = zone['radius'] - strength = zone['strength'] - direction = zone['direction'] - + x, y = zone["x"], zone["y"] + # radius = zone["radius"] + strength = zone["strength"] + direction = zone["direction"] + # Draw wind field visualization arrow_color = (0, 150, 255, 100) # Light blue, semi-transparent arrow_length = strength * 20 - + # Draw main direction arrow end_x = x + direction[0] * arrow_length end_y = y + direction[1] * arrow_length - pygame.draw.line(wind_surface, arrow_color, (x, y), (end_x, end_y), 2) - + pygame.draw.line( + wind_surface, arrow_color, (x, y), (end_x, end_y), 2 + ) + # Draw arrow head pygame.draw.circle(wind_surface, arrow_color, (int(x), int(y)), 5) - - self.screen.blit(wind_surface, (0, 0)) + self.screen.blit(wind_surface, (0, 0))""" + + """ def draw_pressure_overlay(self, particles, active_particles): - pressure_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) - + pressure_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + # Create pressure map for x, y in active_particles: particle = particles[x][y] - if particle and hasattr(particle, 'pressure'): + if particle and hasattr(particle, "pressure"): # Color gradient based on pressure pressure = particle.pressure if pressure > 0: - color = (255, 0, 0, int(min(pressure * 50, 255))) # Red for high pressure + color = ( + 255, + 0, + 0, + int(min(pressure * 50, 255)), + ) # Red for high pressure else: - color = (0, 0, 255, int(min(-pressure * 50, 255))) # Blue for low pressure - - pygame.draw.rect(pressure_surface, color, - (x * self.particle_size, y * self.particle_size, - self.particle_size, self.particle_size)) - - self.screen.blit(pressure_surface, (0, 0)) + color = ( + 0, + 0, + 255, + int(min(-pressure * 50, 255)), + ) # Blue for low pressure + pygame.draw.rect( + pressure_surface, + color, + ( + x * self.particle_size, + y * self.particle_size, + self.particle_size, + self.particle_size, + ), + ) + self.screen.blit(pressure_surface, (0, 0))""" + + """ def draw_temperature_overlay(self, particles, active_particles): - temperature_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) + temperature_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) # Create temperature map for x, y in active_particles: particle = particles[x][y] - if particle and hasattr(particle, 'temperature'): + if particle and hasattr(particle, "temperature"): # Color gradient based on temperature temperature = particle.temperature if temperature > 0: - color = (255, 0, 0, int(min(temperature * 50, 255))) # Red for high temperature + color = ( + 255, + 0, + 0, + int(min(temperature * 50, 255)), + ) # Red for high temperature else: - color = (0, 0, 255, int(min(-temperature * 50, 255))) # Blue for low temperature + color = ( + 0, + 0, + 255, + int(min(-temperature * 50, 255)), + ) # Blue for low temperature - pygame.draw.rect(temperature_surface, color, - (x * self.particle_size, y * self.particle_size, - self.particle_size, self.particle_size)) + pygame.draw.rect( + temperature_surface, + color, + ( + x * self.particle_size, + y * self.particle_size, + self.particle_size, + self.particle_size, + ), + ) - self.screen.blit(temperature_surface, (0, 0)) + self.screen.blit(temperature_surface, (0, 0))""" - def draw_debug_overlay(self, fps, sim): - if not engine_settings['enable_fps'] and not engine_settings['enable_debug']: + """Debugger moving to debugger_system.py""" + + if ( + not engine_settings["enable_fps"] + and not engine_settings["enable_debug"] + ): return - + # Create static debug surface if not exists - if not hasattr(self, 'debug_surface'): + 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 - + current_info = ( int(fps), int(sim.track_tps()), - mouse_x // 10, # Reduced update frequency + mouse_x // 10, # Reduced update frequency mouse_y // 10, - sim.particle_count + sim.particle_count, ) - - if not hasattr(self, '_last_debug_info') or current_info != self._last_debug_info: + + 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'] + font = self.cached_fonts["debug"] y_offset = 10 - - if engine_settings['enable_fps']: - fps_surf = font.render(f"FPS: {fps:.1f} | TPS: {sim.track_tps():.1f}", True, (255, 255, 255)) + + 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 - - if engine_settings['enable_debug']: + + if engine_settings["enable_debug"]: debug_lines = [ f"Mouse: ({mouse_x}, {mouse_y})", f"Grid: ({grid_x}, {grid_y})", - f"Particles: {sim.particle_count}" + 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 + def draw_buttons(self): + """Draws the buttons on the screen.""" self.buttons = {} - + # Draw category buttons on right x_offset = self.width - 100 y_offset = 10 @@ -353,98 +498,120 @@ class Rendering: if particle_type in self.particle_properties: button_rect = pygame.Rect(x_offset, y_offset, 80, 25) self.buttons[particle_type] = button_rect - + # Use cached button surface if particle_type in self.button_surfaces: - self.screen.blit(self.button_surfaces[particle_type], button_rect) + self.screen.blit( + self.button_surfaces[particle_type], button_rect + ) else: # Create and cache button surface if not exists button_surface = pygame.Surface((80, 25)) - color = self.particle_properties[particle_type].get('color', (255, 255, 255)) + color = self.particle_properties[particle_type].get( + "color", (255, 255, 255) + ) button_surface.fill(color) - font = self.cached_fonts.get('button') + 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 self.screen.blit(button_surface, button_rect) - + y_offset += 30 # Stack buttons vertically - final_y_offset = y_offset + 10 - # Draw clear screen button - if 'clear' not in self.button_surfaces: + if "clear" not in self.button_surfaces: clear_surface = pygame.Surface((80, 25)) clear_surface.fill((255, 0, 0)) - font = self.cached_fonts.get('button') + 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 - + self.button_surfaces["clear"] = clear_surface + self.clear_screen_button = pygame.Rect(x_offset, y_offset + 10, 80, 25) - self.screen.blit(self.button_surfaces['clear'], self.clear_screen_button) + self.screen.blit( + self.button_surfaces["clear"], self.clear_screen_button + ) # Draw Settings menu button - if 'settings' not in self.button_surfaces: + if "settings" not in self.button_surfaces: settings_surface = pygame.Surface((80, 25)) settings_surface.fill((255, 255, 255)) - font = self.cached_fonts.get('button') + 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 - + self.button_surfaces["settings"] = settings_surface + self.settings_button = pygame.Rect(x_offset, y_offset + 50, 80, 25) - self.screen.blit(self.button_surfaces['settings'], self.settings_button) + self.screen.blit( + self.button_surfaces["settings"], self.settings_button + ) def render_brush_cursor(self, x, y, radius): - if engine_settings ['enable_cursor']: - # Draw outline circle + """Draw outline circle""" + if engine_settings["enable_cursor"]: pygame.draw.circle(self.screen, (255, 255, 255), (x, y), radius, 1) # Draw slightly transparent fill - cursor_surface = pygame.Surface((radius*2, radius*2), pygame.SRCALPHA) - pygame.draw.circle(cursor_surface, (255, 255, 255, 55), (radius, radius), radius) - self.screen.blit(cursor_surface, (x-radius, y-radius)) + cursor_surface = pygame.Surface( + (radius * 2, radius * 2), pygame.SRCALPHA + ) + pygame.draw.circle( + cursor_surface, (255, 255, 255, 55), (radius, radius), radius + ) + self.screen.blit(cursor_surface, (x - radius, y - radius)) - - def draw_brush_size_slider(self, brush_size): # this is the function that draws the brush size slider - # Draw the slider for brush size + def draw_brush_size_slider(self, brush_size): + """Draw the slider for brush size""" 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 = self.cached_fonts.get('brush_size').render(f"Brush Size: {brush_size}", True, (255, 255, 255)) + pygame.draw.rect( + self.screen, + (255, 0, 0), + (500 + brush_size, 10, 100 - brush_size * 2, 20), + ) + 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): + """Draw the settings menu""" settings_surface = pygame.Surface((300, 400)) settings_surface.fill((50, 50, 50)) - + y_offset = 10 - font = self.cached_fonts.get('settings') - + font = self.cached_fonts.get("settings") + for setting, value in engine_settings.items(): # Create toggle button button_rect = pygame.Rect(10, y_offset, 20, 20) - pygame.draw.rect(settings_surface, (0, 255, 0) if value else (255, 0, 0), button_rect) - + pygame.draw.rect( + settings_surface, + (0, 255, 0) if value else (255, 0, 0), + button_rect, + ) + # Draw setting name - label = font.render(setting.replace('_', ' ').title(), True, (255, 255, 255)) + label = font.render( + setting.replace("_", " ").title(), True, (255, 255, 255) + ) settings_surface.blit(label, (40, y_offset)) - + y_offset += 30 - + return settings_surface - - def clear_screen(self, sim): # this is the function that clears the screen - # Store current particle type + def clear_screen(self, sim): + """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.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() + sim.reset_particle_count() # Clear display surfaces self.background.fill((0, 0, 0)) self.particle_surface.fill((0, 0, 0, 0)) -