Lobby

The room the audience joins before the first question: its QR code and room code, slots that fill as people join, names and join times.

Lobby
View on GitHubgaanim examples/live/lobby.py
Code · 86 lines
"""Lobby: the room the audience joins before the first question, slot by slot.

``scene.audience()`` draws nothing: it gives the QR code and session code to
join, and for each slot, 0 for the first player in, a live nickname and
parameters that tell whether the slot is taken and for how long. A scene
that uses it asks each phone for a nickname as soon as it opens the page.
Outside a live presentation the rehearsal's players join one after another
until the room's first stop. Each label is the code that builds what it shows.
"""

import os

from gaanim import Scene, Zones, computed

scene = Scene()
foreground = scene.canvas.color("foreground")
background = scene.canvas.color("background")
accent = scene.canvas.color("accent")
track = scene.canvas.color("header")
# The rehearsal's 14 players join one after another over 6 seconds.
scene.rehearsal(14, seed=5, arrive=6.0)
audience = scene.audience()
# Phones ask for one more thing when joining; only the saved results show it.
scene.roster("Student code")
SLOTS = 6
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


# Each slot while the room fills: its nickname, joined (0 while empty, then
# 1) and age, the seconds since its player joined, up to 60.
scene.segment("slots")
grid = scene.layout.zones(Zones.grid(rows=4, columns=["7fr"] + ["2fr"] * SLOTS, column_gap="32px",
                                     padding="page_padding"))
code("slot", grid[0], anchor="right")
for slot in range(SLOTS):
    code(str(slot), grid[1 + slot], anchor="center")
code("audience.name(slot)", grid[7], anchor="right")
code("audience.joined(slot)", grid[14], anchor="right")
code("audience.age(slot)", grid[21], anchor="right")
for slot in range(SLOTS):
    audience.name(slot, size=0.55).place(grid[8 + slot])
    cell = grid[15 + slot]
    scene.geometry.circle(0.3).fill(track).no_stroke().place(cell)
    scene.geometry.circle(0.3).fill(accent).no_stroke().place(cell).opacity(audience.joined(slot))
    scene.viz.readout(audience.age(slot), format=".1f", font_size=0.5).place(grid[22 + slot])
start = scene.cursor
scene.wait(2.5)
seeks.extend([start + 0.8, scene.cursor - 0.05])

# The lobby: the QR code on a light card, the session code for phones that
# cannot scan, the head count, and a grid of nicknames that pop in as their
# players join: age drives each name's scale.
scene.segment("room")
page = scene.layout.zones(Zones.columns(["5fr", "11fr"], gap="96px", padding="page_padding"))
join = page[0].split(Zones.rows(["6fr", "1fr", "1fr", "1fr", "1fr", "1fr"], gap="8px"))
card = scene.geometry.rounded_rect(4.0, 4.0, 0.2).fill(foreground).no_stroke().place(join[0])
audience.qr(3.4).fill(background).place(card)
code("audience.qr(3.4)", join[1], anchor="center")
scene.text(audience.code, size=0.6, weight=700).place(join[2])
code("audience.code", join[3], anchor="center")
scene.viz.readout(audience.count(), format=".0f", suffix=" joined", font_size=0.55).place(join[4])
code("audience.count()", join[5], anchor="center")
room, caption = page[1].split(Zones.rows(["7fr", "1fr"], gap="24px"))
seats = room.split(Zones.grid(rows=4, columns=4, gap="24px"))
for slot, seat in enumerate(seats):
    scene.geometry.rounded_rect(seat.width, seat.height, 0.15).fill(track).no_stroke().place(seat)
    # Grows from 0 to full size in the 0.4 s after the slot's player joins.
    pop = computed(lambda age: min(age / 0.4, 1), inputs=[audience.age(slot)])
    audience.name(slot, size=0.6, weight=700).place(seat).scale_to(pop)
code("audience.name(slot).scale_to(pop)", caption, anchor="center")
start = scene.cursor
scene.wait(4.0)
seeks.extend([start + 0.8, start + 2.2, scene.cursor - 0.05])
# Presenting, the room's stop advances by itself once 12 players are in.
scene.stop("room", until=audience.at_least(12))

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Polls

A poll for the audience's phones and its live data: bars in every direction and scale, votes, shares, icons and colors, and rehearsal votes.

Polls
View on GitHubgaanim examples/live/polls.py
Code · 189 lines
"""Polls: a question for the audience's phones and the live data a scene draws from it.

``scene.poll`` draws nothing. It gives a QR code and a session code to join,
bars that follow each answer's votes, live counts, and the shape and color
each answer has on the phones. Outside a live presentation the rehearsal,
a made-up audience, votes instead, so previews and snapshots show the poll
being answered. Each label is the code that builds what it shows.
"""

import os

from gaanim import Scene, Zones

QUESTION = "Which grows fastest?"
ANSWERS = ["x²", "2ˣ", "x·log x"]
VOTING = 2.4

scene = Scene()
foreground = scene.canvas.color("foreground")
background = scene.canvas.color("background")
accent = scene.canvas.color("accent")
track = scene.canvas.color("header")
# Previews, exports and snapshots play this made-up class of 24.
scene.rehearsal(24, seed=3)
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def vote(seconds=VOTING, early=True):
    """Let the rehearsal vote, then pause where a presentation waits; note the seeks."""
    start = scene.cursor
    scene.wait(seconds)
    seeks.extend(([start + 0.4] if early else []) + [scene.cursor - 0.05])


def bar(poll, index, zone, thickness, **options):
    """``poll``'s bar for ``index`` filling ``zone`` along its direction, over a track."""
    vertical = options.get("direction") in ("up", "down")
    length = zone.height if vertical else zone.width
    width, height = (thickness, length) if vertical else (length, thickness)
    radius = options.get("radius", 0)
    scene.geometry.rounded_rect(width, height, radius).fill(track).no_stroke().place(zone)
    return poll.bar(index, length=length, thickness=thickness, **options).fill(accent).no_stroke().place(zone)


# The QR code opens the relay's voting page; the session code is for phones
# that cannot scan. Both are ordinary drawables: a QR reads best dark on a
# light card with a margin. Presenting, the bars and counts follow the votes.
scene.segment("poll")
poll = scene.poll(QUESTION, ANSWERS, rehearse=[3, 7, 2])
page = scene.layout.zones(Zones.columns(["5fr", "11fr"], gap="96px", padding="page_padding"))
join = page[0].split(Zones.rows(["5fr", "1fr", "1fr", "1fr"], gap="8px"))
card = scene.geometry.rounded_rect(4.0, 4.0, 0.2).fill(foreground).no_stroke().place(join[0])
poll.qr(3.4).fill(background).place(card)
scene.text(poll.code, size=0.6, weight=700).place(join[1])
code("poll.qr(3.4)", join[2], anchor="center")
code("poll.code", join[3], anchor="center")
board = page[1].split(Zones.grid(rows=5, columns=["1fr", "4fr", "7fr", "2fr"], column_gap="48px"))
scene.text(poll.question, size=0.7, weight=700).place(board[0], anchor="left")
grow = []
for index, answer in enumerate(poll.options):
    first = 4 + 4 * index
    poll.icon(index, 0.7).place(board[first])
    scene.text(answer, size=0.7).place(board[first + 1], anchor="left")
    grow.append(bar(poll, index, board[first + 2], 0.6, radius=0.3).animate.create())
    scene.viz.readout(poll.votes(index), format=".0f", font_size=0.7).place(board[first + 3], anchor="left")
scene.viz.readout(poll.total(), format=".0f", suffix=" votes", font_size=0.5).place(board[18], anchor="left")
# A bar animates like any drawable: create() draws it in.
scene.play(grow)
vote()
# Presenting, this stop advances by itself once 90% of the phones voted;
# previews and exports treat it as a plain stop.
scene.stop("voting", until=poll.answered(share=0.9))

# A bar's bounds are its full box, so the layout holds still as votes arrive.
# By default the leading answer fills its bar; scale="total" makes each bar
# its share of the votes.
scene.segment("bars")
poll = scene.poll(QUESTION, ANSWERS, rehearse=[3, 7, 2])
grid = scene.layout.zones(Zones.grid(rows=4, columns=["6fr", "7fr"], column_gap="48px", row_gap="40px",
                                     padding="page_padding"))
VARIANTS = [
    ("poll.bar(i)", {}),
    ('poll.bar(i, scale="total")', {"scale": "total"}),
    ("poll.bar(i, radius=0.2)", {"radius": 0.2}),
    ('poll.bar(i, direction="left")', {"direction": "left"}),
]
for row, (label, options) in enumerate(VARIANTS):
    code(label, grid[2 * row], anchor="right")
    lanes = grid[2 * row + 1].split(Zones.rows(len(ANSWERS), gap="10px"))
    for index, lane in enumerate(lanes):
        bar(poll, index, lane, lane.height, **options)
vote()

# direction="up" and "down" grow columns; scale and radius combine with any
# direction.
scene.segment("columns")
poll = scene.poll(QUESTION, ANSWERS, rehearse=[3, 7, 2])
grid = scene.layout.zones(Zones.columns(2, gap="128px", padding="page_padding"))
for zone, direction in zip(grid, ["up", "down"]):
    cell = zone.split(Zones.rows(["1fr", "8fr", "1fr"], gap="24px"))
    code(f'poll.bar(i, direction="{direction}")', cell[0], anchor="center")
    columns = cell[1].split(Zones.columns(len(ANSWERS), gap="48px"))
    icons = cell[2].split(Zones.columns(len(ANSWERS), gap="48px"))
    for index, (column, icon) in enumerate(zip(columns, icons)):
        bar(poll, index, column, column.width, direction=direction)
        poll.icon(index, 0.6).place(icon)
vote()

# Live counts are parameters: show them with readouts, or feed them to
# computed values and anything else that takes a parameter.
scene.segment("numbers")
poll = scene.poll(QUESTION, ANSWERS, rehearse=[3, 7, 2])
grid = scene.layout.zones(Zones.grid(rows=5, columns=["4fr", "3fr", "3fr", "3fr"], column_gap="32px",
                                     padding="page_padding"))
for index, answer in enumerate(poll.options):
    header = grid[1 + index].split(Zones.columns(["2fr", "3fr"], gap="16px"))
    poll.icon(index, 0.6).place(header[0], anchor="right")
    scene.text(answer, size=0.6).place(header[1], anchor="left")
NUMBERS = [
    ("poll.votes(i)", poll.votes, {"format": ".0f"}),
    ("poll.share(i)", poll.share, {"format": ".2f"}),
    ("poll.percent(i)", poll.percent, {"format": ".0f", "suffix": "%"}),
]
for row, (label, number, options) in enumerate(NUMBERS, start=1):
    code(label, grid[4 * row], anchor="right")
    for index in range(len(ANSWERS)):
        scene.viz.readout(number(index), font_size=0.8, **options).place(grid[4 * row + 1 + index])
code("poll.total()", grid[16], anchor="right")
scene.viz.readout(poll.total(), format=".0f", font_size=0.8).place(grid[17])
vote()

# Each answer has a shape and a color on the phones, for the screen to match.
# A poll takes 2 to 6 answers.
scene.segment("shapes")
DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
poll = scene.poll("Which day suits the review?", DAYS)
grid = scene.layout.zones(Zones.grid(rows=2, columns=["5fr"] + ["2fr"] * len(DAYS), column_gap="32px",
                                     row_gap="96px", padding="page_padding"))
code("poll.icon(i, 1.4)", grid[0], anchor="right")
code("fill(poll.color(i))", grid[7], anchor="right")
for index, answer in enumerate(poll.options):
    poll.icon(index, 1.4).place(grid[1 + index])
    side = grid[8 + index].width
    tile = scene.geometry.rounded_rect(side, side, 0.2).fill(poll.color(index)).no_stroke().place(grid[8 + index])
    scene.text(answer, size=0.5, weight=700).fill(background).place(tile)
vote(early=False)

# The rehearsal leans each poll by one weight per answer, or some random way
# without rehearse. With multiple=True a phone picks several answers: votes
# count every choice and total() counts phones. Each poll here opens in turn
# and close() ends its voting, so the rehearsal answers it before the next.
scene.segment("rehearsal")
ROWS = [
    ("rehearse=None", {}),
    ("rehearse=[1, 1, 1]", {"rehearse": [1, 1, 1]}),
    ("rehearse=[6, 2, 1]", {"rehearse": [6, 2, 1]}),
    ("rehearse=[0, 1, 4]", {"rehearse": [0, 1, 4]}),
    ("multiple=True", {"multiple": True}),
]
grid = scene.layout.zones(Zones.grid(rows=len(ROWS) + 1, columns=["5fr", "4fr", "4fr", "4fr", "2fr"],
                                     column_gap="40px", padding="page_padding"))
for index, answer in enumerate(ANSWERS):
    scene.text(answer, size=0.6).place(grid[1 + index])
code("total()", grid[4])
start = scene.cursor
for row, (label, options) in enumerate(ROWS, start=1):
    poll = scene.poll(QUESTION, ANSWERS, **options)
    first = 5 * row
    code(label, grid[first], anchor="right")
    for index in range(len(ANSWERS)):
        cell = grid[first + 1 + index].split(Zones.columns(["2fr", "5fr"], gap="16px"))
        scene.viz.readout(poll.votes(index), format=".0f", font_size=0.6).place(cell[0], anchor="right")
        bar(poll, index, cell[1], 0.5, scale="total")
    scene.viz.readout(poll.total(), format=".0f", font_size=0.6).place(grid[first + 4])
    scene.wait(1.0)
    poll.close()
scene.wait(0.6)
seeks.extend([start + 1.5, start + 3.5, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Quizzes

Quizzes with a right answer, a countdown and a reveal, rehearsed answers, scoring, timing and questions loaded from Markdown with images.

Quizzes
View on GitHubgaanim examples/live/quizzes.py
Code · 173 lines
"""Quizzes: questions with a right answer, a countdown and a reveal, and the kinds of quiz.

``scene.quiz`` is a poll with a correct answer: phones answer once against
the clock, and a right answer earns more points the sooner it comes. The
scene draws the clock, keeps the counts hidden until ``reveal()`` and marks
the right answer; ``load_questions`` reads quizzes and polls from a Markdown
file. The rehearsal answers every quiz, so snapshots show the clock running
down and the room's answers. Each label is the code that builds what it shows.
"""

import os

from gaanim import Scene, Zones, load_questions

scene = Scene()
background = scene.canvas.color("background")
chart = scene.canvas.color("chart")
success = scene.canvas.color("success")
track = scene.canvas.color("header")
scene.rehearsal(20, seed=1)
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def bar(poll, index, zone, thickness=0.4):
    """``poll``'s bar for ``index`` across ``zone``, over a track, sized by its share."""
    scene.geometry.rect(zone.width, thickness).fill(track).no_stroke().place(zone)
    return poll.bar(index, length=zone.width, thickness=thickness, scale="total").fill(chart).no_stroke().place(zone)


def answer_cell(poll, index, zone):
    """Draw a vote count and a bar for one answer, and return the bar."""
    count, stage = zone.split(Zones.columns(["2fr", "5fr"], gap="16px"))
    scene.viz.readout(poll.votes(index), format=".0f", font_size=0.55).place(count, anchor="right")
    return bar(poll, index, stage)


def mark_right(quiz, bars):
    """After the reveal, turn the right answers' bars to the success color."""
    right = quiz.correct if isinstance(quiz.correct, list) else [quiz.correct]
    scene.play([bars[index].animate.fill(success).duration(0.3) for index in right])


# The question: how many players answered, a countdown and a tile per answer
# with its shape and color (phones shuffle a quiz's answers so that nobody
# copies a neighbor). The counts stay hidden while the room answers:
# quiz.revealed() is 0 until reveal() and 1 after, so it drives their opacity.
scene.segment("question")
quiz = scene.quiz("What is the derivative of x²?", ["x", "2x", "x²/2", "2"], correct=1, time=20, rehearse=0.7)
page = scene.layout.zones(Zones.rows(["2fr", "7fr", "1fr"], gap="24px", padding="page_padding"))
top = page[0].split(Zones.columns(["11fr", "4fr"], gap="48px"))
title, answered = top[0].split(Zones.rows(["2fr", "1fr"]))
scene.text(quiz.question, size=0.7, weight=700).place(title, anchor="left")
count, label = answered.split(Zones.columns(["1fr", "1fr"], gap="32px"))
scene.viz.readout(quiz.total(), format=".0f", suffix=" answered", font_size=0.45).place(count, anchor="left")
code("quiz.total()", label, anchor="left")
clock, label = top[1].split(Zones.rows(["3fr", "1fr"]))
scene.viz.readout(quiz.remaining(), format=".0f", font_size=1.3).place(clock)
code("quiz.remaining()", label, anchor="center")
grid = page[1].split(Zones.grid(rows=2, columns=2, gap="32px"))
shown = quiz.revealed()
tiles = []
for index, (answer, zone) in enumerate(zip(quiz.options, grid)):
    tile = scene.geometry.rounded_rect(zone.width, zone.height, 0.25).fill(quiz.color(index)).no_stroke().place(zone)
    face, counts = zone.inset(("48px", "24px")).split(Zones.rows(["3fr", "1fr"]))
    icon_zone, label_zone = face.split(Zones.columns(["1fr", "5fr"], gap="24px"))
    icon = quiz.icon(index, 0.8).fill(background).place(icon_zone)
    label = scene.text(answer, size=0.9, weight=700).fill(background).place(label_zone, anchor="left")
    count, stage = counts.split(Zones.columns(["1fr", "5fr"], gap="24px"))
    hidden_bar = quiz.bar(index, length=stage.width, thickness=0.3, scale="total").fill(background).no_stroke()
    hidden_bar.place(stage).opacity(shown)
    scene.viz.readout(quiz.votes(index), format=".0f", font_size=0.5, color=background).place(count).opacity(shown)
    tiles.append((tile, icon, label))
waiting = code("scene.stop(until=quiz.answered(share=1.0) | quiz.time_up())", page[2], anchor="center")
start = scene.cursor
scene.play([piece.animate.fade_in().duration(0.5) for tile in tiles for piece in tile])
scene.wait(3.0)
seeks.extend([start + 1.2, scene.cursor - 0.2])
# Presenting, the stop advances by itself once every player answered or
# the time is up; reveal() then shows each phone its result.
scene.stop("answering", until=quiz.answered(share=1.0) | quiz.time_up())
quiz.reveal()
revealing = code("quiz.reveal()", page[2], anchor="center").hidden()
scene.play(
    [piece.animate.opacity(0.25) for index, tile in enumerate(tiles) if index != quiz.correct for piece in tile]
    + [waiting.animate.fade_out().duration(0.3), revealing.animate.fade_in().duration(0.3)]
)
scene.wait(1.0)
seeks.append(scene.cursor - 0.05)
scene.stop("answer")

# Quizzes opened in turn, each answered by the rehearsal before its stop and
# then revealed. rehearse is the share that gets it right, or one weight per
# answer; correct=[...] makes a multiple choice quiz, right only when a phone
# picks exactly those answers; time is the countdown.
scene.segment("kinds")
ANSWERS = ["A", "B", "C"]
ROWS = [
    ("correct=1", {"correct": 1}),
    ("rehearse=0.9", {"correct": 1, "rehearse": 0.9}),
    ("rehearse=0.2", {"correct": 1, "rehearse": 0.2}),
    ("rehearse=[5, 1, 1]", {"correct": 1, "rehearse": [5, 1, 1]}),
    ("correct=[0, 2]", {"correct": [0, 2]}),
    ("time=8", {"correct": 1, "time": 8}),
]
grid = scene.layout.zones(Zones.grid(rows=len(ROWS) + 1, columns=["7fr", "4fr", "4fr", "4fr", "4fr"],
                                     column_gap="32px", padding="page_padding"))
code("scene.quiz(...)", grid[0], anchor="right")
code("remaining()", grid[1], anchor="center")
for index, answer in enumerate(ANSWERS):
    scene.text(answer, size=0.6).place(grid[2 + index])
start = scene.cursor
for row, (label, options) in enumerate(ROWS, start=1):
    quiz = scene.quiz("Which one?", ANSWERS, **options)
    first = 5 * row
    code(label, grid[first], anchor="right")
    scene.viz.readout(quiz.remaining(), format=".0f", font_size=0.55).place(grid[first + 1])
    bars = [answer_cell(quiz, index, grid[first + 2 + index]) for index in range(len(ANSWERS))]
    scene.wait(1.0)
    scene.stop()
    quiz.reveal()
    mark_right(quiz, bars)
scene.wait(0.6)
seeks.extend([start + 0.5, start + 6.9, scene.cursor - 0.05])

# Questions written in a Markdown file: each heading a question, each list
# item an answer, [x] the right ones and key: value lines for the time, a
# picture, rehearsal weights or several answers. scene.question(q) opens a
# quiz when q has right answers and a poll otherwise.
scene.segment("files")
questions = load_questions("assets/questions.md")
page = scene.layout.zones(Zones.rows(["1fr"] + ["4fr"] * len(questions), gap="32px", padding="page_padding"))
code('questions = load_questions("assets/questions.md")', page[0], anchor="left")
start = scene.cursor
for row, question in enumerate(questions):
    label, body = page[1 + row].split(Zones.columns(["4fr", "13fr"], gap="32px"))
    code(f"questions[{row}]", label, anchor="right")
    title, data = body.split(Zones.rows(["1fr", "2fr"], gap="8px"))
    scene.text(question.text, size=0.45, weight=700).place(title, anchor="left")
    cells = data.split(Zones.columns(["4fr", "3fr", "3fr", "3fr", "3fr"], gap="32px"))
    if question.image:
        # Phones show the picture above the question; the scene places it itself.
        scene.media.image(question.image, height=cells[0].height).place(cells[0], anchor="left")
        code(f"q.image, q.time == {question.time}", title, anchor="right")
    elif question.is_quiz:
        code(f"q.correct == {question.correct}", title, anchor="right")
    else:
        code(f"q.multiple == {question.multiple}", title, anchor="right")
    poll = scene.question(question)
    bars = []
    for index, answer in enumerate(question.options):
        name, stage = cells[1 + index].split(Zones.rows(["1fr", "1fr"]))
        scene.text(answer, size=0.4).place(name, anchor="left")
        bars.append(answer_cell(poll, index, stage))
    scene.wait(1.2)
    if question.is_quiz:
        scene.stop()
        poll.reveal()
        mark_right(poll, bars)
    else:
        poll.close()
scene.wait(0.6)
seeks.extend([start + 2.0, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Leaderboards

Players ranked by points with their names, bars, points, correct answers, streaks and a podium.

Leaderboards
View on GitHubgaanim examples/live/leaderboards.py
Code · 177 lines
"""Leaderboards: the players of a game ranked by points, and what each one answered.

``scene.leaderboard()`` draws nothing: for each rank, 0 for the leader, it
gives the nickname as live text, the points and other counts as parameters,
and a bar against the leader's score. It also tells what the player at a
rank did on one question. Outside a live presentation the rehearsal plays
the quizzes, so the standings change as each answer is revealed. Each label
is the code that builds what it shows.
"""

import os

from gaanim import Scene, Zones

scene = Scene()
foreground = scene.canvas.color("foreground")
accent = scene.canvas.color("accent")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
track = scene.canvas.color("header")
scene.rehearsal(12, seed=4)
board = scene.leaderboard()
RANKS = 6
COLUMNS = 4
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def number(parameter, zone, **options):
    """A readout of ``parameter`` centered in ``zone``."""
    return scene.viz.readout(parameter, format=".0f", font_size=0.55, **options).place(zone)


# Three quizzes, opened and revealed in turn while the standings show. The
# leaderboard counts a quiz's points when it is revealed.
scene.segment("standings")
QUIZZES = [
    ("7 × 8?", ["54", "56", "64"], 1),
    ("Capital of Australia?", ["Sydney", "Canberra", "Perth"], 1),
    ("Largest planet?", ["Saturn", "Earth", "Jupiter"], 2),
]
page = scene.layout.zones(Zones.rows(["1fr", "6fr"], gap="32px", padding="page_padding"))
top = page[0].split(Zones.columns(["9fr", "3fr", "1fr"], gap="48px"))
code("board.players()", top[1], anchor="right")
scene.viz.readout(board.players(), format=".0f", font_size=0.6).place(top[2], anchor="left")
grid = page[1].split(Zones.grid(rows=RANKS + 1, columns=["1fr", "4fr", "8fr", "4fr"], column_gap="32px"))
for column, label in enumerate(["board.name(rank)", "board.bar(rank)", "board.points(rank)"], start=1):
    code(label, grid[column], anchor="left" if column == 1 else "center")
for rank in range(RANKS):
    first = 4 * (rank + 1)
    scene.text(str(rank + 1), size=0.55).fill(muted).place(grid[first])
    board.name(rank, size=0.55).place(grid[first + 1], anchor="left")
    scene.geometry.rect(grid[first + 2].width, 0.4).fill(track).no_stroke().place(grid[first + 2])
    board.bar(rank, length=grid[first + 2].width, thickness=0.4).fill(accent).no_stroke().place(grid[first + 2])
    number(board.points(rank), grid[first + 3])
captions = []
for question, options, correct in QUIZZES:
    quiz = scene.quiz(question, options, correct=correct)
    caption = scene.text(question, size=0.6, weight=700).place(top[0], anchor="left").hidden()
    scene.play([caption.animate.fade_in().duration(0.3)] + [old.animate.fade_out().duration(0.3) for old in captions])
    captions = [caption]
    scene.wait(0.9)
    scene.stop()
    quiz.reveal()
    scene.wait(1.0)
    seeks.append(scene.cursor - 0.05)
last_quiz = quiz

# A nickname is live text placed by its left edge, center or right edge, so
# names of any length line up; size, weight and fill style it.
scene.segment("names")
VARIANTS = [
    ('board.name(0, align="left")', {"align": "left"}),
    ('board.name(0, align="center")', {"align": "center"}),
    ('board.name(0, align="right")', {"align": "right"}),
    ("board.name(1, size=0.9)", {"size": 0.9, "rank": 1}),
    ("board.name(2, weight=800)", {"weight": 800, "rank": 2}),
    ("board.name(3).fill(accent)", {"rank": 3, "fill": accent}),
]
grid = scene.layout.zones(Zones.grid(rows=len(VARIANTS), columns=["6fr", "5fr"], column_gap="48px",
                                     padding="page_padding"))
for row, (label, options) in enumerate(VARIANTS):
    code(label, grid[2 * row], anchor="right")
    zone = grid[2 * row + 1]
    options = dict(options)
    rank, fill = options.pop("rank", 0), options.pop("fill", None)
    options.setdefault("size", 0.6)
    name = board.name(rank, **options)
    if "align" in options:
        # The name's position, marked by a rule: align picks the edge that sits on it.
        (x, y) = zone.center
        scene.geometry.line(x, y - 0.45, x, y + 0.45).stroke(rule, 0.03)
        name.move_to(x, y)
    else:
        name.place(zone, anchor="left")
    if fill is not None:
        name.fill(fill)
scene.wait(1.5)
seeks.append(scene.cursor - 0.05)

# The game so far for each rank: points, right answers, answered quizzes,
# the streak of right answers up to the last reveal.
scene.segment("game")
ROWS = [
    ("board.points(rank)", board.points),
    ("board.correct(rank)", board.correct),
    ("board.answered(rank)", board.answered),
    ("board.streak(rank)", board.streak),
]


def by_rank(rows):
    """A table with one row per entry of ``rows`` and a column for each of the first ranks."""
    grid = scene.layout.zones(Zones.grid(rows=len(rows) + 1, columns=["9fr"] + ["2fr"] * COLUMNS, column_gap="32px",
                                         padding="page_padding"))
    for rank in range(COLUMNS):
        board.name(rank, size=0.5, weight=700, align="center").move_to(*grid[1 + rank].center)
    for row, (label, value) in enumerate(rows, start=1):
        first = (COLUMNS + 1) * row
        code(label, grid[first], anchor="right")
        for rank in range(COLUMNS):
            value(rank, grid[first + 1 + rank])


by_rank([(label, lambda rank, zone, value=value: number(value(rank), zone)) for label, value in ROWS])
scene.wait(1.5)
seeks.append(scene.cursor - 0.05)

# One question for each rank: which answer the player chose, whether it
# responded and was right, the points it earned and the seconds it took.
# scene.audience() gives the same by joining order instead of rank.
scene.segment("answers")


def chosen(rank, zone):
    """The answers stacked in one place, each shown only if the player chose it."""
    for index, answer in enumerate(last_quiz.options):
        scene.text(answer, size=0.45).place(zone).opacity(board.chose(rank, last_quiz, index))


by_rank([
    ("board.chose(rank, quiz, i)", chosen),
    ("board.responded(rank, quiz)", lambda rank, zone: number(board.responded(rank, last_quiz), zone)),
    ("board.right(rank, quiz)", lambda rank, zone: number(board.right(rank, last_quiz), zone)),
    ("board.earned(rank, quiz)", lambda rank, zone: number(board.earned(rank, last_quiz), zone)),
    ("board.answer_time(rank, quiz)",
     lambda rank, zone: scene.viz.readout(board.answer_time(rank, last_quiz), format=".1f", suffix=" s",
                                          font_size=0.55).place(zone)),
])
scene.wait(1.5)
seeks.append(scene.cursor - 0.05)

# A podium: second, first and third from left to right, each bar measuring
# its points against the leader's.
scene.segment("podium")
page = scene.layout.zones(Zones.rows(["8fr", "1fr"], gap="24px", padding="page_padding"))
grid = page[0].split(Zones.columns(3, gap="96px"))
MEDALS = [accent, foreground, muted]
for rank, zone in zip([1, 0, 2], grid):
    step = zone.split(Zones.rows(["1fr", "1fr", "6fr"], gap="16px"))
    board.name(rank, size=0.7, weight=700, align="center").move_to(*step[0].center)
    number(board.points(rank), step[1], suffix=" pts")
    column = board.bar(rank, length=step[2].height, thickness=step[2].width * 0.8, direction="up", radius=0.15)
    column.fill(MEDALS[rank]).no_stroke().place(step[2])
code('board.bar(rank, direction="up", radius=0.15)', page[1], anchor="center")
scene.wait(1.5)
seeks.append(scene.cursor - 0.05)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Teams

The audience split into teams whose points add up, with team scores, averages, bars, leaders and a tug of war.

Teams
View on GitHubgaanim examples/live/teams.py
Code · 144 lines
"""Teams: the audience split into teams whose players' points add up, and a battle between them.

``scene.teams(names)`` deals each joining player to the smallest team (or
lets phones choose with ``choose=True``) and gives each team's players,
score, average and a bar against the leading team. Live zones know each
player's team, so the scene can line the teams up and stage the battle.
The rehearsal deals its made-up players the same way and, with one skill
per team, plays an uneven game. Each label is the code that builds what it
shows.
"""

import math
import os

from gaanim import Scene, Zones, computed
from gaanim.live import ease_out_back, impact, lerp, pose, smoothstep

scene = Scene()
muted = scene.canvas.color("muted")
track = scene.canvas.color("header")
teams = scene.teams(["Comets", "Rockets"])
# One skill per team: the first answers right far more often.
scene.rehearsal(16, seed=2, skill=[0.85, 0.4], arrive=3.0)
audience = scene.audience()
seeks = []

# Where the teams stand, in scene units: rows of five on each side.
COLUMN_STEP, ROW_STEP, FRONT = 1.1, 2.0, 1.4
FLOOR = -0.4


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def side(p):
    """-1 for the first team, on the left; 1 for the second, on the right."""
    return -1 if p.team == 0 else 1


def spot(p):
    """A player's place on its team's side, by its order of arrival in the team."""
    column, row = p.team_index % 5, p.team_index // 5
    return side(p) * (FRONT + column * COLUMN_STEP), FLOOR - row * ROW_STEP


def line_up(p):
    """Run in from the team's edge of the frame and land in place."""
    x, y = spot(p)
    run = smoothstep(min(p.t / 0.8, 1))
    sx, sy = impact(p.t - 0.8, amount=0.3)
    return pose(lerp(side(p) * 9, x, run), y, sx=sx, sy=sy, flip=side(p) > 0, express="happy", since=0.8)


def tug(p):
    """Everyone leans back on the rope; the leading team drags it to its side."""
    x, y = spot(p)
    ahead = p.team_rank == 0
    drag = 1.0 * ease_out_back(min(p.time / 1.2, 1)) * (1 if ahead else -1)
    heave = 0.12 * math.sin(2 * math.pi * (p.t * 1.4 + p.team_index * 0.13))
    return pose(x + side(p) * (drag + heave), y, lean=side(p) * 0.25, flip=side(p) > 0,
                express="winner" if ahead else "hurt", loop=True)


def team_header(zone):
    """Each team's name in its color, over its half of ``zone``."""
    for team, half in enumerate(zone.split(Zones.columns(2, gap="96px"))):
        scene.text(teams.names[team], size=0.8, weight=800).fill(teams.colors[team]).place(half)


# Players are dealt to the smaller team as they join. In a live zone each
# one knows p.team and p.team_index, its order of arrival in the team.
scene.segment("room")
page = scene.layout.zones(Zones.rows(["1fr", "1fr", "6fr", "1fr"], gap="16px", padding="page_padding"))
team_header(page[0])
for team, half in enumerate(page[1].split(Zones.columns(2, gap="96px"))):
    scene.viz.readout(teams.players(team), format=".0f", suffix=" players", font_size=0.5, color=muted).place(half)
code("teams.names[team], teams.players(team), live_zone(audience, line_up)", page[3], anchor="center")
scene.live_zone(audience, line_up, size=1.6, names=True, name_size=0.24)
start = scene.cursor
scene.wait(4.0)
seeks.extend([start + 1.2, start + 2.4, scene.cursor - 0.05])
scene.stop("room", until=audience.at_least(16))

# Two quizzes, revealed in turn. A player's points count for its team:
# score adds them up, average divides by the team's players, bar measures
# the score against the leading team's, and leader() is that team's index.
scene.segment("scores")
QUIZZES = [
    ("7 × 8?", ["54", "56", "64"], 1),
    ("Largest planet?", ["Saturn", "Earth", "Jupiter"], 2),
]
grid = scene.layout.zones(Zones.grid(rows=6, columns=["7fr", "6fr", "6fr"], column_gap="48px", row_gap="16px",
                                     padding="page_padding"))
caption = grid[0]
for team in range(len(teams)):
    scene.text(teams.names[team], size=0.7, weight=800).fill(teams.colors[team]).place(grid[1 + team])
ROWS = ["teams.score(team)", "teams.average(team)", "teams.bar(team)", "teams.leader() == team"]
for row, label in enumerate(ROWS, start=1):
    code(label, grid[3 * row], anchor="right")
leader = teams.leader()
for team in range(len(teams)):
    scene.viz.readout(teams.score(team), format=".0f", suffix=" pts", font_size=0.6).place(grid[4 + team])
    scene.viz.readout(teams.average(team), format=".0f", suffix=" pts each", font_size=0.6).place(grid[7 + team])
    lane = grid[10 + team]
    scene.geometry.rect(lane.width, 0.5).fill(track).no_stroke().place(lane)
    teams.bar(team, length=lane.width, thickness=0.5).fill(teams.colors[team]).no_stroke().place(lane)
    leading = computed(lambda index, team=team: 1.0 if round(index) == team else 0.15, inputs=[leader])
    scene.geometry.star(5, 0.4, 0.18).fill(teams.colors[team]).no_stroke().place(grid[13 + team]).opacity(leading)
questions = []
for question, options, correct in QUIZZES:
    quiz = scene.quiz(question, options, correct=correct)
    shown = scene.text(question, size=0.6, weight=700).place(caption, anchor="right").hidden()
    scene.play([shown.animate.fade_in().duration(0.3)] + [old.animate.fade_out().duration(0.3) for old in questions])
    questions = [shown]
    scene.wait(0.9)
    scene.stop()
    quiz.reveal()
    scene.wait(1.0)
    seeks.append(scene.cursor - 0.05)

# The battle: p.team_rank is 0 for the leading team, which drags the rope to
# its side; each bar grows away from the middle with direction.
scene.segment("battle")
page = scene.layout.zones(Zones.rows(["1fr", "1fr", "6fr", "1fr"], gap="16px", padding="page_padding"))
team_header(page[0])
for team, half in enumerate(page[1].split(Zones.columns(2, gap="96px"))):
    direction = "left" if team == 0 else "right"
    scene.geometry.rect(half.width, 0.35).fill(track).no_stroke().place(half)
    bar = teams.bar(team, length=half.width, thickness=0.35, direction=direction)
    bar.fill(teams.colors[team]).no_stroke().place(half)
scene.geometry.rect(15, 0.1).fill(muted).no_stroke().move_to(0, FLOOR + 0.6)
code('teams.bar(0, direction="left"), teams.bar(1), live_zone(audience, tug)', page[3], anchor="center")
scene.live_zone(audience, tug, size=1.6, lean=0.0)
start = scene.cursor
scene.wait(3.0)
seeks.extend([start + 0.6, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Characters

The characters the audience builds on their phones: every body, eyes, mouth, color and extra, expressions and named characters.

Characters
View on GitHubgaanim examples/live/characters.py
Code · 92 lines
"""Characters: the figures the audience builds on their phones, as drawables in the scene.

``scene.character(avatar)`` draws a character from five parts, ``[body,
color, eyes, mouth, extra]``, or from a nickname without one. It breathes
and blinks along the timeline and plays the expressions phones show; place,
scale and animate it like any drawable. Each row varies one part from the
same base, and each label is the code that builds it.
"""

import os

from gaanim import Character, Scene, Zones

BASE = [0, 0, 0, 1, 0]
COUNTS = {"body": 7, "color": 10, "eyes": 7, "mouth": 7, "extra": 9}
PARTS = list(COUNTS)

scene = Scene()
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def parts(names, size):
    """One row per part in ``names``, its label above a character for each choice."""
    rows = scene.layout.zones(Zones.rows(len(names), gap="32px", padding="page_padding"))
    for name, row in zip(names, rows):
        label, stage = row.split(Zones.rows(["1fr", "4fr"], gap="8px"))
        position = PARTS.index(name)
        avatar = ", ".join("i" if index == position else str(part) for index, part in enumerate(BASE))
        code(f"scene.character([{avatar}])  # {name}", label)
        cells = stage.split(Zones.columns(COUNTS[name], gap="16px"))
        for choice in range(COUNTS[name]):
            avatar = list(BASE)
            avatar[position] = choice
            scene.character(avatar, size=size).place(cells[choice])
    scene.wait(1.0)
    seeks.append(scene.cursor - 0.05)


# The body's shape and its color.
scene.segment("bodies")
parts(["body", "color"], 2.0)

# The face, and an extra on top: ears, hats, headphones or blush.
scene.segment("faces")
parts(["eyes", "mouth", "extra"], 1.8)

# express() plays an expression from the cursor: once, back to the
# character's own face, or with loop=True until the next one.
scene.segment("expressions")
EXPRESSIONS = Character.expressions()
page = scene.layout.zones(Zones.rows(["1fr", "1fr", "4fr", "1fr", "4fr"], gap="16px", padding="page_padding"))
code("for expression in Character.expressions():", page[0], anchor="left")
heroes = []
for row, label, loop in [(1, "hero.express(expression)", False), (3, "hero.express(expression, loop=True)", True)]:
    code(label, page[row], anchor="left")
    cells = page[row + 1].split(Zones.columns(len(EXPRESSIONS), gap="32px"))
    for index, (expression, cell) in enumerate(zip(EXPRESSIONS, cells)):
        stage, name = cell.split(Zones.rows(["4fr", "1fr"]))
        hero = scene.character([index, 2 * index, 0, 1, 0], size=2.2).place(stage)
        code(f'"{expression}"', name, anchor="center")
        heroes.append((hero, expression, loop))
start = scene.cursor
scene.wait(0.3)
for hero, expression, loop in heroes:
    hero.express(expression, loop=loop)
scene.wait(3.0)
seeks.extend([start + 0.8, start + 1.6, scene.cursor - 0.05])

# Without an avatar, the nickname picks the parts and how the character
# blinks, as for the audience's own characters.
scene.segment("names")
NAMES = ["Ana", "Beto", "Caro", "Dani", "Eli", "Fede"]
page = scene.layout.zones(Zones.rows(["1fr", "6fr", "1fr"], gap="16px", padding="page_padding"))
code("scene.character(name=name, size=3.0)", page[0], anchor="center")
stages = page[1].split(Zones.columns(len(NAMES), gap="32px"))
labels = page[2].split(Zones.columns(len(NAMES), gap="32px"))
for name, stage, label in zip(NAMES, stages, labels):
    scene.character(name=name, size=3.0).place(stage)
    code(f'name="{name}"', label, anchor="center")
scene.wait(1.0)
seeks.append(scene.cursor - 0.05)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Live zones

Live zones that pose the audience's characters every frame: position, rotation, scale, squash, lean, looks, expressions and names.

Live zones
View on GitHubgaanim examples/live/live_zones.py
Code · 193 lines
"""Live zones: the audience's characters inside the scene, posed every frame by a Python function.

``scene.live_zone(audience, behavior)`` brings each player in as the
character they made on their phone, and ``behavior(p)`` returns where it
stands and how it looks with ``gaanim.live.pose``. The function is compiled
when the scene is authored, so a ``.gaanim`` runs it without Python. The
zone's options tune how the engine deforms characters as they move and how
it draws their nicknames. Outside a live presentation the rehearsal's
players fill the zones. Each label is the code that builds what it shows.
"""

import math
import os

from gaanim import Scene, Zones
from gaanim.live import pose

scene = Scene()
rule = scene.canvas.color("rule")
# Ten made-up players, all in the room from the start.
scene.rehearsal(10, seed=3, arrive=0)
audience = scene.audience()
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def outline(zone):
    """A rule-colored frame around ``zone``."""
    return scene.geometry.rect(zone.width, zone.height).no_fill().stroke(rule, 0.02).place(zone)


def feet(zone, lift=0.5):
    """Where a character stands in ``zone``: centered, ``lift`` above its bottom edge."""
    return zone.center[0], zone.bottom + lift




# pose() places a character by its feet and changes how it looks. Here each
# player takes one cell by p.index, its order of arrival, and one variant.
scene.segment("pose")
POSES = ["pose(x, y)", "rotation=0.6", "scale=1.5", "sx=1.4, sy=0.7",
         "lean=0.35", "look_x=-1", "flip=True", "show_name=False"]
cells = scene.layout.zones(Zones.grid(rows=2, columns=4, gap="24px", padding="page_padding"))
for label, cell in zip(POSES, cells):
    outline(cell)
    scene.text(label, role="code").place(cell, anchor="top", padding="16px")
# Module constants are frozen into the behavior when the zone compiles it.
SPOTS = tuple(feet(cell, 0.7) for cell in cells)


def one_pose(p):
    x, y = SPOTS[p.index % 8]
    if p.index == 0:
        return pose(x, y)
    if p.index == 1:
        return pose(x, y, rotation=0.6)
    if p.index == 2:
        return pose(x, y, scale=1.5)
    if p.index == 3:
        return pose(x, y, sx=1.4, sy=0.7)
    if p.index == 4:
        return pose(x, y, lean=0.35)
    if p.index == 5:
        return pose(x, y, look_x=-1)
    if p.index == 6:
        return pose(x, y, flip=True)
    return pose(x, y, show_name=False, visible=p.index == 7)


scene.live_zone(audience, one_pose, size=1.8, names=True, name_size=0.28)
scene.wait(1.5)
seeks.append(scene.cursor - 0.05)

# express plays an expression when the character appears, or at since, the
# p.t it starts; loop=True repeats it until another one.
scene.segment("expressions")
EXPRESSIONS = ["happy", "sad", "hurt", "winner", "surprised"]
page = scene.layout.zones(Zones.grid(rows=3, columns=["5fr"] + ["2fr"] * len(EXPRESSIONS), column_gap="32px",
                                     row_gap="24px", padding="page_padding"))
code("pose(x, y, ...)", page[0], anchor="right")
code("express=e", page[len(EXPRESSIONS) + 1], anchor="right")
code("express=e, loop=True", page[2 * (len(EXPRESSIONS) + 1)], anchor="right")
for index, expression in enumerate(EXPRESSIONS):
    code(expression, page[1 + index], anchor="center")
FACES = tuple(feet(page[row * (len(EXPRESSIONS) + 1) + 1 + column], 0.4)
              for row in (1, 2) for column in range(len(EXPRESSIONS)))


def express(p):
    x, y = FACES[p.index % 10]
    looping = p.index >= 5
    face = p.index % 5
    if face == 0:
        return pose(x, y, express="happy", loop=looping)
    if face == 1:
        return pose(x, y, express="sad", loop=looping)
    if face == 2:
        return pose(x, y, express="hurt", loop=looping)
    if face == 3:
        return pose(x, y, express="winner", loop=looping)
    return pose(x, y, express="surprised", loop=looping)


scene.live_zone(audience, express, size=2.2)
start = scene.cursor
scene.wait(3.0)
seeks.extend([start + 0.6, start + 1.4, scene.cursor - 0.05])

# The engine stretches characters along their velocity (squash, up to
# max_stretch), leans them into horizontal motion (lean, up to max_lean),
# swings their hats and ears (follow) and turns their eyes toward where they
# go (look); 0 turns each off. Each row is its own zone, clipped to bounds.
scene.segment("motion")
ROWS = [
    ("live_zone(audience, run)", {}),
    ("squash=0, lean=0", {"squash": 0, "lean": 0}),
    ("squash=0.12, max_stretch=1.8", {"squash": 0.12, "max_stretch": 1.8}),
    ("lean=0.3, max_lean=0.6", {"lean": 0.3, "max_lean": 0.6}),
    ("follow=0, look=0", {"follow": 0, "look": 0}),
]
grid = scene.layout.zones(Zones.grid(rows=len(ROWS), columns=["6fr", "9fr"], column_gap="48px", row_gap="16px",
                                     padding="page_padding"))
LANE = (grid[1].left + 0.6, grid[1].right - 0.6)
FLOOR = 0.0


def run(p):
    """Three runners dash across the lane and back, hopping as they go."""
    phase = 2 * math.pi * (p.t / 2.4 + p.index * 0.12)
    x = LANE[0] + (LANE[1] - LANE[0]) * (0.5 - 0.5 * math.cos(phase))
    hop = 0.3 * abs(math.sin(3 * phase))
    return pose(x, FLOOR + hop, visible=p.index < 3, show_name=False)


for row, (label, options) in enumerate(ROWS):
    lane = grid[2 * row + 1]
    code(label, grid[2 * row], anchor="right")
    outline(lane)
    # run() reads FLOOR as it is when this row's zone compiles it.
    FLOOR = lane.bottom + 0.1
    scene.live_zone(audience, run, bounds=(lane.left, lane.bottom, lane.right, lane.top), size=1.1, **options)
start = scene.cursor
scene.wait(2.4)
seeks.extend([start + 0.3, start + 0.9, start + 1.5])

# names=True draws each nickname under its character's feet: name_size tall,
# name_gap below them, in name_weight. Characters stay inside bounds, and
# zone.close() ends a zone before its segment does.
scene.segment("names")
ROWS = [
    ("names=True", {"names": True}),
    ("names=True, name_size=0.4", {"names": True, "name_size": 0.4}),
    ("name_gap=0.3, name_weight=400", {"names": True, "name_gap": 0.3, "name_weight": 400}),
    ("bounds=(x0, y0, x_mid, y1)", {}),
    ("zone.close()  # after 1 s", {}),
]
grid = scene.layout.zones(Zones.grid(rows=len(ROWS), columns=["7fr", "8fr"], column_gap="48px", row_gap="16px",
                                     padding="page_padding"))
LANE = (grid[1].left + 0.6, grid[1].right - 0.6)
zones = []


def stand(p):
    """Five players in a row, bobbing in turn."""
    x = LANE[0] + (LANE[1] - LANE[0]) * (p.index % 5) / 4
    bob = 0.1 * abs(math.sin(2 * math.pi * (p.t / 1.2 + p.index * 0.2)))
    return pose(x, FLOOR + bob, visible=p.index < 5)


for row, (label, options) in enumerate(ROWS):
    lane = grid[2 * row + 1]
    code(label, grid[2 * row], anchor="right")
    outline(lane)
    FLOOR = lane.bottom + 0.55
    right = lane.center[0] if label.startswith("bounds") else lane.right
    zones.append(scene.live_zone(audience, stand, bounds=(lane.left, lane.bottom, right, lane.top), size=0.9,
                                 **options))
start = scene.cursor
scene.wait(1.0)
zones[-1].close()
scene.wait(1.0)
seeks.extend([start + 0.5, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Zone behaviors

The motion helpers of gaanim.live, from lerp and easings to springs, wobbles, impacts and ballistic arcs, and state a zone keeps per player.

Zone behaviors
View on GitHubgaanim examples/live/zone_behaviors.py
Code · 192 lines
"""Zone behaviors: the motion helpers of gaanim.live and numbers a zone keeps per player.

A live zone's behavior is a function of the player's time, ``p.t``, so
``gaanim.live`` brings the pieces to animate with: curves from 0 to 1,
springs and wobbles, squash on landing and a crouch before a jump, throws
in an arc, and a random number of each player's own. For memory between
frames, ``state`` and ``update`` keep up to eight numbers per player. Each
label is the code that moves its character.
"""

import math
import os

from gaanim import Scene, Zones
from gaanim.live import (
    STEP,
    anticipate,
    ballistic,
    ease_in,
    ease_in_out,
    ease_out,
    ease_out_back,
    impact,
    landing,
    lerp,
    pose,
    progress,
    smoothstep,
    spring,
    state,
    wobble,
)

scene = Scene()
rule = scene.canvas.color("rule")
track = scene.canvas.color("header")
scene.rehearsal(10, seed=6, arrive=0)
audience = scene.audience()
seeks = []

CROSS = 1.6  # seconds to cross a lane
PERIOD = 2.0  # seconds per loop of the physics cells


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


def outline(zone):
    """A rule-colored frame around ``zone``."""
    return scene.geometry.rect(zone.width, zone.height).no_fill().stroke(rule, 0.02).place(zone)


# Curves from 0 to 1, applied to u = progress(p.t, 0.2, 1.6): one runner per
# lane, picked by p.index, crosses in the same time with another curve.
scene.segment("curves")
CURVES = ["lerp(x0, x1, u)", "lerp(x0, x1, smoothstep(u))", "lerp(x0, x1, ease_in(u))",
          "lerp(x0, x1, ease_out(u))", "lerp(x0, x1, ease_in_out(u))", "lerp(x0, x1, ease_out_back(u))"]
grid = scene.layout.zones(Zones.grid(rows=len(CURVES), columns=["7fr", "8fr"], column_gap="48px",
                                     padding="page_padding"))
for row, label in enumerate(CURVES):
    code(label, grid[2 * row], anchor="right")
    lane = grid[2 * row + 1]
    scene.geometry.rect(lane.width, 0.03).fill(rule).no_stroke().place(lane, anchor="bottom", offset=(0, "12px"))
# Module constants are frozen into the behavior when the zone compiles it.
# A tenth of each lane stays free at both ends, room for ease_out_back to overshoot.
X0, X1 = grid[1].left + grid[1].width * 0.1, grid[1].right - grid[1].width * 0.1
FLOORS = tuple(grid[2 * row + 1].bottom + 0.12 for row in range(len(CURVES)))


def race(p):
    u = progress(p.t, 0.2, CROSS)
    lane = p.index % 6
    if lane == 0:
        x = lerp(X0, X1, u)
    elif lane == 1:
        x = lerp(X0, X1, smoothstep(u))
    elif lane == 2:
        x = lerp(X0, X1, ease_in(u))
    elif lane == 3:
        x = lerp(X0, X1, ease_out(u))
    elif lane == 4:
        x = lerp(X0, X1, ease_in_out(u))
    else:
        x = lerp(X0, X1, ease_out_back(u))
    return pose(x, FLOORS[lane], visible=p.index < 6, look_x=1, look_y=0)


scene.live_zone(audience, race, size=1.2, squash=0, lean=0)
start = scene.cursor
scene.wait(CROSS + 0.6)
seeks.extend([start + 0.7, start + 1.1, scene.cursor - 0.05])

# Physical motion, looping every two seconds: a spring that overshoots, a
# wobble that dies out, squash on landing, a crouch before a jump, a throw
# in an arc, and p.random(k), a number of each player's own.
scene.segment("physics")
PHYSICS = ["spring(t, 0.8, 0.15)", "wobble(t, 0.8, 1, 1)", "impact(t - land)",
           "anticipate(t, at=0.8)", "ballistic(t, x, y, vx, vy)", "p.random(k)"]
cells = scene.layout.zones(Zones.grid(rows=3, columns=2, gap="24px", padding="page_padding"))
for label, cell in zip(PHYSICS, cells):
    outline(cell)
    scene.text(label, role="code").place(cell, anchor="top_left", padding="16px")
BOXES = tuple((cell.left, cell.bottom, cell.right, cell.top) for cell in cells)


def physics(p):
    t = p.t % PERIOD
    cell = min(5, p.index)
    left, bottom, right, top = BOXES[cell]
    middle, floor = (left + right) / 2, bottom + 0.15
    if cell == 0:
        # Released from the left, it overshoots the middle and settles.
        return pose(lerp(left + 0.8, right - 1.6, spring(t, 0.8, 0.15)), floor)
    if cell == 1:
        # Knocked at t = 0, it rocks and comes to rest.
        return pose(middle, floor, rotation=wobble(t, 0.8, 1, 1), express="hurt")
    if cell == 2:
        # Falls for 0.85 s, then squashes on the floor and bounces back.
        land = 0.85
        sx, sy = impact(t - land, amount=0.45)
        y = lerp(top - 1.0, floor, ease_in(min(t / land, 1)))
        return pose(middle, y, sx=sx, sy=sy)
    if cell == 3:
        # Crouches before leaping at 0.8 s, and stretches as it leaves.
        sx, sy = anticipate(t, at=0.8, amount=0.35, duration=0.5)
        jump = max(0, t - 0.8)
        height = max(0, 4.5 * jump - 4.5 * jump * jump / 0.9)
        return pose(middle, floor + height, sx=sx, sy=sy)
    if cell == 4:
        # Thrown up and right; landing() says when it is back on the floor.
        vx, vy = 2.4, 4.5
        air = landing(vy, 0, gravity=9.8)
        x, y = ballistic(min(t, air), middle - 1.1, floor, vx, vy, gravity=9.8)
        return pose(x, y, rotation=-2 * math.pi * min(t / air, 1))
    # Each player its own spot and pace from p.random(k).
    x = lerp(left + 0.6, right - 0.6, p.random(1))
    hop = 0.4 * abs(math.sin(math.pi * (t / PERIOD * (1 + 2 * p.random(2)))))
    return pose(x, floor + hop, scale=0.7 + 0.3 * p.random(3))


scene.live_zone(audience, physics, size=1.4, squash=0, lean=0)
start = scene.cursor
scene.wait(PERIOD)
seeks.extend([start + 0.3, start + 0.9, start + 1.5])

# state gives each player numbers that last from one frame to the next, and
# update(p) computes the next ones every STEP (1/60 s). Here energy charges
# at each player's own pace and every full charge climbs one step.
scene.segment("state")
STEPS = 5
page = scene.layout.zones(Zones.rows(["7fr", "1fr"], gap="24px", padding="page_padding"))
stairs = page[0].split(Zones.columns(5, gap="48px"))
RISE = stairs[0].height / (STEPS + 1)
for stair in stairs:
    width = stair.width / (STEPS + 1)
    for step in range(STEPS + 1):
        height = RISE * (step + 0.2)
        block = scene.geometry.rect(width, height).fill(track).no_stroke()
        block.move_to(stair.left + width * (step + 0.5), stair.bottom + height / 2)
code('state={"energy": 0.0, "steps": 0}, update=charge', page[1], anchor="center")
STARTS = tuple((stair.left, stair.bottom) for stair in stairs)
WIDTH = stairs[0].width / (STEPS + 1)


def charge(p):
    energy = p.state.energy + (0.9 + 1.2 * p.random(4)) * STEP
    if energy >= 1 and p.state.steps < STEPS:
        return state(energy=0, steps=p.state.steps + 1)
    return state(energy=min(energy, 1))


def climb(p):
    left, bottom = STARTS[p.index % 5]
    steps = p.state.steps
    # The fuller its energy, the lower it crouches before the next step.
    crouch = 0.35 * p.state.energy if steps < STEPS else 0
    return pose(left + WIDTH * (steps + 0.5), bottom + RISE * (steps + 0.2), sx=1 + crouch * 0.6, sy=1 - crouch,
                visible=p.index < 5, express="winner" if steps == STEPS else None, loop=True)


scene.live_zone(audience, climb, size=1.2, state={"energy": 0.0, "steps": 0}, update=charge)
start = scene.cursor
scene.wait(4.0)
seeks.extend([start + 1.0, start + 2.5, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()

Zone games

Characters that move with the game: grouped by the answer each player chose, racing by score, and standing on a podium by rank.

Zone games
View on GitHubgaanim examples/live/zone_games.py
Code · 158 lines
"""Zone games: characters that move with the game, by the answer each player chose and its rank.

A live zone's behavior reads the game from its player: ``p.answer`` and
``p.answer_points`` for the last question, ``p.rank``, ``p.score`` and
``p.leader`` for the standings, and ``p.previous_rank`` and ``p.rank_since``
to animate a change of place. Outside a live presentation the rehearsal
answers the quizzes, so the characters run, race and celebrate in
snapshots too. Each label is the code that places the characters.
"""

import math
import os

from gaanim import Scene, Zones
from gaanim.live import ease_out_back, impact, lerp, pose, progress, smoothstep, spring

scene = Scene()
foreground = scene.canvas.color("foreground")
background = scene.canvas.color("background")
accent = scene.canvas.color("accent")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
scene.rehearsal(12, seed=8, arrive=0)
audience = scene.audience()
board = scene.leaderboard()
seeks = []


def code(label, zone, anchor="left"):
    """A variant label: the code that builds it."""
    return scene.text(label, role="code").place(zone, anchor=anchor)


# After the reveal each character runs under the answer it chose, p.answer
# (-1 without one). Right answers earned points, so those players jump; the
# rest slump, and players who did not answer wait in a corner.
scene.segment("answers")
quiz = scene.quiz("How many bones does an adult have?", ["186", "206", "306"], correct=1, rehearse=0.55)
page = scene.layout.zones(Zones.rows(["1fr", "2fr", "6fr", "1fr"], gap="16px", padding="page_padding"))
scene.text(quiz.question, size=0.6, weight=700).place(page[0])
tiles = page[1].split(Zones.columns(3, gap="48px"))
for index, tile in enumerate(tiles):
    scene.geometry.rounded_rect(tile.width, tile.height, 0.2).fill(quiz.color(index)).no_stroke().place(tile)
    scene.text(quiz.options[index], size=0.7, weight=700).fill(background).place(tile)
code("x = COLUMNS[p.answer], jumping if p.answer_points > 0", page[3], anchor="center")
COLUMNS = tuple(tile.center[0] for tile in tiles)
FLOOR, SPREAD = page[2].bottom + 0.3, tiles[0].width * 0.7
CORNER = (page[2].left + 0.4, page[2].bottom + 0.2)
scene.wait(1.0)
scene.stop("answering")
quiz.reveal()


def run_to_answer(p):
    if p.answer < 0:
        return pose(CORNER[0] + 0.5 * p.index, CORNER[1], scale=0.6, express="sad", loop=True)
    x = COLUMNS[p.answer] + SPREAD * (p.random(1) - 0.5)
    y = FLOOR + 2.4 * p.random(2)
    leave = 0.4 * p.random(3)
    run = progress(p.t, leave, 0.8)
    if run < 1:
        # Runs in from below the frame, in an arc.
        return pose(lerp(16 * p.random(4) - 8, x, ease_out_back(run)),
                    lerp(-6, y, smoothstep(run)) + math.sin(math.pi * run), express="surprised", since=0)
    arrived = p.t - leave - 0.8
    if p.answer_points > 0:
        beat = (arrived + p.random(5)) % 0.8 / 0.8
        return pose(x, y + 1.2 * beat * (1 - beat), express="winner", loop=True)
    sx, sy = impact(arrived, amount=0.4)
    return pose(x, y, sx=sx, sy=sy, express="sad", since=leave + 0.8)


scene.live_zone(audience, run_to_answer, size=1.1)
start = scene.cursor
scene.wait(2.5)
seeks.extend([start + 0.5, scene.cursor - 0.05])

# The five leaders race: each runs to its score against the leader's, in the
# lane of its rank, and changes lane with an overshoot when it overtakes or
# is overtaken. Quizzes revealed in this segment move the race.
scene.segment("race")
LANES = 5
page = scene.layout.zones(Zones.rows(["1fr", "6fr", "1fr"], gap="16px", padding="page_padding"))
lanes = page[1].split(Zones.grid(rows=LANES, columns=["2fr", "11fr"], column_gap="32px"))
for rank in range(LANES):
    board.name(rank, size=0.45, weight=700).place(lanes[2 * rank], anchor="left")
    lane = lanes[2 * rank + 1]
    scene.geometry.rect(lane.width, 0.03).fill(rule).no_stroke().place(lane, anchor="bottom")
code("y = LANE[p.rank], x = lerp(START, END, p.score / p.leader)", page[2], anchor="center")
LANE = tuple(lanes[2 * rank + 1].bottom + 0.05 for rank in range(LANES))
START, END = lanes[1].left + 0.4, lanes[1].right - 0.4


def score_race(p):
    y = lerp(LANE[min(p.previous_rank, LANES - 1)], LANE[min(p.rank, LANES - 1)], ease_out_back(p.rank_since / 0.6))
    score = lerp(p.previous_score, p.score, smoothstep(p.score_since / 0.8))
    x = lerp(START - 1, lerp(START, END, score / max(p.leader, 1)), spring(p.t, 0.9, 0.45))
    stride = abs(math.sin(2 * math.pi * 1.6 * p.t))
    mood = "winner" if p.rank == 0 else None
    return pose(x, y + 0.12 * stride, express=mood, loop=True, visible=p.rank < LANES, show_name=False)


scene.live_zone(audience, score_race, size=1.0)
QUIZZES = [("7 × 8?", ["54", "56", "64"], 1), ("Largest planet?", ["Saturn", "Earth", "Jupiter"], 2)]
captions = []
for question, options, correct in QUIZZES:
    quiz = scene.quiz(question, options, correct=correct)
    caption = scene.text(question, size=0.55, weight=700).place(page[0]).hidden()
    scene.play([caption.animate.fade_in().duration(0.3)] + [old.animate.fade_out().duration(0.3) for old in captions])
    captions = [caption]
    scene.wait(0.7)
    scene.stop()
    quiz.reveal()
    reveal = scene.cursor
    scene.wait(1.6)
    seeks.extend([reveal + 0.3, scene.cursor - 0.05])

# The podium: ranks 0 to 2 stand on their blocks and the leader keeps
# jumping; everyone else cheers from the floor.
scene.segment("podium")
page = scene.layout.zones(Zones.rows(["6fr", "2fr", "1fr"], gap="16px", padding="page_padding"))
blocks = page[0].split(Zones.columns(3, gap="64px"))
HEIGHTS = (3.0, 2.2, 1.5)
PLACES = []
for rank, block in zip((1, 0, 2), blocks):
    height = HEIGHTS[rank]
    color = (accent, foreground, muted)[rank]
    scene.geometry.rect(block.width, height).fill(color).no_stroke().place(block, anchor="bottom")
    name = board.name(rank, size=0.5, weight=700, align="center").fill(background)
    name.move_to(block.center[0], block.bottom + height - 0.4)
    PLACES.append((rank, block.center[0], block.bottom + height))
PODIUM = tuple((x, top) for _, x, top in sorted(PLACES))
CROWD = (page[1].left + 0.6, page[1].right - 0.6, page[1].bottom + 0.2)
code("PODIUM[p.rank] if p.rank < 3 else the crowd", page[2], anchor="center")


def celebrate(p):
    if p.rank < 3:
        x, top = PODIUM[p.rank]
        if p.rank > 0:
            return pose(x, top, scale=1.2, lean=0.08 * math.sin(2 * math.pi * p.t / 1.3), look_y=0.6)
        beat = p.t % 1.0
        return pose(x, top + 1.6 * beat * (1 - beat), scale=1.3, express="winner", loop=True)
    left, right, floor = CROWD
    x = lerp(left, right, (p.rank - 3) / 8)
    return pose(x, floor + 0.15 * abs(math.sin(2 * math.pi * (p.t / 0.9 + p.rank / 8))), scale=0.8, look_y=1)


scene.live_zone(audience, celebrate, size=1.2)
start = scene.cursor
scene.wait(2.0)
seeks.extend([start + 0.5, scene.cursor - 0.05])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, seeks)
else:
    scene.render()