Animations: frame-based sprites
The animation system is two objects and one rule. SpriteAnimationSet holds the parsed clips and the loaded spritesheet; AnimationPlayer is a pure frame clock that advances clip time and hands you the right frame image. The rule:AnimationPlayer.update() takes milliseconds, not seconds. Feed it clock.tick(60) directly.
LOADING
SpriteAnimationSet.load() is the one-call entry point: it parses the animation JSON and loads the spritesheet image in one step. The JSON's spritesheet_path is resolved relative to the JSON file; pass spritesheet_path= to override it.
Pass render_scale= to scale the sheet and its atlas grid (tile_size, grid_offset) in one step — handy for hi-res art rendered at a lower resolution or vice versa. The grid is pinned from the original sheet, so fractional scales with a nonzero grid_offset still address cells correctly. Scales that are not finite, not > 0, or that produce zero-sized or non-fitting cells are rejected at load.
from tilemap_parser import SpriteAnimationSet, AnimationPlayer
anim_set = SpriteAnimationSet.load("data/animations/player.json", render_scale=2.0)
# anim_set.warnings collects non-fatal issues from the JSON
player = AnimationPlayer(anim_set, "idle") # animation_name is REQUIREDIf you already have the JSON loaded separately, parse_animation_file(path) (or parse_animation_dict / parse_animation_json) returns the AnimationLibrary: the parsed clips, spritesheet_path, tile_size and grid_offset. Bad files raise AnimationParseError.
PLAYBACK: ONE CALL PER FRAME
Advance the clock, grab the frame image, draw it. The player owns no position. You decide where to blit.
dt_ms = clock.tick(60) # pygame returns MILLISECONDS
player.update(dt_ms)
image = player.get_current_image() # Surface | None
if image is not None:
screen.blit(image, (player_x, player_y))frame_index: current frame position (int).finished: True when a non-looping clip has played out;update()then no-ops untilreset().clip: the currentAnimationClip(orNoneif the name isn't in the library).reset(): back to frame 0 of the current clip.
SWITCHING ANIMATIONS
There is no play() method. The player's animation_name attribute is the switching API. Set it, then reset the clock to start the new clip from frame 0:
target = "run" if moving else "idle"
if player.animation_name != target:
player.animation_name = target
player.reset() # restart the new clip at frame 0Each AnimationClip declares its frames, per-frame duration_ms, loop, fps and metadata. Clips repeat by looping; for an attack you restart the same clip with reset().
ANCHORING AND PIXEL-PERFECT DRAW
Frames are cut from the spritesheet on a grid, honoring the library's tile_size and grid_offset. If the JSON enables trim_transparent, each frame is trimmed to its content. Use get_content_bounds() to ask where the visible pixels are, and anchor your blit against it:
bounds = anim_set.get_content_bounds(player.animation_name)
if bounds is not None:
image = player.get_current_image()
if image is not None:
screen.blit(image, (x - bounds.x, y - bounds.y)) # keep feet plantedMARKERS ARE DATA, NOT CALLBACKS
Clips can carry named AnimationMarkers (e.g. "hit", "footstep") at frame indexes. The player exposes them on clip.markers. There is no built-in callback; you do the frame-crossing check yourself:
prev_frame = 0 # keep this across frames
clip = player.clip
if clip is not None:
f = player.frame_index
if f >= prev_frame:
crossed = set(range(prev_frame + 1, f + 1))
else:
# looped: finish the old run, then frames 0..f of the new one
crossed = set(range(prev_frame + 1, clip.frame_count())) | set(range(0, f + 1))
for m in clip.markers:
if m.name == "hit" and m.frame_index in crossed:
apply_damage()
prev_frame = fOBJECT ANIMATIONS
Objects on an object layer can carry a typed ObjectAnimation (internal dataclass) — frames cut from the object's tileset in a row-major grid (left-to-right, top-to-bottom). Required fields fail early at parse time; optional fields have defaults. Access obj.animation directly for the raw parsed data, or use TilemapData.get_object_animation(obj) which returns normalized AnimData (a dict with frames as surfaces, frame_w/h, frame_duration_ms, loop, animation_mode, and properties).
// inside data.layers[].objects["1"]
{
"area": {"x": 32, "y": 64, "w": 16, "h": 16},
"ttype": 0,
"tileset_type": "object",
"variant": 0,
"animation": {
"frame_count": 4, // Required
"frame_duration_ms": 120, // Required
"speed": 1.0,
"loop": true,
"animation_mode": "default", // or "random_start_times"
"random_phase": false,
"frames": [0, 1, 2, 3] // optional explicit order
}
}from tilemap_parser import load_map
data = load_map("data/map.json")
obj = data.get_layer("Objects").objects[1]
# Access raw parsed animation data from the object
raw_anim = obj.animation # ObjectAnimation | None (internal dataclass)
if raw_anim is not None:
print(raw_anim.frame_count, raw_anim.frame_duration_ms)
# get_object_animation returns normalized AnimData dict with frames + metadata
anim_data = data.get_object_animation(obj) # AnimData | None
if anim_data is not None:
frames = anim_data["frames"] # list[Surface]
print(anim_data["frame_duration_ms"], anim_data["loop"])
print(anim_data["frame_w"], anim_data["frame_h"])
# Draw current frame
screen.blit(frames[frame_index], (obj.area.x, obj.area.y))frame_countandframe_duration_msare required — missing or non-positive values raiseMapParseError.framesoverrides the default0..frame_count-1order when present.- Frame slicing uses
obj.area.w × obj.area.h(one frame) as the cell size across the object's tileset sheet. - Access via
obj.animation.propertiesis not needed — the dataclass is the typed view. Raw JSON stays ondata.parsed.raw.