Skip to content

The World

Every terminal has a world, terminal.world, and everything an effect shows lives in it. Characters and bodies are both nodes in one tree: each has a parent, a position measured against that parent, and moves, turns and scales along with it. This chapter introduces the tree and the three bodies every world starts with.

The idea

  • A node is anything with a place in the world. Characters and bodies are both nodes (SpatialNode).
  • A body is a node that other nodes can belong to. It has a position, a rotation and a scale, and optionally a shape, which gives it a surface to sit on and a mesh to draw.
  • Every node has a parent body. A node's position is measured in its parent's frame, so when the parent moves, turns or scales, its children go with it. Frames and coordinates covers what the numbers mean.
  • A character is always a leaf: it can belong to a body, but nothing can belong to it.

Three bodies come with every world:

Body What it is Who belongs to it
world.root A shapeless body at the world origin that never moves. Free nodes. node.detach() makes a node its child, and new bodies belong to it by default.
world.text_plane A flat body at z = 0, the size of the canvas, facing the camera. Its surface positions are canvas coordinates. Every input character, at first.
world.screen A flat body fixed to the canvas, for labels and overlays. Characters that must stay on their cells whatever the camera does.

The tree: the root holds the text plane, the screen and your bodies; the input characters start on the text plane

A 2D effect only ever uses the text plane, and never notices: with the text plane and the camera as the world made them, a character on it is drawn in exactly the cell its position names.

In TTE

The Nodes example tips the text plane back into a floor and swings a cube over it on an invisible arm. The arm is a shapeless body, and the cube is the arm's child, so turning the arm carries the cube round:

floor = self.world.text_plane
floor.turn_to(rotation_about("x", -70), frames=60, ease=easing.in_out_sine)

self.arm = self.world.body(at=(0, 6, 0))
self.cube = self.world.body(Cube(8), at=(26, 0, 0), parent=self.arm).draw(Shaded())
self.arm.spin("y", turns=1, frames=240, ease=easing.in_out_sine)
nodes.py

Save it in your community effects directory as nodes.py.

"""Nodes: the text plane tips back into a floor while a cube on an arm circles over it, then everything returns.

An Effect Guide example for The World chapter.
"""

from __future__ import annotations

from dataclasses import dataclass

from terminaltexteffects import easing
from terminaltexteffects.engine.base_config import BaseConfig
from terminaltexteffects.engine.base_effect import BaseEffect, BaseEffectIterator
from terminaltexteffects.engine.drawing import Shaded
from terminaltexteffects.engine.shapes import Cube
from terminaltexteffects.engine.tweens import rotation_about
from terminaltexteffects.utils import argutils


def get_effect_resources() -> tuple[str, type[BaseEffect], type[BaseConfig]]:
    """Return the command name, effect class, and configuration class TTE registers."""
    return "nodes", Nodes, NodesConfig


@dataclass
class NodesConfig(BaseConfig):
    """Configuration for the Nodes effect."""

    parser_spec = argutils.ParserSpec(
        name="nodes",
        help="The text tips back into a floor while a cube on an arm circles over it.",
        description="nodes | The text tips back into a floor while a cube on an arm circles over it.",
        epilog="Example: tte --canvas-width 80 --canvas-height 24 --anchor-text c nodes",
    )


class NodesIterator(BaseEffectIterator[NodesConfig]):
    """Produce the frames of the Nodes effect."""

    def __init__(self, effect: Nodes) -> None:
        """Show the text, build the arm and the cube, and queue the moves."""
        super().__init__(effect)
        self.world = self.terminal.world
        self.world.set_light("upper left")
        self.world.camera.set_perspective(2)  # Exaggerate depth, so the floor and the circle read as 3D.
        for character in self.terminal.get_characters():
            if character.input_symbol != " ":
                self.terminal.set_character_visibility(character, is_visible=True)

        # The text plane is a body like any other: tip it back into a floor, then stand it up again.
        floor = self.world.text_plane
        floor.hold(30)
        floor.turn_to(rotation_about("x", -70), frames=60, ease=easing.in_out_sine)
        floor.hold(240)
        floor.turn_to_rest(frames=60, ease=easing.in_out_sine)

        # An arm is a body with no shape: a pivot. The cube is its child, 26 units out along its x axis, so turning
        # the arm carries the cube round in a circle while the cube spins on its own.
        self.arm = self.world.body(at=(0, 6, 0))
        self.cube = self.world.body(Cube(8), at=(26, 0, 0), parent=self.arm).draw(Shaded())
        self.cube.scale = 0.01  # Scales must be positive: start too small to show.
        self.cube.hold(90).scale_to(1.0, frames=30, ease=easing.out_back)
        self.cube.spin("y", turns=3, frames=240, together=True)  # Starts with the scale-up.
        self.cube.scale_to(0.01, frames=30, ease=easing.in_back)
        self.arm.hold(90).spin("y", turns=1, frames=240, ease=easing.in_out_sine)

    def __next__(self) -> str:
        """Play until every body has finished, then remove the cube and its arm."""
        if not self.world.animating:
            if self.world.bodies:
                for body in self.world.bodies:
                    self.world.remove(body)
                self.update()
                return self.frame
            raise StopIteration
        self.update()
        return self.frame


class Nodes(BaseEffect[NodesConfig]):
    """The text tips back into a floor while a cube on an arm circles over it."""

    @property
    def _config_cls(self) -> type[NodesConfig]:
        return NodesConfig

    @property
    def _iterator_cls(self) -> type[NodesIterator]:
        return NodesIterator
tte --input-file concept_input.txt --canvas-width 80 --canvas-height 24 --anchor-text c nodes

The text tips back into a floor, a cube circles over it and behind it, then the text stands up again

  • The text plane is an ordinary body. Its tweens are the same ones any body has, and every character on it goes along. None of the characters is active: the world places each one every frame, whether it ticks or not.
  • world.body(shape, at=, parent=) makes a body. With no shape, it is a pivot: a position, rotation and scale for other nodes to hang from. With a shape, it can also be drawn and carry characters on its surface.
  • at is measured in the parent's frame. The cube sits 26 units along the arm's x axis, wherever the arm happens to point.
  • The cube spins on its own while the arm turns it round. A child's pose adds to its parent's: rotations, positions and scales all compose down the tree.
  • At the end, world.remove(body) takes each body out. world.bodies lists the bodies made with world.body() that are still in the world.

Bodies

A body's pose is three properties, each measured in its parent's frame:

Property Set it now Animate it
position body.position = (x, y, z) A path: body.motion.new_path(...), the same Motion characters use
rotation body.rotation = rotation_about("y", 30), or body.rotate("y", 30) to add a turn spin, settle, turn_to, turn_to_rest, random_pose
scale body.scale = 2.0 scale_to

Transforms explains how the three combine, and Animating in 3D covers the tweens. Bodies move along paths for the same reason characters do: a path can have easing, bezier curves, holds, events and live targets, so anything you know about moving a character works for a body.

pivot = self.world.body(at=(0, 0, -20))
trip = pivot.motion.new_path(speed=0.5, ease=easing.in_out_sine)
trip.new_waypoint(Vec3(30, 0, -20))  # In the pivot's parent's frame: here, the world's.
pivot.motion.activate_path(trip)

Body paths play inside update() along with the tweens, and world.animating stays true until they finish.

Parents and children

  • parent= on world.body() sets a body's parent when it is made. Later, body.attach(other) moves it under another body, and body.detach() makes it free again. Attaching and detaching covers both for characters and bodies alike.
  • body.children lists the nodes that belong to a body, and body.descendants() walks every node below it.
  • A body can't become its own ancestor; attaching it under one of its own descendants raises ValueError.

Removing bodies

world.remove(body) takes a body out of the world and stops drawing it. Its children don't vanish with it: each one is detached and stays exactly where it was, free in the world. Characters that should go back to the text plane need attaching there (see Coming home). The root, the text plane and the screen can't be removed.

The screen

The screen is a flat body like the text plane, but it is fixed to the canvas. A character on the screen sits on the cell its position names whatever the camera does, and reads as facing the viewer in full light. Use it for captions, scores and labels next to a moving scene:

for column, letter in enumerate("ORBIT", start=4):
    label = self.terminal.add_character(letter, Coord(column, 2))
    label.attach(self.world.screen, at=Coord(column, 2))
    self.terminal.set_character_visibility(label, is_visible=True)

The screen never moves, turns or scales, and heights on it are ignored. Its characters still take part in the depth order: they paint at the camera's distance, like the text plane at rest.

Under the hood

Every body can convert between its frame and the world's:

point = cube.to_world((4, 0, 0), surface=False)  # A point in the cube's own axes, in world coordinates.
local = cube.from_world(point, surface=False)  # And back.
where = character.world_position  # Wherever its parent puts it, in world coordinates.

world_position, world_rotation and world_scale give a body's pose composed all the way up the tree. surface=True (the default) reads positions as (column, row, height) on the body's surface instead, which is what characters on it use; Frames and coordinates explains both.

At cell resolution

  • A pivot is invisible. Only its children show where it is, which makes pivots the cheapest way to group things.
  • Removing a body detaches its children where they are. A character left free in the world after its body goes keeps its last place, which may be off the canvas or behind the camera.
  • A body's children move with it in the same frame. Characters and drawn cells never lag a frame behind the body they belong to.