(() => {
  const canvas = document.getElementById('game');
  const ctx = canvas.getContext('2d');

  // L'etage/objectif sont affiches directement sur cette page (pas sur l'overlay).
  const floorEl = document.getElementById('floor');
  const objectiveInput = document.getElementById('objectiveInput');
  const progressFillEl = document.getElementById('progressFill');
  const statusLineEl = document.getElementById('statusLine');
  const destroyerLogEl = document.getElementById('destroyerLog');
  const objectiveBannerEl = document.getElementById('objectiveBanner');
  const hudEl = document.getElementById('hud');
  const fxLayer = document.getElementById('fxLayer');

  // Le panneau ÉTAGE se glisse verticalement a la souris, et sa position se
  // sauvegarde (localStorage) pour rester au meme endroit au prochain lancement.
  (function setupHudDrag() {
    const STORAGE_KEY = 'stackHudOffsetY';
    const saved = parseFloat(localStorage.getItem(STORAGE_KEY));
    let offsetY = Number.isFinite(saved) ? saved : 0;
    hudEl.style.setProperty('--hud-y', offsetY + 'px');

    let dragging = false;
    let startY = 0;
    let startOffset = 0;

    hudEl.addEventListener('pointerdown', (e) => {
      if (e.target === objectiveInput) return;
      dragging = true;
      startY = e.clientY;
      startOffset = offsetY;
      hudEl.classList.add('dragging');
      hudEl.setPointerCapture(e.pointerId);
    });

    hudEl.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      offsetY = startOffset + (e.clientY - startY);
      hudEl.style.setProperty('--hud-y', offsetY + 'px');
    });

    function endDrag() {
      if (!dragging) return;
      dragging = false;
      hudEl.classList.remove('dragging');
      localStorage.setItem(STORAGE_KEY, String(offsetY));
    }

    hudEl.addEventListener('pointerup', endDrag);
    hudEl.addEventListener('pointercancel', endDrag);
  })();

  const BLOCK_HEIGHT = 52;
  const BASE_WIDTH = 320;
  const FOUNDATION_START = 300; // tour deja construite au demarrage
  const FOUNDATION_MARGIN = 100; // en dessous de ce nombre de blocs restants, on en regenere
  const FOUNDATION_CHUNK = 300; // combien on en rajoute a chaque fois
  // Palier de likes et nombre d'etages detruits : surcharges au demarrage par
  // public/overlay/game-config.json (modifiable sans toucher au code).
  let LIKE_MILESTONE = 500;
  let LIKE_MILESTONE_FLOORS = 5;
  const GRAVITY = 0.55;
  // Tolerance PARFAIT : 10% de la taille MAX du bloc (pas de la taille courante),
  // pour que ca reste aussi facile quand le bloc est devenu tout petit.
  const PERFECT_TOLERANCE_RATIO = 0.15; // 10% -> 15% : marge d'erreur plus large
  const MIN_WIDTH = BASE_WIDTH * 0.10; // le bloc ne peut jamais devenir plus fin que ça
  const GROUND_MARGIN = 130;
  const CAMERA_ANCHOR_RATIO = 0.32;
  const CAMERA_RAISE_BLOCKS = 5; // la camera vise 5 blocs au-dessus du sommet de la tour
  const HUE_STEP = 24; // ecart de teinte entre un bloc et le suivant : toujours "de la meme famille"
  const DEPTH = 11; // profondeur de la fausse vue 3D
  const REGROW_AMOUNT = 110; // le prochain bloc redevient bien plus large que l'etage courant
  const REGROW_EVERY = 4; // le bloc ne regrandit qu'apres 4 poses de suite
  const BLOCK_SPEED = 4.6; // vitesse constante du bloc, elle n'accelere jamais
  const DEFAULT_OBJECTIVE = 20000;
  const SOUNDS_BASE = '/overlay/media/sounds/';
  const GIFTS_BASE = '/overlay/media/gifts/';

  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  let width, height;

  // Convention: y=0 est le sol. Les blocs empiles ont des worldTop negatifs (plus on monte, plus c'est negatif).
  let stack, moving, direction, speed, cameraY, cameraTarget, likeTotal, towerCenterX;
  let debris, particles, popups, bladeEffects, shakeAmount, streak, objective, dropsSinceGrow;
  let hasDroppedOnce; // le "premier lancer garanti" ne doit jouer qu'une seule fois par partie,
  // pas a chaque fois que la tour retombe a l'etage 0 apres un rate/une coupe.
  let speedBoosts; // liste d'horodatages d'expiration (confettis : vitesse x2 cumulable, 30s)
  let blackHole; // trou noir en cours (galaxie)
  let blockLabels; // pseudos affiches sur les plaques qui viennent de se former
  let maceFx; // masses du donut qui tombent du ciel et rebondissent
  let holeDebris; // plaques d'arriere-plan aspirees par le trou noir
  let heartModeUntil; // coeur dans les mains : tout clic pose un bloc parfait
  let heartDonor; // dernier donateur du coeur : son pseudo marque chaque bloc pose
  let objectiveDone; // pour ne feter l'objectif qu'une fois par franchissement
  let foundationCount; // nombre de blocs de socle : grandit quand on creuse vers le bas

  const hsl = ToyBox.hsl;
  let skyHue;

  function resize() {
    width = window.innerWidth;
    height = window.innerHeight;
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    canvas.style.width = width + 'px';
    canvas.style.height = height + 'px';
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    if (typeof towerCenterX === 'number') towerCenterX = width / 2;
  }
  window.addEventListener('resize', resize);

  function groundScreenY() {
    return height - GROUND_MARGIN;
  }

  function screenYFromWorld(worldTopY) {
    return groundScreenY() + worldTopY - cameraY;
  }

  function worldTopOf(index) {
    return -(index + 1) * BLOCK_HEIGHT;
  }

  // Positions relatives au sommet reel (et non a l'index) : indispensable pour
  // pouvoir rajouter des etages SOUS la tour sans tout decaler.
  function topWorldTop() {
    return stack[stack.length - 1].worldTop;
  }

  function nextWorldTop() {
    return topWorldTop() - BLOCK_HEIGHT;
  }

  function currentFloor() {
    return stack.length - foundationCount;
  }

  // Quand il ne reste plus que 100 blocs sous la tour, on en fabrique d'autres
  // en dessous : on ne peut donc jamais "toucher le fond".
  function ensureFoundation() {
    if (stack.length > FOUNDATION_MARGIN) return;
    const lowest = stack[0];
    const added = [];
    for (let i = FOUNDATION_CHUNK; i >= 1; i--) {
      added.push({
        x: lowest.x,
        width: lowest.width,
        hue: lowest.hue - HUE_STEP * i,
        worldTop: lowest.worldTop + i * BLOCK_HEIGHT,
        squash: 1,
        snap: 0
      });
    }
    stack.unshift(...added);
    // l'etage affiche ne doit pas bouger : le socle grandit, la reference aussi
    foundationCount += FOUNDATION_CHUNK;
  }

  function computeCameraTarget() {
    return groundScreenY() + topWorldTop() - height * CAMERA_ANCHOR_RATIO - CAMERA_RAISE_BLOCKS * BLOCK_HEIGHT;
  }

  function reset() {
    towerCenterX = width / 2;
    stack = [];
    foundationCount = FOUNDATION_START;
    let hue = Math.random() * 360;
    for (let i = 0; i < FOUNDATION_START; i++) {
      stack.push({
        x: towerCenterX - BASE_WIDTH / 2,
        width: BASE_WIDTH,
        hue,
        worldTop: worldTopOf(i),
        squash: 1,
        snap: 0
      });
      hue += HUE_STEP;
    }

    direction = 1;
    speed = BLOCK_SPEED;
    likeTotal = 0;
    debris = [];
    particles = [];
    popups = [];
    bladeEffects = [];
    shakeAmount = 0;
    streak = 0;
    dropsSinceGrow = 0;
    hasDroppedOnce = false;
    speedBoosts = [];
    blackHole = null;
    blockLabels = [];
    for (const m of maceFx || []) retirerGif(m.el);
    maceFx = [];
    macesEnAttente.length = 0;
    holeDebris = [];
    heartModeUntil = 0;
    heartDonor = null;
    objectiveDone = false;
    stepJobs.length = 0;
    skyHue = stack[stack.length - 1].hue;
    objective = Math.max(1, parseNumber(objectiveInput.value) || DEFAULT_OBJECTIVE);
    objectiveInput.value = formatNumber(objective);
    cameraTarget = computeCameraTarget();
    cameraY = cameraTarget;

    reportFloor();
    spawnMoving();
  }

  function spawnMoving() {
    const top = stack[stack.length - 1];

    // le regrandissement n'arrive qu'apres REGROW_EVERY poses de suite
    if (dropsSinceGrow >= REGROW_EVERY) {
      dropsSinceGrow = 0;
      const grownWidth = Math.min(BASE_WIDTH, top.width + REGROW_AMOUNT);
      if (grownWidth > top.width) {
        // le bloc du dessous regrandit aussi, pour rester sous le nouveau bloc (pas de porte-a-faux)
        const center = top.x + top.width / 2;
        top.width = grownWidth;
        top.x = center - grownWidth / 2;
        top.squash = 0.75;
      }
    }

    moving = {
      x: direction > 0 ? -top.width : width + top.width,
      width: top.width,
      hue: top.hue + HUE_STEP
    };
  }

  // ============ Sequenceur "un par un" ============
  // Beaucoup d'effets doivent se voir etage par etage (avec bruit + animation)
  // plutot que d'un seul coup. Chaque job avance a son propre rythme, en temps
  // reel : intervalMs = ecart voulu, maxPerFrame = combien on peut en enchainer
  // dans la meme image quand on veut que ca "spamme" tres vite.
  const stepJobs = [];

  function enqueueSteps(opts) {
    stepJobs.push({
      total: opts.total,
      remaining: opts.total,
      intervalMs: opts.intervalMs || 0,
      maxPerFrame: opts.maxPerFrame || 1,
      step: opts.step,
      onDone: opts.onDone || null,
      next: 0
    });
  }

  function runStepJobs(now) {
    for (let i = stepJobs.length - 1; i >= 0; i--) {
      const job = stepJobs[i];
      if (!job.next) job.next = now;
      let done = 0;
      while (job.remaining > 0 && now >= job.next && done < job.maxPerFrame) {
        job.step(job.total - job.remaining);
        job.remaining--;
        job.next += job.intervalMs;
        done++;
      }
      if (job.remaining <= 0) {
        stepJobs.splice(i, 1);
        if (job.onDone) job.onDone();
      }
    }
  }

  // Un son par etage serait inecoutable a 200 etages/seconde : on garde le
  // rythme visuel complet mais on espace le son d'au moins `ms`.
  const soundClocks = new Map();
  function throttled(key, ms, fn) {
    const now = performance.now();
    if (now - (soundClocks.get(key) || 0) < ms) return;
    soundClocks.set(key, now);
    fn();
  }

  // 12345 -> "12'345" (apostrophe des milliers)
  function formatNumber(n) {
    const sign = n < 0 ? '-' : '';
    return sign + Math.abs(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
  }

  function parseNumber(text) {
    return parseInt(String(text).replace(/[^0-9-]/g, ''), 10);
  }

  // Retire UN etage avec toute son animation de destruction (lame, debris,
  // particules, secousse) : c'est la brique de base de tous les effets "-N".
  function popOneFloorFx(soundKey) {
    // On regenere le socle AVANT de retirer : un retrait n'est ainsi jamais
    // refuse faute de blocs, et les comptes (-2000, -50...) restent exacts.
    ensureFoundation();
    if (stack.length <= 1) return 0;
    const removed = stack.pop();
    const half = removed.width / 2;
    bladeEffects.push({ x: removed.x, y: removed.worldTop, width: removed.width, t: 0 });
    spawnDebris(removed.x, removed.worldTop, half, removed.hue, -2.4);
    spawnDebris(removed.x + half, removed.worldTop, half, removed.hue, 2.4);
    spawnParticles(removed.x + half, removed.worldTop + BLOCK_HEIGHT / 2, null, 8);
    shake(5);
    if (soundKey) throttled(soundKey, 60, () => SFX.cut());
    streak = 0;
    ensureFoundation();
    cameraTarget = computeCameraTarget();
    reportFloor();
    return 1;
  }

  // Ajoute UN etage parfait avec son petit bruit positif et ses particules.
  function pushOneFloorFx(soundKey, volume) {
    pushPerfectFloor();
    const top = stack[stack.length - 1];
    spawnParticles(top.x + top.width / 2, top.worldTop, top.hue, 6);
    if (soundKey) throttled(soundKey, 60, () => SFX.perfect(volume === undefined ? 0.5 : volume));
    return 1;
  }

  // Pseudo affiche sur les plaques concernees. follow=true : suit le sommet de
  // la tour tant que l'effet fabrique des etages.
  function addBlockLabel(text, durationMs, follow) {
    blockLabels.push({
      text,
      follow: !!follow,
      worldTop: topWorldTop(),
      // leger decalage horizontal : quand plusieurs pseudos s'empilent sur des
      // etages voisins, ils restent tous lisibles
      dx: (Math.random() - 0.5) * 90,
      until: performance.now() + durationMs
    });
  }

  function bumpFloorUI() {
    floorEl.classList.remove('bump');
    void floorEl.offsetWidth;
    floorEl.classList.add('bump');
  }

  function reportFloor() {
    const floor = currentFloor();
    floorEl.textContent = formatNumber(floor);
    const pct = objective > 0 ? Math.max(0, Math.min(100, (floor / objective) * 100)) : 0;
    progressFillEl.style.width = pct + '%';
    bumpFloorUI();
    checkObjective(floor);
  }

  // Petite fenetre de reussite sous le panneau ETAGE. Se rearme si on
  // redescend sous l'objectif, pour pouvoir le refeter plus tard.
  function checkObjective(floor) {
    if (floor >= objective && !objectiveDone) {
      objectiveDone = true;
      objectiveBannerEl.textContent = `🎉 Objectif atteint : ${formatNumber(objective)} étages !`;
      objectiveBannerEl.classList.remove('show');
      void objectiveBannerEl.offsetWidth;
      objectiveBannerEl.classList.add('show');
      SFX.perfect();
      shake(10);
      spawnParticles(width / 2, height * 0.4, null, 40, true);
      setTimeout(() => objectiveBannerEl.classList.remove('show'), 6000);
    } else if (floor < objective) {
      objectiveDone = false;
    }
  }

  const FUNNY_COMMENTS = [
    "la tour dit merci pour ce carnage 😅",
    "ça, c'est de la démolition contrôlée",
    "on efface tout et on recommence",
    "un vrai artiste de la destruction",
    "la gravité approuve",
    "au revoir, chers étages",
    "on a connu des étages plus chanceux",
    "et hop, direction le sol !"
  ];

  // Le dernier message d'evenement reste affiche 10 secondes, puis s'efface.
  const MESSAGE_MS = 10000;
  let messageTimer = null;

  function logEvent(text) {
    destroyerLogEl.textContent = text;
    destroyerLogEl.classList.remove('flash');
    void destroyerLogEl.offsetWidth;
    destroyerLogEl.classList.add('flash');
    if (messageTimer) clearTimeout(messageTimer);
    messageTimer = setTimeout(() => { destroyerLogEl.textContent = ''; }, MESSAGE_MS);
  }

  function logDestroyer(uniqueId, floors) {
    if (!uniqueId) return;
    const comment = FUNNY_COMMENTS[Math.floor(Math.random() * FUNNY_COMMENTS.length)];
    logEvent(`💥 ${uniqueId} a fait tomber ${floors} étage${floors > 1 ? 's' : ''} ! ${comment}`);
  }

  function reportStatus() {
    const now = Date.now();
    speedBoosts = speedBoosts.filter((t) => t > now);
    if (speedBoosts.length > 0) {
      const remaining = Math.max(...speedBoosts.map((t) => t - now));
      statusLineEl.textContent = `⚡ Vitesse x${1 + speedBoosts.length} (${Math.ceil(remaining / 1000)}s)`;
    } else {
      statusLineEl.textContent = '';
    }
  }

  objectiveInput.addEventListener('change', () => {
    objective = Math.max(1, parseNumber(objectiveInput.value) || 1);
    objectiveInput.value = formatNumber(objective);
    objectiveDone = false;
    reportFloor();
  });
  // pendant la saisie on laisse les chiffres bruts, on reformate a la sortie
  objectiveInput.addEventListener('focus', () => { objectiveInput.value = String(objective); });
  objectiveInput.addEventListener('keydown', (e) => e.stopPropagation());
  objectiveInput.addEventListener('pointerdown', (e) => e.stopPropagation());

  function addPopup(worldX, worldTopY, text, streak) {
    popups.push({ x: worldX, y: worldTopY, text, streak: streak || 0, life: 1 });
  }

  function spawnParticles(x, y, hue, count, isScreenSpace) {
    for (let i = 0; i < count && particles.length < 400; i++) {
      const angle = Math.random() * Math.PI * 2;
      const spd = 1.5 + Math.random() * 4;
      particles.push({
        x, y,
        vx: Math.cos(angle) * spd,
        vy: Math.sin(angle) * spd - 2,
        hue: hue === null ? Math.random() * 360 : hue,
        size: 2 + Math.random() * 3,
        life: 1,
        screenSpace: !!isScreenSpace
      });
    }
  }

  function spawnDebris(worldX, worldTopY, w, hue, vxHint) {
    if (w <= 0) return;
    if (debris.length > 60) debris.shift();
    debris.push({
      x: worldX,
      y: worldTopY,
      w,
      h: BLOCK_HEIGHT,
      vx: vxHint !== undefined ? vxHint : (Math.random() - 0.5) * 3,
      vy: -2,
      angle: 0,
      vAngle: (Math.random() - 0.5) * 0.2,
      hue,
      bounces: 0,
      life: 1
    });
  }

  function shake(amount) {
    shakeAmount = Math.max(shakeAmount, amount);
  }

  function playSound(file, volume) {
    try {
      const audio = new Audio(SOUNDS_BASE + file);
      audio.volume = volume === undefined ? 1 : volume;
      audio.play().catch(() => {});
    } catch (e) {
      // ignore
    }
  }

  // Le navigateur partage l'animation entre toutes les <img> qui pointent sur
  // la MEME url : plusieurs copies d'un gif defilent alors en meme temps, et
  // une nouvelle copie demarre au milieu de l'animation en cours. On telecharge
  // donc le fichier une seule fois, puis on fabrique une url d'objet distincte
  // par instance : chacune repart de la frame 1 et vit sa propre vie.
  const gifsTelecharges = new Map();

  function gifBlob(file) {
    if (!gifsTelecharges.has(file)) {
      gifsTelecharges.set(
        file,
        fetch(GIFTS_BASE + file).then((r) => (r.ok ? r.blob() : null)).catch(() => null)
      );
    }
    return gifsTelecharges.get(file);
  }

  function appliquerGif(img, file) {
    gifBlob(file).then((blob) => {
      if (!img.isConnected) return;
      if (!blob) { img.src = GIFTS_BASE + file; return; } // repli si le fichier manque
      const url = URL.createObjectURL(blob);
      img.dataset.objectUrl = url;
      img.src = url;
    });
  }

  function retirerGif(img) {
    if (img.dataset.objectUrl) URL.revokeObjectURL(img.dataset.objectUrl);
    img.remove();
  }

  // Affiche un gif (mace, explosion...) a une position ecran donnee, puis le retire.
  function spawnGifFx(file, screenX, screenY, size, durationMs) {
    const img = document.createElement('img');
    img.className = 'fx-gif';
    img.style.left = screenX + 'px';
    img.style.top = screenY + 'px';
    img.style.width = size + 'px';
    img.style.height = size + 'px';
    fxLayer.appendChild(img);
    appliquerGif(img, file);
    setTimeout(() => retirerGif(img), durationMs);
  }

  function topScreenPos() {
    return {
      x: towerCenterX,
      y: screenYFromWorld(topWorldTop())
    };
  }

  function drop() {
    const top = stack[stack.length - 1];
    // Le lancer garanti ne s'applique qu'au tout premier lancer de la partie,
    // pas a chaque fois que la tour retombe a l'etage 0 apres un rate/une coupe
    // (sinon un clic spamme au mauvais moment pouvait donner un faux PARFAIT).
    const isFirstDrop = !hasDroppedOnce;
    hasDroppedOnce = true;
    const rawOverlapStart = isFirstDrop ? top.x : Math.max(top.x, moving.x);
    const rawOverlapEnd = isFirstDrop ? top.x + top.width : Math.min(top.x + top.width, moving.x + moving.width);
    const overlapWidth = rawOverlapEnd - rawOverlapStart;

    const worldTopY = nextWorldTop();

    // Rate complet : le bloc entier est perdu ET on perd un etage.
    // Le son de destruction est joue par cutTopBlock, on n'en superpose pas un second.
    if (!isFirstDrop && overlapWidth <= 4) {
      dropsSinceGrow = 0;
      spawnDebris(moving.x, worldTopY, moving.width, moving.hue);
      cutTopBlock({ keepMoving: true });
      return;
    }

    const isPerfect = isFirstDrop || (top.width - overlapWidth) <= BASE_WIDTH * PERFECT_TOLERANCE_RATIO;
    // le bloc garde est toujours recentre sur l'axe de la tour : elle reste bien droite,
    // comme "teleportee" en place, meme si le joueur visait un peu a cote.
    const finalWidth = isPerfect ? top.width : Math.max(MIN_WIDTH, overlapWidth);
    const finalX = towerCenterX - finalWidth / 2;

    // Le son ne depend que d'une chose : a-t-on perdu un bout du bloc ?
    // Rien de perdu (bloc entierement pose) -> son positif. Un bout coupe -> son de rate.
    let lostPiece = false;

    if (isPerfect) {
      streak += 1;
      spawnParticles(finalX + finalWidth / 2, worldTopY, 45, 22);
      addPopup(finalX + finalWidth / 2, worldTopY, 'PARFAIT !', streak);
      shake(6);
    } else {
      streak = 0;
      if (moving.x < rawOverlapStart) {
        spawnDebris(moving.x, worldTopY, rawOverlapStart - moving.x, moving.hue);
        lostPiece = true;
      }
      const movingRight = moving.x + moving.width;
      if (movingRight > rawOverlapEnd) {
        spawnDebris(rawOverlapEnd, worldTopY, movingRight - rawOverlapEnd, moving.hue);
        lostPiece = true;
      }
      shake(2);
    }

    if (lostPiece) SFX.drop(); else SFX.perfect();

    stack.push({
      x: finalX,
      width: finalWidth,
      hue: moving.hue,
      worldTop: worldTopY,
      squash: 0.6,
      snap: rawOverlapStart - finalX
    });
    // Le bloc ne regrandit QUE grace aux PARFAITS : une pose a moitie dedans
    // remet le compteur a zero.
    dropsSinceGrow = isPerfect ? dropsSinceGrow + 1 : 0;
    reportFloor();

    cameraTarget = computeCameraTarget();

    direction *= -1;
    spawnMoving();
  }

  // Coupe le bloc du sommet (perd 1 etage), avec l'effet de lame habituel.
  // keepMoving: le bloc en vol garde sa trajectoire (utilise pour rose/follow/likes/rate).
  function cutTopBlock(opts) {
    opts = opts || {};
    if (stack.length <= 1) {
      if (!opts.keepMoving) spawnMoving();
      return;
    }
    const removed = stack.pop();
    const halfWidth = removed.width / 2;

    bladeEffects.push({ x: removed.x, y: removed.worldTop, width: removed.width, t: 0 });
    spawnDebris(removed.x, removed.worldTop, halfWidth, removed.hue, -2.4);
    spawnDebris(removed.x + halfWidth, removed.worldTop, halfWidth, removed.hue, 2.4);
    spawnParticles(removed.x + removed.width / 2, removed.worldTop + BLOCK_HEIGHT / 2, null, 14);

    shake(8);
    SFX.cut();

    ensureFoundation();
    cameraTarget = computeCameraTarget();
    streak = 0;
    reportFloor();

    if (!opts.keepMoving) {
      direction = Math.random() > 0.5 ? 1 : -1;
      spawnMoving();
    }
  }

  // Ajoute un etage parfaitement pose (meme largeur/couleur que le sommet actuel).
  function pushPerfectFloor() {
    const top = stack[stack.length - 1];
    const worldTopY = nextWorldTop();
    stack.push({ x: top.x, width: top.width, hue: top.hue + HUE_STEP, worldTop: worldTopY, squash: 0, snap: 0 });
    cameraTarget = computeCameraTarget();
    reportFloor();
  }

  function resolveDebrisPhysics(d) {
    d.vy += GRAVITY;
    d.x += d.vx;
    d.y += d.vy;
    d.angle += d.vAngle;

    // pas de sol : le debris continue de tomber s'il ne rencontre aucun bloc plus bas
    let surface = Infinity;
    for (const b of stack) {
      if (d.x + d.w <= b.x || d.x >= b.x + b.width) continue;
      if (b.worldTop > d.y && b.worldTop < surface) surface = b.worldTop;
    }

    if (d.y + d.h >= surface) {
      d.y = surface - d.h;
      d.vy *= -0.38;
      d.vx *= 0.7;
      d.vAngle *= 0.5;
      d.bounces += 1;
      if (Math.abs(d.vy) < 1.2 || d.bounces > 4) {
        d.life -= 0.045;
      }
    }
  }

  function updatePhysics() {
    cameraY += (cameraTarget - cameraY) * 0.09;
    if (shakeAmount > 0.05) shakeAmount *= 0.85; else shakeAmount = 0;

    const topHue = stack[stack.length - 1].hue;
    skyHue += (topHue - skyHue) * 0.03;

    const now = Date.now();
    const activeBoosts = speedBoosts.filter((t) => t > now).length;
    if (activeBoosts !== speedBoosts.length || Math.random() < 0.05) reportStatus();
    const speedMultiplier = 1 + activeBoosts;

    moving.x += direction * speed * speedMultiplier;
    const top = stack[stack.length - 1];
    const min = Math.min(top.x - top.width, -moving.width);
    const max = Math.max(top.x + top.width * 2, width + moving.width);
    if (moving.x < min) direction = 1;
    if (moving.x + moving.width > max) direction = -1;

    for (const b of stack) {
      if (b.squash < 1) b.squash = Math.min(1, b.squash + 0.12);
      if (b.snap) { b.snap *= 0.72; if (Math.abs(b.snap) < 0.5) b.snap = 0; }
    }

    for (let i = debris.length - 1; i >= 0; i--) {
      const d = debris[i];
      resolveDebrisPhysics(d);
      if (screenYFromWorld(d.y) > height + 150 || d.life <= 0) debris.splice(i, 1);
    }

    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      if (blackHole && blackHole.pullParticles) {
        const dx = blackHole.x - p.x, dy = blackHole.y - p.y;
        p.vx += dx * 0.01;
        p.vy += dy * 0.01;
      } else {
        p.vy += 0.18;
      }
      p.x += p.vx;
      p.y += p.vy;
      p.life -= 0.02;
      if (p.life <= 0) particles.splice(i, 1);
    }

    for (let i = popups.length - 1; i >= 0; i--) {
      const p = popups[i];
      p.y -= 0.7;
      p.life -= 0.018;
      if (p.life <= 0) popups.splice(i, 1);
    }

    for (let i = bladeEffects.length - 1; i >= 0; i--) {
      bladeEffects[i].t += 0.06;
      if (bladeEffects[i].t >= 1) bladeEffects.splice(i, 1);
    }

    if (blackHole) {
      blackHole.t += 1 / 60;
      if (blackHole.t >= blackHole.duration) blackHole = null;
    }

    // Plaques d'arriere-plan aspirees : elles foncent vers le trou noir en
    // tournant et en retrecissant jusqu'a disparaitre.
    for (let i = holeDebris.length - 1; i >= 0; i--) {
      const d = holeDebris[i];
      if (!blackHole) { holeDebris.splice(i, 1); continue; }
      const dx = blackHole.x - d.x;
      const dy = blackHole.y - d.y;
      d.x += dx * 0.07;
      d.y += dy * 0.07;
      d.angle += 0.16;
      d.scale -= 0.018;
      if (d.scale <= 0.03 || Math.hypot(dx, dy) < 10) holeDebris.splice(i, 1);
    }

    // Masses du donut : chute, rebond sur le sommet de la tour, puis sortie.
    const surfaceY = screenYFromWorld(topWorldTop());
    for (let i = maceFx.length - 1; i >= 0; i--) {
      const m = maceFx[i];
      m.vy += 0.85;
      m.y += m.vy;
      m.x += m.vx;

      if (m.y + m.size / 2 >= surfaceY && m.vy > 0) {
        m.y = surfaceY - m.size / 2;
        m.vy = -m.vy * 0.45;
        m.vx *= 0.8;
        m.bounces++;
        if (!m.hit) {
          m.hit = true;
          throttled('donutSfx', 45, () => playSound('donut-impact.mp3', 0.25));
          popOneFloorFx(null); // le bruit d'impact remplace le bruit de coupe
          markDonorStep(m.uniqueId);
          shake(6);
        }
      }

      m.el.style.left = m.x + 'px';
      m.el.style.top = m.y + 'px';

      if (m.bounces > 3 || m.y > height + 200) {
        // Une masse qui sort de l'ecran sans avoir touche la tour (sommet hors
        // champ, camera en retard...) doit quand meme detruire son etage :
        // sinon le donut ne faisait pas ses -50 pile.
        if (!m.hit) {
          m.hit = true;
          popOneFloorFx(null);
          markDonorStep(m.uniqueId);
        }
        retirerGif(m.el);
        maceFx.splice(i, 1);
      }
    }

    // des qu'une masse disparait, celles en attente prennent sa place
    viderFileMaces();

    // Pseudos affiches sur les plaques : ceux qui "suivent" restent colles au
    // sommet tant que l'effet fabrique des etages.
    const nowLabels = performance.now();
    for (let i = blockLabels.length - 1; i >= 0; i--) {
      const l = blockLabels[i];
      if (l.follow) l.worldTop = topWorldTop();
      if (nowLabels >= l.until) blockLabels.splice(i, 1);
    }

    runStepJobs(nowLabels);
  }

  // Fond anime directement dans le jeu : degrade assorti a la teinte de la tour,
  // grosses bulles floues qui flottent, particules qui montent, vignette douce.
  const bgBlobs = [];
  for (let i = 0; i < 7; i++) {
    bgBlobs.push({
      x: Math.random(),
      y: Math.random(),
      r: 0.16 + Math.random() * 0.2,
      speed: 0.00012 + Math.random() * 0.0002,
      phase: Math.random() * Math.PI * 2,
      hueOffset: (i - 3) * 16,
      drift: 0.05 + Math.random() * 0.1
    });
  }

  const bgSparks = [];
  for (let i = 0; i < 40; i++) {
    bgSparks.push({
      x: Math.random(),
      y: Math.random(),
      size: 1 + Math.random() * 2.2,
      speed: 0.00025 + Math.random() * 0.0005,
      tw: Math.random() * Math.PI * 2
    });
  }

  let bgT = 0;

  function drawBackground() {
    bgT += 1;

    const grad = ctx.createLinearGradient(0, 0, 0, height);
    grad.addColorStop(0, hsl(skyHue, 48, 14));
    grad.addColorStop(0.5, hsl(skyHue + 18, 45, 19));
    grad.addColorStop(1, hsl(skyHue + 36, 42, 24));
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, width, height);

    for (const b of bgBlobs) {
      const bx = (b.x + Math.sin(bgT * b.speed + b.phase) * b.drift) * width;
      const by = (b.y + Math.cos(bgT * b.speed * 0.8 + b.phase) * b.drift) * height;
      const radius = b.r * width;
      const g = ctx.createRadialGradient(bx, by, 0, bx, by, radius);
      g.addColorStop(0, hsl(skyHue + b.hueOffset, 65, 45, 0.16));
      g.addColorStop(1, hsl(skyHue + b.hueOffset, 65, 45, 0));
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(bx, by, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    ctx.fillStyle = '#fff';
    for (const s of bgSparks) {
      s.y -= s.speed;
      if (s.y < -0.02) { s.y = 1.02; s.x = Math.random(); }
      ctx.globalAlpha = 0.1 + (Math.sin(bgT * 0.03 + s.tw) + 1) * 0.12;
      ctx.beginPath();
      ctx.arc(s.x * width, s.y * height, s.size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    const vg = ctx.createRadialGradient(width / 2, height / 2, height * 0.35, width / 2, height / 2, height * 0.75);
    vg.addColorStop(0, 'rgba(0,0,0,0)');
    vg.addColorStop(1, 'rgba(0,0,0,0.32)');
    ctx.fillStyle = vg;
    ctx.fillRect(0, 0, width, height);
  }

  function drawBlock3D(x, y, w, h, hue, squash) {
    ToyBox.drawToyBlock(ctx, x, y, w, h, hue, { squash, depth: DEPTH });
  }

  function drawBlackHole() {
    if (!blackHole) return;
    const t = blackHole.t / blackHole.duration;
    const grow = Math.sin(Math.min(1, t * 3) * Math.PI / 2);
    const radius = blackHole.maxRadius * grow;
    const fade = t > 0.7 ? 1 - (t - 0.7) / 0.3 : 1;

    ctx.save();
    ctx.globalAlpha = fade;
    const g = ctx.createRadialGradient(blackHole.x, blackHole.y, 0, blackHole.x, blackHole.y, radius);
    g.addColorStop(0, 'rgba(0,0,0,0.95)');
    g.addColorStop(0.6, 'rgba(40,0,60,0.75)');
    g.addColorStop(1, 'rgba(40,0,60,0)');
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(blackHole.x, blackHole.y, radius, 0, Math.PI * 2);
    ctx.fill();

    ctx.strokeStyle = `rgba(180,100,255,${0.5 * fade})`;
    ctx.lineWidth = 3;
    for (let i = 0; i < 3; i++) {
      ctx.beginPath();
      ctx.arc(blackHole.x, blackHole.y, radius * (0.5 + i * 0.2), bgT * 0.05 + i, bgT * 0.05 + i + 4);
      ctx.stroke();
    }
    ctx.restore();
  }

  function draw() {
    drawBackground();

    ctx.save();
    if (shakeAmount > 0) {
      ctx.translate((Math.random() - 0.5) * shakeAmount, (Math.random() - 0.5) * shakeAmount);
    }

    // Plaques aspirees par le trou noir : dessinees en arriere-plan, derriere
    // la tour, en train de retrecir vers le centre du trou.
    for (const d of holeDebris) {
      ctx.save();
      ctx.globalAlpha = Math.max(0, Math.min(0.75, d.scale));
      ctx.translate(d.x, d.y);
      ctx.rotate(d.angle);
      ctx.scale(d.scale, d.scale);
      ctx.beginPath();
      ctx.roundRect(-d.w / 2, -d.h / 2, d.w, d.h, 12);
      ctx.fillStyle = hsl(d.hue, 70, 55);
      ctx.fill();
      ctx.restore();
    }
    ctx.globalAlpha = 1;

    for (let i = 0; i < stack.length; i++) {
      const b = stack[i];
      const y = screenYFromWorld(b.worldTop);
      if (y > height + BLOCK_HEIGHT + DEPTH || y < -BLOCK_HEIGHT - DEPTH) continue;
      drawBlock3D(b.x + (b.snap || 0), y, b.width, BLOCK_HEIGHT, b.hue, b.squash);
    }

    for (const d of debris) {
      const y = screenYFromWorld(d.y);
      ctx.save();
      ctx.globalAlpha = Math.max(0, d.life);
      ctx.translate(d.x + d.w / 2, y + d.h / 2);
      ctx.rotate(d.angle);
      ctx.beginPath();
      ctx.roundRect(-d.w / 2, -d.h / 2, d.w, d.h, Math.min(10, d.h * 0.3));
      ctx.fillStyle = hsl(d.hue, 78, 62);
      ctx.fill();
      ctx.lineWidth = 2;
      ctx.strokeStyle = 'rgba(255,255,255,0.7)';
      ctx.stroke();
      ctx.restore();
    }

    drawBlackHole();

    const y = screenYFromWorld(nextWorldTop());
    const pulse = 2 + Math.sin(Date.now() / 120) * 1.5;
    ctx.save();
    ctx.shadowColor = hsl(moving.hue, 80, 65);
    ctx.shadowBlur = 16;
    drawBlock3D(moving.x, y, moving.width, BLOCK_HEIGHT, moving.hue, 1);
    ctx.restore();
    ctx.strokeStyle = 'rgba(255,255,255,0.85)';
    ctx.lineWidth = pulse * 0.6;
    ctx.beginPath();
    ctx.roundRect(moving.x, y, moving.width, BLOCK_HEIGHT, 14);
    ctx.stroke();

    for (const blade of bladeEffects) {
      const by = screenYFromWorld(blade.y);
      const flashAlpha = 1 - blade.t;
      ctx.globalAlpha = flashAlpha;
      ctx.fillStyle = '#fff';
      ctx.fillRect(blade.x - 10, by - 3, blade.width + 20, 6);
      ctx.shadowColor = '#fff';
      ctx.shadowBlur = 20;
      ctx.fillRect(blade.x - 10, by - 1, blade.width + 20, 2);
      ctx.shadowBlur = 0;
      ctx.globalAlpha = 1;
    }

    for (const p of particles) {
      const py = p.screenSpace ? p.y : screenYFromWorld(p.y);
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = hsl(p.hue, 80, 65);
      ctx.beginPath();
      ctx.arc(p.x, py, p.size, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
    }

    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';

    // Pseudo du donateur, pose sur les plaques qui viennent de se former.
    for (const l of blockLabels) {
      const ly = screenYFromWorld(l.worldTop) - 14;
      if (ly < -40 || ly > height + 40) continue;
      const lx = towerCenterX + (l.dx || 0);
      ctx.save();
      ctx.font = 'bold 17px "Baloo 2", Arial, sans-serif';
      const w = ctx.measureText(l.text).width + 24;
      ctx.beginPath();
      ctx.roundRect(lx - w / 2, ly - 15, w, 30, 15);
      ctx.fillStyle = 'rgba(20,16,35,0.85)';
      ctx.fill();
      ctx.lineWidth = 2;
      ctx.strokeStyle = 'rgba(255,255,255,0.45)';
      ctx.stroke();
      ctx.fillStyle = '#fff';
      ctx.fillText(l.text, lx, ly);
      ctx.restore();
    }

    for (const p of popups) {
      const py = screenYFromWorld(p.y);
      const growT = Math.min(1, (1 - p.life) / 0.22);
      const scale = 1.7 - 0.7 * growT; // gros "pop" rond qui se stabilise vite
      ctx.save();
      ctx.globalAlpha = Math.max(0, Math.min(1, p.life * 1.4));
      ctx.translate(p.x, py);
      ctx.scale(scale, scale);
      ctx.font = 'bold 22px "Baloo 2", Arial, sans-serif';
      const label = p.streak > 1 ? `PARFAIT x${p.streak} !` : 'PARFAIT !';
      const metrics = ctx.measureText(label);
      const padX = 18, padY = 10;
      const bw = metrics.width + padX * 2;
      const bh = 22 + padY * 2;
      ctx.beginPath();
      ctx.roundRect(-bw / 2, -bh / 2 - 18, bw, bh, bh / 2);
      const grad = ctx.createLinearGradient(0, -bh / 2 - 18, 0, bh / 2 - 18);
      grad.addColorStop(0, '#ffe066');
      grad.addColorStop(1, '#ffb100');
      ctx.fillStyle = grad;
      ctx.fill();
      ctx.lineWidth = 3;
      ctx.strokeStyle = '#fff';
      ctx.stroke();
      ctx.fillStyle = '#5a3600';
      ctx.fillText(label, 0, -18);
      ctx.restore();
    }
    ctx.textBaseline = 'alphabetic';

    ctx.restore();
  }

  function loop() {
    updatePhysics();
    draw();
    requestAnimationFrame(loop);
  }

  // ============ Mecaniques des dons ============

  // Duree pendant laquelle le pseudo reste pose sur le rectangle concerne.
  const DONOR_ON_BLOCK_MS = 3000;

  // Pseudo pose sur le rectangle touche. `suivre` = reste colle au sommet tant
  // que l'effet continue d'ajouter/enlever des etages.
  function markDonor(uniqueId, suivre) {
    if (!uniqueId) return;
    addBlockLabel(uniqueId, DONOR_ON_BLOCK_MS + (suivre ? 2000 : 0), !!suivre);
  }

  // Pour les effets en chaine (15 etages de Rosa / Grande Box, etc.) : un
  // pseudo pose sur CHAQUE rectangle, pas un seul qui suit le sommet.
  // Plafonne pour rester lisible sur les effets a 2000 etages.
  const MAX_DONOR_LABELS = 60;
  function markDonorStep(uniqueId) {
    if (!uniqueId || blockLabels.length >= MAX_DONOR_LABELS) return;
    addBlockLabel(uniqueId, DONOR_ON_BLOCK_MS, false);
  }

  // Rose / Follow / palier de 500 likes : -1 etage (comme avant).
  function effectMinusOne(uniqueId) {
    cutTopBlock({ keepMoving: true });
    markDonor(uniqueId, false);
    logDestroyer(uniqueId, 1);
  }

  // GG : +1 etage, pose parfaite instantanee.
  function effectPlusOne(uniqueId) {
    pushPerfectFloor();
    spawnParticles(towerCenterX, topWorldTop(), 130, 16);
    SFX.perfect();
    markDonor(uniqueId, false);
  }

  // Rosa : 3 explosions, chacune detruisant 5 etages UN PAR UN (0.1s d'ecart)
  // pour qu'on voie bien chaque plaque exploser. Total -15.
  function effectRosa(uniqueId) {
    for (let i = 0; i < 3; i++) {
      setTimeout(() => {
        const pos = topScreenPos();
        spawnGifFx('rosa-explosion.gif', pos.x, pos.y, 160, 800);
        playSound('rosa-explosion.wav', 0.8);
        shake(12);
        spawnParticles(towerCenterX, topWorldTop(), 10, 20);
        enqueueSteps({
          total: 5,
          intervalMs: 100,
          maxPerFrame: 1,
          step: () => { popOneFloorFx('rosa'); markDonorStep(uniqueId); },
          onDone: i === 2 ? () => logDestroyer(uniqueId, 15) : null
        });
      }, i * 900);
    }
  }

  // Grande Box : +15 etages en 2 secondes, bruits positifs, et le pseudo du
  // donateur reste 3s sur les plaques qui viennent de se former.
  function effectGrantDeBox(uniqueId) {
    enqueueSteps({
      total: 15,
      intervalMs: 2000 / 15,
      maxPerFrame: 1,
      step: () => { pushOneFloorFx('box', 0.55); markDonorStep(uniqueId); }
    });
  }

  // Donut : 50 masses tombent du ciel en 5s et rebondissent sur la tour ;
  // chaque premiere collision casse un etage avec le bruit d'impact.
  function effectDonut(uniqueId) {
    enqueueSteps({
      total: 50,
      intervalMs: 100,
      maxPerFrame: 1,
      step: () => spawnMace(uniqueId),
      onDone: () => logDestroyer(uniqueId, 50)
    });
  }

  // Au-dela de 100 masses a l'ecran ca rame : les suivantes patientent (sans
  // element DOM) et apparaissent des qu'une place se libere. Aucun etage n'est
  // perdu au passage, l'effet est juste etale dans le temps.
  const MAX_MACES = 100;
  const macesEnAttente = [];

  function spawnMace(uniqueId) {
    if (maceFx.length >= MAX_MACES) {
      macesEnAttente.push(uniqueId);
      return;
    }
    creerMace(uniqueId);
  }

  function viderFileMaces() {
    while (maceFx.length < MAX_MACES && macesEnAttente.length > 0) {
      creerMace(macesEnAttente.shift());
    }
  }

  // Masse qui tombe du haut de l'ecran, rebondit sur le sommet de la tour.
  function creerMace(uniqueId) {
    const size = 78;
    const img = document.createElement('img');
    img.className = 'fx-gif';
    img.style.width = size + 'px';
    img.style.height = size + 'px';
    fxLayer.appendChild(img);
    appliquerGif(img, 'donut-mace.gif');
    maceFx.push({
      el: img,
      size,
      x: towerCenterX + (Math.random() - 0.5) * 180,
      y: -size,
      vy: 2 + Math.random() * 2,
      vx: (Math.random() - 0.5) * 1.5,
      bounces: 0,
      hit: false,
      uniqueId
    });
  }

  // You're on a Roll : +50 etages en 5s, avec le petit bruit de pose a chaque
  // etage (un peu moins fort), et une seule musique au debut.
  function effectYouAreOnARoll(uniqueId) {
    playSound('youareonaroll-theme.mp3', 0.7);
    enqueueSteps({
      total: 50,
      intervalMs: 100,
      maxPerFrame: 1,
      step: () => { pushOneFloorFx('roll', 0.35); markDonorStep(uniqueId); }
    });
  }

  // Confettis : vitesse x2 pendant 30s, cumulable si plusieurs arrivent.
  function effectConfettis() {
    speedBoosts.push(Date.now() + 30000);
    reportStatus();
    spawnParticles(towerCenterX, topWorldTop(), null, 24);
  }

  // Coeur dans les mains : pendant 15s, CHAQUE clic (sur le bouton comme
  // n'importe ou sur l'ecran) pose un bloc parfait.
  function effectCoeurDansLesMains(uniqueId) {
    heartModeUntil = Date.now() + 15000;
    // c'est le dernier donateur du coeur qui est credite sur chaque bloc pose
    heartDonor = uniqueId || heartDonor;
    const btn = document.createElement('button');
    btn.className = 'fx-heart-button';
    btn.textContent = '❤️ Clique partout !';
    btn.addEventListener('click', () => heartPlace());
    fxLayer.appendChild(btn);
    setTimeout(() => btn.remove(), 15000);
    if (uniqueId) logEvent(`❤️ ${uniqueId} : chaque clic pose un étage parfait pendant 15s !`);
  }

  function heartPlace() {
    pushOneFloorFx('heart', 0.7);
    const top = stack[stack.length - 1];
    spawnParticles(top.x + top.width / 2, top.worldTop, 340, 18);
    markDonorStep(heartDonor);
    shake(4);
  }

  // Galaxie : trou noir qui aspire les etages UN PAR UN en rafale, avec des
  // plaques d'arriere-plan qui tombent dedans, retrecissent et disparaissent.
  function effectGalaxie(uniqueId) {
    markDonor(uniqueId, true);
    playSound('galaxie-blackhole.mp3', 0.85);
    const pos = topScreenPos();
    blackHole = {
      x: pos.x,
      y: pos.y,
      t: 0,
      duration: 6,
      maxRadius: Math.max(width, height) * 0.4,
      pullParticles: true
    };
    spawnParticles(pos.x, topWorldTop(), 280, 60, false);
    shake(16);

    // EXACTEMENT 2000 etages. (Avant : plafonne au nombre de blocs presents,
    // donc sur une tour fraiche on n'en retirait que ~300. Inutile de plafonner :
    // ensureFoundation() regenere le socle au fur et a mesure.)
    const TOTAL_GALAXIE = 2000;
    enqueueSteps({
      total: TOTAL_GALAXIE,
      intervalMs: 0,
      maxPerFrame: 8, // ~480 etages/seconde : ca spamme, mais on voit chaque plaque partir
      step: () => {
        const removed = stack[stack.length - 1];
        popOneFloorFx('galaxie');
        spawnHoleDebris(removed);
        markDonorStep(uniqueId);
      },
      onDone: () => logDestroyer(uniqueId, TOTAL_GALAXIE)
    });
  }

  // Plaque d'arriere-plan aspiree par le trou noir.
  function spawnHoleDebris(block) {
    if (!blackHole || holeDebris.length > 90) return;
    holeDebris.push({
      x: (block ? block.x + block.width / 2 : towerCenterX) + (Math.random() - 0.5) * 120,
      y: screenYFromWorld(block ? block.worldTop : 0) + (Math.random() - 0.5) * 80,
      w: 70 + Math.random() * 90,
      h: BLOCK_HEIGHT * (0.6 + Math.random() * 0.5),
      hue: block ? block.hue : Math.random() * 360,
      angle: (Math.random() - 0.5) * 0.6,
      scale: 1
    });
  }

  // Gerry la girafe : +2000 etages en 10s, un par un en rafale.
  function effectGerryLaGirafe(uniqueId) {
    playSound('gerrylagirafe-boost.mp3', 0.85);
    enqueueSteps({
      total: 2000,
      intervalMs: 5,
      maxPerFrame: 6, // ~200 etages/seconde -> 2000 en ~10s
      step: () => {
        pushOneFloorFx('gerry', 0.3);
        markDonorStep(uniqueId);
        if (Math.random() < 0.05) shake(3);
      }
    });
  }

  // Envoie l'etage courant vers `cible`, un etage a la fois, en rafale.
  function rushToFloor(cible, maxPerFrame, soundKey, uniqueId) {
    const diff = cible - currentFloor();
    if (diff === 0) return;
    enqueueSteps({
      total: Math.abs(diff),
      intervalMs: 0,
      maxPerFrame,
      // On recalcule l'ecart a CHAQUE etage : si un autre don tombe pendant
      // l'effet, on arrive quand meme pile sur la cible (0 pour Leon,
      // l'objectif pour le jet prive) au lieu de deriver.
      step: () => {
        const restant = cible - currentFloor();
        if (restant === 0) return;
        if (restant > 0) pushOneFloorFx(soundKey, 0.3);
        else popOneFloorFx(soundKey);
        markDonorStep(uniqueId);
      }
    });
  }

  // Leon le chaton : retour a 0, un etage a la fois, EXTREMEMENT vite.
  function effectLeonLeChaton(uniqueId) {
    playSound('leonlechaton-reset.mp3', 0.85);
    shake(16);
    spawnParticles(towerCenterX, topWorldTop(), 0, 40);
    rushToFloor(0, 25, 'leon', uniqueId);
    if (uniqueId) logEvent(`⏪ ${uniqueId} remet tout à zéro !`);
  }

  // Jet prive : direct sur l'objectif, un etage a la fois.
  function effectJetPrive(uniqueId) {
    playSound('jetprive-jackpot.mp3', 0.85);
    shake(12);
    spawnParticles(towerCenterX, topWorldTop(), 50, 50);
    rushToFloor(objective, 12, 'jet', uniqueId);
    if (uniqueId) logEvent(`✈️ ${uniqueId} envoie la tour direct sur l'objectif !`);
  }

  // TikTok Univers : tout casse a l'ecran pendant 60s (objectif en texte
  // aleatoire, etage qui part en vrille, ecran qui tremble/deteint), une
  // seule musique au debut.
  const GLITCH_CHARS = '!@#%&*?▓▒░01XOZ¤§';
  function randomGlitch(len) {
    let s = '';
    for (let i = 0; i < len; i++) s += GLITCH_CHARS[Math.floor(Math.random() * GLITCH_CHARS.length)];
    return s;
  }

  // Un seul chaos a la fois : si un deuxieme TikTok Univers arrive pendant le
  // premier, on PROLONGE au lieu d'en lancer un second (sinon le deuxieme
  // sauvegardait l'objectif deja brouille et son intervalle survivait au
  // nettoyage du premier -> chiffres qui defilaient sans fin).
  let chaosEnCours = null;

  function effectTiktokUnivers() {
    playSound('tiktokunivers-chaos.mp3', 0.9);
    document.body.classList.add('tiktok-chaos');
    shake(24);

    if (!chaosEnCours) {
      chaosEnCours = {
        vraiObjectif: objectiveInput.value,
        typeOrigine: objectiveInput.type,
        interval: setInterval(() => {
          objectiveInput.value = randomGlitch(3 + Math.floor(Math.random() * 4));
          floorEl.textContent = (Math.random() < 0.5 ? '-' : '') + Math.floor(Math.random() * 9999);
          floorEl.classList.toggle('bump');
          if (Math.random() < 0.3) shake(6);
          if (Math.random() < 0.2) spawnParticles(towerCenterX, topWorldTop(), null, 6);
        }, 180),
        timeout: null
      };
      objectiveInput.type = 'text';
      objectiveInput.disabled = true;
    } else {
      clearTimeout(chaosEnCours.timeout);
    }

    chaosEnCours.timeout = setTimeout(() => {
      clearInterval(chaosEnCours.interval);
      document.body.classList.remove('tiktok-chaos');
      objectiveInput.type = chaosEnCours.typeOrigine;
      objectiveInput.disabled = false;
      objectiveInput.value = chaosEnCours.vraiObjectif;
      chaosEnCours = null;
      reportFloor();
    }, 60000);
  }

  // ============ Association nom de don -> mecanique ============
  // On charge gift-legend.json pour faire correspondre id/nom (normalise) du
  // don recu via TikTok a la bonne fonction, sans dupliquer la liste ici.
  const DIACRITICS_RE = new RegExp('[\\u0300-\\u036f]', 'g');
  function normalizeKey(s) {
    return (s || '').toString().normalize('NFD').replace(DIACRITICS_RE, '').toLowerCase().replace(/[^a-z0-9]/g, '');
  }

  const GIFT_HANDLERS = {
    rose: (uid) => effectMinusOne(uid),
    gg: (uid) => effectPlusOne(uid),
    rosa: (uid) => effectRosa(uid),
    grantdebox: (uid) => effectGrantDeBox(uid),
    donut: (uid) => effectDonut(uid),
    youareonaroll: (uid) => effectYouAreOnARoll(uid),
    conffetis: () => effectConfettis(),
    coeurdanslesmains: (uid) => effectCoeurDansLesMains(uid),
    galaxie: (uid) => effectGalaxie(uid),
    gerrylagirafe: (uid) => effectGerryLaGirafe(uid),
    leonlechaton: (uid) => effectLeonLeChaton(uid),
    jetprive: (uid) => effectJetPrive(uid),
    tiktokunivsers: () => effectTiktokUnivers()
  };

  let giftIdByKey = new Map();
  fetch('/overlay/gift-legend.json').then((r) => r.json()).then((legend) => {
    const all = [...legend.grid, ...legend.sideLeft, ...legend.sideRight, legend.special];
    for (const entry of all) {
      giftIdByKey.set(normalizeKey(entry.id), entry.id);
      giftIdByKey.set(normalizeKey(entry.name), entry.id);
      // TikTok envoie les noms en anglais ("Gold Boxing Gloves", "Doughnut"...) :
      // sans ces alias le don arrivait sans declencher aucun effet.
      for (const a of entry.alias || []) giftIdByKey.set(normalizeKey(a), entry.id);
    }
  }).catch(() => {});

  // Reglages : lus au demarrage, puis mis a jour EN DIRECT quand on les change
  // depuis le tableau de bord (evenement 'config' diffuse par le serveur).
  function appliquerConfig(cfg) {
    if (!cfg) return;
    const palier = Math.floor(Number(cfg.likeMilestone));
    if (palier > 0) LIKE_MILESTONE = palier;
    const etages = Math.floor(Number(cfg.likeMilestoneFloors));
    if (etages > 0) LIKE_MILESTONE_FLOORS = etages;
  }

  fetch('/overlay/game-config.json').then((r) => r.json()).then(appliquerConfig).catch(() => {});
  TikTokEvents.on('config', appliquerConfig);

  // Palier de likes : c'est TOUTE la communaute qui declenche, donc les
  // rectangles sont marques "like" et non au pseudo de celui qui a clique.
  function effectLikeMilestone() {
    const pos = topScreenPos();
    spawnGifFx('rosa-explosion.gif', pos.x, pos.y, 160, 800);
    playSound('rosa-explosion.wav', 0.8);
    shake(12);
    spawnParticles(towerCenterX, topWorldTop(), 10, 24);
    enqueueSteps({
      total: LIKE_MILESTONE_FLOORS,
      intervalMs: 100,
      maxPerFrame: 1,
      step: () => { popOneFloorFx('like'); markDonorStep('like'); },
      onDone: () => logDestroyer(`${LIKE_MILESTONE} likes`, LIKE_MILESTONE_FLOORS)
    });
  }

  let paliersLikesCales = false;

  function onLike(data) {
    const before = likeTotal;
    // On suit le total du serveur (le meme que l'overlay et que le live).
    const total = Number(data.likesTotal) || Number(data.likesSession);
    likeTotal = total > 0 ? total : likeTotal + (Number(data.likeCount) || 1);

    // Au tout premier like, le total peut deja etre a 11'000 (live commence
    // avant le jeu) : on se cale dessus sans declencher des dizaines
    // d'explosions d'un coup. Les paliers suivants comptent normalement.
    if (!paliersLikesCales) {
      paliersLikesCales = true;
      return;
    }

    if (Math.floor(likeTotal / LIKE_MILESTONE) > Math.floor(before / LIKE_MILESTONE)) {
      effectLikeMilestone();
    }
  }

  // Un envoi groupe (ex: 7 roses d'un coup) arrive en UN evenement portant
  // repeatCount = 7 : l'effet doit donc se jouer 7 fois, pas une seule.
  const MAX_REPETITIONS = 100; // garde-fou contre une valeur aberrante

  function onGift(data) {
    const giftId = giftIdByKey.get(normalizeKey(data.giftName));
    const handler = giftId && GIFT_HANDLERS[giftId];
    if (!handler) return;
    const demande = Math.floor(Number(data.repeatCount)) || 1;
    const fois = Math.max(1, Math.min(demande, MAX_REPETITIONS));
    for (let i = 0; i < fois; i++) handler(data.uniqueId);
  }

  // Le son du follow est joue par l'overlay (media-config.json), pas ici :
  // sinon les deux pages le jouaient chacune de leur cote -> son double.
  function onFollow(data) {
    effectMinusOne(data.uniqueId);
  }

  // Pendant "coeur dans les mains", n'importe quel appui pose un bloc parfait.
  function handlePress() {
    SFX.unlock();
    if (Date.now() < heartModeUntil) heartPlace();
    else drop();
  }

  canvas.addEventListener('pointerdown', handlePress);
  window.addEventListener('keydown', (e) => {
    if (document.activeElement === objectiveInput) return;
    if (e.code === 'Space') { e.preventDefault(); handlePress(); }
  });

  TikTokEvents.on('like', onLike);
  TikTokEvents.on('gift', onGift);
  TikTokEvents.on('follow', onFollow);

  resize();
  reset();
  requestAnimationFrame(loop);
})();
