tilemap-parserv5.2.0GITHUB

Particles: visual effects

One config per effect, one ParticleSystem per emitter. A system owns exactly one emitter; for two effects you build two systems. Configs come from the tilemap-editor's particle JSON or are built by hand with ParticleSystemConfig.

QUICK START: ONE EFFECT, FIVE LINES

A particle effect has three pieces: a config (everything the effect looks like), a system (the runtime object), and aspawn area (the rect where new particles appear — passed to update() every frame). The engine does the rest:

one effect, whole gamePYTHON
from tilemap_parser import TilemapData
from tilemap_parser.runtime.particles import ParticleSystem

td = TilemapData.load("data/map.json", nodes_dir="data")

# 1. grab the effect's config from the map (emitters placed in the editor)
snow_node = next(n for n in td.particle_emitters if n.name == "snow")

# 2. build the system; maps with render_scale > 1: scale dimensionful
#    fields once, and remember the scale for the area rect (step 3)
rs = td.render_scale
snow_cfg = snow_node.config
snow_cfg.apply_render_scale(rs)
snow = ParticleSystem(snow_cfg)

# 3. every frame: update() spawns inside the area, draw() blits with the camera
r = snow_node.rect
snow.update(dt, r.x * rs, r.y * rs, r.w * rs, r.h * rs)
snow.draw(screen, camera_x, camera_y, 1.0)

The emitter's rect is raw editor pixels, not auto-scaled. Unlike AreaNode rects (which come pre-scaled), particle emitter rects need the same rs multiplier the config got. With render_scale = 1 the multiplication is a no-op, which is why examples that skip it still work.

Same shape when you bypass the map: parse_particle_file() returns the configs directly, and you supply the area rect yourself.

TWO WAYS TO SPEND THE AREA RECT

The area rect is simply the rect where new particles spawn. What it should be depends on the effect you want.

  • Anchored to the map — an explosion at a fixed spot, a torch, a bullet spark. Use the emitter's rect from the editor node (a small, static rect in world coordinates), as in the quick start.
  • Following the camera — full-screen effects like snow or mist that should cover the whole visible screen. Pass the on-screen rect each frame instead; particles keep spawning across the view and the effect follows the camera everywhere.
snow: camera-following, screen-widePYTHON
# the config is stored in the map; the area is the visible screen rect
rs = td.render_scale
snow_cfg = next(n for n in td.particle_emitters if n.name == "snow").config
snow_cfg.apply_render_scale(rs)
snow = ParticleSystem(snow_cfg)

# area = a rect on screen (top quarter of the view), moved with the camera.
# screen rect is already in effective pixels: no rs multiplication here.
snow.update(dt, cam.x, cam.y + HEIGHT // 4, WIDTH, HEIGHT // 2)
snow.draw(screen, cam.x, cam.y, 1.0)
spark: map-anchored burstPYTHON
spark_cfg = next(n for n in td.particle_emitters if n.name == "spark").config
spark_cfg.apply_render_scale(rs)
spark_cfg.spawn_rate = 0            # no streaming; fire on demand
spark = ParticleSystem(spark_cfg)

# fire a fixed burst in world pixels: position and spread are yours to
# pick, rs-scaled if the map is scaled. A burst enters the area rect once,
# then dynamics take over.
spark.emit_burst(24, bullet.x, bullet.y, 8 * rs, 8 * rs)

# zero-area update: nothing new spawns, existing particles keep being animated
spark.update(dt, 0, 0, 0, 0)
spark.draw(screen, cam.x, cam.y, 1.0)

Same config, same loop — only the rectangle changes. Map rect makes the effect stay in place; screen rect makes it travel with the camera; a zero rect means "no new particles, just finish the ones alive".

LOADING AND BUILDING

parse_particle_file() returns a list of ParticleSystemConfigs (one per effect in the file). Pick one, wrap it in a ParticleSystem, done.

setup.pyPYTHON
from tilemap_parser import parse_particle_file, ParticleSystem

configs = parse_particle_file("data/particles/explosion.json")
explosion = ParticleSystem(configs[0])

# maps with render_scale > 1: scale dimensionful fields once
configs[0].apply_render_scale(render_scale)

Everything about the effect lives in the config: spawn_rate, max_particles, lifetime_min/max, speed_min/max, direction + spread, gravity_x/y, start/end colors,start_scale/end_scale, alpha_fade, emission_shape and particle_shape. The valid values for the shape/fade fields are the module constants EMISSION_SHAPES, PARTICLE_SHAPES and ALPHA_FADE_MODES.

UPDATE AND DRAW: THE AREA RECT

update() needs the emitter's emission area: the rect where particles spawn — the two patterns above. Config emission_shape decides how the area is used: point / rect / circle / line. draw() needs the camera offset and zoom. This is the whole per-frame cost:

game loopPYTHON
# update(dt, area_x, area_y, area_w, area_h)  # spawn rect in world px
explosion.update(dt, 320.0, 240.0, 32.0, 32.0)

# draw(screen, offset_x, offset_y, zoom)  # camera offset, not rect
ox, oy = camera.offset
explosion.draw(screen, ox, oy, zoom)

BURSTS AND CONTINUOUS EMISSION

A burst fires a fixed count immediately; that's the explosion pattern. Continuous emission comes from the config's spawn_rate (particles per second, capped by max_particles) fed by update(), the torch pattern. Nothing to toggle at runtime; the config is the switch.

burst.pyPYTHON
# explosion: 120 particles at once, anywhere in the 32x32 area
explosion.emit_burst(120, x, y, 32.0, 32.0)

# torch: config.spawn_rate > 0 and update() every frame emits steadily

RENDERERS

ParticleSystem.draw() internally calls SpriteBatchRenderer, the concrete renderer that caches shape textures, tints, scales and batches blits. The ParticleRenderer base class is abstract; you only meet it if you write your own renderer (implement prepare(particles, config) and draw(screen, offset_x, offset_y, zoom)). clear_texture_caches() frees the cached shape textures when you're done.

EDITOR-PLACED EMITTERS (NODES)

Emitters placed in the tilemap-editor come back as parsed nodes. Wrap each node in a ParticleEmitterNode to get its config and placement rect, then build the system, exactly as examples/particles/src/main.py does:

from the mapPYTHON
from tilemap_parser import parse_nodes_file
from tilemap_parser.runtime.particles import ParticleEmitterNode

for parsed in parse_nodes_file("data/map.nodes.json"):
    if parsed.node_type != "particle_emitter":
        continue
    node = ParticleEmitterNode(parsed)          # .config + .rect
    ps = ParticleSystem(node.config)
    # node.rect is raw editor coords: multiply by the map's render_scale
    # (same rs the config got), like the quick start above
    ps.update(dt, node.rect.x * rs, node.rect.y * rs, node.rect.w * rs, node.rect.h * rs)
    ps.draw(screen, 0, 0, 1)

If you already load the map with TilemapData.load(path, nodes_dir=...), the same emitters come pre-wrapped as td.particle_emitters; skip the manual wrapping.

ADVANCED: PARTICLE FIELDS — FOG, HAZE, DUST

The three modes above are for one-off or streaming particles. Fog, haze, and dust are different: they should already be there and only drift. That's what ParticleField is for — it creates the sheets once, then just moves them. Nothing is ever created or destroyed, so the fog never flickers and costs almost nothing per frame.

field.pyPYTHON
from tilemap_parser import ParticleField

# padded so sheets leave the screen before they wrap
fog = ParticleField(
    area=(-80, -80, 960, 760),
    color=(200, 205, 215),
    alpha=14,          # per-sheet strength (0-255)
    density=1.0,       # sheet count multiplier
    direction=0,       # drift direction, degrees (0 = right)
    speed=(6, 14),     # drift speed range, px/sec
    quality="medium",  # low / medium / high — budget dial
)

# then the normal loop; no particles are born or die
fog.update(dt)
fog.draw(screen)

WHAT EACH ParticleField OPTION DOES

The quick answer for each option — what it changes, and the values it accepts. If an option says profile overrides, it only matters when you have not passed a profile.

optiontypewhat it does
area(x, y, w, h)The world rect where the fog lives. Sheets wrap at the edges, so pad it so sheets are off-screen before they wrap. Required.
profileFieldProfile | NoneThe layered tuning as plain data. Safer and easiest: pass FOG_PROFILE. None builds reasonable defaults from the size/speed/alpha options. Profile overrides those.
shape"circle" | "square" | "diamond" | "star" | "sparkle" | "smoke" | "heart" | "line" | "fog"The sprite each sheet draws. "fog" is a flat, soft-edged square that tiles into continuous haze; "smoke" is rounder with a brighter middle. Profile overrides.
color(r, g, b)Tint for every sheet. The end color is auto-darkened slightly.
alphaint 0-255How strong each sheet is. Only used when there is no profile. Profile overrides.
global_alphafloat 0.0-1.0The master strength knob, multiplied into every layer's alpha. Assign to fade the whole effect live: field.global_alpha = 0.4.
densityfloat > 0How many sheets there are. 1.0 = the default amount; 2.0 = twice that — and roughly twice the work. starting low and raising it only if the look is too thin.
directionfloat degrees | "random"Where sheets drift: 0 = right, 90 = down, 180 = left, 270 = up. Or "random" — every sheet drifts its own way.
speed(min, max) px/secHow fast sheets drift, as a range — each picks one. Varying speed between layers is what stops the fog looking like a grid.
size(min, max) pxSheet size range. Only used when there is no profile. Profile overrides.
spreadfloat 0-360How much wobble around direction; 0 = one straight drift angle.
layersint ≥ 1Depth layers for generic fields (no profile) — more = more depth, more work. Profile overrides.
quality"low" | "medium" | "high"The performance budget: low = fewer sheets (cap 260), medium = default (cap 500), high = most (cap 800). It never removes layers — layer structure comes from the profile. Turn this down on weaker machines.
ground_biasboolTrue keeps the nearest layer in the lower 65% of the area, so the fog reads as hugging the ground.
render_scalefloat > 0Scales sizes and speeds to match the map's render_scale. Pass map_data.render_scale.
blendint (pygame flag)

0 (default) = normal soft alpha. Overlapping sheets just look denser. This is the option for atmosphere: fog, mist, haze.

Any non-zero flag except pygame.BLEND_PREMULTIPLIED changes how the sheet is drawn onto the screen: the soft transparent look is lost and the fog renders solid (opaque). So for realistic fog keep blend=0.

Useful non-zero choices: pygame.BLEND_PREMULTIPLIED = premultiplied alpha, which preserves soft alpha when used with a premul_alpha() surface; pygame.BLEND_RGBA_ADD = additive glow (sparks, fireflies) — start with global_alpha roughly halved or it washes out.

Live tuning: set_color((r, g, b)) retints in place (never touches alphas), set_density(x), set_motion(direction=90, speed=(2, 4)) and set_area((x, y, w, h)) rebuild the field so the change applies immediately. Read the result via field.layers — each layer has .name, .area and .system.

LAYERED FIELDS: DEPTH FROM PARALLEL FIELDS

One layer reads flat: same size, same speed, same alpha — a uniform haze. Run three stacked layers, each with its own size, speed and alpha, and the eye reads depth. The working recipe:

layersizespeedalpharole
farlargestslowestlowestanchors the haze; reads as distance
midmediummediummediumthe main volume
nearsmallestfastestlowground band; reads as proximity

Wrap preserves each sheet's y-offset forever, so sheets that share a speed stay aligned as coherent rows or streaks — the giveaway that it is particles. Spreading speeds and sizes across layers is what dissolves that. The recipe below is a known-good fog; start from it and only touch the dials you care about.

layered fogPYTHON
from tilemap_parser import FOG_PROFILE, ParticleField

fog = ParticleField(
    area=(-160, -90, 1600, 900),
    profile=FOG_PROFILE,        # the shipped fog tuning, as plain data
    color=(200, 50, 80),
    density=1.0,
    global_alpha=1.0,           # 0-1 strength scale
    direction=0,
    speed=(8, 8),
    quality="medium",
    ground_bias=True,   # near layer uses the lower 65% of the area
)

fog.update(dt)
fog.draw(screen, 0, 0, 1)

GENERIC CONTINUOUS FIELDS

Fog is only a preset. For dust, pollen, ash, or magic haze, use ParticleField without a profile — it still fills once and wraps forever, and you pick the shape, alpha, density, size and motion.

generic fieldPYTHON
dust = ParticleField(
    area=(-160, -90, 1600, 900),
    shape="smoke",
    color=(180, 150, 100),
    alpha=10,
    density=0.7,
    direction=180,
    speed=(3, 8),
    size=(20, 45),
    spread=45,
    quality="low",
)

dust.update(dt)
dust.draw(screen)

PROFILES: LAYER TUNING AS PLAIN DATA

FOG_PROFILE is a FieldProfile(name, presets): a named tuple of FieldLayerSpecs. Each spec is (name, size_min, size_max, speed_min_mul, speed_max_mul, alpha, coverage, ground_layer=False) coverage is that layer's share of the area (2.0 = double the sheet area), speed_*_mul multiplies the field's speed range, and ground_layer=True pins the band to the lower 65%. Profiles are immutable data — copy them, never mutate them. profile.with_alpha(factor, name=None) returns a scaled copy (e.g. FOG_PROFILE.with_alpha(0.5, name="mist")) without touching the source. Here is the shipped fog, ready to copy:

layersize (px)speed × basealphacoverageband
far90-1400.38-0.75104.4full
mid60-950.62-1.12162.67full
near40-651.00-1.75101.85ground (lower 65%)

HALF-RESOLUTION RENDERING AND SCENE GLOW

A few hundred big sheets still means per-pixel work every frame. Fog is a soft blur anyway, so render it into a half-resolution buffer and upscale once — a quarter of the blit area, visually identical. This is a rendering recipe, not a ParticleField responsibility:

natural atmospherePYTHON
mist_buffer = pygame.Surface((W // 2, H // 2), pygame.SRCALPHA)
mist_buffer.fill((0, 0, 0, 0))
mist.draw(mist_buffer, 0, 0, 0.5)   # zoom = 1/2

# Two-step scale+blit: the dest-form smoothscale(src, size, dest)
# corrupts display surface pixels, so scale to a fresh surface first.
scaled = pygame.transform.smoothscale(mist_buffer, (W, H))
screen.blit(scaled, (0, 0))         # plain alpha composite

If you want the fog to add light instead of just tint — glowing haze in a dark scene — swap the buffer to plain RGB and composite additively. The fog then brightens the scene where it lies instead of occluding it.

scene glow (additive composite)PYTHON
glow_buffer = pygame.Surface((W // 2, H // 2))   # no alpha channel
glow_buffer.fill((0, 0, 0))
mist.draw(glow_buffer, 0, 0, 0.5)

scaled = pygame.transform.smoothscale(glow_buffer, (W, H))
screen.blit(scaled, (0, 0), special_flags=pygame.BLEND_RGB_ADD)

Alpha particles against black already store their premultiplied color, so the additive blit adds exactly the fog's light. Keep the field's ownblend at 0 for this — the additive behavior comes from the composite, not from the particles.

MANUAL FIELDS

ParticleField is a friendly wrapper around the primitive. If you need full control, build the contract yourself: wrap=True, spawn_rate=0, fill once with emit_field(), then update and draw normally.

manual fieldPYTHON
cfg = ParticleSystemConfig(
    name="mist", particle_shape="fog", emission_shape="rect",
    wrap=True, spawn_rate=0,
    particle_size_min=90, particle_size_max=140,
    speed_min=6.0, speed_max=14.0,
    start_color_a=14, end_color_a=14,
    alpha_fade="none", max_particles=400)

ps = ParticleSystem(cfg)
ps.emit_field(0.6, -80, -80, 960, 760)
ps.update(dt, -80, -80, 960, 760)
ps.draw(screen, 0, 0, 1)