diff --git a/cool-cacti/.github/workflows/lint.yaml b/cool-cacti/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/cool-cacti/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/cool-cacti/.gitignore b/cool-cacti/.gitignore new file mode 100644 index 00000000..12ea8c3c --- /dev/null +++ b/cool-cacti/.gitignore @@ -0,0 +1,39 @@ +horizons_data/* +!horizons_data/template.json +!horizons_data/planets.json + +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +.VSCodeCounter/ +# MacOS +.DS_Store + +# Ruff check +.ruff_cache/ \ No newline at end of file diff --git a/cool-cacti/.pre-commit-config.yaml b/cool-cacti/.pre-commit-config.yaml new file mode 100644 index 00000000..80547ab2 --- /dev/null +++ b/cool-cacti/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format diff --git a/cool-cacti/ATTRIBUTIONS.txt b/cool-cacti/ATTRIBUTIONS.txt new file mode 100644 index 00000000..8373639a --- /dev/null +++ b/cool-cacti/ATTRIBUTIONS.txt @@ -0,0 +1,30 @@ +Music: "25 oh 20 and to the stars", "something about space", "death screen clip" +Composed and created by Elemeno Peter https://kimeklover.newgrounds.com/ +Licensed under CC BY-NC-SA + +Planet sprites generated with Pixel Planet Generator by Deep Fold +https://deep-fold.itch.io/pixel-planet-generator +Software licensed under the MIT License + +Explosion sprites by LinkNinja +https://linkninja.itch.io/simple-explosion-animation +Licensed under CC-0 + +Recycle items art by Clint Bellanger +https://opengameart.org/content/recycle-items-set +Licensed under CC-BY 3.0 + +QR Code Scanner +https://www.hiclipart.com/free-transparent-background-png-clipart-pjnbg +HiClipart is an open community for users to share PNG images, all PNG cliparts in HiClipart are for Non-Commercial Use + +3 Explosion Bangs Copyright 2012 Iwan 'qubodup' Gabovitch +https://opengameart.org/content/3-background-crash-explosion-bang-sounds +Licensed under CC-BY 3.0 + +Rock breaking sound +https://opengameart.org/content/rockbreaking +Licensed under CC-BY 3.0 + +Scan sound from https://www.zapsplat.com +Use with attribution \ No newline at end of file diff --git a/cool-cacti/LICENSE.txt b/cool-cacti/LICENSE.txt new file mode 100644 index 00000000..5a04926b --- /dev/null +++ b/cool-cacti/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/cool-cacti/README.md b/cool-cacti/README.md new file mode 100644 index 00000000..ef910d55 --- /dev/null +++ b/cool-cacti/README.md @@ -0,0 +1,132 @@ +# Planet Scanners: Python Discord Code Jam 2025 + +We created a simple sprite-based, space-themed game using PyScript backed by Pyodide and its JavaScript wrapper +capabilities to leverage JavaScript's convenient API for rendering graphics on HTML canvas elements, playing +audio, and accessing user input. We hope this can demo as an alternative to the use of a pygame wrapper such as +pygbag, without the need for its extra layer of abstraction, as the browser-side execution of our game doesn't use anything +but the Python standard library and the PyScript and Pyodide modules for accessing JavaScript functionality. + +### The Game + +The introduction depicts aliens flying around space past the speed of light, about to turn on their super +advanced intergalactic planet scanner. Today, it fails! Luckily, one of the aliens has an old Earth-based +barcode scanner - the "wrong tool for the +job" - that will have to do today. In the solar system overview screen, seen below, the player can select each of the solar system's planets in turn and must complete +a scan for that planet to complete a mission. + +![Planet selection screen](/readme_images/game1.png) + +The gameplay (shown in the image below) is a variation of the classic asteroids game, where +the player must dodge incoming asteroids to avoid ship damage and stay immobile while holding down the Spacebar +to progress scanning. +An animated rotating planet combined with the movement of stars off-screen in the opposite direction creates a +simple but effective perspective of the player ship orbiting a planet. + +![Planet selection screen](/readme_images/game2.png) + +![Planet selection screen](/readme_images/game3.png) + +## Intended Event Frameworks + +We used PyScript backed by the Pyodide interpreter. We were pleasantly surprised by how few PyScript-specific +oddities we ran into. Using the provided JavaScript wrapper capabilities felt almost as natural as writing +JavaScript directly into the Python files, except writing in Python! To serve our single-page game app, we +included an ultra-minimalistic Flask backend for convenience in designing, though with a little refactoring our page could +also be served statically. There is some very minimalistic HTML and CSS to more or less create an HTML canvas to +draw on with the appropriate styling and create the initial setup for importing our in-browser Python scripts. +It was necessary to provide the contents of a [PyScript.json](/static/PyScript.json) file to the config attribute of the tag +of our [index.html](/templates/index.html) to let the PyScript environment allow the proper imports of modules +into one another. + +One of the few things that adds a bit of awkwardness is needing to wrap function references passed to to JS callbacks by +using `create_proxy`, instead of passing a reference to the `game_loop` function directly: +```py +from Pyodide.ffi import create_proxy + +def game_loop(timestamp: float) -> None: + ... + +game_loop_proxy = create_proxy(game_loop) +window.requestAnimationFrame(game_loop_proxy) +``` + +On the other hand, "writing JavaScript" in Python can feel very elegant sometimes. A CanvasRenderingContext2D's +drawing methods for +example often take a lot of arguments to define the coordinates of objects being draw. There's heavy use of +rectangular bounds given as four parameters: left, top, width, height. Defining a Python Rect class implementing +the iterator protocol... + +```py +@dataclass +class Rect: + left: float + top: float + width: float + height: float + + def __iter__(self) -> Iterator[float]: + yield self.left + yield self.top + yield self.width + yield self.height +``` + +...allows for some drawing calls to be very succinct with unpacking: + +```py +# sprite_coords = Rect(0, 0, sprite_width, sprite_height) +# dest_coords = Rect(dest_left, dest_top, dest_width, dest_height) +ctx.fillRect(*dest_coords) +ctx.drawImage(sprite_image, *sprite_coords, *dest_coords) +``` + +## Installation and Usage + +### Prerequisites to Run +- Python 3.12 +- [uv](https://github.com/astral-sh/uv) is recommended for the package manager +- An active internet connection (to fetch the Pyodide interpreter and PyScript modules from the PyScript CDN) +### Installation +1. Clone the repository: + ```bash + git clone https://github.com/fluffy-marmot/codejam2025 + ``` + +2. Install dependencies using uv: + ```bash + uv sync + ``` +### Without uv +The dependencies are listed in [`pyproject.toml`](pyproject.toml). Since the only server-side dependency for running the +project is flask (PyScript is obtained automatically in browser as needed via CDN), the +project can be run after cloning it by simply using +```bash +pip install flask +python app.py +``` +### Running the Game +Running the [app.py](/app.py) file starts the simple flask server to serve the single html page, which should be at +[http://127.0.0.1:5000](http://127.0.0.1:5000) if testing it locally. We also have a version of our game hosted +at [https://caius.pythonanywhere.com/codejam/](https://caius.pythonanywhere.com/codejam/) although this has been +slightly modified from the current repository to run as a single app within an already existing Django project. +None of the files in the `/static/` directory of the hosted version have been modified, therefore in-browser functionality +should be the same. + +## Individual Contributions + +RealisticTurtle: storyboarding, intro scene and story, game scene, star system, scanning mechanics + +Soosh: library research, core functionality (audio and input modules, gameloop, scene system), code integration, debris +system + +Dark Zero: planet selection scene, sprites, spritesheet helper scripts, player mechanics, asteroid +implementation, collision logic, end scene + +Doomy: dynamic textboxes, end scene and credits, Horizons API functionality, +refactoring and maintenance, scanner refinement, experimented with Marimo + +## Game Demonstration + +This video is a quick demonstration of our game and its mechanics by our teammate RealisticTurtle + +View the demo on [Youtube](https://www.youtube.com/watch?v=J8LKGUsTeAo) \ No newline at end of file diff --git a/cool-cacti/app.py b/cool-cacti/app.py new file mode 100644 index 00000000..69774e8e --- /dev/null +++ b/cool-cacti/app.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +from flask import Flask, render_template + +""" +using a flask backend to serve a very simple html file containing a canvas that we draw on using +very various pyscript scripts. We can send the planets_info variable along with the render_template +request so that it will be accessible in the index.html template and afterwards the pyscript scripts +""" +app = Flask(__name__) + +base_dir = Path(__file__).resolve().parent +static_dir = base_dir / "static" +sprite_dir = static_dir / "sprites" +audio_dir = static_dir / "audio" + +# contains various information and game data about planets +with Path.open(base_dir / "horizons_data" / "planets.json", encoding='utf-8') as f: + planets_info = json.load(f) + +# create a list of available sprite files +sprite_list = [sprite_file.stem for sprite_file in sprite_dir.iterdir() if sprite_file.is_file()] + +# create a list of available audio files +audio_list = [audio_file.name for audio_file in audio_dir.iterdir()] + +with Path.open(static_dir / "lore.txt") as f: + lore = f.read() + +with Path.open(static_dir / "credits.txt") as f: + credits = f.read() + +@app.route("/") +def index(): + return render_template( + "index.html", + planets_info=planets_info, + sprite_list=sprite_list, + audio_list=audio_list, + lore=lore, + credits=credits + ) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/cool-cacti/horizons_data/planets.json b/cool-cacti/horizons_data/planets.json new file mode 100644 index 00000000..d0100903 --- /dev/null +++ b/cool-cacti/horizons_data/planets.json @@ -0,0 +1,267 @@ +[ + { + "id": 10, + "name": "Sun", + "sprite": "sun.png" + }, + { + "id": 199, + "name": "Mercury", + "sprite": "mercury.png", + "x": 50464198.3250268, + "y": 9059796.87177754, + "info": "PLANETARY SCAN COMPLETE: Mercury\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. Mean Radius (km) = 2439.4+-0.1 Density (g cm^-3) = 5.427\n Mass x10^23 (kg) = 3.302 Volume (x10^10 km^3) = 6.085 \n Sidereal rot. period = 58.6463 d Sid. rot. rate (rad/s)= 0.00000124001\n Mean solar day = 175.9421 d Core radius (km) = ~1600 \n Geometric Albedo = 0.106 Surface emissivity = 0.77+-0.06\n GM (km^3/s^2) = 22031.86855 Equatorial radius, Re = 2440.53 km\n GM 1-sigma (km^3/s^2) = Mass ratio (Sun/plnt) = 6023682\n Mom. of Inertia = 0.33 Equ. gravity m/s^2 = 3.701 \n Atmos. pressure (bar) = < 5x10^-15 Max. angular diam. = 11.0\" \n Mean Temperature (K) = 440 Visual mag. V(1,0) = -0.42 \n Obliquity to orbit[1] = 2.11' +/- 0.1' Hill's sphere rad. Rp = 94.4 \n Sidereal orb. per. = 0.2408467 y Mean Orbit vel. km/s = 47.362 \n Sidereal orb. per. = 87.969257 d Escape vel. km/s = 4.435\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 14462 6278 9126\n Maximum Planetary IR (W/m^2) 12700 5500 8000\n Minimum Planetary IR (W/m^2) 6 6 6\n*******************************************************************************\n", + "level": [ + "Mercury - Scan Required", + "\n", + "Asteroid counts : *******", + "Asteroid speed : **********", + "Asteroid damage : *******", + "Asteroid durability: ****", + "Scan difficulty : ********************", + "\n", + "Mercury's sparse asteroid field is more forgiving than most, but the", + "Sun's proximity, and the constant bombardment of high-energy radiation", + "particles will gnaw at the ship over time and slow damage it.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 3.6, + "asteroid": { + "count": 7, + "speed": 10, + "damage": 7, + "durability": 4 + } + }, + { + "id": 299, + "name": "Venus", + "sprite": "venus.png", + "x": 62265012.79592998, + "y": 88255225.26065554, + "info": "PLANETARY SCAN COMPLETE: Venus\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. Mean Radius (km) = 6051.84+-0.01 Density (g/cm^3) = 5.204\n Mass x10^23 (kg) = 48.685 Volume (x10^10 km^3) = 92.843\n Sidereal rot. period = 243.018484 d Sid. Rot. Rate (rad/s)= -0.00000029924\n Mean solar day = 116.7490 d Equ. gravity m/s^2 = 8.870\n Mom. of Inertia = 0.33 Core radius (km) = ~3200\n Geometric Albedo = 0.65 Potential Love # k2 = ~0.25\n GM (km^3/s^2) = 324858.592 Equatorial Radius, Re = 6051.893 km\n GM 1-sigma (km^3/s^2) = +-0.006 Mass ratio (Sun/Venus)= 408523.72\n Atmos. pressure (bar) = 90 Max. angular diam. = 60.2\"\n Mean Temperature (K) = 735 Visual mag. V(1,0) = -4.40\n Obliquity to orbit = 177.3 deg Hill's sphere rad.,Rp = 167.1\n Sidereal orb. per., y = 0.61519726 Orbit speed, km/s = 35.021\n Sidereal orb. per., d = 224.70079922 Escape speed, km/s = 10.361\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 2759 2614 2650\n Maximum Planetary IR (W/m^2) 153 153 153\n Minimum Planetary IR (W/m^2) 153 153 153\n*******************************************************************************\n", + "level": [ + "Venus - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ***************", + "Asteroid damage : ************", + "Asteroid durability: ********", + "Scan difficulty : ************", + "\n", + "Piloting this dense asteroid field will keep you too busy to", + "ponder the mysteries of what lies beneath Venus's veil of toxic clouds.", + "Even the difficulties of navigating its orbit are better than the searing", + "heat and crushing atmosphere of its surface.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.6, + "asteroid": { + "count": 12, + "speed": 15, + "damage": 12, + "durability": 8 + } + }, + { + "id": 399, + "name": "Mars", + "sprite": "mars.png", + "x": 121056565.2223383, + "y": -91071517.42399806, + "info": "PLANETARY SCAN COMPLETE: Mars\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. mean radius (km) = 3389.92+-0.04 Density (g/cm^3) = 3.933(5+-4)\n Mass x10^23 (kg) = 6.4171 Flattening, f = 1/169.779\n Volume (x10^10 km^3) = 16.318 Equatorial radius (km)= 3396.19\n Sidereal rot. period = 24.622962 hr Sid. rot. rate, rad/s = 0.0000708822 \n Mean solar day (sol) = 88775.24415 s Polar gravity m/s^2 = 3.758\n Core radius (km) = ~1700 Equ. gravity m/s^2 = 3.71\n Geometric Albedo = 0.150 \n\n GM (km^3/s^2) = 42828.375662 Mass ratio (Sun/Mars) = 3098703.59\n GM 1-sigma (km^3/s^2) = +- 0.00028 Mass of atmosphere, kg= ~ 2.5 x 10^16\n Mean temperature (K) = 210 Atmos. pressure (bar) = 0.0056 \n Obliquity to orbit = 25.19 deg Max. angular diam. = 17.9\"\n Mean sidereal orb per = 1.88081578 y Visual mag. V(1,0) = -1.52\n Mean sidereal orb per = 686.98 d Orbital speed, km/s = 24.13\n Hill's sphere rad. Rp = 319.8 Escape speed, km/s = 5.027\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 717 493 589\n Maximum Planetary IR (W/m^2) 470 315 390\n Minimum Planetary IR (W/m^2) 30 30 30\n*******************************************************************************\n", + "level": [ + "Mars - Scan Required", + "\n", + "Asteroid counts : *****", + "Asteroid speed : ******************", + "Asteroid damage : ********************", + "Asteroid durability: *******************", + "Scan difficulty : ****************", + "\n", + "Though Mars's asteroid belt is deceptively thin, its dense icy rocks", + "strike with brutal force. Here, survival depends not on dodging many,", + "but on avoiding the few that could end you instantly.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 5, + "speed": 20, + "damage": 40, + "durability": 19 + } + }, + { + "id": 499, + "name": "Earth", + "sprite": "earth.png", + "x": -205766295.2832569, + "y": -121439888.7499874, + "info": "PLANETARY SCAN COMPLETE: Earth\n\n*******************************************************************************\n\n GEOPHYSICAL PROPERTIES:\n Vol. Mean Radius (km) = 6371.01+-0.02 Mass x10^24 (kg)= 5.97219+-0.0006\n Equ. radius, km = 6378.137 Mass layers:\n Polar axis, km = 6356.752 Atmos = 5.1 x 10^18 kg\n Flattening = 1/298.257223563 oceans = 1.4 x 10^21 kg\n Density, g/cm^3 = 5.51 crust = 2.6 x 10^22 kg\n J2 (IERS 2010) = 0.00108262545 mantle = 4.043 x 10^24 kg\n g_p, m/s^2 (polar) = 9.8321863685 outer core = 1.835 x 10^24 kg\n g_e, m/s^2 (equatorial) = 9.7803267715 inner core = 9.675 x 10^22 kg\n g_o, m/s^2 = 9.82022 Fluid core rad = 3480 km\n GM, km^3/s^2 = 398600.435436 Inner core rad = 1215 km\n GM 1-sigma, km^3/s^2 = 0.0014 Escape velocity = 11.186 km/s\n Rot. Rate (rad/s) = 0.00007292115 Surface area:\n Mean sidereal day, hr = 23.9344695944 land = 1.48 x 10^8 km\n Mean solar day 2000.0, s = 86400.002 sea = 3.62 x 10^8 km\n Mean solar day 1820.0, s = 86400.0 Love no., k2 = 0.299\n Moment of inertia = 0.3308 Atm. pressure = 1.0 bar\n Mean surface temp (Ts), K= 287.6 Volume, km^3 = 1.08321 x 10^12\n Mean effect. temp (Te), K= 255 Magnetic moment = 0.61 gauss Rp^3\n Geometric albedo = 0.367 Vis. mag. V(1,0)= -3.86\n Solar Constant (W/m^2) = 1367.6 (mean), 1414 (perihelion), 1322 (aphelion)\n HELIOCENTRIC ORBIT CHARACTERISTICS:\n Obliquity to orbit, deg = 23.4392911 Sidereal orb period = 1.0000174 y\n Orbital speed, km/s = 29.79 Sidereal orb period = 365.25636 d\n Mean daily motion, deg/d = 0.9856474 Hill's sphere radius = 234.9 \n*******************************************************************************\n", + "level": [ + "Earth - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : *********", + "Asteroid damage : *****", + "Asteroid durability: *****************", + "Scan difficulty : *********", + "\n", + "In high orbit over Earth, safely above the new asteroid fields, lies", + "Chiaki Spacestation, which will afford you a unique opportunity for a", + "ship repair upon completing this mission.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.3, + "asteroid": { + "count": 12, + "speed": 9, + "damage": 17, + "durability": 3 + } + + }, + { + "id": 599, + "name": "Jupiter", + "sprite": "jupiter.png", + "x": -100070012.8507128, + "y": 765661098.764396, + "info": "PLANETARY SCAN COMPLETE: Jupiter\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x 10^26 (kg) = 18.9819 Density (g/cm^3) = 1.3262 +- .0003\n Equat. radius (1 bar) = 71492+-4 km Polar radius (km) = 66854+-10\n Vol. Mean Radius (km) = 69911+-6 Flattening = 0.06487\n Geometric Albedo = 0.52 Rocky core mass (Mc/M)= 0.0261\n Sid. rot. period (III)= 9h 55m 29.711 s Sid. rot. rate (rad/s)= 0.00017585\n Mean solar day, hrs = ~9.9259 \n GM (km^3/s^2) = 126686531.900 GM 1-sigma (km^3/s^2) = +- 1.2732\n Equ. grav, ge (m/s^2) = 24.79 Pol. grav, gp (m/s^2) = 28.34\n Vis. magnitude V(1,0) = -9.40\n Vis. mag. (opposition)= -2.70 Obliquity to orbit = 3.13 deg\n Sidereal orbit period = 11.861982204 y Sidereal orbit period = 4332.589 d\n Mean daily motion = 0.0831294 deg/d Mean orbit speed, km/s= 13.0697\n Atmos. temp. (1 bar) = 165+-5 K Escape speed, km/s = 59.5 \n A_roche(ice)/Rp = 2.76 Hill's sphere rad. Rp = 740\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 56 46 51\n Maximum Planetary IR (W/m^2) 13.7 13.4 13.6\n Minimum Planetary IR (W/m^2) 13.7 13.4 13.6\n*******************************************************************************\n", + "level": [ + "Jupiter - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ***************", + "Asteroid damage : **********", + "Asteroid durability: **********", + "Scan difficulty : ************", + "\n", + "Besides Jupiter's dangerous asteroid fields, the solar system's", + "largest planet has a strong gravitational field that you will", + "constantly need to fight against lest it claims you for its endless", + "storms.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.8, + "asteroid": { + "count": 12, + "speed": 15, + "damage": 10, + "durability": 10 + } + }, + { + "id": 699, + "name": "Saturn", + "sprite": "saturn.png", + "x": 1427205111.636179, + "y": -76337597.30005142, + "info": "PLANETARY SCAN COMPLETE: Saturn\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x10^26 (kg) = 5.6834 Density (g/cm^3) = 0.687+-.001\n Equat. radius (1 bar) = 60268+-4 km Polar radius (km) = 54364+-10\n Vol. Mean Radius (km) = 58232+-6 Flattening = 0.09796\n Geometric Albedo = 0.47 Rocky core mass (Mc/M) = 0.1027\n Sid. rot. period (III)= 10h 39m 22.4s Sid. rot. rate (rad/s) = 0.000163785 \n Mean solar day, hrs =~10.656 \n GM (km^3/s^2) = 37931206.234 GM 1-sigma (km^3/s^2) = +- 98\n Equ. grav, ge (m/s^2) = 10.44 Pol. grav, gp (m/s^2) = 12.14+-0.01\n Vis. magnitude V(1,0) = -8.88 \n Vis. mag. (opposition)= +0.67 Obliquity to orbit = 26.73 deg\n Sidereal orbit period = 29.447498 yr Sidereal orbit period = 10755.698 d\n Mean daily motion = 0.0334979 deg/d Mean orbit velocity = 9.68 km/s\n Atmos. temp. (1 bar) = 134+-4 K Escape speed, km/s = 35.5 \n Aroche(ice)/Rp = 2.71 Hill's sphere rad. Rp = 1100\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 16.8 13.6 15.1\n Maximum Planetary IR (W/m^2) 4.7 4.5 4.6\n Minimum Planetary IR (W/m^2) 4.7 4.5 4.6\n*******************************************************************************\n", + "level": [ + "Saturn - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ****************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ****************", + "\n", + "When Sol's system passed through a vast galactic asteroid field,", + "sometime in the early 23rd century, each planet captured its own", + "share of asteroids into its orbit. The collisions between Saturn's", + "newly captured asteroids and its already existing rings have created", + "the perfect rock and ice maelstrom to test even the most daring pilots.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 12, + "speed": 16, + "damage": 18, + "durability": 17 + } + }, + { + "id": 799, + "name": "Uranus", + "sprite": "uranus.png", + "x": 1548241220.947309, + "y": 2474904952.161897, + "info": "PLANETARY SCAN COMPLETE: Uranus\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x10^24 (kg) = 86.813 Density (g/cm^3) = 1.271\n Equat. radius (1 bar) = 25559+-4 km Polar radius (km) = 24973+-20\n Vol. Mean Radius (km) = 25362+-12 Flattening = 0.02293\n Geometric Albedo = 0.51\n Sid. rot. period (III)= 17.24+-0.01 h Sid. rot. rate (rad/s) = -0.000101237\n Mean solar day, h =~17.24 Rocky core mass (Mc/M) = 0.0012 \n GM (km^3/s^2) = 5793950.6103 GM 1-sigma (km^3/s^2) = +-4.3 \n Equ. grav, ge (m/s^2) = 8.87 Pol. grav, gp (m/s^2) = 9.19+-0.02\n Visual magnitude V(1,0)= -7.11\n Vis. mag. (opposition)= +5.52 Obliquity to orbit = 97.77 deg\n Sidereal orbit period = 84.0120465 y Sidereal orbit period = 30685.4 d\n Mean daily motion = 0.01176904 dg/d Mean orbit velocity = 6.8 km/s\n Atmos. temp. (1 bar) = 76+-2 K Escape speed, km/s = 21.3 \n Aroche(ice)/Rp = 2.20 Hill's sphere rad., Rp = 2700\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 4.09 3.39 3.71\n Maximum Planetary IR (W/m^2) 0.72 0.55 0.63\n Minimum Planetary IR (W/m^2) 0.72 0.55 0.63\n*******************************************************************************\n", + "level": [ + "Uranus - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ****************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ****************", + "\n", + "Viewed from far above, the tranquil appearance of Uranus masks a", + "world of ever-present swirling storms and harsh winds laden with", + "icy particles. ", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 12, + "speed": 16, + "damage": 18, + "durability": 17 + } + }, + { + "id": 899, + "name": "Neptune", + "sprite": "neptune.png", + "x": 4470024777.676497, + "y": 12434785.46605173, + "info": "PLANETARY SCAN COMPLETE: Neptune\n\n*******************************************************************************\n\n PHYSICAL DATA (update 2021-May-03):\n Mass x10^24 (kg) = 102.409 Density (g/cm^3) = 1.638\n Equat. radius (1 bar) = 24766+-15 km Volume, 10^10 km^3 = 6254 \n Vol. mean radius (km) = 24624+-21 Polar radius (km) = 24342+-30\n Geometric Albedo = 0.41 Flattening = 0.0171\n Sid. rot. period (III)= 16.11+-0.01 hr Sid. rot. rate (rad/s) = 0.000108338 \n Mean solar day, h =~16.11 h \n GM (km^3/s^2) = 6835099.97 GM 1-sigma (km^3/s^2) = +-10 \n Equ. grav, ge (m/s^2) = 11.15 Pol. grav, gp (m/s^2) = 11.41+-0.03\n Visual magnitude V(1,0)= -6.87\n Vis. mag. (opposition)= +7.84 Obliquity to orbit = 28.32 deg\n Sidereal orbit period = 164.788501027 y Sidereal orbit period = 60189 d\n Mean daily motion = 0.006020076dg/d Mean orbit velocity = 5.43 km/s \n Atmos. temp. (1 bar) = 72+-2 K Escape speed (1 bar) = 23.5 km/s \n Aroche(ice)/Rp = 2.98 Hill's sphere rad., Rp = 4700\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 1.54 1.49 1.51\n Maximum Planetary IR (W/m^2) 0.52 0.52 0.52\n Minimum Planetary IR (W/m^2) 0.52 0.52 0.52\n*******************************************************************************\n", + "level": [ + "Neptune - Scan Required", + "\n", + "Asteroid counts : **************", + "Asteroid speed : ************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ************", + "\n", + "Neptune reigns at the edge of humanity's realm of reasonable exploration;", + "You may be too busy dodging asteroids to fully its deep, arresting blue.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.2, + "asteroid": { + "count": 14, + "speed": 12, + "damage": 18, + "durability": 17 + } + } +] diff --git a/cool-cacti/pyproject.toml b/cool-cacti/pyproject.toml new file mode 100644 index 00000000..b220588f --- /dev/null +++ b/cool-cacti/pyproject.toml @@ -0,0 +1,87 @@ +[project] +# This section contains metadata about your project. +# Don't forget to change the name, description, and authors to match your project! +name = "code-jam-soon-to-be-awesome-project" +description = "no idea yet :)" +authors = [ + { name ="https://github.com/fluffy-marmot", email="10621013+fluffy-marmot@users.noreply.github.com"}, + { name ="https://github.com/Prorammer-4090", email="email@mail.com"}, + { name ="https://github.com/TheRatLord", email="email@mail.com"}, + { name ="https://github.com/spirledaxis", email="email@mail.com"}, +] +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "flask>=3.1.1", +] + +[dependency-groups] +# This `dev` group contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. +dev = [ + "numpy>=2.3.2", + "pillow>=11.3.0", + "pre-commit~=4.2.0", + "ruff~=0.12.2", +] + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# Show applied fixes. +show-fixes = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +fixable = ["I", "F401"] # I = Sorts imports, F401 = Deletes unused imports +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Trailing comma rule, not compatible with formatter + "COM812", + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Security + "S311" +] + +[tool.ruff.lint.isort] +combine-as-imports = true +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] diff --git a/cool-cacti/readme_images/game1.png b/cool-cacti/readme_images/game1.png new file mode 100644 index 00000000..121864c3 Binary files /dev/null and b/cool-cacti/readme_images/game1.png differ diff --git a/cool-cacti/readme_images/game2.png b/cool-cacti/readme_images/game2.png new file mode 100755 index 00000000..706a7833 Binary files /dev/null and b/cool-cacti/readme_images/game2.png differ diff --git a/cool-cacti/readme_images/game3.png b/cool-cacti/readme_images/game3.png new file mode 100755 index 00000000..41a85216 Binary files /dev/null and b/cool-cacti/readme_images/game3.png differ diff --git a/cool-cacti/static/audio/bang1.ogg b/cool-cacti/static/audio/bang1.ogg new file mode 100644 index 00000000..3dfa1339 Binary files /dev/null and b/cool-cacti/static/audio/bang1.ogg differ diff --git a/cool-cacti/static/audio/bang2.ogg b/cool-cacti/static/audio/bang2.ogg new file mode 100644 index 00000000..bfeb9617 Binary files /dev/null and b/cool-cacti/static/audio/bang2.ogg differ diff --git a/cool-cacti/static/audio/bang3.ogg b/cool-cacti/static/audio/bang3.ogg new file mode 100644 index 00000000..5bd826b6 Binary files /dev/null and b/cool-cacti/static/audio/bang3.ogg differ diff --git a/cool-cacti/static/audio/death.ogg b/cool-cacti/static/audio/death.ogg new file mode 100644 index 00000000..3510fc12 Binary files /dev/null and b/cool-cacti/static/audio/death.ogg differ diff --git a/cool-cacti/static/audio/explosion.ogg b/cool-cacti/static/audio/explosion.ogg new file mode 100644 index 00000000..f7f9552a Binary files /dev/null and b/cool-cacti/static/audio/explosion.ogg differ diff --git a/cool-cacti/static/audio/music_main.ogg b/cool-cacti/static/audio/music_main.ogg new file mode 100644 index 00000000..d62013a1 Binary files /dev/null and b/cool-cacti/static/audio/music_main.ogg differ diff --git a/cool-cacti/static/audio/music_thematic.ogg b/cool-cacti/static/audio/music_thematic.ogg new file mode 100644 index 00000000..84b8f34a Binary files /dev/null and b/cool-cacti/static/audio/music_thematic.ogg differ diff --git a/cool-cacti/static/audio/scan.ogg b/cool-cacti/static/audio/scan.ogg new file mode 100644 index 00000000..9e3c53dd Binary files /dev/null and b/cool-cacti/static/audio/scan.ogg differ diff --git a/cool-cacti/static/audio/text.ogg b/cool-cacti/static/audio/text.ogg new file mode 100644 index 00000000..4390feb8 Binary files /dev/null and b/cool-cacti/static/audio/text.ogg differ diff --git a/cool-cacti/static/credits.txt b/cool-cacti/static/credits.txt new file mode 100644 index 00000000..ea729290 --- /dev/null +++ b/cool-cacti/static/credits.txt @@ -0,0 +1,49 @@ +MISSION COMPLETE! + +A Space Exploration Adventure +Made for Python Discord's 2025 CodeJam +"Wrong Tool for the Job" + +Developer Team (Cool Cacti) + +Dark_Zero +Doomy +RealisticTurtle +Soosh + +Special Thanks (Music by) +Elemeno Peter + + +Thank you for playing! + + + + +Great job on scanning a grand total 8e0 planets! + + + + +Are you still here? + + + + + + + + +The credits are already over. + + + +...can we move on? + + + + + + + +42 \ No newline at end of file diff --git a/cool-cacti/static/favicon.ico b/cool-cacti/static/favicon.ico new file mode 100644 index 00000000..c8aa787c Binary files /dev/null and b/cool-cacti/static/favicon.ico differ diff --git a/cool-cacti/static/lore.txt b/cool-cacti/static/lore.txt new file mode 100644 index 00000000..dc42c987 --- /dev/null +++ b/cool-cacti/static/lore.txt @@ -0,0 +1,24 @@ +Why does boss want so much info on random planets...? +Yeah man...this mission seems pointless. We already scanned 2.3e+15 planets yesterday, including rogue ones...what could he possibly do with all that data? +Well, at least we can scan efficiently. Remember yesterday? We were scanning 8e+10 planets per second! +That's true. I heard some civilizations actually need to be in orbit to get a planet's data! +Seriously?? I remember a few years ago we only had a range of a light-year, even that was terrible... +I can't imagine such primitive technology! +Yeah, if you had to orbit each planet, you couldn't travel 200x light speed! +I bet their technology is so bad that they have to stay in orbit for a minute or so just to scan one planet. +Really now? Then they could only get hundreds of planets a day then. +Boss won't even consider a number without scientific notation nowadays... +Yeah, at least we're advanced...anyway, let's get the day started. Turn on the planet scanner! +*Flips switch* Hey! The terminal is reading SyntaxError: invalid syntax on line 42: print(hello world). +??? What do you mean, shouldn't the console print 'hello world'? +No, dingus. There aren't quotes. No wonder you failed programming 101... +Since you're soooo smart, why don't you fix the error? +We don't have access to the source code of this ship... +What does that mean...? +I can't fix the error, we can't scan planets. +ARE YOU SERIOUS!!! We are 12 TRILLION universes away from home, and NOW you tell me that? +Not my fault, man. We'll be fine though. I just remembered that I have this wireless barcode scanner from work that I accidentally took from my shift. It only has a range of several hundred km though, so we'll have to go orbit planets for a minute or so to scan them. +Uhm, excuse me? That sounds just like that primitive technology! I am NOT having it! +Boss will kill us if we return home with no planet data... +But..a barcode scanner? To scan a planet? That's INSANE! +We'll have to make do with......THE WRONG TOOL FOR THE JOB. \ No newline at end of file diff --git a/cool-cacti/static/pyscript.json b/cool-cacti/static/pyscript.json new file mode 100644 index 00000000..611bdf05 --- /dev/null +++ b/cool-cacti/static/pyscript.json @@ -0,0 +1,20 @@ +{ + "files": { + "/static/scripts/asteroid.py": "", + "/static/scripts/audio.py": "", + "/static/scripts/common.py": "", + "/static/scripts/consolelogger.py": "", + "/static/scripts/controls.py": "", + "/static/scripts/debris.py": "", + "/static/scripts/game.py": "", + "/static/scripts/overlay.py": "", + "/static/scripts/player.py": "", + "/static/scripts/scene_classes.py": "", + "/static/scripts/scene_descriptions.py": "", + "/static/scripts/solar_system.py": "", + "/static/scripts/spacemass.py": "", + "/static/scripts/sprites.py": "", + "/static/scripts/stars.py": "", + "/static/scripts/window.py": "" + } +} \ No newline at end of file diff --git a/cool-cacti/static/scripts/asteroid.py b/cool-cacti/static/scripts/asteroid.py new file mode 100644 index 00000000..ec688e97 --- /dev/null +++ b/cool-cacti/static/scripts/asteroid.py @@ -0,0 +1,274 @@ +import math +import random + +from js import document # type: ignore[attr-defined] +from common import Position, PlanetData +from scene_classes import SceneObject +from window import window, SpriteSheet +from consolelogger import getLogger + +log = getLogger(__name__) + +# Canvas dimensions +canvas = document.getElementById("gameCanvas") +container = document.getElementById("canvasContainer") +SCREEN_W, SCREEN_H = container.clientWidth, container.clientHeight + +ASTEROID_SHEET = window.sprites["asteroids"] + +# "magic numbers" obtained via a script in assets/make_spritesheets.py, end of the printout +# Updated to include recycle sprite collision radii (positions 104-119) +ASTEROID_RADII = [22, 26, 18, 19, 21, 25, 18, 23, 26, 20, 24, 13, 22, 18, 21, 23, 30, 19, 18, 18, 18, 21, 26, + 20, 21, 16, 24, 22, 18, 25, 18, 20, 19, 21, 22, 18, 24, 20, 23, 20, 22, 20, 24, 17, 16, 16, + 18, 21, 17, 22, 24, 25, 14, 24, 25, 14, 22, 23, 21, 18, 20, 18, 18, 19, 24, 23, 23, 27, 19, + 24, 25, 20, 23, 21, 25, 22, 19, 25, 21, 16, 30, 26, 24, 30, 23, 21, 20, 18, 25, 16, 24, 21, + 23, 18, 21, 24, 20, 23, 29, 20, 24, 22, 22, 19, 21, 37, 31, 43, 31, 32, 23, 24, 22, 20, 24, 21, 25, 33, 23, 21] # noqa + + +class Asteroid(SceneObject): + def __init__( + self, sheet: SpriteSheet, + x: float, y: float, + vx: float, vy: float, + target_size_px: float, + sprite_index: int, + grid_cols: int = 11, + cell_size: float = 0, + grow_rate=6.0, + health: int = 450, + damage_mul: float= 1.0 + ): + super().__init__() + super().set_position(x, y) + self.sheet = sheet + self.velocity_x = vx + self.velocity_y = vy + self.rotation = 0.0 + self.rotation_speed = random.uniform(-0.5, 0.5) + self.target_size = target_size_px + self.size = 5.0 + self.grow_rate = target_size_px / random.uniform(grow_rate - 1.8, grow_rate + 2.5) + self.sprite_index = sprite_index + self.grid_cols = grid_cols + self.cell_size = cell_size + self.hitbox_scale = 0.45 + self.hitbox_radius = ASTEROID_RADII[sprite_index] + self._last_timestamp = None + self.linger_time = 0.5 + self.full_size_reached_at = None + self.health = random.uniform(health * 0.8, health * 1.2) + self.damage_mul = random.uniform(damage_mul * 0.9, damage_mul * 1.1) + + def _ensure_cell_size(self): + if not self.cell_size: + if self.sheet.width: + self.cell_size = max(1, int(self.sheet.width // self.grid_cols)) + + def _src_rect(self): + self._ensure_cell_size() + col = self.sprite_index % self.grid_cols + row = self.sprite_index // self.grid_cols + x = col * self.cell_size + y = row * self.cell_size + return x, y, self.cell_size, self.cell_size + + def update(self, timestamp: float): + if self._last_timestamp is None: + self._last_timestamp = timestamp + return + dt = (timestamp - self._last_timestamp) / 1000.0 # seconds + self._last_timestamp = timestamp + + # Movement + self.x += self.velocity_x * dt + self.y += self.velocity_y * dt + self.rotation += self.rotation_speed * dt + + # Growth towards target size + if self.size < self.target_size: + self.size = self.size + self.grow_rate * dt + if self.size >= self.target_size: + self.full_size_reached_at = timestamp + + def render(self, ctx, timestamp_ms: float): + self.update(timestamp_ms) + self._ensure_cell_size() + if not self.cell_size: + return + + x, y, w, h = self._src_rect() + size = self.size + + ctx.save() + ctx.translate(self.x, self.y) + ctx.rotate(self.rotation) + + # Draw centered + ctx.drawImage(self.sheet.image, x, y, w, h, -size / 2, -size / 2, size, size) + + # Debug hit circle + if getattr(window, "DEBUG_DRAW_HITBOXES", False): + ctx.beginPath() + ctx.strokeStyle = "#FF5555" + ctx.lineWidth = 2 + ctx.arc(0, 0, size * self.hitbox_radius / 100 * 1, 0, 2 * math.pi) + ctx.stroke() + ctx.restore() + + def is_off_screen(self, w=SCREEN_W, h=SCREEN_H, margin=50) -> bool: + return self.x < -margin or self.x > w + margin or self.y < -margin or self.y > h + margin + + def get_hit_circle(self): + return (self.x, self.y, self.size * self.hitbox_radius / 100 * 1) + + def should_be_removed(self): + """Check if asteroid should be removed (off screen or lingered too long)""" + if self.is_off_screen(): + return True + if self.full_size_reached_at and (self._last_timestamp - self.full_size_reached_at) > ( + self.linger_time * 1000 + ): + return True + if self.health <= 0: + window.debris.generate_debris(window.player.get_position(), self.get_position(), 4) + window.debris.generate_debris(window.player.get_position(), self.get_position(), 3.75) + return True + return False + +# updated spawn_on_player, it looked goofy near planets with high chance +class AsteroidAttack: + def __init__(self, spritesheet, width: int, height: int, max_size_px: float, spawnrate: int = 500, spawn_at_player_chance: int = 50): + self.sheet = spritesheet + self.w = width + self.h = height + self.max_size = max_size_px or 256 + self.spawnrate = spawnrate + self.asteroids: list[Asteroid] = [] + self._last_spawn = 0.0 + self._max_asteroids = 50 # default max asteroids that can appear on the screen + self.cell_size = 0 + self._use_grow_rate = 6.0 # default growth rate (how fast they appear to approach the player) + self._use_health = 450 # default durability (affects asteroids being destroyed by impacts w/ player) + self._use_damage_mul = 1.0 + self.spawn_at_player_chance = spawn_at_player_chance + def _spawn_one(self): + # Don't spawn if at the limit + if len(self.asteroids) >= self._max_asteroids: + return + + # Planet area (left side) + planet_width = self.w * 0.3 + space_start_x = planet_width + 50 + if random.randint(1, self.spawn_at_player_chance) == 1: + x = window.player.x + y = window.player.y + else: + x = random.uniform(space_start_x, self.w) + y = random.uniform(0, self.h) + + if x < (SCREEN_W / 2): + velocity_x = random.uniform(-15, -5) + if y < (SCREEN_H / 2): + velocity_y = random.uniform(-15, -5) + else: + velocity_y = random.uniform(5, 15) + else: + velocity_x = random.uniform(5, 15) + if y < (SCREEN_H / 2): + velocity_y = random.uniform(-15, -5) + else: + velocity_y = random.uniform(5, 15) + + # Use recycle sprites (104-119) for Earth, regular asteroids (0-103) for other planets + if hasattr(self, '_current_planet_name') and self._current_planet_name.lower() == 'earth': + idx = random.randint(104, 119) # Recycle sprites + # Scale recycle items smaller since they're items, not large asteroids + target = random.uniform(self.max_size * 0.25, self.max_size * 0.45) + # log.debug("Spawning recycle sprite %d for Earth with smaller target size %f", idx, target) + else: + idx = random.randint(0, 103) # Regular asteroid sprites + target = random.uniform(self.max_size * 0.7, self.max_size * 1.3) + # if hasattr(self, '_current_planet_name'): + # log.debug("Spawning asteroid sprite %d for %s", idx, self._current_planet_name) + + a = Asteroid( + self.sheet, x, y, velocity_x, velocity_y, target, idx, + grow_rate=self._use_grow_rate, + health=self._use_health, + damage_mul=self._use_damage_mul + ) + self.asteroids.append(a) + + # Spawn at interval and only if under limit + def spawn_and_update(self, timestamp: float): + # adjust spawnrate by a random factor so asteroids don't spawn at fixed intervals + spawnrate = self.spawnrate * random.uniform(0.2, 1.0) + + # Increase spawn rate for smaller recycle items on Earth + if hasattr(self, '_current_planet_name') and self._current_planet_name.lower() == 'earth': + spawnrate *= 0.1 # 10x faster spawn rate for Earth recycle items (1/10 = 0.1) + + # slow down spawnrate for this attempt a bit if there already many asteroids active + spawnrate = spawnrate * max(1, 1 + (len(self.asteroids) - 35) * 0.1) + if self._last_spawn == 0.0 or (timestamp - self._last_spawn) >= spawnrate: + if len(self.asteroids) < self._max_asteroids: + self._last_spawn = timestamp + self._spawn_one() + + # Remove asteroids + before_count = len(self.asteroids) + self.asteroids = [a for a in self.asteroids if not a.should_be_removed()] + after_count = len(self.asteroids) + + # If we removed asteroids, we can spawn new ones + if after_count < before_count: + self._last_spawn = timestamp - (self.spawnrate * 0.7) + + def update_and_render(self, ctx, timestamp: float): + self.spawn_and_update(timestamp) + for a in self.asteroids: + a.render(ctx, timestamp) + + def reset(self, planet_data: PlanetData): + """ reset the asteroid management system with the given difficulty parameters """ + + # Store the planet name for sprite selection + self._current_planet_name = planet_data.name + + # the asteroid difficulty settings are on a 1-20 scale of ints + asteroid_settings = planet_data.asteroid + + spawnrate = 500 + # clamp max between 10-80, default is 50 at difficulty 10 + max_asteroids = min(max(10, 5 * asteroid_settings.count), 80) + + # this determines how quickly asteroids seem to be approaching player (sprite growing in size) + # NOTE: the relationship is inverse, smaller growth rate = faster approaching asteroids + # a value of 6.0 feels like a pretty good rough default, not too slow + use_grow_rate = max(1.2, 10.5 - (asteroid_settings.speed - 5) * 0.5) + # how easily asteroids fall apart from collisions, default 450 health at level 10 + use_health = 50 + 40 * asteroid_settings.durability + # range of 0.3 to 2.2 multiplier + use_damage_mul = 0.2 + 0.1 * asteroid_settings.damage + + log.debug("Resetting asteroids with difficulty parameters for planet %s:", planet_data.name) + log.debug("Max asteroids: %s (%s), default 50", max_asteroids, asteroid_settings.count) + log.debug("Grow rate(approach speed): %s (%s), default 6.0", use_grow_rate, asteroid_settings.speed) + log.debug("Asteroid durability: %s (%s), default 450", use_health, asteroid_settings.durability) + log.debug("Damage multiplier: %s (%s), default 1.0", use_damage_mul, asteroid_settings.damage) + + # Special difficulty adjustments for Earth recycle items + if planet_data.name.lower() == 'earth': + max_asteroids = min(max_asteroids * 2, 120) # Allow up to 2x more recycle items + use_grow_rate *= 0.6 # Make them approach 40% faster + use_health *= 1.5 # Make them 50% more durable + use_damage_mul *= 0.7 # Decrease damage by 30% + + self._max_asteroids = max_asteroids + self._use_grow_rate = use_grow_rate + self._use_health = use_health + self._use_damage_mul = use_damage_mul + + self.asteroids.clear() + self._last_spawn = 0.0 + self.cell_size = 0 diff --git a/cool-cacti/static/scripts/audio.py b/cool-cacti/static/scripts/audio.py new file mode 100644 index 00000000..59f42b53 --- /dev/null +++ b/cool-cacti/static/scripts/audio.py @@ -0,0 +1,84 @@ +import random +from typing import Union +from functools import partial + +from js import Audio # type: ignore[attr-defined] + + +class AudioHandler: + def __init__(self, static_url: str) -> None: + self.static_url = static_url + self.volume: int = 1.0 + + self.text_sound = self.load_audio("text.ogg") + self.scan_sound = self.load_audio("scan.ogg") + self.explosion_sound = self.load_audio("explosion.ogg") + + self.music_main = self.load_audio("music_main.ogg") + self.music_thematic = self.load_audio("music_thematic.ogg") + self.music_death = self.load_audio("death.ogg") + + self.active_music = None + + def set_volume(self, volume: float) -> None: + """ set volume to somewhere between 0.0 and 1.0 if a valid value is given """ + if 0.0 <= volume <= 1.0: + self.volume = volume + + def load_audio(self, audio_name: str) -> Audio: + return Audio.new(f"{self.static_url}audio/{audio_name}") + + def play_sound(self, audio_name: Union[str, "Audio"], volume=1.0) -> None: + """ + play a sound file + audio_name: name of sound file, without any path included, as it appears in static/audio/ + volume: adjust this for loud sound files, 0.0 to 1.0, where 1.0 is full volume + """ + # sometimes we want to load new instances of Audio objcts, other times we want a persistent one + if isinstance(audio_name, str): + sound = self.load_audio(audio_name) + else: + sound = audio_name + sound.volume = volume * self.volume + sound.play() + + def play_bang(self) -> None: + # these bangs are kind of loud, playing it at reduced volume + self.play_sound(random.choice(["bang1.ogg", "bang2.ogg", "bang3.ogg"]), volume=0.4) + + def play_unique_sound(self, audio: Audio, pause_it=False, volume=1.0) -> None: + if not pause_it and audio.paused: + self.play_sound(audio, volume=volume) + elif pause_it: + audio.pause() + audio.currentTime = 0 + + def play_text(self, pause_it=False, volume=0.8) -> None: + self.play_unique_sound(self.text_sound, pause_it, volume=volume) + + def play_scan(self, pause_it=False, volume=0.4) -> None: + self.play_unique_sound(self.scan_sound, pause_it, volume=volume) + + def play_explosion(self, pause_it=False, volume=0.6) -> None: + self.play_unique_sound(self.explosion_sound, pause_it, volume=volume) + + def _play_music(self, music_audio, pause_it=False, volume=1.0) -> None: + if pause_it: + music_audio.pause() + music_audio.currentTime = 0 + self.active_music = None + return + # if another music file is playing, don't play this one + if self.active_music and not self.active_music.paused: + return + self.active_music = music_audio + self.play_unique_sound(music_audio, volume=volume) + + def play_music_main(self, pause_it=False, volume=0.65) -> None: + self._play_music(self.music_main, pause_it=pause_it, volume=volume) + + def play_music_death(self, pause_it=False, volume=1.0) -> None: + self._play_music(self.music_death, pause_it=pause_it, volume=volume) + + def play_music_thematic(self, pause_it=False, volume=1.0) -> None: + self._play_music(self.music_thematic, pause_it=pause_it, volume=volume) \ No newline at end of file diff --git a/cool-cacti/static/scripts/common.py b/cool-cacti/static/scripts/common.py new file mode 100644 index 00000000..e96be15f --- /dev/null +++ b/cool-cacti/static/scripts/common.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterator + +HTMLImageElement = Any +CanvasRenderingContext2D = Any + +@dataclass +class AsteroidData: + """Dataclass for asteroid data from asteroids.json. Difficulty stuff is on a 1-20 scale""" + + count: int + speed: int + damage: int + durability: int + +@dataclass +class PlanetData: + """Dataclass for planet data from planets.json""" + + id: int + name: str + sprite: str + x: float = 0.0 + y: float = 0.0 + info: str = "" + level: list[str] = field(default_factory=list) + scan_multiplier: float = 1.0 + asteroid: AsteroidData | None = None + spritesheet: SpriteSheet | None = None # JS Image object added in HTML + + @classmethod + def from_dict(cls, data: dict) -> 'PlanetData': + """Create PlanetData from dictionary, handling nested asteroid data.""" + data = data.copy() + # Handle nested asteroid data + asteroid_data = None + if 'asteroid' in data: + asteroid_dict = data.pop('asteroid') # Remove from data to avoid duplicate in **data + asteroid_data = AsteroidData(**asteroid_dict) + + # Create instance with unpacked dictionary + return cls(asteroid=asteroid_data, **data) + +@dataclass +class Rect: + left: float + top: float + width: float + height: float + + def __iter__(self) -> Iterator[float]: + yield self.left + yield self.top + yield self.width + yield self.height + + def contains(self, point: Position) -> bool: + return self.left <= point.x <= self.right and self.top <= point.y <= self.bottom + + @property + def right(self) -> float: + return self.left + self.width + + @right.setter + def right(self, value: float) -> None: + self.left = value - self.width + + @property + def bottom(self) -> float: + return self.top + self.height + + @bottom.setter + def bottom(self, value: float) -> None: + self.top = value - self.height + + +@dataclass +class Position: + x: float + y: float + + def __iter__(self) -> Iterator[float]: + yield self.x + yield self.y + + def __add__(self, other_pos: Position) -> Position: + return Position(self.x + other_pos.x, self.y + other_pos.y) + + def midpoint(self, other_pos: Position) -> Position: + return Position((self.x + other_pos.x) / 2, (self.y + other_pos.y) / 2) + + def distance(self, other_pos: Position) -> float: + return ((self.x - other_pos.x) ** 2 + (self.y - other_pos.y) ** 2) ** 0.5 + + +@dataclass +class PlanetState: + """State for planet""" + + mass: float + radius: float + initial_velocity: float = 0.0 + x: float = 0 + y: float = 0 + angle: float = 0.0 + velocity_x: float = 0.0 + velocity_y: float = 0.0 + + +class SpriteSheet: + """Wrapper for individual sprites with enhanced functionality.""" + + def __init__(self, key: str, image: "HTMLImageElement"): + self.key = key.lower() + self.image = image + + @property + def height(self): + """Height of the sprite image.""" + return self.image.height + + @property + def width(self): + """Width of the sprite image.""" + return self.image.width + + @property + def frame_size(self): + """Size of each frame (assuming square frames).""" + return self.height + + @property + def is_loaded(self): + return self.height > 0 and self.width > 0 + + @property + def num_frames(self): + """Number of frames in the spritesheet.""" + if not self.is_loaded: + return 1 + return self.width // self.frame_size + + def get_frame_position(self, frame: int) -> Position: + """Get the position of a specific frame in the spritesheet with overflow handling.""" + if self.num_frames == 0: + return Position(0, 0) + frame_index = frame % self.num_frames + x = frame_index * self.frame_size + return Position(x, 0) + + # Delegate other attributes to the underlying image + def __getattr__(self, name): + return getattr(self.image, name) diff --git a/cool-cacti/static/scripts/consolelogger.py b/cool-cacti/static/scripts/consolelogger.py new file mode 100644 index 00000000..ab1b183b --- /dev/null +++ b/cool-cacti/static/scripts/consolelogger.py @@ -0,0 +1,47 @@ +import logging + +from js import console # type: ignore[attr-defined] + + +class ConsoleHandler(logging.Handler): + def emit(self, record): + try: + msg = self.format(record) + + if record.levelno >= logging.ERROR: + console.error(msg) + elif record.levelno >= logging.WARNING: + console.warn(msg) + elif record.levelno >= logging.INFO: + console.info(msg) + else: + console.debug(msg) + except Exception: + self.handleError(record) + + +# why the heck does python's standard lib use camelCase? :( I'm just mimicking logging.getLogger ... +def getLogger(name, show_time: bool = False) -> logging.Logger: + """ + to get a logger in another file that outputs only to the browser javascript console and doesn't insert + its output into the webpage, simply use: + + from consolelogger import getLogger + log = getLogger(__name__) + log.debug. ("This is a log message") # etc. + """ + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + + # set up the logger so it only outputs to the browser's javascript console. Spiffy + handler = ConsoleHandler() + if not show_time: + handler.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s")) + else: + formatter = logging.Formatter("[%(levelname)s %(asctime)s] %(name)s: %(message)s", datefmt="%H:%M:%S") + handler.setFormatter(formatter) + + logger.handlers.clear() + logger.addHandler(handler) + + return logger diff --git a/cool-cacti/static/scripts/controls.py b/cool-cacti/static/scripts/controls.py new file mode 100644 index 00000000..5bbde962 --- /dev/null +++ b/cool-cacti/static/scripts/controls.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from time import time + +from common import Position +from consolelogger import getLogger +from pyodide.ffi import create_proxy # type: ignore[attr-defined] + +log = getLogger(__name__) + + +@dataclass +class MousePositions: + mousedown: Position + mouseup: Position + click: Position + move: Position + + +class GameControls: + """ + in game.py using + controls = GameControls(canvas) + controls object gives access to what keys are being currently pressed, accessible properties: + - controls.pressed is a set of strings representing keys and mouse buttons currently held down + the strings for mouse buttons are given by GameControls.MOUSE_LEFT, etc. + - controls.mouse gives access to all the coordinates of the last registered mouse event of each kind as the + tuples controls.mouse.mousedown, controls.mouse.mouseup, controls.mouse.click, controls.mouse.move + - use controls.mouse.move for best current coordinates of the mouse + - additionally, controls.click is a boolean representing if a click just occurred. It is set to False at the + end of each game loop if nothing makes use of the click event + - use enable_logging=False if spam of mouse/key events in browser console gets annoying + """ + + MOUSE_LEFT = "mouse_left" + MOUSE_RIGHT = "mouse_right" + MOUSE_MIDDLE = "mouse_middle" + + # just to use internally in the class to translate the 0, 1, 2 javascript convention + mouse_button_map = {0: MOUSE_LEFT, 1: MOUSE_MIDDLE, 2: MOUSE_RIGHT} + + def __init__(self, canvas, enable_logging=False): + # keep track of what keys \ mouse buttons are currently pressed in this variable + self.pressed = set() + # keep track of the last coordinates used by all mouse events + self.mouse = MousePositions(Position(0, 0), Position(0, 0), Position(0, 0), Position(0, 0)) + # keep track of whether a click has occurred + self.click = False + + # enable logging of mouse and key events in the console for debug purposes + self._logging = enable_logging + self._last_mousemove_log = 0 + + on_canvas_mousedown_proxy = create_proxy(self.on_canvas_mousedown) + on_canvas_mouseup_proxy = create_proxy(self.on_canvas_mouseup) + on_canvas_click_proxy = create_proxy(self.on_canvas_click) + on_canvas_mousemove_proxy = create_proxy(self.on_canvas_mousemove) + on_keydown_proxy = create_proxy(self.on_keydown) + on_keyup_proxy = create_proxy(self.on_keyup) + + canvas.addEventListener("mousedown", on_canvas_mousedown_proxy) + canvas.addEventListener("mouseup", on_canvas_mouseup_proxy) + canvas.addEventListener("click", on_canvas_click_proxy) + canvas.addEventListener("mousemove", on_canvas_mousemove_proxy) + canvas.addEventListener("keydown", on_keydown_proxy) + canvas.addEventListener("keyup", on_keyup_proxy) + + # helper method so we don't need to copy and paste this to every mouse event + def get_mouse_event_coords(self, event) -> Position: + canvas_rect = event.target.getBoundingClientRect() + return Position(event.clientX - canvas_rect.left, event.clientY - canvas_rect.top) + + def on_canvas_mousedown(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.mousedown = pos + + if event.button in self.mouse_button_map: + button = self.mouse_button_map[event.button] + self.pressed.add(button) + + if self._logging: + log.debug("mousedown %s %s, %s", button, pos.x, pos.y) + + def on_canvas_mouseup(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.mouseup = pos + + if event.button in self.mouse_button_map: + button = self.mouse_button_map[event.button] + if button in self.pressed: + self.pressed.remove(button) + + if self._logging: + log.debug("mouseup %s %s, %s", button, pos.x, pos.y) + + def on_canvas_click(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.click = pos + + self.click = True + if self._logging: + log.debug("click %s, %s", pos.x, pos.y) + + def on_canvas_mousemove(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + + # throttle number of mousemove logs to prevent spamming the debug log + if self._logging and (now := time()) - self._last_mousemove_log > 2.5: + log.debug("mousemove %s, %s", pos.x, pos.y) + self._last_mousemove_log = now + + # TODO: check event.buttons here (tells which buttons are pressed during mouse move) if mouse is pressed + # down on canvas, then moved off, and button is unpressed while off the canvas, mouse buttons may be + # flagged as down when they aren't anymore, checking event.buttons would be a good way to 'unstuck' them + + def on_keydown(self, event): + if event.key in ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]: + event.preventDefault() + self.pressed.add(event.key) + if self._logging: + log.debug("keydown %s", event.key) + + def on_keyup(self, event): + if event.key in self.pressed: + self.pressed.remove(event.key) + if self._logging: + log.debug("keyup %s", event.key) + + # TODO: probably also need a way to handle canvas losing focus and missing key up events, for example if alt + # tabbing away, it registers a key down event, but the not a key up event since it has already lost focus by + # that point diff --git a/cool-cacti/static/scripts/debris.py b/cool-cacti/static/scripts/debris.py new file mode 100644 index 00000000..2474df35 --- /dev/null +++ b/cool-cacti/static/scripts/debris.py @@ -0,0 +1,128 @@ +import math +from random import randint + +from common import Position +from scene_classes import SceneObject +from window import window + + +class Debris(SceneObject): + def __init__(self, position: Position, color: str, radius: float, duration: int, rotation: float) -> None: + super().__init__() + super().set_position(position) + + self.color = color + self.radius = radius + # number of frames until this debris should decay + self.initial_duration = self.duration = duration + self.rotation = rotation + self.momentum = Position(0, 0) + + def update(self) -> None: + # decay duration by 1 frame + self.duration -= 1 + + # adjust position based on momentum and break Newton's laws a little + self.x += self.momentum.x * 0.4 + self.y += self.momentum.y * 0.4 + self.momentum.x *= 0.97 + self.momentum.y *= 0.97 + + def render(self, ctx, timestamp) -> None: + ctx.save() + + ctx.translate(*self.get_position()) + ctx.rotate(self.rotation) + + ctx.beginPath() + # Outer arc + ctx.arc(0, 0, self.radius, 0, 2 * math.pi, False) + # Inner cut arc (opposite winding, offset) + ctx.arc( + self.radius * 0.3 * self.duration / self.initial_duration, + 0, + self.radius * (1.2 - 0.8 * self.duration / self.initial_duration), + 0, + 2 * math.pi, + True, + ) + + ctx.closePath() + ctx.fillStyle = self.color + ctx.globalAlpha = min(self.duration / 255, 1.0) # normalize to 0..1 + ctx.fill() + + ctx.restore() + # DEBUGGING THE CIRCLES DEFINING CRESCENT ABOVE ^ + if window.DEBUG_DRAW_HITBOXES: + ctx.save() + ctx.translate(*self.get_position()) + ctx.rotate(self.rotation) + ctx.strokeStyle = "#FF0000" + ctx.beginPath() + ctx.arc(0, 0, self.radius, 0, 2 * math.pi, False) + ctx.stroke() + ctx.closePath() + + ctx.strokeStyle = "#00FF00" + ctx.beginPath() + ctx.arc( + self.radius * 0.3 * self.duration / self.initial_duration, + 0, + self.radius * (1.2 - 0.8 * self.duration / self.initial_duration), + 0, + 2 * math.pi, + True, + ) + ctx.stroke() + ctx.closePath() + ctx.restore() + + super().render(ctx, timestamp) + + +class DebrisSystem(SceneObject): + def __init__(self) -> None: + super().__init__() + + self.debris_list: list[Debris] = [] # will be filled with debris object instances + + def update(self) -> None: + # tick each debris' timer and discard any debris whose timer has run out + for debris in self.debris_list: + debris.update() + self.debris_list = list(filter(lambda deb: deb.duration > 0, self.debris_list)) + + def generate_debris(self, player_pos: Position, asteroid_pos: Position, max_size=3) -> None: + distance = player_pos.distance(asteroid_pos) + new_debris = [] + for _ in range(randint(3, 5)): + position = player_pos.midpoint(asteroid_pos) + Position(randint(-20, 20), randint(-20, 20)) + shade = randint(128, 255) + color = f"#{shade:x}{shade:x}{shade:x}" + radius = randint(15, 25) * min(50 / distance, max_size) + duration = randint(100, 200) + rotation = 0 + + new_debris.append(Debris(position, color, radius, duration, rotation)) + + new_debris_center = Position( + sum(debris.x for debris in new_debris) / len(new_debris), + sum(debris.y for debris in new_debris) / len(new_debris), + ) + + for debris in new_debris: + debris.momentum = Position((debris.x - new_debris_center.x) / 5.0, (debris.y - new_debris_center.y) / 5.0) + debris.rotation = math.atan2(-debris.y + new_debris_center.y, -debris.x + new_debris_center.x) + + self.debris_list.extend(new_debris) + + def render(self, ctx, timestamp) -> None: + """Render every debris""" + for debris in self.debris_list: + debris.render(ctx, timestamp) + + super().render(ctx, timestamp) + + def reset(self): + self.debris_list = [] diff --git a/cool-cacti/static/scripts/game.py b/cool-cacti/static/scripts/game.py new file mode 100644 index 00000000..554dce15 --- /dev/null +++ b/cool-cacti/static/scripts/game.py @@ -0,0 +1,76 @@ +from asteroid import AsteroidAttack +from consolelogger import getLogger +from controls import GameControls +from debris import DebrisSystem +from js import document # type: ignore[attr-defined] +from player import Player, Scanner +from pyodide.ffi import create_proxy # type: ignore[attr-defined] +from scene_classes import Scene +from scene_descriptions import create_scene_manager +from window import window + +log = getLogger(__name__) + +# References to the useful html elements +loadingLabel = document.getElementById("loadingLabel") +container = document.getElementById("canvasContainer") +width, height = container.clientWidth, container.clientHeight +canvas = window.canvas +ctx = window.ctx = window.canvas.getContext("2d") + +window.DEBUG_DRAW_HITBOXES = False + +# TODO: the resizing and margins needs work, I suck with CSS / html layout +def resize_canvas(event=None) -> None: + width, height = container.clientWidth, container.clientHeight + canvas.width = width + canvas.height = height + canvas.style.width = f"{width}px" + canvas.style.height = f"{height}px" + + +resize_proxy = create_proxy(resize_canvas) +window.addEventListener("resize", resize_proxy) +resize_canvas() + +""" +I'm not entirely clear on what this create_proxy is doing, but when passing python functions as callbacks to +"javascript" (well pyscript wrappers for javascript functionality) we need to wrap them in these proxy objects +instead of passing them as straight up python function references. +""" + +# setup of important systems, expose them globally via window object +controls = window.controls = GameControls(canvas) +scene_manager = window.scene_manager = create_scene_manager() +player = window.player = Player( + window.get_sprite("player"), window.get_sprite("health"), canvas.width / 2, canvas.height / 2, scale=0.1 +) +window.asteroids = AsteroidAttack(window.get_sprite("asteroids"), width, height, 256) +window.debris = DebrisSystem() + +scanner = window.scanner = Scanner(window.get_sprite("scanner"), player, min_x=width * 0.45, scan_mult=1) +log.info("Created player at position (%s, %s)", player.x, player.y) + +loadingLabel.style.display = "none" + + +def game_loop(timestamp: float) -> None: + """Timestamp argument will be time since the html document began to load, in miliseconds.""" + + # these should disable bilinear filtering smoothing, which isn't friendly to pixelated graphics + ctx.imageSmoothingEnabled = False + ctx.webkitImageSmoothingEnabled = False + ctx.mozImageSmoothingEnabled = False + ctx.msImageSmoothingEnabled = False + + active_scene: Scene = scene_manager.get_active_scene() + active_scene.render(ctx, timestamp) + + # if a click event occurred and nothing made use of it during this loop, clear the click flag + controls.click = False + # Schedule next frame + window.requestAnimationFrame(game_loop_proxy) + +# Start loop +game_loop_proxy = create_proxy(game_loop) +window.requestAnimationFrame(game_loop_proxy) \ No newline at end of file diff --git a/cool-cacti/static/scripts/overlay.py b/cool-cacti/static/scripts/overlay.py new file mode 100644 index 00000000..8e9840de --- /dev/null +++ b/cool-cacti/static/scripts/overlay.py @@ -0,0 +1,321 @@ +import re + +from window import window +from common import Position, CanvasRenderingContext2D, Rect +from consolelogger import getLogger +from scene_classes import Scene, SceneManager +from spacemass import SpaceMass + +log = getLogger(__name__) + +def rgba_to_hex(rgba_str): + """ + Convert "rgba(r, g, b, a)" to hex string "#RRGGBB". + Alpha is ignored. + """ + # Extract the numbers + match = re.match(r"rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\s*\)", rgba_str) + if not match: + raise ValueError(f"Invalid RGBA string: {rgba_str}") + + r, g, b = map(int, match.groups()) + return f"#{r:02X}{g:02X}{b:02X}" + +class TextOverlay(Scene): + DEFAULT = "No information found :(" + + def __init__(self, name: str, scene_manager: SceneManager, text: str, color="rgba(0, 255, 0, 0.8)", rect=None, hint=None): + super().__init__(name, scene_manager) + self.bold = False + self.color = color + self.calculate_and_set_font() + self.set_text(text) + self.char_delay = 10 # milliseconds between characters + self.margins = Position(200, 50) + self.button_label = None + self.button_click_callable = None + self.other_click_callable = None + self.deactivate() + self.rect = rect # tuple: (x, y, width, height) + self.muted = True + self.center = False + self.hint = hint + + def deactivate(self): + self.active = False + # pause text sound in case it was playing + window.audio_handler.play_text(pause_it=True) + + def set_text(self, text: str): + """ + Set a new text message for this object to display and resets relevant properties like the + current character position to be ready to start over. Text width is calculated for centered text + rendering. + """ + self.displayed_text = "" + self.text = text + self.char_index = 0 + self.last_char_time = 0 + + # calculate text width in case we want centered text, we won't have to calculate it every frame + self._prepare_font(window.ctx) + self._text_width = max(window.ctx.measureText(line).width for line in self.text.split("\n")) + + def set_button(self, button_label: str | None): + self.button_label = button_label + + def calculate_and_set_font(self) -> str: + # Set text style based on window size + base_size = min(window.canvas.width, window.canvas.height) / 50 + font_size = max(12, min(20, base_size)) # Scale between 12px and 20px + self.font = {"size": font_size, "font": "'Courier New', monospace"} + return self.font + + def update_textstream(self, timestamp): + """Update streaming text""" + + if timestamp - self.last_char_time > self.char_delay and self.char_index < len(self.text): + if not self.muted: + window.audio_handler.play_text() + + chars_to_add = min(3, len(self.text) - self.char_index) + self.displayed_text += self.text[self.char_index : self.char_index + chars_to_add] + self.char_index += chars_to_add + self.last_char_time = timestamp + if self.char_index == len(self.text): + window.audio_handler.play_text(pause_it=True) + + def _prepare_font(self, ctx): + font = self.font or self.calculate_and_set_font() + ctx.font = f"{'bold ' if self.bold else ''}{font['size']}px {font['font']}" + ctx.fillStyle = rgba_to_hex(self.color) + return font + + def render_and_handle_button(self, ctx: CanvasRenderingContext2D, overlay_bounds: Rect) -> Rect: + """ + this function returns the button's bounding Rect as a byproduct, so it can be + conveniently used to check for click events in the calling function + """ + if not self.button_label: + return None + + ctx.save() + ctx.font = "14px Courier New" + text_width = ctx.measureText(self.button_label).width + + button_bounds = Rect(overlay_bounds.right - (text_width + 30), overlay_bounds.bottom - 44, text_width + 20, 34) + + ctx.fillStyle = "rgba(0, 0, 0, 0.95)" + ctx.fillRect(*button_bounds) + + # check whether mouse is currently moving over the button + if button_bounds.contains(window.controls.mouse.move): + ctx.fillStyle = "#ffff00" + else: + ctx.fillStyle = "#00ff00" + + ctx.fillText(self.button_label, button_bounds.left + 10, button_bounds.bottom - 10) + ctx.strokeStyle = "rgba(0, 255, 0, 0.95)" + ctx.lineWidth = 2 + ctx.strokeRect(*button_bounds) + + ctx.restore() + return button_bounds + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + if not self.active or not self.text: + return + + self.update_textstream(timestamp) + + if self.rect: + x, y, width, height = self.rect + overlay_bounds = Rect(x, y, width, height) + else: + overlay_width = window.canvas.width - 2 * self.margins.x + overlay_height = window.canvas.height - 2 * self.margins.y + overlay_bounds = Rect(self.margins.x, self.margins.y, overlay_width, overlay_height) + + # Draw transparent console background + ctx.fillStyle = "rgba(0, 0, 0, 0.8)" + + ctx.fillRect(*overlay_bounds) + + # Draw console border + ctx.strokeStyle = self.color + ctx.lineWidth = 2 + ctx.strokeRect(*overlay_bounds) + ctx.strokeRect( + overlay_bounds.left + 3, overlay_bounds.top + 3, overlay_bounds.width - 6, overlay_bounds.height - 6 + ) + + font = self._prepare_font(ctx) + + # Draw streaming text + lines = self.displayed_text.split("\n") + line_height = font["size"] + 4 + + if self.center: + # Center both horizontally and vertically + total_text_height = len(lines) * line_height + start_y = overlay_bounds.top + (overlay_bounds.height - total_text_height) / 2 + font["size"] + start_x = (window.canvas.width - self._text_width) / 2 + else: + start_y = overlay_bounds.top + font["size"] + 10 # use overlay_bounds.top + start_x = overlay_bounds.left + 10 + + for i, line in enumerate(lines): + y_pos = start_y + i * line_height + if y_pos < overlay_bounds.bottom - 10: # don't draw outside overlay + ctx.fillText(line, start_x, y_pos) + + # Draw hint if any at bottom left + if self.hint: + ctx.fillText(self.hint, overlay_bounds.left + 10, overlay_bounds.bottom - 10) + + button_bounds = self.render_and_handle_button(ctx, overlay_bounds) + if window.controls.click: + # log.debug(self.button_click_callable) + # log.debug(self.other_click_callable) + # if a click occurred and we don't have a button or we clicked outside the button + if button_bounds is None or not button_bounds.contains(window.controls.mouse.click): + if self.other_click_callable is not None: + self.other_click_callable() + # otherwise, button was clicked + elif self.button_click_callable is not None: + self.button_click_callable() + + +class ResultsScreen(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager, planet: SpaceMass): + self.planet_data = window.get_planet(planet.name) + text = self.planet_data.info if self.planet_data else "" + super().__init__(name, scene_manager, text) + # default sizing for scan results screen + self.margins = Position(200, 50) + +class DeathScreen(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__(name, scene_manager, "GAME OVER", color="rgba(0, 255, 0, 0.9)") + # Center the death screen + self.margins = Position(150, 150) + self.center = True + self.muted = True # This only refers to text terminal sound, not audio in general + self.bold = True + + def calculate_and_set_font(self) -> str: + base_size = min(window.canvas.width, window.canvas.height) / 15 + font_size = max(32, min(72, base_size)) # Scale between 32px and 72px + self.font = {"size": font_size, "font": "'Courier New', monospace"} + return self.font + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + window.audio_handler.play_music_death() + super().render(ctx, timestamp) + + +class Dialogue(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager, text: str): + # Initialize the first line using the TextOverlay constructor + lines = text.split("\n") + first_line = lines[0] if lines else "" + super().__init__(name, scene_manager, first_line) + + # Store all lines and keep track of current index + self.lines = lines + self.current_index = 0 + self.swap_color = False + self.is_col1 = False + self.switch_color() + self.done = False + + def next(self): + """Advance to the next line of dialogue.""" + self.current_index += 1 + if self.current_index < len(self.lines): + self.switch_color() + # Use the TextOverlay method to set the next line + self.set_text(self.lines[self.current_index].strip()) + self.active = True + else: + # No more lines + self.done = True + self.deactivate() + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + """Render the currently active line.""" + + message_parts = self.lines[self.current_index].strip().split(' ') + split_message = [] + len_text_line = 0 + partial_message = '' + + for part in message_parts: + word_width = ctx.measureText(part + ' ').width # include space + if len_text_line + word_width <= self.rect[2]: + partial_message += part + ' ' + len_text_line += word_width + else: + # save current line before adding the new word + split_message.append(partial_message.rstrip()) + # start new line with current word + partial_message = part + ' ' + len_text_line = word_width + + if partial_message: + split_message.append(partial_message.rstrip()) + + formatted_message = '' + for part in split_message: + formatted_message += part + '\n' + self.text = formatted_message + + super().render(ctx, timestamp) + + def switch_color(self): + self.is_col1 = not self.is_col1 + if self.is_col1: + self.color = "rgba(0, 255, 0, 0.8)" + else: + self.color = "rgba(170, 255, 0, 0.8)" + +class Credits: + """Simple scrolling credits""" + def __init__(self, credits_text: str, fill_color: str): + self.credits_lines = credits_text.split("\n") if credits_text else ["No credits available"] + self.scroll_speed = 0.4 # pixels per frame + self.y_offset = window.canvas.height * 0.7 # Start near bottom of screen + self.line_height = 30 + self.fill_color = fill_color + self.finished = False + + def update(self, timestamp): + """Update the scroll position.""" + self.y_offset -= self.scroll_speed + + # Check if credits have finished scrolling + if not self.finished: + last_line_y = self.y_offset + (len(self.credits_lines) * self.line_height) + # log.debug("Credits Last Line Y Offset: %s", last_line_y) + if last_line_y < 0: + self.finished = True + + def render(self, ctx, timestamp): + """Render the scrolling credits.""" + if self.finished: + return + + ctx.save() + ctx.font = f"18px Courier New" + ctx.fillStyle = self.fill_color + ctx.textAlign = "center" + + # Draw each line of credits + for i, line in enumerate(self.credits_lines): + y_pos = self.y_offset + (i * self.line_height) + # Only render if the line is visible on screen + if -self.line_height <= y_pos <= window.canvas.height + self.line_height: + ctx.fillText(line, window.canvas.width / 2, y_pos) + + ctx.restore() + diff --git a/cool-cacti/static/scripts/player.py b/cool-cacti/static/scripts/player.py new file mode 100644 index 00000000..702f80f8 --- /dev/null +++ b/cool-cacti/static/scripts/player.py @@ -0,0 +1,575 @@ +import math +import time +from collections import deque +from dataclasses import dataclass +import random + +from asteroid import Asteroid +from common import Position +from consolelogger import getLogger +from scene_classes import SceneObject +from window import SpriteSheet, window + +log = getLogger(__name__) + +class Player(SceneObject): + """Controllable player sprite. + + Exposed globally as window.player so other modules can use it. + Movement keys: WASD or Arrow keys. + """ + + FULL_HEALTH = 1000 + + def __init__( + self, + sprite: SpriteSheet, + bar_icon: SpriteSheet, + x: float, + y: float, + speed: float = 100.0, + scale: float = 0.1, + hitbox_scale: float = 0.5, + ): + super().__init__() + + self.health = Player.FULL_HEALTH + self.health_history = deque([Player.FULL_HEALTH] * 200) + self.sprite = sprite + self.set_position(x, y) + self.default_pos = (x, y) + self.speed = speed + self.momentum = [0, 0] + self.scale = scale + self._half_w = 0 + self._half_h = 0 + self.hitbox_scale = hitbox_scale + self.rotation = 0.0 # rotation in radians + self.target_rotation = 0.0 + self.max_tilt = math.pi / 8 # Maximum tilt angle (22.5 degrees) + self.rotation_speed = 8.0 + self.is_moving = False + self.is_disabled = False + self.bar_icon = bar_icon + self.active = False + self.invincible = False + self.key_cooldown = {} + + def _update_sprite_dims(self): + w = self.sprite.width + h = self.sprite.height + if w and h: + self._half_w = (w * self.scale) / 2 + self._half_h = (h * self.scale) / 2 + + def update(self, timestamp: float): + """Update player position based on pressed keys. + + dt: time delta (seconds) + controls: GameControls instance for key state + """ + if not self.sprite: + return + + # update sprite dimensions if needed + if not self._half_w or not self._half_h: + self._update_sprite_dims() + + keys = window.controls.pressed + dx = dy = 0.0 + if not self.is_disabled: + if "w" in keys or "ArrowUp" in keys: + dy -= 1.75 + if "s" in keys or "ArrowDown" in keys: + dy += 1.75 + if "a" in keys or "ArrowLeft" in keys: + dx -= 1.75 + if "d" in keys or "ArrowRight" in keys: + dx += 1.75 + + # TODO: remove this, for testing momentum + if "m" in keys: + if timestamp - self.key_cooldown.setdefault("m", 0) < 1000: return + angle = random.uniform(0, 6.28) + self.momentum[0] = math.cos(angle) * 5 + self.momentum[1] = math.sin(angle) * 5 + self.key_cooldown["m"] = timestamp + # DEBUG: switch hitbox visibility + if "c" in keys: + if timestamp - self.key_cooldown.setdefault("c", 0) < 100: return + window.DEBUG_DRAW_HITBOXES = not window.DEBUG_DRAW_HITBOXES + self.key_cooldown["c"] = timestamp + # DEBUG: instant death for testing + if "k" in keys: + self.health = 0 + + # miliseconds to seconds since that's what was being used + dt = (timestamp - self.last_timestamp) / 1000 + + # Update target rotation based on horizontal movement + if dx < 0: # Moving left + self.target_rotation = -self.max_tilt # Tilt left + elif dx > 0: # Moving right + self.target_rotation = self.max_tilt # Tilt right + else: + self.target_rotation = 0.0 + + # Smoothly interpolate current rotation toward target + rotation_diff = self.target_rotation - self.rotation + self.rotation += rotation_diff * self.rotation_speed * dt + + if dx or dy: + # normalize diagonal movement + mag = (dx * dx + dy * dy) ** 0.5 + dx /= mag + dy /= mag + self.x += dx * self.speed * dt + self.y += dy * self.speed * dt + + self.is_moving = True + else: + self.is_moving = False + + # update player position based on momentum (after they were hit and bumped by an asteroid) + if self.momentum[0] or self.momentum[1]: + self.x += self.momentum[0] * self.speed * dt + self.y += self.momentum[1] * self.speed * dt + self.momentum[0] *= 0.97 + self.momentum[1] *= 0.97 + if abs(self.momentum[0]) < 0.5: + self.momentum[0] = 0 + if abs(self.momentum[1]) < 0.5: + self.momentum[1] = 0 + + # clamp inside canvas + canvas = getattr(window, "gameCanvas", None) + if canvas and self._half_w and self._half_h: + max_x = canvas.width - self._half_w + max_y = canvas.height - self._half_h + self.x = min(max(self._half_w, self.x), max_x) + self.y = min(max(self._half_h, self.y), max_y) + + def render(self, ctx, timestamp): + if not self.sprite: + log.debug("Player render: no sprite") + return + + self.update(timestamp) + + if not self._half_w or not self._half_h: + self._update_sprite_dims() + + scaled_w = self._half_w * 2 + scaled_h = self._half_h * 2 + + # Save the canvas state before applying rotation + ctx.save() + + # Move to player center and apply rotation + ctx.translate(self.x, self.y) + ctx.rotate(self.rotation) + + # Draw sprite centered at origin + ctx.drawImage(self.sprite.image, -self._half_w, -self._half_h, scaled_w, scaled_h) + + # Debug draw hitbox + if window.DEBUG_DRAW_HITBOXES: + ctx.strokeStyle = "white" + ctx.lineWidth = 2 + ctx.strokeRect(-self._half_w, -self._half_h, scaled_w, scaled_h) + + # Restore canvas state (removes rotation and translation) + ctx.restore() + + # Collision detection (done after restore so it's in world coordinates) + if self.active: + for asteroid in window.asteroids.asteroids: + self.check_collision(asteroid) + self.render_health_bar(ctx) + + super().render(ctx, timestamp) + + def render_health_bar(self, ctx): + outer_width = window.canvas.width // 4 + outer_height = 12 + inner_width = outer_width - 4 + inner_height = outer_height - 4 + padding = 30 + + ctx.drawImage( + self.bar_icon.image, + window.canvas.width - outer_width - padding - 30, + window.canvas.height - outer_height - padding - 2, + ) + + ctx.lineWidth = 1 + ctx.strokeStyle = "#FFFFFF" + ctx.strokeRect( + window.canvas.width - outer_width - padding, + window.canvas.height - outer_height - padding, + outer_width, + outer_height, + ) + + ctx.fillStyle = "#FF0000" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height - outer_height - padding + 2, + inner_width * self.health_history.popleft() / Player.FULL_HEALTH, + inner_height, + ) + self.health_history.append(self.health) + + ctx.fillStyle = "#00FF00" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height - outer_height - padding + 2, + inner_width * self.health / Player.FULL_HEALTH, + inner_height, + ) + + def check_collision(self, asteroid: Asteroid): + # skip if asteroid is too far in the background + if asteroid.size < asteroid.target_size * 0.70: + return + # use invicible flag (toggled when planet is done) + if self.invincible: + return + + ast_x, ast_y, ast_radius = asteroid.get_hit_circle() + player_x_min, player_x_max = self.x - self._half_w, self.x + self._half_w + player_y_min, player_y_max = self.y - self._half_h, self.y + self._half_h + + hitbox_closest_x = max(player_x_min, min(ast_x, player_x_max)) + hitbox_closest_y = max(player_y_min, min(ast_y, player_y_max)) + + # if the closest point on the rectangle is inside the asteroid's circle, we have collision: + if (hitbox_closest_x - ast_x) ** 2 + (hitbox_closest_y - ast_y) ** 2 < ast_radius**2: + distance_between_centers = math.dist((ast_x, ast_y), (self.x, self.y)) + # log.debug("Asteroid collision with distance %s", distance_between_centers) + asteroid.health -= max(80, 240 - distance_between_centers) + # Make Newton proud + self.momentum[0] = (self.x - ast_x) / distance_between_centers * 5.0 + self.momentum[1] = (self.y - ast_y) / distance_between_centers * 5.0 + asteroid.velocity_x += (ast_x - self.x) / 2.0 + asteroid.velocity_y += (ast_y - self.y) / 2.0 + self.health = max(0, self.health - 100 / distance_between_centers * 5 * asteroid.damage_mul) + + # # Reduce scanner progress when hit by asteroid + # if hasattr(window, 'scanner') and window.scanner: + # # Reduce progress by 2-6% of max progress based on damage taken + # damage_taken = 100 / (distance_between_centers * 5 * asteroid.damage_mul + # progress_loss = window.scanner._bar_max * (0.02 + (damage_taken / 1000) * 0.04) + # window.scanner.scanning_progress = max(0, window.scanner.scanning_progress - progress_loss) + # # log.debug("Scanner progress reduced by %f due to asteroid collision", progress_loss) + + window.audio_handler.play_bang() + window.debris.generate_debris(self.get_position(), Position(ast_x, ast_y)) + + def nudge_towards(self, pos: Position, gravity_strength: float = 0.75) -> None: + distance = self.get_position().distance(pos) + if distance == 0: return + + x_dir = (pos.x - self.x) / distance + y_dir = (pos.y - self.y) / distance + + self.x += x_dir * gravity_strength + self.y += y_dir * gravity_strength + + # x_dir = math.cos((pos.x - self.x )/(pos.y - self.y)) + # y_dir = math.sin((pos.x - self.x )/(pos.y - self.y)) + + # if abs(self.momentum[0]) < 1: + # self.momentum[0] += x_dir * momentum_amount + # if abs(self.momentum[1]) < 1: + # self.momentum[1] += y_dir * momentum_amount + + def get_hit_circle(self) -> tuple[float, float, float]: + """Get the hit circle for the player""" + if not self._half_w or not self._half_h: + self._update_sprite_dims() + r = min(self._half_w, self._half_h) * self.hitbox_scale + return (self.x, self.y, r) + + def get_aabb(self) -> tuple[float, float, float, float]: + """Get the axis-aligned bounding box (AABB) for the player""" + if not self._half_w or not self._half_h: + self._update_sprite_dims() + hw = self._half_w * self.hitbox_scale + hh = self._half_h * self.hitbox_scale + return (self.x - hw, self.y - hh, self.x + hw, self.y + hh) + + def reset_position(self): + self.x, self.y = self.default_pos + self.rotation = 0.0 + self.target_rotation = 0.0 + self.momentum = [0, 0] + + +@dataclass +class ScanStatus: + active: bool = False # Whether the scan is active + too_close: bool = False # Whether the scan is valid + player_interrupted: bool = False # Whether the scan was interrupted + locked: bool = False # Whether the scan is locked + + @property + def valid(self): + return not self.too_close and not self.player_interrupted and not self.locked + + +class Scanner: + def __init__( + self, + sprite: SpriteSheet, + player: Player, + min_x: float, + scan_mult: float = 1, + scale: float = 0.1, + disable_ship_ms: float = 1000, + beamwidth=100, + scanning_dur_s=15, + ): + self.sprite = sprite + self.scale = scale + self.player = player + self.min_x = min_x + self.disable_ship_ms = disable_ship_ms + self.disable_timer = 0 + + # Core scanning parameters + self.scanning_dur_ms = scanning_dur_s * 1000 + self.scan_mult = scan_mult + self.beamwidth = beamwidth + + # State variables + self.status = ScanStatus() + self.scanning_progress = 0 + self.finished = False + self._last_scan_tick = None + + # Calculate max based on current parameters + self._update_bar_max() + + def _update_bar_max(self): + """Update the maximum progress value based on current parameters""" + self._bar_max = self.scanning_dur_ms * self.scan_mult + + def set_scan_parameters(self, scan_mult: float | None = None, scanning_dur_s: float | None = None): + """Update scanning parameters and recalculate max value""" + if scan_mult is not None: + self.scan_mult = scan_mult + if scanning_dur_s is not None: + self.scanning_dur_ms = scanning_dur_s * 1000 + self._update_bar_max() + + def update(self, ctx, current_time): + if self.finished: + return + + keys = window.controls.pressed + + self.status.active = " " in keys + + self.status.too_close = self.player.x <= self.min_x + self.status.player_interrupted = self.player.momentum != [0, 0] + + # Lock if interrupted and stay locked until released + if self.status.player_interrupted: + self.status.locked = True + elif not self.status.active: + self.status.locked = False + + if self.status.active and self.status.valid: + self.player.is_disabled = True + + if self._last_scan_tick is None: + self._last_scan_tick = current_time + + elapsed_since_last = current_time - self._last_scan_tick + self.scanning_progress = min(self.scanning_progress + elapsed_since_last, self._bar_max) + self._last_scan_tick = current_time + else: + self._last_scan_tick = None + + # Re-enable player if disable_time has elapsed or player is not scanning + if current_time - self.disable_timer >= self.disable_ship_ms or not self.status.active: + if " " not in keys: + self.player.is_disabled = False + self.disable_timer = current_time + + def render_beam(self, ctx): # seprate function so it can go under the planet + + if not self.status.active or not self.status.valid: + window.audio_handler.play_scan(pause_it=True) + return + + window.audio_handler.play_scan() + + player_x, player_y = self.player.get_position() + origin_x = player_x - 150 + origin_y = player_y - 15 + + # Create animated pulsing effect based on time + pulse = (math.sin(time.time() * 8) + 1) / 2 # 0 to 1 + beam_alpha = 0.3 + pulse * 0.3 # Vary alpha from 0.3 to 0.6 + + # Create gradient for the beam + gradient = ctx.createLinearGradient(origin_x, origin_y, 0, player_y) + gradient.addColorStop(0, f"rgba(255, 100, 100, {beam_alpha})") + gradient.addColorStop(0.5, f"rgba(255, 50, 50, {beam_alpha * 0.8})") + gradient.addColorStop(1, f"rgba(255, 0, 0, {beam_alpha * 0.5})") + + # Main beam cone + ctx.fillStyle = gradient + ctx.beginPath() + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y - self.beamwidth) + ctx.lineTo(0, player_y + self.beamwidth) + ctx.closePath() + ctx.fill() + + # Add animated scanning lines + scan_cycle = (time.time() * 2) % 1 # 0 to 1, cycling every 0.5 seconds + num_lines = 5 + + for i in range(num_lines): + line_progress = (scan_cycle + i * 0.2) % 1 + line_x = origin_x - line_progress * origin_x + line_alpha = (1 - line_progress) * 0.8 + beam_height = self.beamwidth + + if line_alpha > 0.1: # Only draw visible lines + ctx.strokeStyle = f"rgba(255, 255, 255, {line_alpha})" + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(line_x, player_y - beam_height) + ctx.lineTo(line_x, player_y + beam_height) + ctx.stroke() + + # Add edge glow effect + ctx.strokeStyle = f"rgba(255, 150, 150, {beam_alpha * 0.6})" + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y - self.beamwidth) + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y + self.beamwidth) + ctx.stroke() + + def render(self, ctx, current_time): + "Renders the scanner sprite and the progress bar" + if "f" in window.controls.pressed and window.player.active: + self.finished = True + + player_x, player_y = self.player.get_position() + # progress bar + outer_width = window.canvas.width // 4 + outer_height = 12 + inner_width = outer_width - 4 + inner_height = outer_height - 4 + padding = 30 + + ctx.drawImage( + self.sprite.image, + window.canvas.width - outer_width - padding - 30, + window.canvas.height + outer_height - padding - 2, + 16, + 16, + ) + + ctx.lineWidth = 1 + ctx.strokeStyle = "#FFFFFF" + ctx.strokeRect( + window.canvas.width - outer_width - padding, + window.canvas.height + outer_height - padding, + outer_width, + outer_height, + ) + + ctx.fillStyle = "#FF0000" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height + outer_height - padding + 2, + inner_width * self.scanning_progress / self._bar_max, + inner_height, + ) + + if self.finished: + return + + if self.status.active: + if self.status.valid: + scaled_w = self.sprite.width * self.scale + scaled_h = self.sprite.height * self.scale + ctx.drawImage(self.sprite.image, player_x - 175, player_y - 25, scaled_w, scaled_h) + elif self.status.too_close: + ctx.fillStyle = "white" + ctx.font = "15px Courier New" + ctx.fillText("Too close to planet!", player_x - 90, player_y - 50) + + if self.scanning_progress >= self._bar_max: + log.debug(f"Done scanning") + self.status.active = False + self.finished = True + + def reset(self): + self.finished = False + self.scanning_progress = 0 + self._update_bar_max() + +class PlayerExplosion(): + def __init__(self): + self.explosion_sprite = window.get_sprite("Explosion Animation") + self.active = False + self.current_frame = 0 + self.frame_count = 11 # Number of frames + self.frame_duration = 100 # milliseconds per frame + self.last_frame_time = 0 + self.position = (0, 0) + self.scale = 4.0 + self.finished = False + + def start_explosion(self, x: float, y: float): + """Start the explosion animation at the given position""" + self.active = True + self.current_frame = 0 + self.position = (x, y) + self.last_frame_time = 0 + self.finished = False + + def update(self, timestamp: float): + """Update the explosion animation""" + if not self.active or self.finished: + return + + if timestamp - self.last_frame_time >= self.frame_duration: + self.current_frame += 1 + self.last_frame_time = timestamp + + if self.current_frame >= self.frame_count: + self.finished = True + self.active = False + + def render(self, ctx, timestamp: float): + """Render the current explosion frame""" + if not self.active or self.finished: + return + + self.update(timestamp) + + frame_width = self.explosion_sprite.width // self.frame_count + frame_height = self.explosion_sprite.height + + source_x = self.current_frame * frame_width + source_y = 0 + + scaled_width = frame_width * self.scale + scaled_height = frame_height * self.scale + + ctx.drawImage( + self.explosion_sprite.image, + source_x, source_y, frame_width, frame_height, # source rectangle + self.position[0] - scaled_width/2, self.position[1] - scaled_height/2, # destination position + scaled_width, scaled_height # destination size + ) diff --git a/cool-cacti/static/scripts/scene_classes.py b/cool-cacti/static/scripts/scene_classes.py new file mode 100644 index 00000000..acd990b8 --- /dev/null +++ b/cool-cacti/static/scripts/scene_classes.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import overload + +from common import CanvasRenderingContext2D, Position + +# ==================================================== +# Scene Object abstract class +# ==================================================== + + +class SceneObject: + def __init__(self): + """every scene object keeps track of the last milisecond timestamp when it was rendered""" + self.last_timestamp = 0 + + def render(self, ctx: CanvasRenderingContext2D, timestamp: float): + # update the last rendered timestamp + self.last_timestamp = timestamp + + """ + A few subclasses use these position methods so moved them here for shared functionality. + SceneObject subclasses where these don't make sense can just ignore them. (e.g. SolarSystem) + """ + + @overload + def set_position(self, x: float, y: float): ... + + @overload + def set_position(self, x: Position): ... + + def set_position(self, x_or_pos, y=None): + if y is not None: + x = x_or_pos + self.x = x + self.y = y + else: + pos = x_or_pos + self.x = pos.x + self.y = pos.y + + def get_position(self) -> Position: + return Position(self.x, self.y) + + +# -------------------- +# Scene Class +# -------------------- + + +class Scene(SceneObject): + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__() + self.name = name + self.active = False + self.scene_manager = scene_manager + + +# -------------------- +# Scene Manager Class +# -------------------- + + +class SceneManager: + def __init__(self): + self._scenes: list[Scene] = [] + + def add_scene(self, scene: Scene): + self._scenes.append(scene) + + def activate_scene(self, scene_name): + """ + Deactivate all scenes, and only activate the one with the provided name + """ + for scene in self._scenes: + scene.active = False + next(scene for scene in self._scenes if scene.name == scene_name).active = True + + def get_active_scene(self): + return next(scene for scene in self._scenes if scene.active) diff --git a/cool-cacti/static/scripts/scene_descriptions.py b/cool-cacti/static/scripts/scene_descriptions.py new file mode 100644 index 00000000..9db636a6 --- /dev/null +++ b/cool-cacti/static/scripts/scene_descriptions.py @@ -0,0 +1,553 @@ +from functools import partial + +from player import Player, PlayerExplosion +from common import PlanetState, Position, Rect +from consolelogger import getLogger +from scene_classes import Scene, SceneManager +from solar_system import SolarSystem +from spacemass import SpaceMass +from stars import StarSystem, StarSystem3d +from window import window +from overlay import TextOverlay, ResultsScreen, DeathScreen, Dialogue, Credits + +from js import document #type:ignore +canvas = document.getElementById("gameCanvas") +container = document.getElementById("canvasContainer") +log = getLogger(__name__, False) + +# -------------------- +# methods useful across various scenes +# -------------------- + +ORBITING_PLANETS_SCENE = "orbiting-planets-scene" +FINAL_SCENE = "final-scene" +START_SCENE = "start-scene" + +def get_controls(): + return window.controls + +def get_player(): + return window.player + +def get_asteroid_system(): + return window.asteroids + +def get_debris_system(): + return window.debris + +def get_scanner(): + return window.scanner + +def draw_black_background(ctx): + ctx.fillStyle = "black" + ctx.fillRect(0, 0, window.canvas.width, window.canvas.height) + +# -------------------- +# our main scene with the planets orbiting the sun +# -------------------- + +class OrbitingPlanetsScene(Scene): + """ + Scene that handles the functionality of the part of the game where planets are orbiting around the sun + and the player can select a level by clicking planets + """ + + def __init__(self, name: str, scene_manager: SceneManager, solar_system: SolarSystem): + super().__init__(name, scene_manager) + + self.solar_sys = solar_system + + self.stars = StarSystem( + num_stars=400, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=2, + pulse_freq_min=3, + pulse_freq_max=6, + ) + self.planet_info_overlay = TextOverlay("planet-info-overlay", scene_manager, "") + # attach a behavior to click event outside the overlay's button - hide the overlay + self.planet_info_overlay.other_click_callable = self.planet_info_overlay.deactivate + self.planet_info_overlay.set_button("Travel") + self.planet_info_overlay.muted = False + self.planet_info_overlay.center = True + self.scene_manager = scene_manager + # Debug button label + self._debug_btn_label = "" # disable the extra button by default + + self.show_cheats_menu() + + # just a temporary function for demo-ing project + def show_cheats_menu(self): + cheats_info = """ +Hello, thanks for checking out our project! +In order to more easily demo the functionality +of different parts of the game, we have included +the following cheats: + +In planet overview screen: +[C] - Instantly jump to credits / victory screen + +During ship flight: +[C] - Toggle collision boxes (for fun) +[K] - Kill the player (can start a new game) +[F] - Finish the current planet scan +""" + self.planet_info_overlay.set_button(None) + self.planet_info_overlay.set_text(cheats_info) + self.planet_info_overlay.margins = Position(300, 150) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = False + + def render(self, ctx, timestamp): + + # some temporary functionality for testing + if "c" in window.controls.pressed: + self.scene_manager.activate_scene(FINAL_SCENE) + window.audio_handler.play_music_main() + + draw_black_background(ctx) + self.highlight_hovered_planet() + + self.stars.render(ctx, timestamp) + self.solar_sys.update_orbits(0.20) + self.solar_sys.render(ctx, timestamp) + + # If all planets are complete, switch to the final scene + if all(p.complete for p in self.solar_sys.planets): + self.scene_manager.activate_scene(FINAL_SCENE) + self._debug_btn_label = "View Credits Again" + return + + # from this scene, be ready to switch to a big planet scene if planet is clicked + if self.planet_info_overlay.active: + self.planet_info_overlay.render(ctx, timestamp) + else: + self.check_planet_click() + + # Debug: button to set all planets to complete + self._render_debug_complete_all_button(ctx) + + def _render_debug_complete_all_button(self, ctx): + label = self._debug_btn_label + if not label: return + ctx.save() + ctx.font = "14px Courier New" + text_width = ctx.measureText(label).width + pad_x, pad_y = 10, 8 + x, y = 16, 16 + w, h = text_width + pad_x * 2, 30 + bounds = Rect(x, y, w, h) + + # Background + ctx.fillStyle = "rgba(0, 0, 0, 0.75)" + ctx.fillRect(*bounds) + + # Hover state + is_hover = bounds.contains(get_controls().mouse.move) + ctx.strokeStyle = "#ffff00" if is_hover else "#00ff00" + ctx.lineWidth = 2 + ctx.strokeRect(*bounds) + ctx.fillStyle = ctx.strokeStyle + ctx.fillText(label, x + pad_x, y + h - 10) + + # Click handling + if window.controls.click and bounds.contains(window.controls.mouse.click): + for p in self.solar_sys.planets: + p.complete = True + log.debug("Debug: set all planet completions to True") + ctx.restore() + + def check_planet_click(self): + """Check whether a UI action needs to occur due to a click event.""" + + planet = self.solar_sys.get_object_at_position(window.controls.mouse.click) + if window.controls.click and planet: + planet_data = window.get_planet(planet.name) + log.debug("Clicked on: %s", planet.name) + self.planet_info_overlay.hint = "Click anywhere to close" + if planet.complete: + self.planet_info_overlay.set_button(None) + self.planet_info_overlay.set_text(planet_data.info) + self.planet_info_overlay.margins = Position(200, 50) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = True + else: + self.planet_info_overlay.set_button("Travel") + self.planet_info_overlay.button_click_callable = partial(self.switch_planet_scene, planet.name) + self.planet_info_overlay.set_text("\n".join(planet_data.level)) + self.planet_info_overlay.margins = Position(300, 120) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = False + + def highlight_hovered_planet(self): + # Reset all planets' highlight state first + for planet in self.solar_sys.planets: + planet.highlighted = False + + planet = self.solar_sys.get_object_at_position(window.controls.mouse.move) + if planet is not None and not self.planet_info_overlay.active: + planet.highlighted = True + + def switch_planet_scene(self, planet_name): + """Prepare what is needed to transition to a gameplay scene.""" + + planet_scene_name = f"{planet_name}-planet-scene" + log.debug("Activating planet scene: %s", planet_scene_name) + + planet = window.get_planet(planet_name) + if planet is None: + log.error("Planet not found: %s", planet_name) + return + + log.debug(planet) + self.planet_info_overlay.deactivate() + self.scene_manager.activate_scene(planet_scene_name) + self.solar_sys.get_planet(planet_name).switch_view() + get_player().reset_position() + get_player().active = True + get_asteroid_system().reset(planet) + get_debris_system().reset() + get_scanner().set_scan_parameters(planet.scan_multiplier) + get_scanner().reset() + +# -------------------- +# game scene with zoomed in planet on left +# -------------------- + +class PlanetScene(Scene): + """ + Scene that handles the functionality of the part of the game where the player's ship is active and dodging + asteroids. Also handles the scan results display as a child scene. + """ + + def __init__(self, name: str, scene_manager: SceneManager, planet: SpaceMass): + super().__init__(name, scene_manager) + + self.stars = StarSystem( + num_stars=100, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=3, + pulse_freq_min=3, + pulse_freq_max=6, + ) + self.planet = planet + planet.set_position(0, window.canvas.height // 2) + self.results_overlay = ResultsScreen(f"{planet.name}-results", scene_manager, self.planet) + self.results_overlay.other_click_callable = self.handle_scene_completion + self.results_overlay.muted = False + self.results_overlay.center = True + self.results_overlay.hint = "Click anywhere to continue" + + # Add death screen + self.death_screen = DeathScreen(f"{planet.name}-death", scene_manager) + self.death_screen.button_click_callable = self.handle_player_death + self.death_screen.set_button("Play Again") + + # Add explosion animation + self.player_explosion = PlayerExplosion() + self.explosion_started = False + + def render(self, ctx, timestamp): + draw_black_background(ctx) + self.stars.star_shift(timestamp, 5) + self.stars.render(ctx, timestamp) + get_scanner().update(ctx, timestamp) + get_scanner().render_beam(ctx) + self.planet.render(ctx, timestamp) + + # Update + render handles spawn and drawing + get_asteroid_system().update_and_render(ctx, timestamp) + self.check_special_level_interactions(timestamp) + + # Check for player death first + if get_player().health <= 0: + if not self.explosion_started: + window.audio_handler.play_explosion() + # Start explosion animation at player position + player_x, player_y = get_player().get_position() + self.player_explosion.start_explosion(player_x, player_y) + self.explosion_started = True + get_player().invincible = True + window.audio_handler.play_music_main(pause_it=True) + + # Render explosion instead of player + if self.player_explosion.active: + self.player_explosion.render(ctx, timestamp) + # Only show death screen after explosion is finished + elif self.player_explosion.finished: + self.death_screen.active = True + else: + # Normal player rendering when alive + get_player().render(ctx, timestamp) + + get_debris_system().update() + get_debris_system().render(ctx, timestamp) + + get_scanner().render(ctx, timestamp) + + # Activate the results sub-scene if scanner progress is complete + if get_scanner().finished: + self.results_overlay.active = True + get_player().invincible = True + elif get_player().health > 0: # Only reset invincibility if player is alive + get_player().invincible = False + + # Handle death screen display and interaction + if self.death_screen.active: + self.death_screen.render(ctx, timestamp) + # Handle results screen display and interaction + self.results_overlay.render(ctx, timestamp) + + def check_special_level_interactions(self, timestamp: int): + """ + Handle special level interactions + + This is probably not best place to handle the special level stuff like Jupiter gravity affecting + player and Mercury slowly damaging player, but it's crunch time so whatever works :) + """ + # nudge player in the direction of jupiter if on the left 2/3 of the screen + if self.planet.name.lower() == "jupiter": + get_player().nudge_towards(self.planet.get_position(), 0.5) + elif self.planet.name.lower() == "mercury": + get_player().health = max(0, get_player().health - (timestamp - self.last_timestamp) / 1_200_000) + + def handle_scene_completion(self): + """Handle when the scanning is finished and planet is complete.""" + log.debug(f"Finished planet {self.planet.name}! Reactivating orbiting planets scene.") + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + get_player().active = False + self.results_overlay.active = True + get_player().health = min(get_player().health + Player.FULL_HEALTH / 3, Player.FULL_HEALTH) + self.planet.switch_view() + self.planet.complete = True + + def handle_player_death(self): + """Handle when the player dies and clicks on the death screen.""" + window.audio_handler.play_music_death(pause_it=True) + log.debug(f"Player died on {self.planet.name}! Returning to orbiting planets scene.") + + # Reset all planet completions when player dies + orbiting_scene = next(scene for scene in self.scene_manager._scenes if scene.name == ORBITING_PLANETS_SCENE) + for planet in orbiting_scene.solar_sys.planets: + planet.complete = False + log.debug("All planet completions reset due to player death") + + window.audio_handler.play_explosion(pause_it=True) + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + get_player().active = False + get_player().health = 1000 # Reset player health to FULL_HEALTH + self.death_screen.deactivate() + self.explosion_started = False # Reset explosion state + self.planet.switch_view() + + # special level interaction: finishing earth gives player full health back + if self.planet.name.lower() == "earth": + get_player().health = Player.FULL_HEALTH + log.debug(window.audio_handler.music_death.paused) + +# -------------------- +# game intro scene with dialogue +# -------------------- + +class StartScene(Scene): + """Scene for handling the alien dialogue for introducing the game.""" + + def __init__(self, name: str, scene_manager: SceneManager, bobbing_timer = 135, bobbing_max = 20): + super().__init__(name, scene_manager) + self.stars = StarSystem( + num_stars=100, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=1, + pulse_freq_min=3, + pulse_freq_max=6, + ) + + self.dialogue_manager = Dialogue('dialogue', scene_manager, window.lore) + self.dialogue_manager.active = True + self.dialogue_manager.margins = Position(300, 150) + self.dialogue_manager.rect=(0, window.canvas.height-150, window.canvas.width, 150) + self.dialogue_manager.set_button("Skip Intro") + self.dialogue_manager.button_click_callable = self.finalize_scene + self.starsystem = StarSystem3d(100, max_depth=100) + self.player = None + self.bobbing_timer = bobbing_timer + self.bobbing_max = bobbing_max + self.is_bobbing_up = True + self.bobbing_offset = 0 + self.animation_timer = 0 + + def render(self, ctx, timestamp): + if self.player is None: + player = get_player() + player.is_disabled = True + + if timestamp - self.animation_timer >= self.bobbing_timer: + # log.debug(f"bobbing, val={self.bobbing_offset}") + self.animation_timer = timestamp + if self.is_bobbing_up: + self.bobbing_offset += 1 + else: + self.bobbing_offset -= 1 + + player.y = (window.canvas.height // 2 + self.bobbing_offset) + + if abs(self.bobbing_offset) > self.bobbing_max: + self.is_bobbing_up = not self.is_bobbing_up + + draw_black_background(ctx) + #self.stars.render(ctx, timestamp) + self.dialogue_manager.render(ctx, timestamp) + + self.starsystem.render(ctx, speed=0.3, scale=70) + player.render(ctx, timestamp) + if window.controls.click: + self.dialogue_manager.next() + window.audio_handler.play_music_thematic() + + if self.dialogue_manager.done: + self.finalize_scene() + + def finalize_scene(self): + window.audio_handler.play_music_thematic(pause_it=True) + window.audio_handler.play_music_main() + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + +# -------------------- +# final \ credits scene +# -------------------- + +class FinalScene(Scene): + """Scene for the final credits.""" + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__(name, scene_manager) + # Sparse stars for space backdrop + self.stars = StarSystem( + num_stars=200, + radius_min=1, + radius_max=2, + pulse_freq_min=10, + pulse_freq_max=50, + ) + # Rotating Earth spritesheet + self.earth_sprite = window.get_sprite("earth") + self.earth_frame = 0 + self.earth_frame_duration = 200 + self.earth_last_frame_time = 0 + self.fill_color = "#00FF00" + + # Moon sprite for lunar surface + try: + self.moon_sprite = window.get_sprite("moon") + except Exception: + self.moon_sprite = None + + self.credits = Credits(window.credits, self.fill_color) + + def _draw_earth(self, ctx, timestamp): + # Advance frame based on time + if self.earth_sprite and self.earth_sprite.is_loaded: + if self.earth_last_frame_time == 0: + self.earth_last_frame_time = timestamp + if timestamp - self.earth_last_frame_time >= self.earth_frame_duration: + self.earth_frame = (self.earth_frame + 1) % max(1, self.earth_sprite.num_frames) + self.earth_last_frame_time = timestamp + + frame_size = self.earth_sprite.frame_size if self.earth_sprite.num_frames > 1 else self.earth_sprite.height + sx = (self.earth_frame % max(1, self.earth_sprite.num_frames)) * frame_size + sy = 0 + + # Position Earth in upper-right, smaller size like the reference image + target_size = int(min(window.canvas.width, window.canvas.height) * 0.15) + dw = dh = target_size + dx = window.canvas.width * 0.65 # Right side of screen + dy = window.canvas.height * 0.15 # Upper portion + + ctx.drawImage( + self.earth_sprite.image, + sx, sy, frame_size, frame_size, + dx, dy, dw, dh + ) + + def _draw_lunar_surface(self, ctx): + # Draw lunar surface with the top portion visible, like looking across the lunar terrain + if self.moon_sprite and getattr(self.moon_sprite, "is_loaded", False): + # Position moon sprite so its upper portion is visible as foreground terrain + surface_height = window.canvas.height * 0.5 + + # Scale to fill screen width + scale = (window.canvas.width / self.moon_sprite.width) + sprite_scaled_height = self.moon_sprite.height * scale + + # Position so the moon extends below the screen, showing only the top portion + dy = window.canvas.height - surface_height + + ctx.drawImage( + self.moon_sprite.image, + 0, 0, self.moon_sprite.width, self.moon_sprite.height, + window.canvas.width - (window.canvas.width * scale)/1.25, dy, # target left, top + window.canvas.width * scale, sprite_scaled_height # target width, height + ) + + def render(self, ctx, timestamp): + window.audio_handler.play_music_main(pause_it=True) + window.audio_handler.play_music_thematic() + + draw_black_background(ctx) + + # Sparse stars + self.stars.render(ctx, timestamp) + + # Update and render scrolling credits before lunar surface + self.credits.update(timestamp) + self.credits.render(ctx, timestamp) + + # Draw lunar surface after credits so it appears as foreground + self._draw_lunar_surface(ctx) + + # Draw Earth in the distance + self._draw_earth(ctx, timestamp) + + if self.credits.finished: + ctx.font = f"{max(12, int(min(window.canvas.width, window.canvas.height)) * 0.025)}px Courier New" + instruction = "Click anywhere to return to solar system" + ctx.fillText(instruction, window.canvas.width * 0.05, window.canvas.height * 0.25) + ctx.restore() + + # Handle click to go back to orbiting planets scene + if window.controls.click: + # Reset all planet completions so we don't immediately return to final scene + orbiting_scene = next(scene for scene in self.scene_manager._scenes if scene.name == ORBITING_PLANETS_SCENE) + for planet in orbiting_scene.solar_sys.planets: + planet.complete = False + log.debug("Reset all planet completions when returning from final scene") + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + +# -------------------- +# create scene manager +# -------------------- + +def create_scene_manager() -> SceneManager: + """ + Create all the scenes and add them to a scene manager that can be used to switch between them The object + instance returned by this is used by the main game loop in game.py to check which scene is active when a + frame is drawn and that scene's render method is called. Only one scene listed in the scene manager is + active at a time, though scenes may have their own subscenes, such as textboxes that they render as part of + their routine. + """ + manager = SceneManager() + planet_scene_state = PlanetState(0, window.canvas.height, 120.0, x=0, y=window.canvas.height // 2) + solar_system = SolarSystem([window.canvas.width, window.canvas.height], planet_scene_state=planet_scene_state) + orbiting_planets_scene = OrbitingPlanetsScene(ORBITING_PLANETS_SCENE, manager, solar_system) + start_scene = StartScene(START_SCENE, manager) + manager.add_scene(start_scene) + manager.add_scene(orbiting_planets_scene) + # Final victory scene (activated when all planets complete) + final_scene = FinalScene(FINAL_SCENE, manager) + manager.add_scene(final_scene) + + for planet in solar_system.planets: + big_planet_scene = PlanetScene(f"{planet.name}-planet-scene", manager, planet) + manager.add_scene(big_planet_scene) + + manager.activate_scene(START_SCENE) # initial scene + return manager diff --git a/cool-cacti/static/scripts/solar_system.py b/cool-cacti/static/scripts/solar_system.py new file mode 100644 index 00000000..e4ef03d6 --- /dev/null +++ b/cool-cacti/static/scripts/solar_system.py @@ -0,0 +1,146 @@ +import math + +from common import PlanetState, Position +from scene_classes import SceneObject +from spacemass import SpaceMass +from window import window + +GRAVI_CONST = 0.67 + +from consolelogger import getLogger + +log = getLogger(__name__) + + +class SolarSystem(SceneObject): + def __init__(self, screen_size=[512, 512], *, planet_scene_state: PlanetState): + super().__init__() + + # Sun position (center of screen) + self.sun_pos: Position = Position(screen_size[0] // 2, screen_size[1] // 2) + + # Sun + self.sun = SpaceMass(window.get_sprite("sun"), PlanetState(1000.0, 120.0, 0.0), planet_scene_state) + self.sun.set_position(self.sun_pos) + + # Inner planets + self.mercury = SpaceMass(window.get_sprite("mercury"), PlanetState(3.3, 10, 2.5), planet_scene_state) + self.venus = SpaceMass(window.get_sprite("venus"), PlanetState(48.7, 14, 2.0), planet_scene_state) + self.earth = SpaceMass(window.get_sprite("earth"), PlanetState(59.7, 16, 1.8), planet_scene_state) + self.mars = SpaceMass(window.get_sprite("mars"), PlanetState(6.4, 12, 1.5), planet_scene_state) + + # Outer planets + self.jupiter = SpaceMass(window.get_sprite("jupiter"), PlanetState(1898.0, 64.0, 1.0), planet_scene_state) + self.saturn = SpaceMass(window.get_sprite("saturn"), PlanetState(568.0, 46.0, 0.8), planet_scene_state) + self.uranus = SpaceMass(window.get_sprite("uranus"), PlanetState(86.8, 36.0, 0.6), planet_scene_state) + self.neptune = SpaceMass(window.get_sprite("neptune"), PlanetState(102.0, 15.0, 0.4), planet_scene_state) + + self.planets = [ + self.mercury, + self.venus, + self.earth, + self.mars, + self.jupiter, + self.saturn, + self.uranus, + self.neptune, + ] + + # Initial positions (distance from sun in pixels) + self.planet_distances = [110, 140, 160, 200, 270, 350, 420, 470] + self.planet_angles: list[float] = [20, 220, 100, 45, 0, 155, 270, 15] + + # Initialize planet positions + for i, planet in enumerate(self.planets): + angle_rad = math.radians(self.planet_angles[i]) + x = self.sun_pos.x + self.planet_distances[i] * math.cos(angle_rad) + y = self.sun_pos.y + self.planet_distances[i] * math.sin(angle_rad) + planet.set_position(Position(x, y)) + planet.complete = False + + def update(self): + self.update_orbits(0.20) + + def get_planet(self, planet_name: str) -> SpaceMass | None: + for planet in self.planets: + if planet.name == planet_name: + return planet + + def update_orbits(self, dt: float): + """Update planet positions using simple circular orbits""" + for i, planet in enumerate(self.planets): + angular_velocity = planet.state.initial_velocity * 0.01 + + # Update angle + self.planet_angles[i] += angular_velocity * dt * 60 # Scale for 60 FPS + + # Keep angle in range [0, 360) + self.planet_angles[i] = self.planet_angles[i] % 360 + + # Calculate new position using circular motion + angle_rad = math.radians(self.planet_angles[i]) + x = self.sun_pos.x + self.planet_distances[i] * math.cos(angle_rad) + y = self.sun_pos.y + self.planet_distances[i] * math.sin(angle_rad) + + # Update position + self.planets[i].set_position(Position(x, y)) + + def render(self, ctx, timestamp): + """Render the entire solar system""" + # Render sun at center + self.sun.render(ctx, timestamp) + + # Render all planets + highlighted_planet = None + for planet in self.planets: + if planet.highlighted: + highlighted_planet = planet + continue + planet.render(ctx, timestamp) + + # If a planet is highlighted, draw it last, so its text label is in front of other planets + if highlighted_planet: + highlighted_planet.render(ctx, timestamp) + + super().render(ctx, timestamp) + + # I Couldn't get this to work 〒__〒 + def calculateGForce(self, planet_index: int) -> float: + """Calculate gravitational force between the sun and a planet""" + # Get planet position + planet_pos = self.planets[planet_index].get_position() + planet = self.planets[planet_index] + + # Calculate distance between sun and planet + distance = planet_pos.distance(self.sun_pos) + + # Prevent division by zero + if distance == 0: + return 0 + + # F = G * m1 * m2 / r^2 + force = GRAVI_CONST * self.sun.state.mass * planet.state.mass / (distance * distance) + + return force + + def get_object_at_position(self, pos: Position) -> SpaceMass | None: + """Get the space object at the specified position, excluding the sun. + + Arguments: + pos (Position): The position to check. + + Returns: + The space object at the position if found, otherwise None. + """ + closest_planet = None + closest_distance = float("inf") + for planet in self.planets: + rect = planet.get_bounding_box() + if rect.left <= pos.x <= rect.right and rect.top <= pos.y <= rect.bottom: + # Calculate distance from click point to planet center + planet_center = Position(rect.left + rect.width / 2, rect.top + rect.height / 2) + distance = planet_center.distance(pos) + if distance < closest_distance: + closest_distance = distance + closest_planet = planet + return closest_planet diff --git a/cool-cacti/static/scripts/spacemass.py b/cool-cacti/static/scripts/spacemass.py new file mode 100644 index 00000000..1203eaf5 --- /dev/null +++ b/cool-cacti/static/scripts/spacemass.py @@ -0,0 +1,111 @@ +from common import PlanetState, Rect +from consolelogger import getLogger +from scene_classes import SceneObject +from window import SpriteSheet + +log = getLogger(__name__) + + +class SpaceMass(SceneObject): + def __init__(self, spritesheet: SpriteSheet, orbit_state: PlanetState, planet_scene_state: PlanetState) -> None: + super().__init__() + + self.spritesheet = spritesheet + self.name = spritesheet.key + + self.state: PlanetState = orbit_state + self._saved_state: PlanetState = planet_scene_state + + self.x = self.state.x + self.y = self.state.y + + self.current_frame = 0 + self.animation_timer = 0 + self.frame_delay = 135 # (approximately 6 FPS) + + self.highlighted = False + self.complete = False + + # State management + + def get_bounding_box(self) -> Rect: + # Scale sprite based on radius + sprite_size = int(self.state.radius) / 80.0 + frame_size = self.spritesheet.height + + left = self.x - frame_size // 2 * sprite_size + top = self.y - frame_size // 2 * sprite_size + size = frame_size * sprite_size + + return Rect(left, top, size, size) + + def render(self, ctx, timestamp): + # Update animation timing + if timestamp - self.animation_timer >= self.frame_delay: + self.current_frame = (self.current_frame + 1) % self.spritesheet.num_frames + self.animation_timer = timestamp + + bounds = self.get_bounding_box() + frame_position = self.spritesheet.get_frame_position(self.current_frame) + ctx.drawImage( + self.spritesheet.image, + frame_position.x, + frame_position.y, + self.spritesheet.frame_size, + self.spritesheet.frame_size, + bounds.left, + bounds.top, + bounds.width, + bounds.height, + ) + if self.complete: + highlight = "#00ff00" + else: + highlight = "#ffff00" # yellow highlight + + offset = 5 + # Draw highlight effect if planet is highlighted + if self.highlighted: + if self.complete: + # log.debug("planet complete") + highlight = "#00ff00" + else: + # log.debug("planet not complete") + highlight = "#ffff00" # yellow highlight + ctx.save() + ctx.strokeStyle = highlight + ctx.shadowColor = highlight + ctx.lineWidth = 3 + ctx.shadowBlur = 10 + + # Draw a circle around the planet + center_x = bounds.left + bounds.width / 2 + center_y = bounds.top + bounds.height / 2 + radius = bounds.width / 2 + offset # Slightly larger than the planet + + ctx.beginPath() + ctx.arc(center_x, center_y, radius, 0, 2 * 3.14159) + ctx.stroke() + + # draw planet name labels when hovering over + ctx.shadowBlur = 0 + ctx.beginPath() + ctx.moveTo(center_x, center_y - radius) + ctx.lineTo(center_x + 10, center_y - radius - 10) + ctx.font = "14px Courier New" + ctx.fillStyle = highlight + text_width = ctx.measureText(self.name.capitalize()).width + ctx.lineTo(center_x + 15 + text_width, center_y - radius - 10) + ctx.fillText(self.name.capitalize(), center_x + 15, center_y - radius - 15) + ctx.stroke() + + ctx.restore() + + super().render(ctx, timestamp) + + def switch_view(self) -> None: + """Configure planet view""" + self.state.x, self.state.y = self.x, self.y + self.state, self._saved_state = self._saved_state, self.state + self.x, self.y = self.state.x, self.state.y + self.highlighted = False # Clear highlighting when switching views diff --git a/cool-cacti/static/scripts/sprites.py b/cool-cacti/static/scripts/sprites.py new file mode 100644 index 00000000..4e1a2f17 --- /dev/null +++ b/cool-cacti/static/scripts/sprites.py @@ -0,0 +1,53 @@ +from common import Position +from consolelogger import getLogger +from js import window as js_window # type: ignore[attr-defined] + +log = getLogger(__name__) + + +class SpriteSheet: + """Wrapper for individual sprites with enhanced functionality.""" + + def __init__(self, key: str): + self.key = key.lower() + # Get raw image from js_window.sprites directly to avoid circular import + self.image = js_window.sprites[self.key] + + @property + def height(self): + """Height of the sprite image.""" + return self.image.height + + @property + def width(self): + """Width of the sprite image.""" + return self.image.width + + @property + def frame_size(self): + """Size of each frame (assuming square frames).""" + return self.height + + @property + def is_loaded(self): + return self.height > 0 and self.width > 0 + + @property + def num_frames(self): + """Number of frames in the spritesheet.""" + if not self.is_loaded: + log.warning("Frame size is zero for sprite '%s'", self.key) + return 1 + return self.width // self.frame_size + + def get_frame_position(self, frame: int) -> Position: + """Get the position of a specific frame in the spritesheet with overflow handling.""" + if self.num_frames == 0: + return Position(0, 0) + frame_index = frame % self.num_frames + x = frame_index * self.frame_size + return Position(x, 0) + + # Delegate other attributes to the underlying image + def __getattr__(self, name): + return getattr(self.image, name) diff --git a/cool-cacti/static/scripts/stars.py b/cool-cacti/static/scripts/stars.py new file mode 100644 index 00000000..d87f1954 --- /dev/null +++ b/cool-cacti/static/scripts/stars.py @@ -0,0 +1,206 @@ +import math +import random + +from scene_classes import SceneObject +from window import window +from js import document #type: ignore +loadingLabel = document.getElementById("loadingLabel") +container = document.getElementById("canvasContainer") +width, height = container.clientWidth, container.clientHeight + + +class Star: + def __init__(self, radius, x, y, pulse_freq, color, shade=0, fade_in=True) -> None: + self.radius = radius + self.frame_delay = 135 + self.pulse_freq = pulse_freq # renaming of animation timer + self.x = x + self.y = y + self.shade = shade # defines r,g, and b + self.alpha = 1 + self.color = color + self.fade_in = fade_in + self.animation_timer = 0 + self.glisten = False + + def render(self, ctx, timestamp, num_stars) -> None: + # pulse + if timestamp - self.animation_timer >= self.pulse_freq: + self.animation_timer = timestamp + if self.fade_in: + self.shade += 1 + else: + self.shade -= 1 + + if self.shade > 255 or self.shade < 1: + self.fade_in = not self.fade_in + + self.render_color = self.rgba_to_str(*self.color, self.shade / 255.0) + + # draw star + ctx.fillStyle = self.render_color + ctx.beginPath() + ctx.ellipse(self.x, self.y, self.radius, self.radius, 0, 0, 2 * math.pi) + ctx.fill() + + chance_glisten = random.randint(1, num_stars * 4) + if chance_glisten == num_stars: + self.glisten = True + # glisten + if self.shade > 240 and self.glisten: + glisten_line_col = self.render_color + + ctx.strokeStyle = glisten_line_col # or any visible color + ctx.lineWidth = 2 # thick enough to see + ctx.beginPath() + ctx.moveTo(self.x, self.y - self.radius - 5) # start drawing curve a bit lower than star pos + ctx.bezierCurveTo( + self.x - self.radius, + self.y - self.radius, + self.x + self.radius, + self.y + self.radius, + self.x, + self.y + self.radius + 5, + ) + ctx.stroke() + else: + self.glisten = False + + def rgba_to_str(self, r: int, g: int, b: int, a: int) -> str: + return f"rgba({r}, {g}, {b}, {a})" + + +class StarSystem(SceneObject): + + WHITE = (255, 255, 255) + YELLOW = (255, 223, 79) + BLUE = (100, 149, 237) + RED = (255, 99, 71) + PURPLE = (186, 85, 211) + + COLORS = [WHITE, YELLOW, BLUE, RED, PURPLE] + # chance for each color to be used, most will be white but other colors can also occur + WEIGHTS = [100, 15, 15, 15, 3] + + def __init__(self, num_stars, radius_min, radius_max, pulse_freq_min, pulse_freq_max, num_frames=50): + super().__init__() + + self.num_frames = num_frames + self.radius_min = radius_min + self.radius_max = radius_max + self.pulse_freq_min = pulse_freq_min + self.pulse_freq_max = pulse_freq_max + self.frame_delay = 135 + self.num_stars = num_stars + self.animation_timer = 0 + self.stars: list[Star] = [] # will be filled with star object instances + + for _ in range(num_stars): + self.stars.append(self.create_star("random", "random")) + + def random_color(self) -> tuple: + return random.choices(StarSystem.COLORS, weights=StarSystem.WEIGHTS)[0] + + def render(self, ctx, timestamp) -> None: + """Render every star.""" + for star in self.stars: + star.render(ctx, timestamp, self.num_stars) + + if len(self.stars) == 0: + raise ValueError("There are no stars! Did you populate?") + + super().render(ctx, timestamp) + + def create_star(self, x="random", y="random"): + if x == "random": + x = random.randint(0, window.canvas.width) + if y == "random": + y = random.randint(0, window.canvas.height) + + pulse_freq = random.randint(self.pulse_freq_min, self.pulse_freq_max) + radius = random.randint(self.radius_min, self.radius_max) + shade = random.randint(0, 255) + fade_in = random.choice([True, False]) + return Star(radius, x, y, pulse_freq, self.random_color(), shade=shade, fade_in=fade_in) + + def star_shift(self, current_time, shift_time): + if current_time - self.animation_timer >= shift_time: + self.animation_timer = current_time + replacement_stars = [] + for index, star in enumerate(self.stars): + star.x += 1 + if abs(star.x) > window.canvas.width or abs(star.y) > window.canvas.height: + self.stars.pop(index) + replacement_star = self.create_star(0, "random") + replacement_stars.append(replacement_star) + + for star in replacement_stars: + self.stars.append(star) + + def star_scale(self, current_time, shift_time): + if current_time - self.animation_timer >= shift_time: + self.animation_timer = current_time + +class Star3d(Star): + def __init__(self, radius, x, y, z, pulse_freq, shade=0, fade_in=True): + super().__init__(radius, x, y, pulse_freq, shade, fade_in) + self.z = z + + def update(self, speed, max_depth): + """Move the star closer by reducing z.""" + self.z -= speed + if self.z <= 0: # if it passes the camera, recycle + self.z = max_depth + self.x = random.uniform(-1, 1) + self.y = random.uniform(-1, 1) + + def project(self, cx, cy, max_radius, scale): + """Project 3D coords to 2D screen coords.""" + screen_x = cx + (self.x / self.z) * scale + screen_y = cy + (self.y / self.z) * scale + size = max(1, (1 / self.z) * scale * 0.5) # star grows as z decreases + if size > max_radius: + size = max_radius + + return screen_x, screen_y, size + + +class StarSystem3d: + def __init__(self, num_stars, max_depth=5, max_radius = 20): + self.num_stars = num_stars + self.max_depth = max_depth + self.max_radius = max_radius + self.stars: list[Star3d] = [] + for _ in range(num_stars): + self.stars.append(self.create_star()) + + def create_star(self): + x = random.randint(-width//2, width//2) + y = random.randint(-height//2, height//2) + z = random.uniform(20, self.max_depth) + pulse_freq = random.randint(30, 80) # tweak as desired + radius = 1 + shade = random.randint(150, 255) + fade_in = True + return Star3d(radius, x, y, z, pulse_freq, shade=shade, fade_in=fade_in) + + def render(self, ctx, speed=0.4, scale=300): + cx = window.canvas.width / 2 + cy = window.canvas.height / 2 + + for index, star in enumerate(self.stars): + star.update(speed, self.max_depth) + sx, sy, size = star.project(cx, cy, self.max_radius, scale) + + # If star leaves screen, recycle it + if sx < 0 or sx > window.canvas.width or sy < 0 or sy > window.canvas.height: + self.stars.pop(index) + self.stars.append(self.create_star()) + + # Draw star (brightens as it approaches) + shade = int(255 * (1 - star.z / self.max_depth)) + ctx.fillStyle = f"rgba({shade}, {shade}, {shade}, 1)" + ctx.beginPath() + ctx.ellipse(sx, sy, size, size, 0, 0, 2 * math.pi) + ctx.fill() + diff --git a/cool-cacti/static/scripts/window.py b/cool-cacti/static/scripts/window.py new file mode 100644 index 00000000..0ec61255 --- /dev/null +++ b/cool-cacti/static/scripts/window.py @@ -0,0 +1,145 @@ +"""Typed wrapper over window and stored objects + +Use instead of importing directly from js + +Usage +------- +from window import window +""" + +from typing import TYPE_CHECKING, Any + +from js import window # type: ignore[attr-defined] + +if TYPE_CHECKING: + from asteroid import AsteroidAttack + from audio import AudioHandler + from common import HTMLImageElement + from controls import GameControls + from debris import DebrisSystem + from player import Player, Scanner + +from common import SpriteSheet, AsteroidData, PlanetData + + +class SpritesInterface: + """Interface for accessing window.sprites with SpriteSheet wrapping.""" + + def __init__(self, js_window: Any) -> None: + self._window = js_window + + def __getitem__(self, key: str) -> "SpriteSheet": + """Access sprites as SpriteSheet objects.""" + return SpriteSheet(key, self._window.sprites[key]) + + +class WindowInterface: + """Typed interface for accessing window object properties with dynamic fallback. + + Sprites, AudioHandler, and Planets are internally managed and changes to them are not + reflected in the underlying JS objects. Other properties are accessed directly from the JS + window object. + """ + + def __init__(self, js_window: Any) -> None: + self._window = js_window + self._sprites = SpritesInterface(js_window) # Wrap sprites in SpritesInterface + self.DEBUG_DRAW_HITBOXES: bool = getattr(js_window, "DEBUG_DRAW_HITBOXES", False) + self.audio_handler = js_window.audio_handler + self._planet_dataclasses: dict[str, PlanetData] = {} + self._serialize_planets() + + def _serialize_planets(self) -> None: + """Convert raw planet data from JS to PlanetData dataclass instances.""" + raw_planets = getattr(self._window, 'planets', []) + self._planet_dataclasses = {} + + for planet_dict in raw_planets: + planet = PlanetData.from_dict(planet_dict) + self._planet_dataclasses[planet.name] = planet + + @property + def audio_handler(self) -> "AudioHandler": + return self._window.audio_handler + + @audio_handler.setter + def audio_handler(self, value: "AudioHandler") -> None: + self._window.audio_handler = value + + @property + def controls(self) -> "GameControls": + return self._window.controls + + @controls.setter + def controls(self, value: "GameControls") -> None: + self._window.controls = value + + @property + def player(self) -> "Player": + return self._window.player + + @player.setter + def player(self, value: "Player") -> None: + self._window.player = value + + @property + def asteroids(self) -> "AsteroidAttack": + return self._window.asteroids + + @asteroids.setter + def asteroids(self, value: "AsteroidAttack") -> None: + self._window.asteroids = value + + @property + def debris(self) -> "DebrisSystem": + return self._window.debris + + @debris.setter + def debris(self, value: "DebrisSystem") -> None: + self._window.debris = value + + @property + def scanner(self) -> "Scanner": + return self._window.scanner + + @scanner.setter + def scanner(self, value: "Scanner") -> None: + self._window.scanner = value + + @property + def planets(self) -> dict[str, PlanetData]: + return self._planet_dataclasses + + @planets.setter + def planets(self, value: dict[str, PlanetData]) -> None: + self._planet_dataclasses = value + + def get_planet(self, name: str) -> PlanetData | None: + return self._planet_dataclasses.get(name.title()) + + @property + def sprites(self) -> SpritesInterface: + """Access sprites as SpriteSheet objects.""" + return self._sprites + + def get_sprite(self, key: str) -> SpriteSheet: + """Get a sprite by key - more intuitive than sprites[key].""" + return self._sprites[key] + + def __getattr__(self, name: str) -> Any: + """Dynamic fallback for accessing any window property.""" + return getattr(self._window, name) + + def __setattr__(self, name: str, value: Any) -> None: + """Dynamic fallback for setting any window property.""" + if name.startswith("_"): + super().__setattr__(name, value) + else: + setattr(self._window, name, value) + + +# Create typed interface instance +window_interface = WindowInterface(window) + +# Expose for backward compatibility +window = window_interface diff --git a/cool-cacti/static/sprites/Explosion Animation.png b/cool-cacti/static/sprites/Explosion Animation.png new file mode 100644 index 00000000..988560af Binary files /dev/null and b/cool-cacti/static/sprites/Explosion Animation.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1015757868.png b/cool-cacti/static/sprites/asteroid sprites/1015757868.png new file mode 100644 index 00000000..04d2fd7e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1015757868.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1024695167.png b/cool-cacti/static/sprites/asteroid sprites/1024695167.png new file mode 100644 index 00000000..9d673523 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1024695167.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1057991680.png b/cool-cacti/static/sprites/asteroid sprites/1057991680.png new file mode 100644 index 00000000..a7aa6d6d Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1057991680.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1069494109.png b/cool-cacti/static/sprites/asteroid sprites/1069494109.png new file mode 100644 index 00000000..65c948f2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1069494109.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/109305397.png b/cool-cacti/static/sprites/asteroid sprites/109305397.png new file mode 100644 index 00000000..38a214c7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/109305397.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1145028418.png b/cool-cacti/static/sprites/asteroid sprites/1145028418.png new file mode 100644 index 00000000..b51dbe48 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1145028418.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1145439098.png b/cool-cacti/static/sprites/asteroid sprites/1145439098.png new file mode 100644 index 00000000..6d7e7ffd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1145439098.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1150396768.png b/cool-cacti/static/sprites/asteroid sprites/1150396768.png new file mode 100644 index 00000000..f2b76c40 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1150396768.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1153566565.png b/cool-cacti/static/sprites/asteroid sprites/1153566565.png new file mode 100644 index 00000000..f45e07cd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1153566565.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/118564947.png b/cool-cacti/static/sprites/asteroid sprites/118564947.png new file mode 100644 index 00000000..b5f9573b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/118564947.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/123618070.png b/cool-cacti/static/sprites/asteroid sprites/123618070.png new file mode 100644 index 00000000..85330978 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/123618070.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/124465317.png b/cool-cacti/static/sprites/asteroid sprites/124465317.png new file mode 100644 index 00000000..d2b60b61 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/124465317.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1246437094.png b/cool-cacti/static/sprites/asteroid sprites/1246437094.png new file mode 100644 index 00000000..fd47cf88 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1246437094.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1329742594.png b/cool-cacti/static/sprites/asteroid sprites/1329742594.png new file mode 100644 index 00000000..854404d0 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1329742594.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1424204855.png b/cool-cacti/static/sprites/asteroid sprites/1424204855.png new file mode 100644 index 00000000..1725e7c1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1424204855.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1446250313.png b/cool-cacti/static/sprites/asteroid sprites/1446250313.png new file mode 100644 index 00000000..bade0eb1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1446250313.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1468273590.png b/cool-cacti/static/sprites/asteroid sprites/1468273590.png new file mode 100644 index 00000000..adb8b0ce Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1468273590.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1488973772.png b/cool-cacti/static/sprites/asteroid sprites/1488973772.png new file mode 100644 index 00000000..97aed693 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1488973772.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/150029986.png b/cool-cacti/static/sprites/asteroid sprites/150029986.png new file mode 100644 index 00000000..933676bd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/150029986.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1691535978.png b/cool-cacti/static/sprites/asteroid sprites/1691535978.png new file mode 100644 index 00000000..15e43511 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1691535978.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1725661796.png b/cool-cacti/static/sprites/asteroid sprites/1725661796.png new file mode 100644 index 00000000..48c282fb Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1725661796.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1739499255.png b/cool-cacti/static/sprites/asteroid sprites/1739499255.png new file mode 100644 index 00000000..20a087c1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1739499255.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/17996437.png b/cool-cacti/static/sprites/asteroid sprites/17996437.png new file mode 100644 index 00000000..0984f7e6 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/17996437.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1888508484.png b/cool-cacti/static/sprites/asteroid sprites/1888508484.png new file mode 100644 index 00000000..1d1b8c83 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1888508484.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1926091214.png b/cool-cacti/static/sprites/asteroid sprites/1926091214.png new file mode 100644 index 00000000..ec5d96ee Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1926091214.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1952977097.png b/cool-cacti/static/sprites/asteroid sprites/1952977097.png new file mode 100644 index 00000000..a877ed46 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1952977097.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/202411860.png b/cool-cacti/static/sprites/asteroid sprites/202411860.png new file mode 100644 index 00000000..3a9563c8 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/202411860.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2075950734.png b/cool-cacti/static/sprites/asteroid sprites/2075950734.png new file mode 100644 index 00000000..2dce77c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2075950734.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2079996232.png b/cool-cacti/static/sprites/asteroid sprites/2079996232.png new file mode 100644 index 00000000..0c3051ee Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2079996232.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2106171138.png b/cool-cacti/static/sprites/asteroid sprites/2106171138.png new file mode 100644 index 00000000..2c608da2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2106171138.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2118383358.png b/cool-cacti/static/sprites/asteroid sprites/2118383358.png new file mode 100644 index 00000000..b9b80f1a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2118383358.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2183076256.png b/cool-cacti/static/sprites/asteroid sprites/2183076256.png new file mode 100644 index 00000000..d46e66e5 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2183076256.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2195883195.png b/cool-cacti/static/sprites/asteroid sprites/2195883195.png new file mode 100644 index 00000000..607913da Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2195883195.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/224063959.png b/cool-cacti/static/sprites/asteroid sprites/224063959.png new file mode 100644 index 00000000..353b7e43 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/224063959.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2241089568.png b/cool-cacti/static/sprites/asteroid sprites/2241089568.png new file mode 100644 index 00000000..2096ca4e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2241089568.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2274699037.png b/cool-cacti/static/sprites/asteroid sprites/2274699037.png new file mode 100644 index 00000000..98f9c1b9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2274699037.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2299188070.png b/cool-cacti/static/sprites/asteroid sprites/2299188070.png new file mode 100644 index 00000000..85330978 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2299188070.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/233236783.png b/cool-cacti/static/sprites/asteroid sprites/233236783.png new file mode 100644 index 00000000..5356e197 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/233236783.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2363091580.png b/cool-cacti/static/sprites/asteroid sprites/2363091580.png new file mode 100644 index 00000000..1822803a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2363091580.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2384002807.png b/cool-cacti/static/sprites/asteroid sprites/2384002807.png new file mode 100644 index 00000000..ef2f3cc3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2384002807.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/238590564.png b/cool-cacti/static/sprites/asteroid sprites/238590564.png new file mode 100644 index 00000000..8e3c85ce Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/238590564.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2445912905.png b/cool-cacti/static/sprites/asteroid sprites/2445912905.png new file mode 100644 index 00000000..4075361e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2445912905.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2450256434.png b/cool-cacti/static/sprites/asteroid sprites/2450256434.png new file mode 100644 index 00000000..509dd835 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2450256434.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2460470952.png b/cool-cacti/static/sprites/asteroid sprites/2460470952.png new file mode 100644 index 00000000..b91ee2ef Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2460470952.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2492836461.png b/cool-cacti/static/sprites/asteroid sprites/2492836461.png new file mode 100644 index 00000000..6363e944 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2492836461.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2516703929.png b/cool-cacti/static/sprites/asteroid sprites/2516703929.png new file mode 100644 index 00000000..18286a86 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2516703929.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2541679965.png b/cool-cacti/static/sprites/asteroid sprites/2541679965.png new file mode 100644 index 00000000..fc5d3958 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2541679965.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2607102593.png b/cool-cacti/static/sprites/asteroid sprites/2607102593.png new file mode 100644 index 00000000..23531159 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2607102593.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2633938101.png b/cool-cacti/static/sprites/asteroid sprites/2633938101.png new file mode 100644 index 00000000..dd0f46d1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2633938101.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/271782734.png b/cool-cacti/static/sprites/asteroid sprites/271782734.png new file mode 100644 index 00000000..2dce77c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/271782734.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2745850591.png b/cool-cacti/static/sprites/asteroid sprites/2745850591.png new file mode 100644 index 00000000..848f32d7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2745850591.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2764849719.png b/cool-cacti/static/sprites/asteroid sprites/2764849719.png new file mode 100644 index 00000000..ccac8517 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2764849719.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2785063265.png b/cool-cacti/static/sprites/asteroid sprites/2785063265.png new file mode 100644 index 00000000..e4221e8a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2785063265.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2789899429.png b/cool-cacti/static/sprites/asteroid sprites/2789899429.png new file mode 100644 index 00000000..41b4e6da Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2789899429.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2791378748.png b/cool-cacti/static/sprites/asteroid sprites/2791378748.png new file mode 100644 index 00000000..0fb2c6b9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2791378748.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2844830477.png b/cool-cacti/static/sprites/asteroid sprites/2844830477.png new file mode 100644 index 00000000..7862dc07 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2844830477.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2896295212.png b/cool-cacti/static/sprites/asteroid sprites/2896295212.png new file mode 100644 index 00000000..b0beab28 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2896295212.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2924163492.png b/cool-cacti/static/sprites/asteroid sprites/2924163492.png new file mode 100644 index 00000000..ea65e49c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2924163492.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2925291501.png b/cool-cacti/static/sprites/asteroid sprites/2925291501.png new file mode 100644 index 00000000..f1f7d4ba Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2925291501.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3077723619.png b/cool-cacti/static/sprites/asteroid sprites/3077723619.png new file mode 100644 index 00000000..a38560c2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3077723619.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3130864082.png b/cool-cacti/static/sprites/asteroid sprites/3130864082.png new file mode 100644 index 00000000..a3bc860e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3130864082.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3167532641.png b/cool-cacti/static/sprites/asteroid sprites/3167532641.png new file mode 100644 index 00000000..104533b1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3167532641.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3168216978.png b/cool-cacti/static/sprites/asteroid sprites/3168216978.png new file mode 100644 index 00000000..15e43511 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3168216978.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3170379696.png b/cool-cacti/static/sprites/asteroid sprites/3170379696.png new file mode 100644 index 00000000..36c05e75 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3170379696.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3193832944.png b/cool-cacti/static/sprites/asteroid sprites/3193832944.png new file mode 100644 index 00000000..945ca9c2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3193832944.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3288721588.png b/cool-cacti/static/sprites/asteroid sprites/3288721588.png new file mode 100644 index 00000000..17e90e25 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3288721588.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3297273057.png b/cool-cacti/static/sprites/asteroid sprites/3297273057.png new file mode 100644 index 00000000..c60001b7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3297273057.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3396683436.png b/cool-cacti/static/sprites/asteroid sprites/3396683436.png new file mode 100644 index 00000000..02200593 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3396683436.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3398415923.png b/cool-cacti/static/sprites/asteroid sprites/3398415923.png new file mode 100644 index 00000000..de19da49 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3398415923.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3417753021.png b/cool-cacti/static/sprites/asteroid sprites/3417753021.png new file mode 100644 index 00000000..0870257b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3417753021.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3428788332.png b/cool-cacti/static/sprites/asteroid sprites/3428788332.png new file mode 100644 index 00000000..0fa9096a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3428788332.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3482339427.png b/cool-cacti/static/sprites/asteroid sprites/3482339427.png new file mode 100644 index 00000000..c5550123 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3482339427.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3517180902.png b/cool-cacti/static/sprites/asteroid sprites/3517180902.png new file mode 100644 index 00000000..1a5c8a54 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3517180902.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3517671125.png b/cool-cacti/static/sprites/asteroid sprites/3517671125.png new file mode 100644 index 00000000..9d1d70c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3517671125.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3535940278.png b/cool-cacti/static/sprites/asteroid sprites/3535940278.png new file mode 100644 index 00000000..e4986f36 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3535940278.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3575891895.png b/cool-cacti/static/sprites/asteroid sprites/3575891895.png new file mode 100644 index 00000000..02ff6698 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3575891895.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3622594717.png b/cool-cacti/static/sprites/asteroid sprites/3622594717.png new file mode 100644 index 00000000..91cfe09a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3622594717.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3634889999.png b/cool-cacti/static/sprites/asteroid sprites/3634889999.png new file mode 100644 index 00000000..45a5225a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3634889999.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3636126819.png b/cool-cacti/static/sprites/asteroid sprites/3636126819.png new file mode 100644 index 00000000..910bbc70 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3636126819.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3642894544.png b/cool-cacti/static/sprites/asteroid sprites/3642894544.png new file mode 100644 index 00000000..c4af9c5e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3642894544.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3700018417.png b/cool-cacti/static/sprites/asteroid sprites/3700018417.png new file mode 100644 index 00000000..6159edec Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3700018417.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3726866554.png b/cool-cacti/static/sprites/asteroid sprites/3726866554.png new file mode 100644 index 00000000..38f58c22 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3726866554.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3736627942.png b/cool-cacti/static/sprites/asteroid sprites/3736627942.png new file mode 100644 index 00000000..74abc7d4 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3736627942.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3763505851.png b/cool-cacti/static/sprites/asteroid sprites/3763505851.png new file mode 100644 index 00000000..206e20f8 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3763505851.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3776567904.png b/cool-cacti/static/sprites/asteroid sprites/3776567904.png new file mode 100644 index 00000000..4ac97c73 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3776567904.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3826558147.png b/cool-cacti/static/sprites/asteroid sprites/3826558147.png new file mode 100644 index 00000000..495fbfa7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3826558147.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3989211066.png b/cool-cacti/static/sprites/asteroid sprites/3989211066.png new file mode 100644 index 00000000..2dac3126 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3989211066.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/401461502.png b/cool-cacti/static/sprites/asteroid sprites/401461502.png new file mode 100644 index 00000000..5a0591e6 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/401461502.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4021916695.png b/cool-cacti/static/sprites/asteroid sprites/4021916695.png new file mode 100644 index 00000000..60dfb81c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4021916695.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/402551533.png b/cool-cacti/static/sprites/asteroid sprites/402551533.png new file mode 100644 index 00000000..f04c982c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/402551533.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4071461444.png b/cool-cacti/static/sprites/asteroid sprites/4071461444.png new file mode 100644 index 00000000..b31e2c8e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4071461444.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4193216520.png b/cool-cacti/static/sprites/asteroid sprites/4193216520.png new file mode 100644 index 00000000..452eaf11 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4193216520.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4247189585.png b/cool-cacti/static/sprites/asteroid sprites/4247189585.png new file mode 100644 index 00000000..4214deac Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4247189585.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4279862316.png b/cool-cacti/static/sprites/asteroid sprites/4279862316.png new file mode 100644 index 00000000..a63e21aa Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4279862316.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/465935841.png b/cool-cacti/static/sprites/asteroid sprites/465935841.png new file mode 100644 index 00000000..8e97fb0a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/465935841.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/483449870.png b/cool-cacti/static/sprites/asteroid sprites/483449870.png new file mode 100644 index 00000000..f51854e9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/483449870.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/514412427.png b/cool-cacti/static/sprites/asteroid sprites/514412427.png new file mode 100644 index 00000000..c5550123 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/514412427.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/569870057.png b/cool-cacti/static/sprites/asteroid sprites/569870057.png new file mode 100644 index 00000000..c60001b7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/569870057.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/605085139.png b/cool-cacti/static/sprites/asteroid sprites/605085139.png new file mode 100644 index 00000000..56a6410a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/605085139.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/687857303.png b/cool-cacti/static/sprites/asteroid sprites/687857303.png new file mode 100644 index 00000000..d79f5f44 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/687857303.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/767603859.png b/cool-cacti/static/sprites/asteroid sprites/767603859.png new file mode 100644 index 00000000..cd5df681 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/767603859.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/886635296.png b/cool-cacti/static/sprites/asteroid sprites/886635296.png new file mode 100644 index 00000000..2c59703f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/886635296.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/938145699.png b/cool-cacti/static/sprites/asteroid sprites/938145699.png new file mode 100644 index 00000000..f49cccb9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/938145699.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/950121403.png b/cool-cacti/static/sprites/asteroid sprites/950121403.png new file mode 100644 index 00000000..99b3d1af Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/950121403.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_00.png b/cool-cacti/static/sprites/asteroid sprites/recycle_00.png new file mode 100644 index 00000000..85ac1bfc Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_00.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_01.png b/cool-cacti/static/sprites/asteroid sprites/recycle_01.png new file mode 100644 index 00000000..db17320f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_01.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_02.png b/cool-cacti/static/sprites/asteroid sprites/recycle_02.png new file mode 100644 index 00000000..710a1d93 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_02.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_03.png b/cool-cacti/static/sprites/asteroid sprites/recycle_03.png new file mode 100644 index 00000000..d98c1feb Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_03.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_04.png b/cool-cacti/static/sprites/asteroid sprites/recycle_04.png new file mode 100644 index 00000000..1b406fa1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_04.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_05.png b/cool-cacti/static/sprites/asteroid sprites/recycle_05.png new file mode 100644 index 00000000..1b7e90a5 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_05.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_06.png b/cool-cacti/static/sprites/asteroid sprites/recycle_06.png new file mode 100644 index 00000000..b814a0e9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_06.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_07.png b/cool-cacti/static/sprites/asteroid sprites/recycle_07.png new file mode 100644 index 00000000..ef3db48f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_07.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_08.png b/cool-cacti/static/sprites/asteroid sprites/recycle_08.png new file mode 100644 index 00000000..04a15a24 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_08.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_09.png b/cool-cacti/static/sprites/asteroid sprites/recycle_09.png new file mode 100644 index 00000000..d19191c7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_09.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_10.png b/cool-cacti/static/sprites/asteroid sprites/recycle_10.png new file mode 100644 index 00000000..b2331eac Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_10.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_11.png b/cool-cacti/static/sprites/asteroid sprites/recycle_11.png new file mode 100644 index 00000000..1c1be376 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_11.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_12.png b/cool-cacti/static/sprites/asteroid sprites/recycle_12.png new file mode 100644 index 00000000..9909ab53 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_12.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_13.png b/cool-cacti/static/sprites/asteroid sprites/recycle_13.png new file mode 100644 index 00000000..ffaf2808 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_13.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_14.png b/cool-cacti/static/sprites/asteroid sprites/recycle_14.png new file mode 100644 index 00000000..b0c5190b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_14.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_15.png b/cool-cacti/static/sprites/asteroid sprites/recycle_15.png new file mode 100644 index 00000000..e2548d44 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_15.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_items.png b/cool-cacti/static/sprites/asteroid sprites/recycle_items.png new file mode 100644 index 00000000..ee9b847a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_items.png differ diff --git a/cool-cacti/static/sprites/asteroids.png b/cool-cacti/static/sprites/asteroids.png new file mode 100644 index 00000000..88dccb59 Binary files /dev/null and b/cool-cacti/static/sprites/asteroids.png differ diff --git a/cool-cacti/static/sprites/earth.png b/cool-cacti/static/sprites/earth.png new file mode 100644 index 00000000..3bd1d862 Binary files /dev/null and b/cool-cacti/static/sprites/earth.png differ diff --git a/cool-cacti/static/sprites/earthtest.png b/cool-cacti/static/sprites/earthtest.png new file mode 100644 index 00000000..18fcc8af Binary files /dev/null and b/cool-cacti/static/sprites/earthtest.png differ diff --git a/cool-cacti/static/sprites/health.png b/cool-cacti/static/sprites/health.png new file mode 100644 index 00000000..fbd57f8c Binary files /dev/null and b/cool-cacti/static/sprites/health.png differ diff --git a/cool-cacti/static/sprites/jupiter.png b/cool-cacti/static/sprites/jupiter.png new file mode 100644 index 00000000..feebe2db Binary files /dev/null and b/cool-cacti/static/sprites/jupiter.png differ diff --git a/cool-cacti/static/sprites/mars.png b/cool-cacti/static/sprites/mars.png new file mode 100644 index 00000000..bd0a5068 Binary files /dev/null and b/cool-cacti/static/sprites/mars.png differ diff --git a/cool-cacti/static/sprites/mercury.png b/cool-cacti/static/sprites/mercury.png new file mode 100644 index 00000000..c4c4a2ee Binary files /dev/null and b/cool-cacti/static/sprites/mercury.png differ diff --git a/cool-cacti/static/sprites/moon.png b/cool-cacti/static/sprites/moon.png new file mode 100644 index 00000000..11e7f1db Binary files /dev/null and b/cool-cacti/static/sprites/moon.png differ diff --git a/cool-cacti/static/sprites/neptune.png b/cool-cacti/static/sprites/neptune.png new file mode 100644 index 00000000..3680e83e Binary files /dev/null and b/cool-cacti/static/sprites/neptune.png differ diff --git a/cool-cacti/static/sprites/player.png b/cool-cacti/static/sprites/player.png new file mode 100644 index 00000000..b9c4b406 Binary files /dev/null and b/cool-cacti/static/sprites/player.png differ diff --git a/cool-cacti/static/sprites/saturn.png b/cool-cacti/static/sprites/saturn.png new file mode 100644 index 00000000..6986fc11 Binary files /dev/null and b/cool-cacti/static/sprites/saturn.png differ diff --git a/cool-cacti/static/sprites/scanner.png b/cool-cacti/static/sprites/scanner.png new file mode 100644 index 00000000..bb1509a2 Binary files /dev/null and b/cool-cacti/static/sprites/scanner.png differ diff --git a/cool-cacti/static/sprites/spaceship.png b/cool-cacti/static/sprites/spaceship.png new file mode 100644 index 00000000..0d9a34e4 Binary files /dev/null and b/cool-cacti/static/sprites/spaceship.png differ diff --git a/cool-cacti/static/sprites/sun.png b/cool-cacti/static/sprites/sun.png new file mode 100644 index 00000000..462d214b Binary files /dev/null and b/cool-cacti/static/sprites/sun.png differ diff --git a/cool-cacti/static/sprites/uranus.png b/cool-cacti/static/sprites/uranus.png new file mode 100644 index 00000000..5940fcd2 Binary files /dev/null and b/cool-cacti/static/sprites/uranus.png differ diff --git a/cool-cacti/static/sprites/venus.png b/cool-cacti/static/sprites/venus.png new file mode 100644 index 00000000..363171e1 Binary files /dev/null and b/cool-cacti/static/sprites/venus.png differ diff --git a/cool-cacti/static/styles.css b/cool-cacti/static/styles.css new file mode 100644 index 00000000..4864996e --- /dev/null +++ b/cool-cacti/static/styles.css @@ -0,0 +1,40 @@ +body { + margin: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: black; + overflow: hidden; +} + +#canvasContainer { + margin: 20px; + display: flex; + justify-content: center; + align-items: center; + background-color: black; + flex: 1; +} + +#gameCanvas { + margin: 0px; + background: black; + flex: 1; +} + +#loadingLabel { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Courier New', monospace; + font-weight: 900; + font-size: 1.5rem; + color: #ffff00; + z-index: 10; +} + +audio { + display: none; +} \ No newline at end of file diff --git a/cool-cacti/templates/index.html b/cool-cacti/templates/index.html new file mode 100644 index 00000000..55e347bb --- /dev/null +++ b/cool-cacti/templates/index.html @@ -0,0 +1,61 @@ + + + + + + Codejam 2025 Project + + + + + + + +
+ +
+ +
Loading...
+ + {% for audio_file in audio_list %} + + {% endfor %} + + + from js import Image, window + + sprites_url = "{{ url_for('static', filename='sprites/') }}" + + window.sprites = {} + for sprite in {{ sprite_list }}: + # Skip the folder name "asteroid sprites" which would request "asteroid%20sprites.png" + if sprite == "asteroid sprites": + continue + window.sprites[sprite] = Image.new() + window.sprites[sprite].src = sprites_url + sprite + ".png" + + window.audio_list = {{ audio_list }} + + window.sprites["asteroids"] = Image.new() + window.sprites["asteroids"].src = sprites_url + "asteroids.png" + + window.planets = {{ planets_info|tojson|safe }} + for planet in window.planets: + planet["spritesheet"] = Image.new() + planet["spritesheet"].src = sprites_url + planet["sprite"] + + window.credits = {{ credits | tojson | safe }} + + window.lore = {{ lore|tojson|safe }} + + # exposing canvas globally (used by Player clamp logic) + from js import document + window.canvas = document.getElementById('gameCanvas') + + # initialize game scripts + from audio import AudioHandler + window.audio_handler = AudioHandler("{{ url_for('static', filename='') }}") + import game + + + \ No newline at end of file diff --git a/cool-cacti/tools/fetch_horizons.py b/cool-cacti/tools/fetch_horizons.py new file mode 100644 index 00000000..c0a974e7 --- /dev/null +++ b/cool-cacti/tools/fetch_horizons.py @@ -0,0 +1,52 @@ +import json +import logging +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from horizons_api import HorizonsClient, TimePeriod + +# api access point +HORIZONS_URL = "https://ssd.jpl.nasa.gov/api/horizons.api" +HORIZONS_DATA_DIR = "horizons_data" + +SUN_ID = 10 + +# set logging config here, since this is a standalone script +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +if __name__ == "__main__": + client = HorizonsClient() + # create dir for horizons API data if it doesn't already exist + working_dir = Path.cwd() + horizons_path = working_dir / HORIZONS_DATA_DIR + if not horizons_path.exists(): + Path.mkdir(horizons_path, parents=True, exist_ok=True) + + with (horizons_path / "planets.json").open(encoding='utf-8') as f: + template = json.load(f) + + """ + This is a special query that returns info of major bodies ("MB") in the solar system, + useful for knowing the IDs of planets, moons etc. that horizons refers to things as internally. + """ + major_bodies = client.get_major_bodies(save_to=horizons_path / "major_bodies.txt") + + today = datetime.now(tz=UTC) + tomorrow = today + timedelta(days=1) + + for planet in template: + id: int = planet["id"] + name: str = planet["name"] + time_period = TimePeriod(start=today, end=tomorrow) + + object = client.get_object_data(id) + + if id == SUN_ID: + continue # skip sun since we don't need its position + + pos_response = client.get_vectors(id, time_period) + planet["info"] = object.text + + with(horizons_path / "planets.json").open("w", encoding='utf-8') as f: + json.dump(template, f, indent=4) diff --git a/cool-cacti/tools/generate_pyscript_config.py b/cool-cacti/tools/generate_pyscript_config.py new file mode 100644 index 00000000..6ff9977b --- /dev/null +++ b/cool-cacti/tools/generate_pyscript_config.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Script to automatically generate pyscript.json configuration file by scanning for all Python files. + +Pyscript config files don't allow directory replication, so it's necessary to map every file. +""" + +import argparse +import json +import logging +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +WORKING_DIR = Path(__file__).parent.parent +OUTPUT_DIR = WORKING_DIR / "static" +APPLICATION_DIR = OUTPUT_DIR / "scripts" + + +def generate_pyscript_config(base_path: Path, output_file: str = "pyscript.json") -> None: + """Generate pyscript.json configuration by scanning for Python files. + + Args: + base_path: Base directory to scan + output_file: Output path for pyscript.json + + """ + if not base_path.exists(): + print(f"Error: Base directory '{base_path}' does not exist") + return + + files_config = {} + + # Find all Python files recursively + for py_file in base_path.rglob("*.py"): + # Get relative path from the working directory + rel_from_working = py_file.relative_to(WORKING_DIR) + pyscript_path = "/" + str(rel_from_working).replace("\\", "/") + + # Check if file is in a subdirectory of base_path + if py_file.parent != base_path: + subdir = py_file.relative_to(base_path).parent + files_config[pyscript_path] = str(subdir).replace("\\", "/") + "/" + else: + files_config[pyscript_path] = "" + + files_config = dict(sorted(files_config.items())) + + config = {"files": files_config} + + output_path = Path(OUTPUT_DIR) / output_file + + with output_path.open("w") as f: + json.dump(config, f, indent=2) + + log.info("Generated %s with %d Python files at %s", output_file, len(files_config), output_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate pyscript.json configuration") + parser.add_argument( + "--base-dir", + type=Path, + default=APPLICATION_DIR, + help=f"Base directory to scan for Python files (default: static/scripts)", + ) + parser.add_argument( + "--output", type=str, default="pyscript.json", help="Output file name (default: pyscript.json)" + ) + + args = parser.parse_args() + + generate_pyscript_config(args.base_dir, args.output) diff --git a/cool-cacti/tools/horizons_api/__init__.py b/cool-cacti/tools/horizons_api/__init__.py new file mode 100644 index 00000000..2c97c28c --- /dev/null +++ b/cool-cacti/tools/horizons_api/__init__.py @@ -0,0 +1,3 @@ +from .client import * # noqa: F403 +from .exceptions import * # noqa: F403 +from .models import * # noqa: F403 diff --git a/cool-cacti/tools/horizons_api/client.py b/cool-cacti/tools/horizons_api/client.py new file mode 100644 index 00000000..0408cbdd --- /dev/null +++ b/cool-cacti/tools/horizons_api/client.py @@ -0,0 +1,119 @@ +import logging +from pathlib import Path +from urllib import parse, request + +from .exceptions import HorizonsAPIError, ParsingError +from .models import MajorBody, ObjectData, TimePeriod, VectorData +from .parsers import MajorBodyTableParser, ObjectDataParser, VectorDataParser + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +__all__ = ("HorizonsClient",) + + +class HorizonsClient: + """A client for the JPL Horizons API.""" + + BASE_URL = "https://ssd.jpl.nasa.gov/api/horizons.api" + TIME_FORMAT = "%Y-%m-%d" + + def _request(self, params: dict, save_to: Path | None = None) -> str: + """Make a request to the Horizons API and return the result string.""" + params["format"] = "text" + url = f"{self.BASE_URL}?{parse.urlencode(params)}" + logger.info("Horizons query from %s", url) + + try: + with request.urlopen(url) as response: # noqa: S310 + data = response.read().decode() + + if save_to: + with Path.open(save_to, "w") as f: + f.write(data) + + except Exception as e: + logger.exception("Horizon query raising %s", type(e).__name__) + msg = f"Failed to retrieve data from Horizons API: {e}" + raise HorizonsAPIError(msg) from e + + return data + + def get_major_bodies(self, save_to: Path | None = None) -> list[MajorBody]: + """Get a list of major bodies. + + Arguments: + save_to (Path | None): Optional path to save the raw response data. + + Returns: + list[MajorBody]: A list of major bodies. + + """ + result_text = self._request( + { + "COMMAND": "MB", + "OBJ_DATA": "YES", + "MAKE_EPHEM": "NO", + }, + save_to=save_to, + ) + return MajorBodyTableParser().parse(result_text) + + def get_object_data(self, object_id: int, *, small_body: bool = False, save_to: Path | None = None) -> ObjectData: + """Get physical data for a specific body. + + Arguments: + object_id (int): The ID of the object. + small_body (bool): Whether the object is a small body. + save_to (Path | None): Optional path to save the raw response data. + + Returns: + ObjectData: The physical data for the object. + + """ + result_text = self._request( + { + "COMMAND": str(object_id) + (";" if small_body else ""), + "OBJ_DATA": "YES", + "MAKE_EPHEM": "NO", + }, + save_to=save_to, + ) + + return ObjectDataParser().parse(result_text) + + def get_vectors( + self, object_id: int, time_options: TimePeriod, center: int = 10, save_to: Path | None = None + ) -> VectorData: + """Get positional vectors for a specific body. + + Arguments: + object_id (int): The ID of the object. + time_options (TimePeriod): The time period for the ephemeris. + center (int): The object id for center for the ephemeris. Default 10 for the sun. + save_to (Path | None): Optional path to save the raw response data. + + Returns: + VectorData: The positional vectors for the object. + + """ + result_text = self._request( + { + "COMMAND": str(object_id), + "OBJ_DATA": "NO", + "MAKE_EPHEM": "YES", + "EPHEM_TYPE": "VECTORS", + "CENTER": f"@{center}", + "START_TIME": time_options.start.strftime(self.TIME_FORMAT), + "STOP_TIME": time_options.end.strftime(self.TIME_FORMAT), + "STEP_SIZE": time_options.step, + }, + save_to=save_to, + ) + + vector_data = VectorDataParser().parse(result_text) + if vector_data is None: + msg = "Failed to find all vector components in the text." + logger.warning(msg) + raise ParsingError(msg) + return vector_data diff --git a/cool-cacti/tools/horizons_api/exceptions.py b/cool-cacti/tools/horizons_api/exceptions.py new file mode 100644 index 00000000..b07a4363 --- /dev/null +++ b/cool-cacti/tools/horizons_api/exceptions.py @@ -0,0 +1,9 @@ +__all__ = ("HorizonsAPIError", "ParsingError") + + +class HorizonsAPIError(Exception): + """Base exception for Horizons API errors.""" + + +class ParsingError(Exception): + """Base exception for parsing errors.""" diff --git a/cool-cacti/tools/horizons_api/models.py b/cool-cacti/tools/horizons_api/models.py new file mode 100644 index 00000000..fb5d459b --- /dev/null +++ b/cool-cacti/tools/horizons_api/models.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from datetime import datetime + +__all__ = ( + "MajorBody", + "ObjectData", + "TimePeriod", + "VectorData", +) + + +@dataclass +class MajorBody: + """Represents a major body in the solar system.""" + + id: str + name: str | None = None + designation: str | None = None + aliases: str | None = None + + +@dataclass +class ObjectData: + """Represents physical characteristics of a celestial body.""" + + text: str + radius: float | None = None + + +@dataclass +class VectorData: + """Represents position and velocity vectors.""" + + x: float + y: float + z: float | None = None + + +@dataclass +class TimePeriod: + """Represents a time period for ephemeris data.""" + + start: datetime + end: datetime + step: str = "2d" diff --git a/cool-cacti/tools/horizons_api/parsers.py b/cool-cacti/tools/horizons_api/parsers.py new file mode 100644 index 00000000..907fd1be --- /dev/null +++ b/cool-cacti/tools/horizons_api/parsers.py @@ -0,0 +1,134 @@ +import logging +import re +from abc import ABC, abstractmethod + +from .exceptions import ParsingError +from .models import MajorBody, ObjectData, VectorData + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BaseParser(ABC): + """Abstract base class for all parsers.""" + + @abstractmethod + def parse(self, text: str) -> object: + """Parse the given text and return structured data.""" + raise NotImplementedError + + +class MajorBodyTableParser(BaseParser): + """Parse a table of major bodies from the Horizons API.""" + + def parse(self, text: str) -> list[MajorBody]: + """Parse the text output from the NASA/JPL Horizons API into a list of objects. + + This function orchestrates the parsing process by calling helper methods to: + 1. Find the data section. + 2. Determine column boundaries. + 3. Parse each data row individually. + + Arguments: + text: The multi-line string data from the Horizons API. + + Returns: + A list of MajorBody objects. + + """ + lines = text.strip().split("\n") + + separator_line_index = self._find_separator_index(lines) + if separator_line_index is None: + logger.warning("Could not find header or separator line. Unable to parse.") + return [] + data_start_index = separator_line_index + 1 + data_end_index = self._find_data_end_index(lines, data_start_index) + column_boundaries = self._get_column_boundaries(lines[separator_line_index]) + if not column_boundaries: + logger.warning("Could not determine column boundaries. Parsing may be incomplete.") + return [] + + data_lines = lines[data_start_index:data_end_index] + + parsed_objects = [] + for line in data_lines: + body = self._parse_row(line, column_boundaries) + if body: + parsed_objects.append(body) + + return parsed_objects + + def _find_separator_index(self, lines: list[str]) -> int | None: + """Find the separator line and the starting index of the data rows.""" + header_line_index = -1 + separator_line_index = -1 + for i, line in enumerate(lines): + if "ID#" in line and "Name" in line: + header_line_index = i + if "---" in line and header_line_index != -1: + separator_line_index = i + break + + if separator_line_index == -1 or header_line_index + 1 != separator_line_index: + return None + + return separator_line_index + + def _find_data_end_index(self, lines: list[str], start_index: int) -> int: + """Find the end index of the data rows.""" + for i in range(start_index, len(lines)): + if not lines[i].strip(): + return i + return len(lines) + + def _get_column_boundaries(self, separator_line: str) -> list[tuple[int, int]] | None: + """Determine column boundaries from the separator line using its dash groups.""" + dash_groups = re.finditer(r"-+", separator_line) + return [match.span() for match in dash_groups] + + def _parse_row(self, line: str, column_boundaries: list[tuple[int, int]]) -> MajorBody | None: + """Parse a single data row string into a MajorBody object.""" + if not line.strip(): + return None + + try: + body_data = [line[start:end].strip() for start, end in column_boundaries] + except IndexError: # Line is malformed or shorter than expected + return None + + if not body_data or not body_data[0]: + return None + + return MajorBody(*body_data) + + +class ObjectDataParser(BaseParser): + """Parses the physical characteristics of an object.""" + + def parse(self, text: str) -> ObjectData: + """Parse the text to find the object's radius.""" + radius_match = re.search(r"Radius \(km\)\s*=\s*([\d\.]+)", text) + radius = float(radius_match.group(1)) if radius_match else None + return ObjectData(text=text, radius=radius) + + +class VectorDataParser(BaseParser): + """Parses vector data from the API response.""" + + def parse(self, text: str) -> VectorData | None: + """Parse the text to find X, Y, and Z vector components.""" + # TODO: should probably add error checking for the re searches and horizons queries + # looking for patterns like "X =-2367823E+10" or "Y = 27178E-02" since the API returns coordinates + # in scientific notation + pattern = r"\s*=\s*(-?[\d\.]+E[\+-]\d\d)" + x_match = re.search("X" + pattern, text) + y_match = re.search("Y" + pattern, text) + z_match = re.search("Z" + pattern, text) + + if not (x_match and y_match and z_match): + msg = "Failed to find all vector components in the text." + logger.warning(msg) + raise ParsingError(msg) + + return VectorData(x=float(x_match.group(1)), y=float(y_match.group(1)), z=float(z_match.group(1))) diff --git a/cool-cacti/tools/make_spritesheets.py b/cool-cacti/tools/make_spritesheets.py new file mode 100644 index 00000000..ff23c97e --- /dev/null +++ b/cool-cacti/tools/make_spritesheets.py @@ -0,0 +1,74 @@ +""" +just a quick and dirty script to turn a series of 50 .png sprites into a single spritesheet file. We're not +using this otherwise and we may well not need it again, but this can live here just in case we generate more +planet sprites on that website +""" + +import os +from pathlib import Path + +import numpy as np +from PIL import Image + +cur_dir = Path(__file__).resolve().parent + +# Planet spritesheets +for planet in "earth jupiter mars mercury neptune saturn sun uranus venus".split(): + planet_dir = cur_dir / f"{planet} sprites" + if planet_dir.exists(): + first_frame = Image.open(planet_dir / "sprite_1.png") + width, height = first_frame.size + spritesheet = Image.new("RGBA", (width * 50, height), (0, 0, 0, 0)) + for fr in range(1, 51): + frame = Image.open(planet_dir / f"sprite_{fr}.png") + spritesheet.paste(frame, (width * (fr - 1), 0)) + + spritesheet.save(cur_dir.parent / "static" / "sprites" / f"{planet}.png") + +# Asteroid spritesheet +asteroid_dir = cur_dir.parent / "static" / "sprites" / "asteroid sprites" +if asteroid_dir.exists(): + # Get all PNG files in the asteroid directory + asteroid_files = sorted([f for f in os.listdir(asteroid_dir) if f.endswith(".png")]) + + if asteroid_files: + # Load first asteroid to get dimensions + first_asteroid = Image.open(asteroid_dir / asteroid_files[0]) + width, height = first_asteroid.size + + # Calculate grid layout (try to make roughly square) + num_asteroids = len(asteroid_files) + cols = int(num_asteroids**0.5) + 1 + rows = (num_asteroids + cols - 1) // cols + + print(f"Creating asteroid spritesheet: {cols}x{rows} grid for {num_asteroids} asteroids") + + # Create the spritesheet + spritesheet = Image.new("RGBA", (width * cols, height * rows), (0, 0, 0, 0)) + + collision_radii = [] + # Paste each asteroid + for i, filename in enumerate(asteroid_files): + asteroid = Image.open(asteroid_dir / filename) + pixel_alpha_values = np.array(asteroid)[:, :, 3] + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Calculate position in grid + col = i % cols + row = i // cols + x = col * width + y = row * height + + spritesheet.paste(asteroid, (x, y)) + print(f"Added {filename} at position ({col}, {row})") + + # Save the spritesheet + output_path = cur_dir.parent / "static" / "sprites" / "asteroids.png" + spritesheet.save(output_path) + print(f"Asteroid spritesheet saved to: {output_path}") + print(f"Grid dimensions: {cols} columns x {rows} rows") + print(f"Each sprite: {width}x{height} pixels") + + print("Collision radii:") + print(collision_radii) diff --git a/cool-cacti/tools/process_recycle_sprites.py b/cool-cacti/tools/process_recycle_sprites.py new file mode 100644 index 00000000..2b3c9a9a --- /dev/null +++ b/cool-cacti/tools/process_recycle_sprites.py @@ -0,0 +1,208 @@ +""" +Script to extract sprites from recycle_items.png, resize them to 100x100 with padding, +and add them to the asteroid spritesheet. +""" + +import os +from pathlib import Path + +import numpy as np +from PIL import Image + +cur_dir = Path(__file__).resolve().parent + +def extract_recycle_sprites(): + """Extract individual sprites from recycle_items.png""" + recycle_path = cur_dir.parent / "static" / "sprites" / "asteroid sprites" / "recycle_items.png" + + if not recycle_path.exists(): + print(f"Error: {recycle_path} not found") + return [] + + # Load the recycle items spritesheet + recycle_sheet = Image.open(recycle_path) + sheet_width, sheet_height = recycle_sheet.size + + print(f"Recycle spritesheet dimensions: {sheet_width}x{sheet_height}") + + # Estimate sprite size by looking at the image + # We'll assume it's a horizontal strip of sprites + # Let's try to detect individual sprites by looking for vertical gaps + + # Convert to numpy array for analysis + sheet_array = np.array(recycle_sheet) + + # Check if there are transparent columns that separate sprites + alpha_channel = sheet_array[:, :, 3] if sheet_array.shape[2] == 4 else np.ones((sheet_height, sheet_width)) * 255 + + # Find columns that are completely transparent + transparent_cols = np.all(alpha_channel == 0, axis=0) + + # Find transitions from non-transparent to transparent (sprite boundaries) + boundaries = [] + in_sprite = False + sprite_start = 0 + + for col in range(sheet_width): + if not transparent_cols[col] and not in_sprite: + # Start of a sprite + sprite_start = col + in_sprite = True + elif transparent_cols[col] and in_sprite: + # End of a sprite + boundaries.append((sprite_start, col)) + in_sprite = False + + # Handle case where last sprite goes to the edge + if in_sprite: + boundaries.append((sprite_start, sheet_width)) + + print(f"Found {len(boundaries)} sprites with boundaries: {boundaries}") + + # If we can't detect boundaries automatically, assume equal-width sprites + if not boundaries: + # Let's assume 16 sprites in a horizontal row (common for item spritesheets) + sprite_width = sheet_width // 16 + boundaries = [(i * sprite_width, (i + 1) * sprite_width) for i in range(16)] + print(f"Using equal-width assumption: {sprite_width}px wide sprites") + + # Extract each sprite + sprites = [] + for i, (start_x, end_x) in enumerate(boundaries): + # Extract the sprite + sprite = recycle_sheet.crop((start_x, 0, end_x, sheet_height)) + + # Resize to 100x100 with padding + resized_sprite = resize_with_padding(sprite, (100, 100)) + sprites.append(resized_sprite) + + # Save individual sprite for debugging + debug_path = cur_dir.parent / "static" / "sprites" / "asteroid sprites" / f"recycle_{i:02d}.png" + resized_sprite.save(debug_path) + print(f"Saved recycle sprite {i} to {debug_path}") + + return sprites + +def resize_with_padding(image, target_size): + """Resize image to target size while maintaining aspect ratio and adding transparent padding""" + target_width, target_height = target_size + + # Calculate scaling factor to fit within target size + width_ratio = target_width / image.width + height_ratio = target_height / image.height + scale_factor = min(width_ratio, height_ratio) + + # Calculate new size after scaling + new_width = int(image.width * scale_factor) + new_height = int(image.height * scale_factor) + + # Resize the image + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Create new image with transparent background + result = Image.new("RGBA", target_size, (0, 0, 0, 0)) + + # Center the resized image + x_offset = (target_width - new_width) // 2 + y_offset = (target_height - new_height) // 2 + + result.paste(resized, (x_offset, y_offset), resized if resized.mode == 'RGBA' else None) + + return result + +def rebuild_asteroid_spritesheet(): + """Rebuild the asteroid spritesheet including the new recycle sprites""" + asteroid_dir = cur_dir.parent / "static" / "sprites" / "asteroid sprites" + + # Get all existing asteroid PNG files (excluding recycle_items.png and newly created recycle_XX.png) + all_files = [f for f in os.listdir(asteroid_dir) if f.endswith(".png")] + asteroid_files = [f for f in all_files if f != "recycle_items.png" and not f.startswith("recycle_")] + + print(f"Found {len(asteroid_files)} original asteroid files") + + # Extract recycle sprites + recycle_sprites = extract_recycle_sprites() + print(f"Extracted {len(recycle_sprites)} recycle sprites") + + # Load all asteroid images + all_sprites = [] + collision_radii = [] + + # Add original asteroids + for filename in sorted(asteroid_files): + asteroid = Image.open(asteroid_dir / filename) + all_sprites.append(asteroid) + + # Calculate collision radius based on non-transparent pixels + pixel_alpha_values = np.array(asteroid)[:, :, 3] if np.array(asteroid).shape[2] == 4 else np.ones(asteroid.size[::-1]) * 255 + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Add recycle sprites + for i, sprite in enumerate(recycle_sprites): + all_sprites.append(sprite) + + # Calculate collision radius for recycle sprites + pixel_alpha_values = np.array(sprite)[:, :, 3] + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Create the combined spritesheet + if all_sprites: + # Assume all sprites are now the same size (100x100 for recycle, variable for asteroids) + # We need to standardize - let's make everything 100x100 + standardized_sprites = [] + + for sprite in all_sprites: + if sprite.size != (100, 100): + # Resize asteroid sprites to 100x100 with padding + standardized_sprite = resize_with_padding(sprite, (100, 100)) + standardized_sprites.append(standardized_sprite) + else: + standardized_sprites.append(sprite) + + # Calculate grid layout + num_sprites = len(standardized_sprites) + cols = int(num_sprites**0.5) + 1 + rows = (num_sprites + cols - 1) // cols + + print(f"Creating combined spritesheet: {cols}x{rows} grid for {num_sprites} sprites") + + # Create the spritesheet + sprite_size = 100 # All sprites are now 100x100 + spritesheet = Image.new("RGBA", (sprite_size * cols, sprite_size * rows), (0, 0, 0, 0)) + + # Paste each sprite + for i, sprite in enumerate(standardized_sprites): + col = i % cols + row = i // cols + x = col * sprite_size + y = row * sprite_size + + spritesheet.paste(sprite, (x, y)) + + sprite_type = "recycle" if i >= len(asteroid_files) else "asteroid" + print(f"Added {sprite_type} sprite {i} at position ({col}, {row})") + + # Save the new spritesheet + output_path = cur_dir.parent / "static" / "sprites" / "asteroids.png" + spritesheet.save(output_path) + print(f"Combined spritesheet saved to: {output_path}") + print(f"Grid dimensions: {cols} columns x {rows} rows") + print(f"Each sprite: {sprite_size}x{sprite_size} pixels") + print(f"Total sprites: {len(asteroid_files)} asteroids + {len(recycle_sprites)} recycle items = {num_sprites}") + + print("Collision radii:") + print(collision_radii) + + return True + else: + print("No sprites found to process") + return False + +if __name__ == "__main__": + success = rebuild_asteroid_spritesheet() + if success: + print("Successfully rebuilt asteroid spritesheet with recycle items!") + else: + print("Failed to rebuild spritesheet") diff --git a/cool-cacti/uv.lock b/cool-cacti/uv.lock new file mode 100644 index 00000000..cce1186f --- /dev/null +++ b/cool-cacti/uv.lock @@ -0,0 +1,411 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, +] + +[[package]] +name = "code-jam-soon-to-be-awesome-project" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, +] + +[package.dev-dependencies] +dev = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "flask", specifier = ">=3.1.1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "numpy", specifier = ">=2.3.2" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "pre-commit", specifier = "~=4.2.0" }, + { name = "ruff", specifier = "~=0.12.2" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "flask" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, +] + +[[package]] +name = "identify" +version = "2.6.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420 }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660 }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382 }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258 }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409 }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317 }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262 }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342 }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610 }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292 }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071 }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074 }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311 }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022 }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135 }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147 }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989 }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052 }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843 }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876 }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786 }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395 }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374 }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864 }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533 }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007 }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914 }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708 }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678 }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832 }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049 }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935 }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906 }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607 }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110 }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050 }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292 }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913 }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180 }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809 }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410 }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821 }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303 }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524 }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519 }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972 }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439 }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479 }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805 }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830 }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665 }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777 }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856 }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226 }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "ruff" +version = "0.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315 }, + { url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653 }, + { url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690 }, + { url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923 }, + { url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612 }, + { url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745 }, + { url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885 }, + { url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381 }, + { url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271 }, + { url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783 }, + { url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672 }, + { url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626 }, + { url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162 }, + { url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212 }, + { url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382 }, + { url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482 }, + { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718 }, +] + +[[package]] +name = "virtualenv" +version = "20.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/60/4f20960df6c7b363a18a55ab034c8f2bcd5d9770d1f94f9370ec104c1855/virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8", size = 6082160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ff/ded57ac5ff40a09e6e198550bab075d780941e0b0f83cbeabd087c59383a/virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67", size = 6060362 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +]