Created
August 5, 2026 17:58
-
-
Save sampottinger/7bee89492f51579a36114ae6712192df to your computer and use it in GitHub Desktop.
Changing attractors simulation under BSD-3-Clause license runnable in editor.sketchingpy.org.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import math | |
| import random | |
| import sketchingpy | |
| BACKGROUND_COLOR = '#333333' | |
| CENTER_MASS = 10 | |
| CENTER_X = 350 | |
| CENTER_Y = 350 | |
| START_RADIUS = 200 | |
| FIELD_COLORS = { | |
| 'a': '#FF5555', | |
| 'b': '#55FF55', | |
| 'c': '#5555FF', | |
| 'none': '#FFFFFF' | |
| } | |
| WIDTH = 700 | |
| HEIGHT = 700 | |
| MAX_SPEED = 4 | |
| FORCE_STRENGTH = 800 | |
| class Particle: | |
| def __init__(self, field, x, y, mass, direction, magnitude): | |
| self._field = field | |
| self._x = x | |
| self._y = y | |
| self._mass = mass | |
| self._direction = direction | |
| self._magnitude = magnitude | |
| def update(self, active_field, center_x, center_y): | |
| # Decompose current velocity into x/y components | |
| vx = self._magnitude * math.cos(self._direction) | |
| vy = self._magnitude * math.sin(self._direction) | |
| # Vector from particle toward center | |
| dx = center_x - self._x | |
| dy = center_y - self._y | |
| distance = math.sqrt(dx * dx + dy * dy) | |
| if distance > 0: | |
| # Normalise direction | |
| nx = dx / distance | |
| ny = dy / distance | |
| # Force falls off with distance (gravity-like), scaled by mass | |
| force = (FORCE_STRENGTH * self._mass) / (distance * distance + 100) | |
| # Matching field → attracted; others → repelled | |
| if self._field == active_field: | |
| vx += nx * force | |
| vy += ny * force | |
| else: | |
| vx -= nx * force | |
| vy -= ny * force | |
| # Cap speed so particles don't fly off instantly | |
| speed = math.sqrt(vx * vx + vy * vy) | |
| if speed > MAX_SPEED: | |
| vx = (vx / speed) * MAX_SPEED | |
| vy = (vy / speed) * MAX_SPEED | |
| speed = MAX_SPEED | |
| # Store back as direction + magnitude | |
| self._direction = math.atan2(vy, vx) | |
| self._magnitude = speed | |
| # Move particle | |
| self._x += vx | |
| self._y += vy | |
| # Wrap around screen edges so particles don't escape permanently | |
| self._x = self._x % WIDTH | |
| self._y = self._y % HEIGHT | |
| def get_field(self): | |
| return self._field | |
| def get_x(self): | |
| return self._x | |
| def get_y(self): | |
| return self._y | |
| def get_mass(self): | |
| return self._mass | |
| class Simulation: | |
| def __init__(self, center_x=CENTER_X, center_y=CENTER_Y, | |
| center_mass=CENTER_MASS): | |
| self._center_x = center_x | |
| self._center_y = center_y | |
| self._center_mass = center_mass | |
| self._particles = [self._make_random_particle() for x in range(0, 1000)] | |
| self._active_field = 'none' | |
| def step(self, sketch): | |
| for particle in self._particles: | |
| particle.update(self._active_field, self._center_x, self._center_y) | |
| if random.randint(0, 200) == 0: | |
| self._select_new_field() | |
| sketch.clear(BACKGROUND_COLOR) | |
| self._draw_center(sketch) | |
| sketch.clear_stroke() | |
| sketch.set_ellipse_mode('radius') | |
| for particle in self._particles: | |
| field = particle.get_field() | |
| color = FIELD_COLORS[field] + '50' | |
| sketch.set_fill(color) | |
| sketch.draw_ellipse( | |
| particle.get_x(), | |
| particle.get_y(), | |
| particle.get_mass(), | |
| particle.get_mass() | |
| ) | |
| def _draw_center(self, sketch): | |
| sketch.clear_fill() | |
| sketch.set_stroke_weight(5) | |
| sketch.set_stroke(FIELD_COLORS[self._active_field] + '80') | |
| sketch.set_ellipse_mode('radius') | |
| sketch.draw_ellipse( | |
| self._center_x, | |
| self._center_y, | |
| self._center_mass, | |
| self._center_mass | |
| ) | |
| def _make_random_particle(self): | |
| field = self._randomly_choose_field() | |
| angle = random.uniform(0, 2 * math.pi) | |
| x = self._center_x + START_RADIUS * math.cos(angle) | |
| y = self._center_y + START_RADIUS * math.sin(angle) | |
| mass = random.randint(1, 5) | |
| return Particle(field, x, y, mass, 0, 0) | |
| def _select_new_field(self): | |
| # Only pick from real fields, never back to 'none' | |
| real_fields = [k for k in FIELD_COLORS if k != 'none'] | |
| self._active_field = random.choice(real_fields) | |
| def _randomly_choose_field(self): | |
| # Particles are only ever assigned real fields, never 'none' | |
| real_fields = [k for k in FIELD_COLORS if k != 'none'] | |
| return random.choice(real_fields) | |
| simulation = Simulation() | |
| sketch = sketchingpy.Sketch2DWeb(700, 700) | |
| sketch.on_step(lambda x: simulation.step(x)) | |
| sketch.show() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment