打开/关闭菜单
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

MediaWiki:Gadget-AnniversaryTheme.js

MediaWiki界面页面

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5Ctrl-R(Mac为⌘-R
  • Google Chrome:Ctrl-Shift-R(Mac为⌘-Shift-R
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5
/* Gadget: OTTOWikiAnniversary - confetti cannons (left+right) + 🎉 hint + ribbons + ♿ easter egg
 * Changes:
 * - Canvas does NOT fade out; particles fall off-screen naturally, then canvas removed.
 * - Only 🎉 hint fades out.
 * - Right 🎉 is mirrored (symmetry).
 */
(function () {
  if (typeof mw === 'undefined' || !mw.config) return;

  /** =======================
   *  配置区:按需改这里
   * ======================= */
  var CFG = {
    mainPageOnly: true,

    oncePerSession: true,
    sessionKey: 'ottowiki_anni_confetti_shown',

    respectReducedMotion: true,

    // 活动日期范围:不想限制就填 null
    activeFrom: null, // '2026-02-01'
    activeTo: null,   // '2026-02-07'

    // 进入后延迟多久再放(ms)
    startDelay: 1000,

    // 超时兜底:即使还有粒子没走完,超过这个时间也会结束(避免极端情况下不结束)
    // 建议给足够大(比如 12s)
    maxRuntime: 12000,

    // 每边礼炮“普通碎片”数量(两边*2;第二波再*2)
    particlesPerSide: 150,

    // 每边“彩带”数量(两边*2;第二波再*2)
    ribbonsPerSide: 22,

    // 第二波延迟(ms),设为 0 就只有一波
    secondWaveDelay: 260,

    // 颜色(周年红+金+点缀)
    colors: ['#d81b2a', '#f3c46b', '#ff6b6b', '#ffd166', '#ffffff'],

    zIndex: 99999,

    // 🎉 提示(两侧显示)
    showHintEmoji: true,
    hintEmoji: '🎉',

    // 🎉 轻微弹跳
    hintEmojiBounce: true,

    // ===== ♿ 彩蛋(混入爆炸物)=====
    eggEnabled: true,
    eggEmojis: ['♿'],
    // 每一波每一侧的 ♿ 数量随机范围(彩蛋式但偏多一点)
    eggMinPerSidePerWave: 4,
    eggMaxPerSidePerWave: 10,
    // 字号范围
    eggFontMin: 16,
    eggFontMax: 24
  };

  function inActiveWindow() {
    if (!CFG.activeFrom || !CFG.activeTo) return true;
    var start = new Date(CFG.activeFrom + 'T00:00:00');
    var end = new Date(CFG.activeTo + 'T23:59:59');
    var now = new Date();
    return now >= start && now <= end;
  }

  function shouldRun() {
    if (CFG.mainPageOnly && !mw.config.get('wgIsMainPage')) return false;

    if (CFG.respectReducedMotion && window.matchMedia) {
      try {
        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false;
      } catch (e) {}
    }

    if (!inActiveWindow()) return false;

    if (CFG.oncePerSession) {
      try {
        if (sessionStorage.getItem(CFG.sessionKey) === '1') return false;
        sessionStorage.setItem(CFG.sessionKey, '1');
      } catch (e) {}
    }

    return true;
  }

  function rand(min, max) {
    return Math.random() * (max - min) + min;
  }

  function randInt(min, max) {
    return Math.floor(rand(min, max + 1));
  }

  function clamp(n, a, b) {
    return Math.max(a, Math.min(b, n));
  }

  function createHintEmojiUI(z) {
    var wrap = document.createElement('div');
    wrap.className = 'ottowiki-anni-hint-wrap';
    wrap.style.cssText = [
      'position:fixed',
      'inset:0',
      'pointer-events:none',
      'z-index:' + (z + 1),
      'opacity:1'
    ].join(';');

    // 用子 span 做镜像,避免跟父级 transform/动画打架
    function makeOne(side) {
      var el = document.createElement('div');
      el.className = 'ottowiki-anni-hint ottowiki-anni-hint-' + side;

      var span = document.createElement('span');
      span.textContent = CFG.hintEmoji;
      span.setAttribute('aria-hidden', 'true');

      // 右侧镜像
      if (side === 'right') {
        span.style.transform = 'scaleX(-1)';
        span.style.display = 'inline-block';
      }

      el.appendChild(span);

      var left = side === 'left' ? '6%' : '94%';
      el.style.cssText = [
        'position:absolute',
        'left:' + left,
        'top:78%',
        'transform:translate(-50%,-50%)',
        'font-size:28px',
        'filter:drop-shadow(0 2px 6px rgba(0,0,0,.25))',
        'opacity:0',
        'transition:opacity 260ms ease, transform 260ms ease'
      ].join(';');

      wrap.appendChild(el);
      return el;
    }

    var leftEl = makeOne('left');
    var rightEl = makeOne('right');

    var styleEl = null;
    if (CFG.hintEmojiBounce) {
      styleEl = document.createElement('style');
      styleEl.textContent =
        "@keyframes ottowikiAnniHintBounce{0%,100%{transform:translate(-50%,-60%) scale(1)}50%{transform:translate(-50%,-72%) scale(1.06)}}";
      document.head.appendChild(styleEl);

      leftEl.style.animation = 'ottowikiAnniHintBounce 900ms ease-in-out 1';
      rightEl.style.animation = 'ottowikiAnniHintBounce 900ms ease-in-out 1';
    }

    document.body.appendChild(wrap);

    requestAnimationFrame(function () {
      leftEl.style.opacity = '1';
      leftEl.style.transform = 'translate(-50%,-60%)';
      rightEl.style.opacity = '1';
      rightEl.style.transform = 'translate(-50%,-60%)';
    });

    return {
      fadeOutAndRemove: function () {
        wrap.style.transition = 'opacity 360ms ease';
        wrap.style.opacity = '0';
        setTimeout(function () {
          if (wrap && wrap.parentNode) wrap.parentNode.removeChild(wrap);
          if (styleEl && styleEl.parentNode) styleEl.parentNode.removeChild(styleEl);
        }, 380);
      }
    };
  }

  function launchConfettiCannons() {
    var hintUI = null;
    if (CFG.showHintEmoji) {
      hintUI = createHintEmojiUI(CFG.zIndex);
    }

    var canvas = document.createElement('canvas');
    canvas.className = 'ottowiki-anni-confetti';
    canvas.style.cssText = [
      'position:fixed',
      'inset:0',
      'width:100%',
      'height:100%',
      'pointer-events:none',
      'z-index:' + CFG.zIndex
    ].join(';');

    var ctx = canvas.getContext('2d');
    document.body.appendChild(canvas);

    function resize() {
      var dpr = Math.max(1, window.devicePixelRatio || 1);
      canvas.width = Math.floor(window.innerWidth * dpr);
      canvas.height = Math.floor(window.innerHeight * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    }
    resize();
    window.addEventListener('resize', resize, { passive: true });

    var particles = [];
    var W = function () { return window.innerWidth; };
    var H = function () { return window.innerHeight; };

    function pickColor() {
      return CFG.colors[(Math.random() * CFG.colors.length) | 0];
    }

    function makeEggParticle(baseX, baseY, aim) {
      var angleCenter = (-Math.PI / 2) + (aim * Math.PI * 0.18);
      var angle = angleCenter + rand(-0.40, 0.40);
      var speed = rand(7, 13);

      var fontSize = rand(CFG.eggFontMin, CFG.eggFontMax);
      var text = CFG.eggEmojis[(Math.random() * CFG.eggEmojis.length) | 0];

      particles.push({
        type: 'emoji',
        text: text,
        x: baseX + rand(-10, 10),
        y: baseY + rand(-10, 10),
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        g: rand(0.10, 0.16),
        drag: rand(0.985, 0.994),
        rot: rand(-0.25, 0.25),
        vr: rand(-0.06, 0.06),
        fontSize: fontSize
        // 注意:emoji 不再 fade,靠“落出屏幕”消失
      });
    }

    // type: 'confetti' | 'smoke' | 'ribbon' | 'emoji'
    function fireCannon(from) {
      var baseX = from === 'left' ? W() * 0.08 : W() * 0.92;
      var baseY = H() * 0.78;
      var aim = from === 'left' ? 1 : -1;

      // ♿ 彩蛋目标数量(每波每侧随机)
      var eggTarget = 0;
      if (CFG.eggEnabled) {
        var minEgg = clamp(CFG.eggMinPerSidePerWave, 0, 999);
        var maxEgg = clamp(CFG.eggMaxPerSidePerWave, minEgg, 999);
        eggTarget = randInt(minEgg, maxEgg);
      }
      var eggCount = 0;

      // 1) confetti(部分随机替换为 emoji)
      for (var i = 0; i < CFG.particlesPerSide; i++) {
        if (CFG.eggEnabled && eggCount < eggTarget) {
          var remainingSlots = (CFG.particlesPerSide - i);
          var remainingEgg = (eggTarget - eggCount);
          var pReplace = clamp(remainingEgg / Math.max(1, remainingSlots), 0.08, 0.45);

          if (Math.random() < pReplace) {
            makeEggParticle(baseX, baseY, aim);
            eggCount++;
            continue;
          }
        }

        var angleCenter = (-Math.PI / 2) + (aim * Math.PI * 0.18);
        var angle = angleCenter + rand(-0.55, 0.55);
        var speed = rand(7, 14);
        var size = rand(3, 7);

        particles.push({
          type: 'confetti',
          x: baseX + rand(-8, 8),
          y: baseY + rand(-8, 8),
          vx: Math.cos(angle) * speed,
          vy: Math.sin(angle) * speed,
          g: rand(0.10, 0.16),
          drag: rand(0.985, 0.994),
          rot: rand(0, Math.PI),
          vr: rand(-0.28, 0.28),
          w: size,
          h: size * 0.7,
          color: pickColor(),
          shape: Math.random() < 0.75 ? 'rect' : 'circle'
          // 注意:confetti 不再 fade,靠“落出屏幕”消失
        });
      }

      // 补齐彩蛋(极小概率)
      if (CFG.eggEnabled && eggCount < eggTarget) {
        var need = eggTarget - eggCount;
        for (var k = 0; k < need; k++) makeEggParticle(baseX, baseY, aim);
      }

      // 2) ribbons(不 fade,靠落出屏幕消失)
      for (var r = 0; r < CFG.ribbonsPerSide; r++) {
        var raCenter = (-Math.PI / 2) + (aim * Math.PI * 0.16);
        var ra = raCenter + rand(-0.35, 0.35);
        var rs = rand(6.5, 11.5);

        particles.push({
          type: 'ribbon',
          x: baseX + rand(-10, 10),
          y: baseY + rand(-10, 10),
          vx: Math.cos(ra) * rs,
          vy: Math.sin(ra) * rs,
          g: rand(0.07, 0.12),
          drag: rand(0.987, 0.996),
          rot: rand(0, Math.PI),
          vr: rand(-0.16, 0.16),
          w: rand(3, 5),
          h: rand(18, 30),
          swayPhase: rand(0, Math.PI * 2),
          swaySpeed: rand(0.06, 0.12),
          swayAmp: rand(0.25, 0.55),
          color: pickColor()
        });
      }

      // 3) smoke(烟雾仍然 fade 掉比较自然)
      var smokeCount = 44;
      for (var s = 0; s < smokeCount; s++) {
        var sa = (-Math.PI / 2) + (aim * Math.PI * 0.18) + rand(-0.7, 0.7);
        var ss = rand(3, 6);
        particles.push({
          type: 'smoke',
          x: baseX + rand(-10, 10),
          y: baseY + rand(-10, 10),
          vx: Math.cos(sa) * ss,
          vy: Math.sin(sa) * ss,
          g: rand(0.03, 0.07),
          drag: rand(0.97, 0.985),
          w: rand(10, 18),
          alpha: rand(0.20, 0.42),
          fade: rand(0.004, 0.010),
          color: 'rgba(255,255,255,0.9)'
        });
      }
    }

    // 第一波
    fireCannon('left');
    fireCannon('right');

    // 第二波
    if (CFG.secondWaveDelay && CFG.secondWaveDelay > 0) {
      setTimeout(function () {
        fireCannon('left');
        fireCannon('right');
      }, CFG.secondWaveDelay);
    }

    var start = performance.now();

    function draw(now) {
      var t = now - start;
      ctx.clearRect(0, 0, W(), H());

      for (var i = particles.length - 1; i >= 0; i--) {
        var p = particles[i];

        // 更新物理
        p.vx *= p.drag;
        p.vy = p.vy * p.drag + p.g;

        if (p.type === 'ribbon') {
          p.swayPhase += p.swaySpeed;
          p.x += p.vx + Math.sin(p.swayPhase) * p.swayAmp;
          p.y += p.vy;
          p.rot += p.vr;
        } else {
          p.x += p.vx;
          p.y += p.vy;
          if (p.type === 'emoji') {
            p.rot += p.vr;
          }
        }

        // smoke alpha fade
        if (p.type === 'smoke') {
          p.alpha -= p.fade;
          if (p.alpha <= 0) {
            particles.splice(i, 1);
            continue;
          }
        }

        // 出界移除(不靠 fade)
        if (p.y > H() + 140 || p.x < -200 || p.x > W() + 200) {
          particles.splice(i, 1);
          continue;
        }

        // 绘制
        if (p.type === 'smoke') {
          ctx.save();
          ctx.globalAlpha = Math.max(0, p.alpha);
          ctx.fillStyle = p.color;
          ctx.beginPath();
          ctx.arc(p.x, p.y, (p.w * 0.55), 0, Math.PI * 2);
          ctx.fill();
          ctx.restore();
          continue;
        }

        if (p.type === 'emoji') {
          ctx.save();
          ctx.globalAlpha = 1;
          ctx.translate(p.x, p.y);
          ctx.rotate(p.rot);

          ctx.font = '600 ' + Math.round(p.fontSize) + 'px system-ui, Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji';
          ctx.textAlign = 'center';
          ctx.textBaseline = 'middle';
          ctx.fillText(p.text, 0, 0);

          ctx.restore();
          continue;
        }

        ctx.save();
        ctx.globalAlpha = 1;
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rot);

        if (p.type === 'ribbon') {
          ctx.fillStyle = p.color;
          ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);

          // 折光边
          ctx.globalAlpha = 0.22;
          ctx.fillStyle = 'rgba(255,255,255,0.9)';
          ctx.fillRect(-p.w / 2, -p.h / 2, Math.max(1, p.w * 0.35), p.h);
        } else {
          // confetti
          ctx.fillStyle = p.color;
          if (p.shape === 'circle') {
            ctx.beginPath();
            ctx.arc(0, 0, Math.max(1, p.w * 0.55), 0, Math.PI * 2);
            ctx.fill();
          } else {
            ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
          }
        }

        ctx.restore();
      }

      // 结束条件:粒子走完(自然落出屏幕)或超时兜底
      if (particles.length === 0 || t > CFG.maxRuntime) {
        window.removeEventListener('resize', resize);

        // 只让 🎉 渐隐
        if (hintUI) hintUI.fadeOutAndRemove();

        // canvas 直接移除(不做渐变)
        if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
        return;
      }

      requestAnimationFrame(draw);
    }

    requestAnimationFrame(draw);
  }

  function run() {
    if (!shouldRun()) return;

    function start() {
      setTimeout(function () {
        if (!document.body) return;
        launchConfettiCannons();
      }, Math.max(0, CFG.startDelay | 0));
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
      start();
    } else {
      document.addEventListener('DOMContentLoaded', start, { once: true });
    }
  }

  run();
})();