tilemap-parserv5.2.0GITHUB

Full Physics World

The engine assembled end to end in one runnable mini game: a player, pushable crates, a one-way platform, and a body filtered by collision layer. Four small files, each with one job. This is docs/physics-world.md made runnable.

Assets are generated at runtime, so this example has no external asset dependencies: the first run draws a tiny spritesheet and its animation JSON into generated/ inside the example folder.

FEATURES

  • Tile collision: solid ground and wall, plus a one-way platform
  • PhysicsWorld assembly: tile_map, tileset collision, tile size
  • Static bodies (the pillar) and kinematic bodies (the crates)
  • A push loop through move_grounded with an explicit velocity
  • A move_platformer player controller with runtime-generated animation
  • Collision layers and masks: the pillar is on layer 2 and the player's mask excludes it
  • Rendering: tiles, dashed one-way edges, hollow layer-2 bodies, animated sprite

PROJECT STRUCTURE

treeTEXT
examples/full-physics-world/
├── main.py     the game loop: input, movement, rendering
├── world.py    the physics space: tiles, one-way platform, bodies
├── player.py   the animated player (procedural spritesheet)
└── crate.py    kinematic crate pushing through move_grounded

RUNNING

terminalBASH
pip install -e .
cd examples/full-physics-world
python main.py

ARCHITECTURE

Everything flows through the world. The runner is attached to it with CollisionRunner.from_world(world), so tiles and bodies resolve together in every movement call.

data flowDIAGRAM

SOURCE

main.py: the game loop

Input, one move_platformer call, the push hook, crate physics, then drawing. The only file with a while loop.

main.pyPYTHON
"""Full physics world — every collision lane wired into one runnable demo.

This is the "how the engine is intended to be assembled" example:

  world.py    builds the PhysicsWorld: tiles, one-way platform, bodies
  player.py   the animated player controller (procedural spritesheet)
  crate.py    kinematic crate pushing through move_grounded
  main.py     the game loop: input, movement, rendering

Controls:  arrows / WASD to move,  Space to jump,  R to reset

Assets are generated at runtime into ``./generated``, so this example
has no external asset dependencies.

What to try:

1. Push a crate right — crates block each other, so a pushed crate
   stops at the next one, the pillar, or the wall.
2. Walk through the hollow pillar: it is on layer 2 and the player's
   collision_mask excludes layer 2, so the player passes while crates
   stop.
3. Jump up through the dashed one-way platform from below, then land
   on top of it.
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))

import pygame

from tilemap_parser import CollisionRunner

from crate import PUSH_SPEED, body_ahead, push
from player import PLAYER_H, Player
from world import COLS, GRAVITY_GROUND_Y, ROWS, TILE, build_world

SCREEN_W, SCREEN_H = COLS * TILE, ROWS * TILE
FPS = 60
ASSET_DIR = Path(__file__).resolve().parent / "generated"


def draw_tiles(screen, world):
    for (tx, ty), tile_id in world.tile_map.items():
        x, y = tx * TILE, ty * TILE
        pygame.draw.rect(screen, (70, 70, 90), (x, y, TILE, TILE))
        pygame.draw.rect(screen, (55, 55, 75), (x, y, TILE, TILE), 1)
        if world.tileset_collision.tiles[tile_id].shapes[0].one_way:
            # dashed top edge: you can jump up through this platform
            for gx in range(x, x + TILE, 8):
                pygame.draw.line(screen, (240, 220, 120), (gx, y), (gx + 4, y))


def draw_bodies(screen, world):
    for body in world.bodies:
        shape = body.collision_shape
        rect = (
            body.x + shape.offset[0],
            body.y + shape.offset[1],
            shape.width,
            shape.height,
        )
        if body.mode == "kinematic":
            pygame.draw.rect(screen, (230, 150, 60), rect)
            pygame.draw.rect(screen, (40, 40, 40), rect, 2)
        else:
            # layer-2 pillar, drawn hollow: the player walks through it
            x, y, w, h = (int(v) for v in rect)
            for gx in range(x, x + w, 8):
                pygame.draw.line(screen, (150, 150, 180), (gx, y), (gx + 4, y))
                pygame.draw.line(screen, (150, 150, 180), (gx, y + h), (gx + 4, y + h))
            for gy in range(y, y + h, 8):
                pygame.draw.line(screen, (150, 150, 180), (x, gy), (x, gy + 4))
                pygame.draw.line(screen, (150, 150, 180), (x + w, gy), (x + w, gy + 4))


def main():
    pygame.init()
    screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
    pygame.display.set_caption("Full physics world")
    clock = pygame.time.Clock()
    font = pygame.font.SysFont("monospace", 11)

    world = build_world()
    runner = CollisionRunner.from_world(world)
    player = Player(96, GRAVITY_GROUND_Y - PLAYER_H, ASSET_DIR)

    def reset():
        nonlocal world, runner, player
        world = build_world()
        runner = CollisionRunner.from_world(world)
        player = Player(96, GRAVITY_GROUND_Y - PLAYER_H, ASSET_DIR)

    running = True
    while running:
        dt = clock.tick(FPS) / 1000.0

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
                reset()

        keys = pygame.key.get_pressed()
        axis = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (
            keys[pygame.K_LEFT] or keys[pygame.K_a]
        )
        jump = keys[pygame.K_SPACE] or keys[pygame.K_UP] or keys[pygame.K_w]
        if axis:
            player.facing = 1 if axis > 0 else -1

        result = runner.move_platformer(
            player, None, None, dt, input_x=float(axis), jump_pressed=jump
        )

        if result.hit_wall_x and axis != 0:
            block = world.collides_with_body(player)
            if block is None:
                block = body_ahead(world, player, axis)
            if block is not None and block.mode == "kinematic":
                block.vx = axis * PUSH_SPEED

        push(runner, world, dt)
        player.update_animation(dt)

        screen.fill((35, 35, 45))
        draw_tiles(screen, world)
        draw_bodies(screen, world)
        player.draw(screen)

        lines = [
            "Arrows/WASD: move  Space: jump  R: reset",
            "Push a crate right: it stops at another crate, the pillar, or the wall.",
            "The hollow pillar is layer 2: you pass, crates stop.",
            "Jump up through the dashed one-way platform, then land on it.",
        ]
        for i, line in enumerate(lines):
            screen.blit(font.render(line, True, (200, 200, 200)), (4, 4 + i * 13))

        pygame.display.flip()

    pygame.quit()


if __name__ == "__main__":
    main()

world.py: the physics space

The scene: ground, a wall, a one-way platform, three crates and the layer-2 pillar. Returns a ready PhysicsWorld.

world.pyPYTHON
"""world.py — the physics space: tiles, one-way platform, solid bodies.

This module owns the scene geometry: the tile layer, the tileset
collision data, and the solid bodies (pushable crates + a layer-2
pillar).  Nothing moves here — movement happens in main.py through a
CollisionRunner attached to the world.
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))

from tilemap_parser import Body, PhysicsWorld, RectangleShape
from tilemap_parser.parser.collision import (
    CollisionPolygon,
    TileCollisionData,
    TilesetCollision,
)

TILE = 32
COLS, ROWS = 26, 15
GRAVITY_GROUND_Y = 12 * TILE  # top of the ground row (the floor line)

FULL_TILE = [(0.0, 0.0), (float(TILE), 0.0), (float(TILE), float(TILE)), (0.0, float(TILE))]

# Collision layers: the player lives on layer 1 with a mask that excludes
# layer 2, so it walks through the pillar.  Crates keep the default mask
# (everything), so they stop at it.  Bodies only collide when both sides'
# layer/mask agree (see docs/physics-world.md).
PILLAR_LAYER = 2


def build_world():
    """Create the world: ground, walls, platforms, crates and the pillar."""
    tile_map: dict[tuple[int, int], int] = {}
    for x in range(COLS):
        for y in (12, 13):
            tile_map[(x, y)] = 0  # ground, solid
    for y in range(8, 12):
        tile_map[(20, y)] = 0  # right wall
    for x in range(12, 16):
        tile_map[(x, 9)] = 1  # one-way platform (tile id 1)

    tileset = TilesetCollision(
        tileset_name="ground",
        tile_size=(TILE, TILE),
        tiles={
            0: TileCollisionData(
                tile_id=0,
                shapes=[CollisionPolygon(vertices=FULL_TILE)],
            ),
            1: TileCollisionData(
                tile_id=1,
                shapes=[CollisionPolygon(vertices=FULL_TILE, one_way=True)],
            ),
        },
    )
    world = PhysicsWorld(
        tile_map=tile_map, tileset_collision=tileset, tile_size=(TILE, TILE)
    )

    for x in (8, 10, 13):
        world.add_body(
            Body(
                RectangleShape(width=TILE, height=TILE),
                x=x * TILE,
                y=GRAVITY_GROUND_Y - TILE,
                mode="kinematic",
                game_id="crate",
            )
        )
    world.add_body(
        Body(
            RectangleShape(width=TILE, height=6 * TILE),
            x=16 * TILE,
            y=6 * TILE,
            mode="static",
            collision_layer=PILLAR_LAYER,
            game_id="pillar",
        )
    )
    return world

player.py: the sprite and its art

The player is a plain class with the attributes every movement function reads. The spritesheet and animation JSON are generated here.

player.pyPYTHON
"""player.py — the animated player: procedurally generated art + controller.

The spritesheet and its animation JSON are written into ``./generated``
the first time the example runs.  Assets are generated at runtime, so
this example has no external asset dependencies.

The player is a plain class exposing the attributes a collision runner
reads: ``x``, ``y``, ``vx``, ``vy``, ``on_ground`` and
``collision_shape`` — nothing more.
"""

import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))

import pygame

from tilemap_parser import AnimationPlayer, RectangleShape, SpriteAnimationSet

PLAYER_W, PLAYER_H = 24, 28
CELL = 32

DURATIONS = {"idle": 450.0, "walk": 120.0, "jump": 500.0}

INK = (20, 25, 35)
BODY = (60, 200, 190)
DARK = (35, 120, 120)
VISOR = (120, 230, 250)

# (legs, blink): each leg is (x, y) of a 4x8 pixel leg within the cell
FRAMES = [
    ([(12, 24), (16, 24)], False),  # 0 idle, eyes open
    ([(12, 24), (16, 24)], True),  # 1 idle, eyes closed
    ([(10, 24), (18, 24)], False),  # 2 walk, stride A
    ([(18, 24), (10, 24)], False),  # 3 walk, stride B
    ([(12, 20), (16, 20)], False),  # 4 jump, legs tucked
]


def build_player_assets(asset_dir: Path) -> Path:
    """Draw the 5-frame spritesheet, write the animation JSON, return its path."""
    asset_dir.mkdir(parents=True, exist_ok=True)

    sheet = pygame.Surface((len(FRAMES) * CELL, CELL), pygame.SRCALPHA)
    for i, (legs, blink) in enumerate(FRAMES):
        x = i * CELL
        for lx, ly in legs:
            pygame.draw.rect(sheet, INK, (x + lx - 1, ly, 6, 10))
            pygame.draw.rect(sheet, DARK, (x + lx, ly, 4, 8))
        pygame.draw.rect(sheet, INK, (x + 9, 7, 14, 18))
        pygame.draw.rect(sheet, BODY, (x + 10, 8, 12, 16))
        pygame.draw.rect(sheet, INK, (x + 11, 1, 10, 9))
        pygame.draw.rect(sheet, BODY, (x + 12, 2, 8, 7))
        pygame.draw.rect(sheet, INK, (x + 13, 3, 6, 5))
        pygame.draw.rect(sheet, VISOR if not blink else DARK, (x + 13, 3, 6, 4))

    sheet_path = asset_dir / "player.png"
    pygame.image.save(sheet, str(sheet_path))

    json_path = asset_dir / "player.anim.json"
    json_path.write_text(
        json.dumps(
            {
                "spritesheet_path": "player.png",
                "tile_size": [CELL, CELL],
                "grid_offset": [0, 0],
                "animations": {
                    name: {
                        "name": name,
                        "frames": [
                            {"variant_id": fid, "duration_ms": DURATIONS[name]}
                            for fid in fids
                        ],
                        "loop": name != "jump",
                    }
                    for name, fids in {"idle": [0, 1], "walk": [2, 3], "jump": [4]}.items()
                },
            },
            indent=2,
        )
    )
    return json_path


class Player:
    """Animated platformer controller (satisfies the sprite protocol)."""

    def __init__(self, x: float, y: float, asset_dir: Path):
        self.x = float(x)
        self.y = float(y)
        self.vx = 0.0
        self.vy = 0.0
        self.on_ground = True
        self.facing = 1  # 1 = right, -1 = left
        self.collision_layer = 1
        self.collision_mask = 0xFFFFFFFD  # everything except layer 2 (the pillar)
        self.collision_shape = RectangleShape(width=PLAYER_W, height=PLAYER_H)

        anim_set = SpriteAnimationSet.load(build_player_assets(asset_dir))
        self.anims = {
            name: AnimationPlayer(anim_set, name) for name in ("idle", "walk", "jump")
        }
        self._state = None
        self._set_state("idle")

    def _set_state(self, name: str) -> None:
        if name != self._state:
            self._state = name
            self.anims[name].reset()

    def update_animation(self, dt: float) -> None:
        if not self.on_ground:
            self._set_state("jump")
        elif self.vx != 0.0:
            self._set_state("walk")
        else:
            self._set_state("idle")
        self.anims[self._state].update(dt * 1000)

    def draw(self, screen, offset_x: float = 0.0, offset_y: float = 0.0) -> None:
        frame = self.anims[self._state].get_current_image()
        if self.facing < 0:
            frame = pygame.transform.flip(frame, True, False)
        rect = frame.get_rect(
            midbottom=(self.x + PLAYER_W / 2 - offset_x, self.y + PLAYER_H - offset_y)
        )
        screen.blit(frame, rect)

crate.py: kinematic bodies

Bodies never move themselves. A kinematic body is moved with an explicit velocity through move_grounded, resolved by the same collision lane as the player.

crate.pyPYTHON
"""crate.py — kinematic bodies: how a push works.

Bodies never move themselves.  A ``mode="kinematic"`` body is moved by
the game with an explicit velocity, resolved through the same collision
lane as the player: ``move_grounded(crate, None, None, dt,
velocity=(vx, 0))``.  Tiles and other bodies stop it in that one call.
"""

import copy
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))

PUSH_SPEED = 260.0


def crates(world):
    """All kinematic bodies in the world (the pushable ones)."""
    return [b for b in world.bodies if b.mode == "kinematic"]


def body_ahead(world, sprite, axis, probe=8.0):
    """Find the body the sprite is pressed against.

    The runner stops a sprite just *short* of a body (a sub-pixel skin
    gap), so a resting-position ``collides_with_body`` check can miss
    it.  Probe a few pixels into the push direction instead.
    """
    s = copy.copy(sprite)
    s.x = sprite.x + axis * probe
    return world.collides_with_body(s)


def push(runner, world, dt):
    """Give every sliding crate one frame of velocity, collision-resolved."""
    for crate in crates(world):
        if crate.vx:
            result = runner.move_grounded(
                crate, None, None, dt, velocity=(crate.vx, 0.0)
            )
            if result.hit_wall_x:
                crate.vx = 0.0
            else:
                crate.vx *= 0.9
                if abs(crate.vx) < 1.0:
                    crate.vx = 0.0

READING ORDER

  1. main.py the loop. This is where everything is wired: input, movement, the push hook, drawing.
  2. world.py the space. It owns the tile layer, the tileset collision data and the bodies; nothing moves here.
  3. player.py the sprite contract. A plain class with the attributes every movement function reads, plus procedural art.
  4. crate.py kinematic bodies. How a push works, and why bodies never move themselves.

Then the two guides this example builds on: Physics & Bodies for the object contract and CollisionRunner for the movement presets. The [source] lives in the repo.