# © 2026 CodeToUse / surfstable (Johannes Tils)
# Nutzung nur gemäß Lizenz / Use only under license: https://codetouse.surfstable.workers.dev/#lizenz
# Kommerzielle Nutzung nur mit Pro-Lizenz. / Commercial use requires a Pro license.
"""
Zauberwald – kurzer Clip im Stil eines klassischen Animationsfilms (eigene Figur).

    blender -b -P zauberwald.py -- --out /pfad/ordner [--samples 16]

Baut die komplette Szene per Skript, animiert alles und speichert zauberwald.blend.
Gerendert wird danach z. B. mit:  blender -b zauberwald.blend -a
"""
import bpy, bmesh, math, random, sys, os
from mathutils import Vector, Euler, noise

random.seed(11)
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def arg(name, default=None):
    return argv[argv.index(name) + 1] if name in argv else default
OUT = arg("--out")
SAMPLES = int(arg("--samples", 16))

FPS, END = 24, 168
RES = (960, 402)                 # 2.39:1 Kinoformat
F = range(1, END + 1)

bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
col = scene.collection

# ------------------------------------------------------------------ Helfer
def smooth(t):
    t = max(0.0, min(1.0, t)); return t * t * (3 - 2 * t)
def lerp(a, b, t): return a + (b - a) * t
def remap(f, a, b): return max(0.0, min(1.0, (f - a) / (b - a)))
def keys(ks, f):
    if f <= ks[0][0]: return ks[0][1]
    for (f0, v0), (f1, v1) in zip(ks, ks[1:]):
        if f0 <= f <= f1: return lerp(v0, v1, smooth((f - f0) / (f1 - f0)))
    return ks[-1][1]

def set_curve(idblock, path, index, values):
    if not idblock.animation_data: idblock.animation_data_create()
    if not idblock.animation_data.action:
        idblock.animation_data.action = bpy.data.actions.new(idblock.name + "_act")
    fc = idblock.animation_data.action.fcurves.new(path, index=index)
    fc.keyframe_points.add(len(values))
    co = []
    for i, v in enumerate(values): co += [1 + i, v]
    fc.keyframe_points.foreach_set("co", co)
    for kp in fc.keyframe_points: kp.interpolation = 'LINEAR'
    fc.update()

def bake_vec(obj, path, vecs):
    for i in range(3): set_curve(obj, path, i, [v[i] for v in vecs])
def bake_uniform_scale(obj, vals): bake_vec(obj, "scale", [(s, s, s) for s in vals])

def mesh_from(fn, name, smooth_faces=True):
    me = bpy.data.meshes.new(name); bm = bmesh.new(); fn(bm)
    if smooth_faces:
        for f in bm.faces: f.smooth = True
    bm.to_mesh(me); bm.free(); return me

SPH = mesh_from(lambda bm: bmesh.ops.create_uvsphere(bm, u_segments=40, v_segments=20, radius=1), "sph")
SPH_LO = mesh_from(lambda bm: bmesh.ops.create_uvsphere(bm, u_segments=14, v_segments=8, radius=1), "sph_lo")
ICO = mesh_from(lambda bm: bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1), "ico")
ICO2 = mesh_from(lambda bm: bmesh.ops.create_icosphere(bm, subdivisions=3, radius=1), "ico2")

def add(name, mesh, mat=None, parent=None, loc=(0, 0, 0), scale=(1, 1, 1), rot=(0, 0, 0)):
    o = bpy.data.objects.new(name, mesh); col.objects.link(o)
    o.location, o.rotation_euler = loc, rot
    o.scale = (scale, scale, scale) if isinstance(scale, (int, float)) else scale
    if parent: o.parent = parent
    if mat is not None and mesh is not None:
        if not mesh.materials: mesh.materials.append(None)
        o.material_slots[0].link = 'OBJECT'; o.material_slots[0].material = mat
    return o
def empty(name, parent=None, loc=(0, 0, 0)): return add(name, None, None, parent, loc)

def mat(name, color, rough=0.6, emit=None, strength=0.0, sheen=0.0):
    m = bpy.data.materials.new(name); m.use_nodes = True
    b = m.node_tree.nodes["Principled BSDF"]
    b.inputs["Base Color"].default_value = (*color, 1)
    b.inputs["Roughness"].default_value = rough
    if emit:
        b.inputs["Emission Color"].default_value = (*emit, 1)
        b.inputs["Emission Strength"].default_value = strength
    if emit: m.cycles.emission_sampling = 'NONE'
    if sheen:
        b.inputs["Sheen Weight"].default_value = sheen
        b.inputs["Sheen Roughness"].default_value = 0.4
    return m
def mat_emit(name, color, strength):
    m = bpy.data.materials.new(name); m.use_nodes = True; nt = m.node_tree
    nt.nodes.remove(nt.nodes["Principled BSDF"])
    e = nt.nodes.new("ShaderNodeEmission")
    e.inputs[0].default_value = (*color, 1); e.inputs[1].default_value = strength
    nt.links.new(e.outputs[0], nt.nodes["Material Output"].inputs[0])
    m.cycles.emission_sampling = 'NONE'
    return m

def gh(x, y):                                   # Bodenhöhe
    d = math.hypot(x, y)
    h = noise.noise(Vector((x * 0.25, y * 0.25, 0.0))) * 0.35 + noise.noise(Vector((x * 0.9, y * 0.9, 3.0))) * 0.06
    return h * smooth((d - 1.2) / 3.0)

# ------------------------------------------------------------------ Welt & Himmel
world = bpy.data.worlds.new("Nacht"); world.use_nodes = True; scene.world = world
nt = world.node_tree; bg = nt.nodes["Background"]
tc = nt.nodes.new("ShaderNodeTexCoord"); sep = nt.nodes.new("ShaderNodeSeparateXYZ")
ramp = nt.nodes.new("ShaderNodeValToRGB")
nt.links.new(tc.outputs["Generated"], sep.inputs[0]); nt.links.new(sep.outputs["Z"], ramp.inputs["Fac"])
nt.links.new(ramp.outputs["Color"], bg.inputs["Color"])
e = ramp.color_ramp.elements
e[0].position, e[0].color = 0.0, (0.10, 0.20, 0.38, 1)
e[1].position, e[1].color = 0.32, (0.012, 0.02, 0.07, 1)
bg.inputs["Strength"].default_value = 1.3

M_moon = mat_emit("Mond", (0.85, 0.93, 1.0), 6)
add("Mond", SPH, M_moon, loc=(-9.5, 70, 9.6), scale=4.2)
M_star = mat_emit("Stern", (0.9, 0.95, 1.0), 9)
for i in range(75):
    a = random.uniform(-0.75, 0.75); el = random.uniform(0.03, 0.26)
    d = 95
    add("Stern", ICO, M_star, loc=(d * math.sin(a), d * math.cos(a), 1 + d * math.tan(el)), scale=random.uniform(0.07, 0.12))

# ------------------------------------------------------------------ Boden
def make_ground(bm):
    bmesh.ops.create_grid(bm, x_segments=110, y_segments=110, size=34)
    for v in bm.verts: v.co.z = gh(v.co.x, v.co.y)
ground = add("Boden", mesh_from(make_ground, "boden"))
M_ground = mat("Boden", (0.04, 0.14, 0.10), 0.9)
gnt = M_ground.node_tree
nz = gnt.nodes.new("ShaderNodeTexNoise"); nz.inputs["Scale"].default_value = 1.4
gr = gnt.nodes.new("ShaderNodeValToRGB")
gr.color_ramp.elements[0].position, gr.color_ramp.elements[0].color = 0.35, (0.03, 0.10, 0.10, 1)
gr.color_ramp.elements[1].position, gr.color_ramp.elements[1].color = 0.7, (0.06, 0.22, 0.12, 1)
gnt.links.new(nz.outputs["Fac"], gr.inputs["Fac"])
gnt.links.new(gr.outputs["Color"], gnt.nodes["Principled BSDF"].inputs["Base Color"])
ground.data.materials.append(M_ground)

# ------------------------------------------------------------------ Bäume, Büsche, Hügel (Tiefenstaffelung)
M_haze = bpy.data.materials.new("Dunst"); M_haze.use_nodes = True
hnt = M_haze.node_tree; hb = hnt.nodes["Principled BSDF"]
oi = hnt.nodes.new("ShaderNodeObjectInfo")
hnt.links.new(oi.outputs["Color"], hb.inputs["Base Color"]); hnt.links.new(oi.outputs["Color"], hb.inputs["Emission Color"])
hb.inputs["Emission Strength"].default_value = 0.8; hb.inputs["Roughness"].default_value = 0.9
M_haze.cycles.emission_sampling = 'NONE'

def depth_color(y, green=0.0):
    t = remap(y, -2, 30) ** 0.8
    return (lerp(0.012, 0.045, t), lerp(0.012, 0.085 + green * 0.03, t), lerp(0.02, 0.15, t), 1)

def make_trunk(bm):
    bmesh.ops.create_cone(bm, cap_ends=True, segments=20, radius1=1.0, radius2=0.62, depth=18)
    for v in bm.verts:
        v.co.z += 9
        if v.co.z < 0.1: v.co.x *= 1.35; v.co.y *= 1.35      # Wurzelansatz
TRUNK = mesh_from(make_trunk, "stamm")
trees = [(-3.2, -1.5, 0.36), (3.35, -0.9, 0.42)]
while len(trees) < 28:
    x, y = random.uniform(-14, 14), random.uniform(3.0, 28)
    if -12.5 < math.degrees(math.atan2(x, y + 6.5)) < -1.8: continue      # Blick auf den Mond frei lassen
    if abs(x) < 1.4 or any(math.hypot(x - tx, y - ty) < 1.6 for tx, ty, _ in trees): continue
    trees.append((x, y, random.uniform(0.3, 0.75)))
for x, y, r in trees:
    o = add("Baum", TRUNK, M_haze, loc=(x, y, gh(x, y) - 0.3), scale=(r, r, 1),
            rot=(random.uniform(-0.05, 0.05), random.uniform(-0.05, 0.05), random.uniform(0, 6)))
    o.color = depth_color(y)
for i in range(40):
    x, y = random.uniform(-15, 15), random.uniform(4.5, 27)
    if abs(x) < 2.2 and y < 9: continue
    o = add("Busch", ICO2, M_haze, loc=(x, y, gh(x, y) + 0.1),
            scale=(random.uniform(0.8, 2.2), random.uniform(0.8, 1.6), random.uniform(0.5, 1.2)))
    o.color = depth_color(y, 1)
for i, x in enumerate((-34, -19, -5, 9, 23, 37)):
    o = add("Huegel", ICO2, M_haze, loc=(x, 36 + (i % 2) * 4, 0), scale=(11, 5, random.uniform(2.2, 3.6)))
    o.color = depth_color(40)

# ------------------------------------------------------------------ Gras
def make_tuft(bm):
    for _ in range(7):
        bx, by = random.uniform(-0.07, 0.07), random.uniform(-0.07, 0.07)
        a = random.uniform(0, 6.28); lean = random.uniform(0.1, 0.55); h = random.uniform(0.16, 0.34); w = 0.02
        dx, dy = math.cos(a), math.sin(a); px, py = -dy, dx
        v = [bm.verts.new((bx + px * w, by + py * w, 0)), bm.verts.new((bx - px * w, by - py * w, 0)),
             bm.verts.new((bx + dx * lean * h * 0.35 - px * w * 0.6, by + dy * lean * h * 0.35 - py * w * 0.6, h * 0.55)),
             bm.verts.new((bx + dx * lean * h * 0.35 + px * w * 0.6, by + dy * lean * h * 0.35 + py * w * 0.6, h * 0.55)),
             bm.verts.new((bx + dx * lean * h, by + dy * lean * h, h))]
        bm.faces.new(v[:4]); bm.faces.new((v[3], v[2], v[4]))
TUFT = mesh_from(make_tuft, "grasbueschel", False)
M_grass = mat("Gras", (0.07, 0.32, 0.17), 0.5)
n = 0
while n < 750:
    x, y = random.uniform(-9, 9), random.uniform(-2.6, 15)
    if math.hypot(x, y) < 0.72: continue
    add("Gras", TUFT, M_grass, loc=(x, y, gh(x, y)), scale=random.uniform(0.7, 1.7), rot=(0, 0, random.uniform(0, 6.28)))
    n += 1

# ------------------------------------------------------------------ Figur
M_body = mat("Fell", (0.80, 0.78, 0.95), 0.75, sheen=1.0)
M_dark = mat("FellDunkel", (0.55, 0.52, 0.80), 0.75, sheen=1.0)
M_belly = mat("Bauch", (1.0, 0.94, 0.82), 0.8, sheen=0.6)
M_pink = mat("Rosa", (1.0, 0.45, 0.55), 0.6)
M_white = mat("Auge", (1, 1, 1), 0.25)
M_pupil = mat("Pupille", (0.015, 0.008, 0.03), 0.08)
M_hl = mat_emit("Glanzlicht", (1, 1, 1), 6)
M_mouth = mat("Mund", (0.25, 0.08, 0.15), 0.6)

root = empty("Figur")
head = empty("Kopf", root, (0, 0, 0.62))
add("Koerper", SPH, M_body, root, (0, 0, 0.40), (0.44, 0.40, 0.41))
add("Bauch", SPH, M_belly, root, (0, -0.13, 0.37), (0.31, 0.30, 0.33))
for sx in (-1, 1):
    add("Fuss", SPH, M_dark, root, (sx * 0.18, -0.27, 0.055), (0.12, 0.19, 0.06))
add("Schweif", SPH, M_body, root, (0.42, 0.22, 0.22), 0.16)
add("Schweifspitze", SPH, M_belly, root, (0.54, 0.25, 0.36), 0.11)
arms = []
for sx in (-1, 1):
    p = empty("ArmPivot", root, (sx * 0.39, -0.06, 0.56))
    add("Arm", SPH, M_dark, p, (0, 0, -0.15), (0.075, 0.075, 0.17))
    arms.append((p, sx))

add("KopfKugel", SPH, M_body, head, (0, 0, 0.30), (0.43, 0.39, 0.37))
add("Nase", SPH, M_pink, head, (0, -0.385, 0.22), (0.048, 0.04, 0.036))
for sx in (-1, 1):
    add("Wange", SPH, M_pink, head, (sx * 0.27, -0.262, 0.17), (0.07, 0.03, 0.05))
cu = bpy.data.curves.new("laecheln", 'CURVE'); cu.dimensions = '3D'
sp = cu.splines.new('BEZIER'); sp.bezier_points.add(2)
pts = [(-0.075, -0.332, 0.135), (0, -0.347, 0.103), (0.075, -0.332, 0.135)]
tans = [(0.03, -0.006, -0.02), (0.035, 0, 0), (0.03, 0.006, 0.02)]
for bp, p, t in zip(sp.bezier_points, pts, tans):
    bp.handle_left_type = bp.handle_right_type = 'FREE'
    bp.co = p; bp.handle_left = Vector(p) - Vector(t); bp.handle_right = Vector(p) + Vector(t)
cu.bevel_depth = 0.009; cu.bevel_resolution = 2; cu.use_fill_caps = True
smile = bpy.data.objects.new("Laecheln", cu); col.objects.link(smile); smile.parent = head
cu.materials.append(M_mouth)

EYE = [Vector((sx * 0.16, -0.27, 0.34)) for sx in (-1, 1)]
eyes = []
for ec in EYE:
    sock = empty("Augenhoehle", head, ec)
    add("Augapfel", SPH, M_white, sock, scale=0.125)
    piv = empty("AugenPivot", sock)
    add("Pupille", SPH, M_pupil, piv, (0, -0.083, 0), (0.088, 0.05, 0.098))
    add("Glanz", SPH_LO, M_hl, piv, (0.032, -0.126, 0.042), 0.021)
    add("Glanz2", SPH_LO, M_hl, piv, (-0.03, -0.128, -0.03), 0.009)
    eyes.append((sock, piv))
ears = []
for sx in (-1, 1):
    p = empty("OhrPivot", head, (sx * 0.22, 0, 0.56))
    add("Ohr", SPH, M_body, p, (0, 0, 0.2), (0.11, 0.06, 0.25))
    add("OhrInnen", SPH, M_pink, p, (0, -0.036, 0.19), (0.062, 0.04, 0.17))
    ears.append((p, sx))

# --- Sprung: Stauchen & Strecken
SZ = [(1, 1), (104, 1), (111, 0.72), (115, 1.25), (120, 1.05), (127, 1.15), (130, 0.68),
      (135, 1.12), (140, 0.96), (146, 1.0), (END, 1.0)]
JUMP_H = 0.6
def root_z(f):
    t = (f - 112) / 16
    return JUMP_H * 4 * t * (1 - t) if 0 < t < 1 else 0.0
def root_s(f):
    sz = keys(SZ, f) * (1 + 0.012 * math.sin(f * 0.22)); sx = 1 / math.sqrt(sz)
    return Vector((sx, sx, sz))
bake_vec(root, "location", [(0, 0, root_z(f)) for f in F])
bake_vec(root, "scale", [root_s(f) for f in F])

NOSE_LOCAL = Vector((0, -0.455, 0.62 + 0.255))
def nose(f):
    s = root_s(f)
    return Vector((0, NOSE_LOCAL.y * s.y, NOSE_LOCAL.z * s.z + root_z(f)))

# ------------------------------------------------------------------ Glühwürmchen
def orbit(f):
    R = lerp(3.7, 1.0, smooth(f / 22)) if f < 22 else lerp(1.0, 0.62, remap(f, 22, 80))
    th = -0.1 - 7.75 * (f / 78)
    z = lerp(2.0, 1.30, smooth(f / 26)) + 0.10 * math.sin(f * 0.33)
    return Vector((R * math.cos(th), R * math.sin(th), z))
def spiral(f):
    u = (f - 112) / 56
    th = -math.pi / 2 - 10 * u ** 1.5
    R = lerp(0.5, 1.3, u)
    return Vector((R * math.cos(th), -0.6 + R * math.sin(th) * 0.8, lerp(1.0, 2.4, smooth(u * 1.3))))
def FF(f):
    if f < 72: return orbit(f)
    if f < 86: return orbit(f).lerp(nose(f), smooth((f - 72) / 14))
    if f < 112: return nose(f)
    return nose(f).lerp(spiral(f), smooth((f - 112) / 8))

M_ff = mat_emit("Gluehwuermchen", (1.0, 0.78, 0.35), 90)
ff = add("Gluehwuermchen", SPH_LO, M_ff)
bake_vec(ff, "location", [FF(f) for f in F])
bake_uniform_scale(ff, [0.034 * (1 + 0.25 * math.sin(f * 0.9)) for f in F])
ld = bpy.data.lights.new("GWLicht", 'POINT'); ld.color = (1.0, 0.72, 0.32); ld.shadow_soft_size = 0.06
fl = bpy.data.objects.new("GWLicht", ld); col.objects.link(fl); fl.parent = ff
es = M_ff.node_tree.nodes["Emission"].inputs[1]
for fr, v in ((72, 90), (86, 22), (112, 22), (120, 90)):
    es.default_value = v; es.keyframe_insert("default_value", frame=fr)
set_curve(ld, "energy", 0, [keys([(1, 70), (72, 70), (86, 3), (112, 4), (122, 80), (END, 80)], f)
                             * (1 + 0.15 * math.sin(f * 0.9)) for f in F])

# Feenstaub-Schweif
M_gold = mat_emit("StaubGold", (1.0, 0.8, 0.4), 45)
M_cyan = mat_emit("StaubCyan", (0.4, 0.95, 1.0), 45)
M_rose = mat_emit("StaubRosa", (1.0, 0.5, 0.8), 45)
for i in range(48):
    delay = 0.8 + i * 0.5
    jit = Vector((random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)))
    s0 = random.uniform(0.010, 0.024); ph = random.uniform(0, 6.28)
    loc, sc = [], []
    for f in F:
        g = f - delay
        p = FF(g) + jit * delay * 0.011 + Vector((0, 0, -0.0009 * delay * delay))
        speed = (FF(g) - FF(g - 1)).length
        s = s0 * (1 - delay / 26) * (0.55 + 0.45 * math.sin(f * 1.9 + ph)) * min(1.0, speed / 0.04)
        loc.append(p); sc.append(max(s, 0.0))
    o = add("Staub", ICO, M_gold if i % 4 else M_cyan)
    bake_vec(o, "location", loc); bake_uniform_scale(o, sc)

# ------------------------------------------------------------------ Kopf & Augen folgen dem Glühwürmchen
yaw = pitch = 0.0
head_rot, eye_rot = [], [[], []]
FWD = Vector((0, -1, 0))
for f in F:
    T = FF(f); s = root_s(f); rz = root_z(f)
    hp = Vector((0, 0, 0.92 * s.z + rz)); d = T - hp
    if f < 86: w = smooth(remap(f, 6, 14)) * (1 - smooth(remap(f, 70, 82)))
    elif f >= 112: w = smooth(remap(f, 112, 122))
    else: w = 0.0
    yt = math.atan2(d.x, -d.y)
    if d.y > 0.15: yt = math.copysign(0.65, d.x)
    yt = max(-0.65, min(0.65, yt))
    el = math.atan2(d.z, math.hypot(d.x, d.y))
    pt = -max(-0.2, min(0.7, el)) * 0.7
    yaw += (yt * w - yaw) * 0.3; pitch += (pt * w - pitch) * 0.3
    head_rot.append((pitch, 0, yaw))
    Rinv = Euler((pitch, 0, yaw), 'XYZ').to_matrix().inverted()
    tl = Rinv @ (Vector((T.x / s.x, T.y / s.y, (T.z - rz) / s.z)) - Vector((0, 0, 0.62)))
    we = smooth(remap(f, 3, 9))
    for k, ec in enumerate(EYE):
        dirv = (tl - ec).normalized()
        ang = FWD.angle(dirv)
        if ang > 0.62: dirv = FWD.slerp(dirv, 0.62 / ang)
        dirv = FWD.slerp(dirv, we)
        eye_rot[k].append(dirv.to_track_quat('-Y', 'Z').to_euler())
bake_vec(head, "rotation_euler", head_rot)
BLINK = [(1, 1), (29, 1), (31, 0.06), (34, 1), (92, 1), (94, 0.06), (97, 1), (100, 1), (102, 0.06), (105, 1),
         (128, 1), (130, 0.3), (134, 1), (END, 1)]
for k, (sock, piv) in enumerate(eyes):
    bake_vec(piv, "rotation_euler", eye_rot[k])
    bake_vec(sock, "scale", [(1, 1, keys(BLINK, f) * keys([(1, 1), (84, 1), (88, 1.12), (104, 1.12), (111, 1), (END, 1)], f)) for f in F])

PERK = [(1, 0), (6, 0), (11, 1), (20, 0.4), (84, 0.4), (89, 1.25), (96, 0.8), (104, 0.8), (111, -0.7), (116, 1.0),
        (128, 0.3), (132, -0.9), (138, 1.1), (146, 0.5), (END, 0.6)]
for p, sx in ears:
    bake_vec(p, "rotation_euler", [(0.06 * math.sin(f * 0.5 + sx) * remap(f, 138, 150), sx * (0.5 - 0.33 * keys(PERK, f)), 0) for f in F])
ARM = [(1, 0.3), (84, 0.3), (90, 0.55), (104, 0.5), (111, 0.15), (118, 2.6), (126, 2.4), (132, 0.9), (138, 1.6), (146, 0.6), (END, 0.45)]
for p, sx in arms:
    bake_vec(p, "rotation_euler", [(0, -sx * keys(ARM, f), 0) for f in F])

# ------------------------------------------------------------------ Leuchtpilze
def make_stem(bm):
    bmesh.ops.create_cone(bm, cap_ends=True, segments=14, radius1=0.11, radius2=0.07, depth=0.7)
    for v in bm.verts: v.co.z += 0.35
STEM = mesh_from(make_stem, "stiel")
def make_cap(bm):
    bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=1)
    for v in bm.verts:
        if v.co.z < -0.25: v.co.z = -0.25 + (v.co.z + 0.25) * 0.15
CAP = mesh_from(make_cap, "hut")
M_stem = mat("Stiel", (0.9, 0.85, 0.75), 0.7, emit=(0.9, 0.85, 0.75), strength=0.05)
PAL = [(0.10, 0.85, 1.0), (1.0, 0.22, 0.65), (0.55, 0.35, 1.0)]
shrooms = [(-1.25, -0.55, 0.42), (-1.8, 0.35, 0.32), (1.2, -0.75, 0.36), (1.75, 0.2, 0.52), (-0.9, 1.3, 0.58),
           (0.95, 1.5, 0.46), (2.7, 1.9, 0.62), (-2.9, 1.6, 0.52), (0.55, -1.35, 0.22), (-0.5, -1.5, 0.18)]
while len(shrooms) < 30:
    x, y = random.uniform(-8, 8), random.uniform(2.6, 14)
    if any(math.hypot(x - a, y - b) < 0.9 for a, b, _ in shrooms): continue
    shrooms.append((x, y, random.uniform(0.45, 1.0)))
LIT0 = 128
for i, (x, y, h) in enumerate(shrooms):
    c = PAL[i % 3]
    t0 = LIT0 + 2 + math.hypot(x, y) * 3.2
    e = empty("Pilz", loc=(x, y, gh(x, y) - 0.02))
    e.rotation_euler = (random.uniform(-0.12, 0.12), random.uniform(-0.12, 0.12), 0)
    add("Stiel", STEM, M_stem, e)
    m = mat(f"Hut{i}", c, 0.5, emit=c, strength=0.22)
    add("Hut", CAP, m, e, (0, 0, 0.78), (0.42, 0.42, 0.28))
    sock = m.node_tree.nodes["Principled BSDF"].inputs["Emission Strength"]
    for fr, v in ((t0, 0.22), (t0 + 4, 4.5), (t0 + 12, 2.2)):
        sock.default_value = v; sock.keyframe_insert("default_value", frame=fr)
    for fr, v in ((1, h), (t0, h), (t0 + 4, h * 1.3), (t0 + 9, h * 0.95), (t0 + 14, h)):
        e.scale = (v, v, v); e.keyframe_insert("scale", frame=fr)
    pl = bpy.data.lights.new("PilzLicht", 'POINT'); pl.color = c; pl.shadow_soft_size = 0.15; pl.use_shadow = False
    po = bpy.data.objects.new("PilzLicht", pl); col.objects.link(po); po.parent = e; po.location = (0.0, -0.25, 0.45)
    for fr, v in ((t0, 0.8 * h), (t0 + 4, 30 * h), (t0 + 12, 16 * h)):
        pl.energy = v; pl.keyframe_insert("energy", frame=fr)

# ------------------------------------------------------------------ Magie bei der Landung
bpy.ops.mesh.primitive_torus_add(major_radius=1, minor_radius=0.02, major_segments=96, minor_segments=8, location=(0, 0, 0.1))
ring = bpy.context.object; ring.name = "Ring"
M_ring = mat_emit("Ring", (1.0, 0.85, 0.5), 0); ring.data.materials.append(M_ring)
rs = M_ring.node_tree.nodes["Emission"].inputs[1]
for fr, v in ((LIT0 - 1, 0), (LIT0, 14), (LIT0 + 16, 0)):
    rs.default_value = v; rs.keyframe_insert("default_value", frame=fr)
bake_vec(ring, "scale", [((lerp(0.3, 5.0, remap(f, LIT0, LIT0 + 16) ** 0.6),) * 2 + (1 + 3 * remap(f, LIT0, LIT0 + 16),)) if LIT0 <= f <= LIT0 + 16 else (0, 0, 0) for f in F])
fd = bpy.data.lights.new("Blitz", 'POINT'); fd.color = (1.0, 0.8, 0.45); fd.shadow_soft_size = 0.3
fo = bpy.data.objects.new("Blitz", fd); col.objects.link(fo); fo.location = (0, -0.45, 0.25)
for fr, v in ((LIT0 - 1, 0), (LIT0 + 2, 280), (LIT0 + 14, 60), (END, 45)):
    fd.energy = v; fd.keyframe_insert("energy", frame=fr)

for i in range(90):                              # Funkenstoß
    a = random.uniform(0, 6.28); vh = random.uniform(0.03, 0.12); vv = random.uniform(0.05, 0.16)
    life = random.uniform(24, 42); s0 = random.uniform(0.012, 0.03); ph = random.uniform(0, 6.28)
    loc, sc = [], []
    for f in F:
        t = f - LIT0
        if t < 0: loc.append((0, 0, -1)); sc.append(0); continue
        te = (1 - math.exp(-0.05 * t)) / 0.05
        loc.append((math.cos(a) * (0.45 + vh * te), math.sin(a) * (0.45 + vh * te), 0.08 + vv * te - 0.0018 * t * t))
        sc.append(s0 * max(0.0, 1 - t / life) ** 0.6 * (0.55 + 0.45 * math.sin(f * 2.1 + ph)))
    o = add("Funke", ICO, (M_gold, M_gold, M_cyan, M_rose)[i % 4])
    bake_vec(o, "location", loc); bake_uniform_scale(o, sc)

for i in range(70):                              # aufsteigende Lichter
    x, y = random.uniform(-5.5, 5.5), random.uniform(-1.6, 7)
    t0 = LIT0 + 3 + math.hypot(x, y) * 3.2 + random.uniform(0, 14)
    vz = random.uniform(0.012, 0.032); s0 = random.uniform(0.012, 0.026); ph = random.uniform(0, 6.28)
    loc, sc = [], []
    for f in F:
        t = max(0.0, f - t0)
        loc.append((x + 0.12 * math.sin(t * 0.12 + ph), y + 0.12 * math.cos(t * 0.1 + ph), gh(x, y) + 0.1 + vz * t))
        sc.append(s0 * smooth(t / 6) * (0.6 + 0.4 * math.sin(f * 1.3 + ph)))
    o = add("Lichtpunkt", ICO, (M_gold, M_cyan, M_rose)[i % 3])
    bake_vec(o, "location", loc); bake_uniform_scale(o, sc)

M_amb = mat_emit("FerneGluehwuermchen", (0.75, 1.0, 0.45), 14)
for i in range(34):                              # ferne Glühwürmchen im Hintergrund
    x, y, z = random.uniform(-8, 8), random.uniform(1.5, 15), random.uniform(0.4, 2.8)
    ph = random.uniform(0, 6.28); amp = random.uniform(0.25, 0.6); s0 = random.uniform(0.022, 0.04)
    o = add("FernesGW", ICO, M_amb)
    bake_vec(o, "location", [(x + amp * math.sin(f * 0.04 + ph), y + amp * math.sin(f * 0.031 + ph * 2), z + 0.5 * amp * math.sin(f * 0.057 + ph * 3)) for f in F])
    bake_uniform_scale(o, [s0 * (0.5 + 0.5 * math.sin(f * 0.16 + ph)) ** 2 for f in F])

# ------------------------------------------------------------------ Licht
sd = bpy.data.lights.new("Mondlicht", 'SUN'); sd.color = (0.55, 0.72, 1.0); sd.energy = 5.0; sd.angle = math.radians(3)
so = bpy.data.objects.new("Mondlicht", sd); col.objects.link(so)
so.rotation_euler = Vector((0.28, -1, -0.62)).normalized().to_track_quat('-Z', 'Y').to_euler()
ad = bpy.data.lights.new("Aufheller", 'AREA'); ad.color = (0.55, 0.62, 1.0); ad.energy = 260; ad.size = 6
ao = bpy.data.objects.new("Aufheller", ad); col.objects.link(ao); ao.location = (-2.5, -6.5, 4.0)
ao.rotation_euler = (Vector((0, 0, 0.8)) - Vector(ao.location)).normalized().to_track_quat('-Z', 'Y').to_euler()

# ------------------------------------------------------------------ Kamera
cd = bpy.data.cameras.new("Kamera"); cd.lens = 35; cd.sensor_width = 36
cam = bpy.data.objects.new("Kamera", cd); col.objects.link(cam); scene.camera = cam
tgt = empty("KameraZiel")
tr = cam.constraints.new('TRACK_TO'); tr.target = tgt; tr.track_axis = 'TRACK_NEGATIVE_Z'; tr.up_axis = 'UP_Y'
def cam_pos(f):
    if f <= 106:
        t = smooth(remap(f, 1, 106)); return (lerp(0.45, -0.15, t), lerp(-6.4, -5.3, t), lerp(0.98, 0.92, t))
    t = smooth(remap(f, 106, END)); return (lerp(-0.15, 0.35, t), lerp(-5.3, -8.0, t), lerp(0.92, 1.45, t))
bake_vec(cam, "location", [cam_pos(f) for f in F])
bake_vec(tgt, "location", [(0, 0, lerp(0.80, 1.28, smooth(remap(f, 110, END))) + 0.4 * root_z(f)) for f in F])
cd.dof.use_dof = True; cd.dof.focus_object = root; cd.dof.aperture_fstop = 1.0

# ------------------------------------------------------------------ Render & Compositing
r = scene.render
r.engine = 'CYCLES'; scene.cycles.device = 'CPU'
scene.cycles.samples = SAMPLES; scene.cycles.use_denoising = True
scene.cycles.max_bounces = 2; scene.cycles.diffuse_bounces = 1; scene.cycles.glossy_bounces = 1
scene.cycles.denoising_prefilter = 'FAST'
scene.cycles.use_adaptive_sampling = True; scene.cycles.adaptive_threshold = 0.05; scene.cycles.adaptive_min_samples = 6
scene.cycles.sample_clamp_indirect = 3.0; scene.cycles.caustics_reflective = False; scene.cycles.caustics_refractive = False
r.use_persistent_data = True
r.resolution_x, r.resolution_y = RES; r.fps = FPS
scene.frame_start, scene.frame_end = 1, END
r.image_settings.file_format = 'PNG'; r.use_overwrite = False
scene.view_settings.view_transform = 'AgX'
try: scene.view_settings.look = 'AgX - Punchy'
except Exception: pass

scene.use_nodes = True; ct = scene.node_tree; ct.nodes.clear()
rl = ct.nodes.new("CompositorNodeRLayers")
g1 = ct.nodes.new("CompositorNodeGlare"); g1.glare_type = 'STREAKS'; g1.quality = 'HIGH'
g1.streaks = 4; g1.threshold = 7.0; g1.fade = 0.86; g1.iterations = 3; g1.mix = 0.0
g2 = ct.nodes.new("CompositorNodeGlare"); g2.glare_type = 'FOG_GLOW'; g2.quality = 'HIGH'
g2.threshold = 1.6; g2.size = 7; g2.mix = -0.15
el = ct.nodes.new("CompositorNodeEllipseMask"); el.width = 0.98; el.height = 0.86
bl = ct.nodes.new("CompositorNodeBlur"); bl.filter_type = 'FAST_GAUSS'; bl.size_x = bl.size_y = 150
mx = ct.nodes.new("CompositorNodeMixRGB"); mx.blend_type = 'MULTIPLY'; mx.inputs[0].default_value = 0.55
comp = ct.nodes.new("CompositorNodeComposite")
ct.links.new(rl.outputs["Image"], g1.inputs[0]); ct.links.new(g1.outputs[0], g2.inputs[0])
ct.links.new(el.outputs[0], bl.inputs[0])
ct.links.new(g2.outputs[0], mx.inputs[1]); ct.links.new(bl.outputs[0], mx.inputs[2])
ct.links.new(mx.outputs[0], comp.inputs[0])

if OUT:
    os.makedirs(OUT, exist_ok=True)
    r.filepath = "//frames/f_"
    bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT, "zauberwald.blend"))
