Skip to content

Kinetic type

Kinetic type

Run it here, in your browser

A word made of dots. Move the mouse over it and the dots scatter, then spring back to their places. Press Enter, type a new word, and press Enter again. Two sliders set how hard the mouse pushes and how tight the springs are. Press G (or click "record GIF") to record three seconds as kinetic.gif.

How it works

  • f.text_to_points() gives a list of points along the letters. Each point becomes a dot: a home place and a position, both f.Vector objects, and a speed (also a Vector).
  • Every frame each dot feels two forces. The mouse pushes it away, more strongly the closer the mouse is. A spring pulls it home, harder the further it has gone. The speed is then slowed a little, so the dot settles and does not swing for ever.
  • f.noise() moves each home a tiny way as time passes, so the word is never quite still. Dots near each other move alike, because noise gives nearby numbers nearby answers.
  • A dot's colour comes from how fast it is moving. f.lerp_color() mixes calm blue with hot orange.
  • f.save_gif("kinetic.gif", 3) records the next three seconds. While it records, a ghost mouse sweeps across the word, so the GIF shows a scatter even if your mouse is elsewhere.

Make it yours

  • Change DAMPING from 0.86 to 0.95 for a bouncier word, or to 0.7 for a stiff one.
  • Change REACH, how far from the mouse the dots feel it, from 110 to 200.
  • Change the spacing in f.text_to_points(): a smaller number gives more, smaller dots.
  • Colour the dots by where they are: use f.hsb() with the x place of the home for the hue.
  • Pull the dots toward the mouse and not away: change the sign in push().

The code

Copy it into a file and run it.

"""Kinetic type

A word made of dots. Move the mouse over it and the dots scatter, then spring back to their places.
Press Enter, type a new word, and press Enter again. Two sliders set how hard the mouse pushes and
how tight the springs are. Press G (or click "record GIF") to record three seconds as kinetic.gif.

How it works:
- f.text_to_points() gives a list of points along the letters. Each point becomes a dot: a home
  place and a position, both f.Vector objects, and a speed (also a Vector).
- Every frame each dot feels two forces. The mouse pushes it away, more strongly the closer the
  mouse is. A spring pulls it home, harder the further it has gone. The speed is then slowed a
  little, so the dot settles and does not swing for ever.
- f.noise() moves each home a tiny way as time passes, so the word is never quite still. Dots near
  each other move alike, because noise gives nearby numbers nearby answers.
- A dot's colour comes from how fast it is moving. f.lerp_color() mixes calm blue with hot orange.
- f.save_gif("kinetic.gif", 3) records the next three seconds. While it records, a ghost mouse
  sweeps across the word, so the GIF shows a scatter even if your mouse is elsewhere.

Make it yours:
- Change DAMPING from 0.86 to 0.95 for a bouncier word, or to 0.7 for a stiff one.
- Change REACH, how far from the mouse the dots feel it, from 110 to 200.
- Change the spacing in f.text_to_points(): a smaller number gives more, smaller dots.
- Colour the dots by where they are: use f.hsb() with the x place of the home for the hue.
- Pull the dots toward the mouse and not away: change the sign in push().
"""
import math

import funground as f

W, H = 640, 320
FPS = 60
REACH = 110                                # how far from the mouse a dot feels it
DAMPING = 0.86                             # each frame a dot keeps this much of its speed
MOST_LETTERS = 10
RECORD_SECONDS = 3
CALM, HOT = (70, 140, 230), (255, 130, 60)

word = "FUNGROUND"
typing = False
dots = []                                  # each dot: {"home": Vector, "pos": Vector, "speed": Vector}
note = ""
note_until = 0
recording_until = 0                        # the frame when a recording ends (0 for none)


def make_dots(text, from_scattered=False):
    """One dot for each point along the letters. The big word is made smaller to fit."""
    size = 170
    f.text_style("bold")
    f.text_size(size)
    wide = f.text_width(text)
    if wide > W - 60:
        size = int(size * (W - 60) / wide)
    f.text_size(size)
    f.text_align("center", "center")
    points = f.text_to_points(text, W / 2, H / 2 - 10, max(4, size / 18))
    found = []
    for x, y in points:
        home = f.Vector(x, y)
        start = f.Vector(f.random(0, W), f.random(0, H)) if from_scattered else home.copy()
        found.append({"home": home, "pos": start, "speed": f.Vector(0, 0)})
    return found


def mouse_place():
    """Where the mouse pushes: the real mouse, or a ghost that sweeps across while recording."""
    if recording_until and not (0 < f.mouse_x < W and 0 < f.mouse_y < H):
        left = recording_until - RECORD_SECONDS * FPS
        t = (f.frame_count - left) / (RECORD_SECONDS * FPS)
        return f.Vector(W * (0.1 + 0.8 * t), H / 2 + 50 * math.sin(t * 9))
    if 0 < f.mouse_x < W and 0 < f.mouse_y < H:     # 0, 0 means no mouse yet
        return f.Vector(f.mouse_x, f.mouse_y)
    return None


def push(dot, mouse, strength):
    """Push a dot away from the mouse. Closer means harder, and past REACH there is no push."""
    away = dot["pos"] - mouse
    d = away.mag()
    if 0 < d < REACH:
        dot["speed"] += away.set_mag(strength * (1 - d / REACH))


def pull(dot, spring, t):
    """Pull a dot toward its home. The home itself drifts a little, following noise."""
    home = dot["home"]
    drift = f.Vector(f.noise(home.x * 0.02, home.y * 0.02, t) - 0.5,
                     f.noise(home.x * 0.02 + 40, home.y * 0.02, t) - 0.5) * 8
    dot["speed"] += (home + drift - dot["pos"]) * spring


def step(mouse):
    t = f.frame_count * 0.02
    strength, spring = scatter_slider.value(), spring_slider.value()
    for dot in dots:
        if mouse is not None:
            push(dot, mouse, strength)
        pull(dot, spring, t)
        dot["speed"] *= DAMPING
        dot["pos"] += dot["speed"]


def paint():
    f.background(14, 16, 30)
    f.no_stroke()
    for dot in dots:
        heat = f.constrain(dot["speed"].mag() / 6, 0, 1)
        f.fill(f.lerp_color(CALM, HOT, heat))
        f.circle(dot["pos"].x, dot["pos"].y, 5 + 3 * heat)
    f.fill(150, 160, 190)
    f.text_style("normal")
    f.text_size(13)
    f.text_align("left", "center")
    if typing:
        f.text("Typing: " + word + ("|" if (f.frame_count // 20) % 2 == 0 else " ") + "   (Enter to finish)", 14, H - 16)
    elif f.frame_count < FPS * 20 or note:
        f.text(note or "Move the mouse over the word. Enter: type a word.  G: record a GIF.", 14, H - 16)


def start_recording():
    global recording_until, note, note_until
    if recording_until:
        return
    f.save_gif("kinetic.gif", RECORD_SECONDS)
    recording_until = f.frame_count + RECORD_SECONDS * FPS + 1
    note, note_until = "Recording kinetic.gif ...", recording_until


def key_pressed():
    global typing, word, dots
    if f.key == "enter":
        typing = not typing
        if not typing and word.strip():
            dots = make_dots(word, True)
    elif typing and f.key == "backspace":
        word = word[:-1]
    elif not typing and f.key in ("g", "G"):
        start_recording()


def key_typed():
    global word
    if typing and len(word) < MOST_LETTERS and f.key.isprintable() and len(f.key) == 1:
        word += f.key.upper()


def setup():
    global scatter_slider, spring_slider, record_button, dots
    f.size(W, H, fps=FPS)
    f.random_seed(11)
    f.noise_seed(11)
    scatter_slider = f.create_slider(1, 12, 5, step=0.5, label="scatter")
    spring_slider = f.create_slider(0.01, 0.2, 0.06, step=0.01, label="spring")
    record_button = f.create_button("record GIF")
    dots = make_dots(word)


def draw():
    global recording_until, note, note_until
    if record_button.clicked():
        start_recording()
    mouse = mouse_place()
    step(mouse)
    if recording_until and f.frame_count >= recording_until:
        recording_until = 0
        note, note_until = "Saved kinetic.gif", f.frame_count + 180
    if note and not recording_until and f.frame_count >= note_until:
        note = ""
    paint()


f.run()

View on GitHub ยท Back to the gallery