跳转到内容
打开/关闭菜单
  • 450 条目
  • 805 文件
  • 22 用户
  • 9101 编辑
Blockland Wiki
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

MediaWiki:KuranokaPageTransition.js:修订间差异

MediaWiki界面页面
无编辑摘要
无编辑摘要
第1行: 第1行:
/* ===============================================================
/* =================================================================
   Kuranoka Page Transition v2 — REAL CANVAS FIRE
   KURANOKA PAGE TRANSITION V3 — ASHES / REBIRTH
   REPLACE MediaWiki:KuranokaPageTransition.js with this entire file.
   Paste into MediaWiki:KuranokaPageTransition.js (replace V2).
   No global styles. No external JavaScript dependencies.
  Load ONLY on wiki pages Kuranoka and Kuronata via Citizen.js.
   =============================================================== */
   Needs the matching KuranokaPageTransition.css. No libraries.
   ================================================================= */
(function () {
(function () {
     'use strict';
     'use strict';


     if (window.__kkptV2 || window !== window.top) return;
    // The destination iframe loads this script too. NEVER nest previews.
     window.__kkptV2 = true;
     if (window !== window.top || window.__kkptV3) return;
     window.__kkptV3 = true;
     if (!window.mw || !mw.config) return;
     if (!window.mw || !mw.config) return;
     if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;
     if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;
第18行: 第20行:
     const target = burn ? 'Kuronata' : 'Kuranoka';
     const target = burn ? 'Kuronata' : 'Kuranoka';
     const targetUrl = '/wiki/' + encodeURIComponent(target);
     const targetUrl = '/wiki/' + encodeURIComponent(target);
     const DURATION = burn ? 3450 : 2850;
     const DURATION = burn ? 3900 : 3200;
    const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
    const warmedImages = new Map();
    const rand = (min, max) => min + Math.random() * (max - min);
    const smooth = t => t * t * (3 - 2 * t);
    const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
    const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
 
    let iframe = null;
    let warmPromise = null;
    let loaded = false;
     let busy = false;
     let busy = false;
    let button = null;
    // Look at actual CSS in BOTH documents. This finds Background template
    // body backgrounds as well as CSS pseudo-element backdrops.
    function findBackgroundUrls(doc) {
        const urls = new Set();
        const nodes = [doc.documentElement, doc.body];
        const selectors = [
            '#mw-content-text', '.mw-parser-output', '.citizen-page-container',
            '.citizen-body-container', '.mw-page-container',
            '.background-theme', '[class*="background"]', '[id*="background"]',
            '[style*="background"]', '[data-background]'
        ];
        selectors.forEach(selector => {
            try {
                doc.querySelectorAll(selector).forEach((node, index) => {
                    if (index < 45 && !nodes.includes(node)) nodes.push(node);
                });
            } catch (e) { /* selector may not exist in an older skin */ }
        });


    function makeButton() {
        function add(value) {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
            if (!value || typeof value !== 'string') return;
        const button = document.createElement('a');
            const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
        button.id = 'kkpt-toggle';
            let match;
        button.href = targetUrl;
            let hasCssUrl = false;
        button.setAttribute('aria-label', burn ? '焚毁当前世界,切换到 Kuronata' : '破晓,返回 Kuranoka');
            while ((match = urlPattern.exec(value))) {
                hasCssUrl = true;
                if (match[2]) {
                    try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
                }
            }
            if (!hasCssUrl && /^(https?:\/\/|\/|\.\/|\.\.\/).+\.(?:png|jpe?g|webp|avif|gif|svg)(?:[?#].*)?$/i.test(value)) {
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
            }
        }


         const icon = document.createElement('span');
         nodes.forEach(node => {
        icon.className = 'kkpt-icon';
            if (!node) return;
        icon.setAttribute('aria-hidden', 'true');
            try {
        icon.textContent = burn ? '✦' : '☀';
                const win = doc.defaultView;
                [null, '::before', '::after'].forEach(pseudo => {
                    const style = win.getComputedStyle(node, pseudo);
                    if (style) {
                        add(style.backgroundImage);
                        add(style.maskImage);
                        add(style.getPropertyValue('--background-image'));
                        add(style.getPropertyValue('--bg-image'));
                    }
                });
            } catch (e) { /* ignore unsupported pseudo styles */ }


        const label = document.createElement('span');
            // MediaWiki Background templates often expose URLs as data-*.
        label.className = 'kkpt-text';
            if (node.dataset) Object.entries(node.dataset).forEach(([key, value]) => {
        const title = document.createElement('span');
                if (/background|wallpaper|image|backdrop|bg/i.test(key)) add(value);
        title.textContent = target.toUpperCase();
            });
        const sub = document.createElement('span');
         });
        sub.className = 'kkpt-sub';
        sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        label.append(title, sub);
        button.append(icon, label);
         document.body.append(button);


         button.addEventListener('click', function (event) {
         return [...urls];
            // Preserve opening in a new tab, modifiers, and browser behavior.
    }
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey ||
                event.shiftKey || event.altKey) return;
            event.preventDefault();
            if (busy) return;
            busy = true;
            button.classList.add('kkpt-loading');


            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    function decodeBackground(url) {
                window.location.assign(targetUrl);
        if (warmedImages.has(url)) return warmedImages.get(url);
                 return;
        const image = new Image();
        image.decoding = 'async';
        image.fetchPriority = 'high';
        image.src = url;
        const promise = new Promise(resolve => {
            if (image.complete) return resolve(image.naturalWidth > 0);
            image.onload = () => resolve(true);
            image.onerror = () => resolve(false);
        }).then(async success => {
            if (success && image.decode) {
                 try { await image.decode(); } catch (e) { /* already loaded */ }
             }
             }
             begin().catch(function (err) {
             return success;
                console.error('[KuranokaPageTransition] Transition failed:', err);
                window.location.assign(targetUrl);
            });
         });
         });
        // Keep an Image object alive so the browser can reuse its decoded data.
        warmedImages.set(url, promise);
        if (!window.__kkptImagePool) window.__kkptImagePool = [];
        window.__kkptImagePool.push(image);
        return promise;
    }
    async function warmBackgrounds(doc) {
        const urls = findBackgroundUrls(doc);
        if (urls.length) await Promise.all(urls.map(decodeBackground));
        return urls.length;
     }
     }


    // Use an actual same-origin rendering of the destination as the layer
     function createPreviewEarly() {
    // that shows through the burnt holes. If framing is blocked, fall back
         if (warmPromise) return warmPromise;
    // to a solid atmospheric color while retaining the fire animation.
 
     function preparePreview() {
        iframe = document.createElement('iframe');
         return new Promise(function (resolve) {
        iframe.id = 'kkpt-preview';
            const iframe = document.createElement('iframe');
        iframe.title = target + ' — 预载画面';
            iframe.id = 'kkpt-preview';
        iframe.tabIndex = -1;
            iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky';
        iframe.setAttribute('aria-hidden', 'true');
            iframe.title = 'Transition preview';
        iframe.setAttribute('loading', 'eager');
            iframe.tabIndex = -1;
        iframe.style.clipPath = startClip;
            iframe.setAttribute('aria-hidden', 'true');
            iframe.style.clipPath = burn
                ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                : 'polygon(0 0,0 100%,0 100%,0 0)';


             let completed = false;
        warmPromise = new Promise(resolve => {
             const finish = function (success) {
             let settled = false;
                 if (completed) return;
             function done(ok) {
                 completed = true;
                 if (settled) return;
                 clearTimeout(timeout);
                 settled = true;
                 if (success) {
                 loaded = ok;
                    try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ }
                 resolve(ok);
                    resolve(iframe);
             }
                } else {
                    iframe.remove();
                    const fallback = document.createElement('div');
                    fallback.id = 'kkpt-preview';
                    fallback.className = burn ? 'kkpt-blood' : 'kkpt-sky';
                    fallback.style.clipPath = burn
                        ? 'polygon(100% 0,100% 100%,100% 100%,100% 0)'
                        : 'polygon(0 0,0 100%,0 100%,0 0)';
                    document.body.append(fallback);
                    resolve(fallback);
                }
             };


            const timeout = setTimeout(function () { finish(false); }, 3500);
             iframe.addEventListener('load', async function () {
             iframe.addEventListener('load', function () {
                let valid = false;
                 try {
                 try {
                     valid = Boolean(iframe.contentDocument &&
                     const doc = iframe.contentDocument;
                        iframe.contentDocument.querySelector('#mw-content-text'));
                    if (!doc || !doc.querySelector('#mw-content-text')) return done(false);
                 } catch (e) { valid = false; }
                    // Allow background JavaScript / async MediaWiki styles to run.
                finish(valid);
                    await pause(200);
                    const fonts = doc.fonts && doc.fonts.ready;
                    if (fonts) await Promise.race([fonts.catch(() => {}), pause(1300)]);
                    await warmBackgrounds(doc);
                    await pause(170);
                    await warmBackgrounds(doc); // catches late body/background CSS
                    iframe.contentWindow.scrollTo(0, 0);
                    done(true);
                 } catch (err) {
                    console.warn('[KuranokaPageTransition] Unable to pre-render the next page:', err);
                    done(false);
                }
             }, { once: true });
             }, { once: true });
             iframe.addEventListener('error', function () { finish(false); }, { once: true });
             iframe.addEventListener('error', () => done(false), { once: true });
             document.body.append(iframe);
             document.body.appendChild(iframe);
             iframe.src = targetUrl;
             iframe.src = targetUrl;
         });
         });
        // Warm the current page as well, in case an incoming navigation needs it.
        warmBackgrounds(document).catch(() => {});
        setTimeout(() => warmBackgrounds(document).catch(() => {}), 500);
        return warmPromise;
     }
     }


     function createSprite(stops) {
     function makeSprite(stops) {
         const canvas = document.createElement('canvas');
         const canvas = document.createElement('canvas');
         canvas.width = canvas.height = 96;
         canvas.width = canvas.height = 96;
         const c = canvas.getContext('2d');
         const c = canvas.getContext('2d');
         const gradient = c.createRadialGradient(48, 48, 0, 48, 48, 48);
         const g = c.createRadialGradient(48, 48, 0, 48, 48, 48);
         stops.forEach(function (stop) { gradient.addColorStop(stop[0], stop[1]); });
         stops.forEach(stop => g.addColorStop(stop[0], stop[1]));
         c.fillStyle = gradient;
         c.fillStyle = g;
         c.fillRect(0, 0, 96, 96);
         c.fillRect(0, 0, 96, 96);
         return canvas;
         return canvas;
第127行: 第184行:


     const SPRITES = {
     const SPRITES = {
         flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]),
         fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
         ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]),
         spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
         smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]),
         smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
         blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']])
         ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
        sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
     };
     };


     function runAnimation(preview) {
     function runAnimation() {
         return new Promise(function (resolve) {
         return new Promise(resolve => {
             const canvas = document.createElement('canvas');
             const canvas = document.createElement('canvas');
             canvas.id = 'kkpt-canvas';
             canvas.id = 'kkpt-canvas';
第142行: 第200行:
             if (!ctx) { canvas.remove(); resolve(); return; }
             if (!ctx) { canvas.remove(); resolve(); return; }


             let width = 0;
             let width = 0, height = 0;
            let height = 0;
             function resize() {
            let ratio = 1;
             function size() {
                 width = window.innerWidth;
                 width = window.innerWidth;
                 height = window.innerHeight;
                 height = window.innerHeight;
                 ratio = Math.min(window.devicePixelRatio || 1, 1.7);
                 const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
                 canvas.width = Math.round(width * ratio);
                 canvas.width = Math.round(width * pixelRatio);
                 canvas.height = Math.round(height * ratio);
                 canvas.height = Math.round(height * pixelRatio);
                 ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
                 ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
             }
             }
             size();
             resize();


             const particles = [];
             const particles = [];
             const rand = function (a, b) { return a + Math.random() * (b - a); };
             const shockwaves = [];
             const smooth = function (x) { return x * x * (3 - 2 * x); };
             const maxParticles = width < 700 ? 580 : 1100;
             let previous = performance.now();
             const start = performance.now();
             let start = previous;
             let previous = start;
             let done = false;
             let burstClock = 0;


             function spawn(x, y) {
             function addParticle(x, y) {
                 if (particles.length > 1300) return;
                 if (particles.length >= maxParticles) return;
                 const r = Math.random();
                 const r = Math.random();
                 const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue';
                 const type = burn
                 const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75);
                    ? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
                    : (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
                 const isSmoke = type === 'smoke';
                const isDust = type === 'ash';
                const life = isSmoke ? rand(.9, 1.85) : isDust ? rand(.8, 2.3) : rand(.35, 1.05);
                 particles.push({
                 particles.push({
                     kind: kind,
                     type, x: x + rand(-24, 24), y: y + rand(-22, 22),
                    x: x + rand(-13, 13),
                     vx: burn ? rand(-255, 96) : rand(-86, 210),
                    y: y + rand(-14, 14),
                     vy: isSmoke ? rand(-115, -25) : isDust ? rand(-95, 25) : rand(-280, -52),
                     vx: rand(burn ? -125 : -55, burn ? 55 : 85),
                     size: isSmoke ? rand(38, 102) : isDust ? rand(3, 11) :
                     vy: rand(-185, -35),
                        type === 'spark' ? rand(3, 10) : rand(14, 42),
                    life: life,
                     life, remain: life, angle: rand(-Math.PI, Math.PI), spin: rand(-1.2, 1.2)
                     remaining: life,
                    radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47),
                     angle: rand(-.4, .4)
                 });
                 });
             }
             }


             function render(now) {
             function paint(now) {
                if (done) return;
                 const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
                 const delta = Math.min(.05, Math.max(0, (now - previous) / 1000));
                 previous = now;
                 previous = now;
                 const raw = Math.min(1, (now - start) / DURATION);
                 const progress = Math.min(1, (now - start) / DURATION);
                 const eased = smooth(raw);
                 const ease = smooth(progress);
                const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
                const direction = burn ? -1 : 1;
                 const center = burn
                 const center = burn
                     ? (width + 100) - (width + 200) * eased
                     ? width + 120 - (width + 260) * ease
                     : -100 + (width + 200) * eased;
                     : -120 + (width + 260) * ease;
                const waviness = Math.sin(Math.PI * raw);


                 function edgeX(y) {
                 function edge(y) {
                     return center + waviness * (
                     return center + wobble * (
                         16 * Math.sin(y * .023 + now * .0055) +
                         32 * Math.sin(y * .012 + now * .0037) +
                        9 * Math.sin(y * .063 - now * .009) +
                        21 * Math.sin(y * .035 - now * .0061) +
                         5 * Math.sin(y * .13 + now * .012)
                        12 * Math.sin(y * .079 + now * .0107) +
                         7 * Math.sin(y * .156 - now * .014)
                     );
                     );
                 }
                 }


                 const pts = [];
                 const pts = [];
                 const count = Math.max(28, Math.ceil(height / 25));
                 const count = Math.max(35, Math.ceil(height / 17));
                 for (let i = 0; i <= count; i++) {
                 for (let i = 0; i <= count; i++) {
                     const y = height * i / count;
                     const y = height * i / count;
                     pts.push([edgeX(y), y]);
                     pts.push([edge(y), y]);
                 }
                 }
                 const reverse = pts.slice().reverse().map(function (p) {
                 const pathToEdge = pts.slice().reverse().map(p =>
                     return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px';
                     p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
                 }).join(',');
                 ).join(',');
                 preview.style.clipPath = burn
                 iframe.style.clipPath = burn
                     ? 'polygon(100% 0,100% 100%,' + reverse + ')'
                     ? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
                     : 'polygon(0 0,0 100%,' + reverse + ')';
                     : 'polygon(0 0,0 100%,' + pathToEdge + ')';


                 ctx.clearRect(0, 0, width, height);
                 ctx.clearRect(0, 0, width, height);


                 // Smoke first, so bright fire and sparks appear in front.
                 // Flare spreading over the whole screen makes this feel like
                 ctx.globalCompositeOperation = 'source-over';
                // the world's light changes, rather than a moving divider.
                const flash = .025 * (1 + Math.sin(now * .048)) +
                    .07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
                ctx.fillStyle = burn
                    ? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
                    : 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
                ctx.fillRect(0, 0, width, height);
 
                function trace(offset) {
                    ctx.beginPath();
                    pts.forEach((p, i) => {
                        if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
                        else ctx.lineTo(p[0] + offset, p[1]);
                    });
                }
 
                // A broad charred-paper zone is consumed BEFORE the bright core.
                // Its gradient is on the remaining old page side of the flame.
                 ctx.save();
                const x0 = burn ? -133 : 133;
                const x1 = burn ? 18 : -18;
                const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
                if (burn) {
                    dark.addColorStop(0, '#130d0d00');
                    dark.addColorStop(.35, '#150c0eaa');
                    dark.addColorStop(.70, '#10080ce9');
                    dark.addColorStop(.91, '#48170df5');
                    dark.addColorStop(1, '#fa42156a');
                } else {
                    dark.addColorStop(0, '#0b1f3100');
                    dark.addColorStop(.35, '#052744aa');
                    dark.addColorStop(.7, '#041c35e9');
                    dark.addColorStop(.91, '#215b8bf5');
                    dark.addColorStop(1, '#8ce8ff77');
                }
                ctx.beginPath();
                pts.forEach((p, i) => {
                    const jitter = 10 * Math.sin(p[1] * .11 + now * .007);
                    const x = p[0] + x0 + jitter;
                    if (i === 0) ctx.moveTo(x, p[1]);
                    else ctx.lineTo(x, p[1]);
                });
                pts.slice().reverse().forEach(p => ctx.lineTo(p[0] + x1, p[1]));
                ctx.closePath();
                ctx.fillStyle = dark;
                ctx.fill();
                ctx.restore();
 
                // Smoke is below the light-emitting flames.
                ctx.save();
                 for (let i = particles.length - 1; i >= 0; i--) {
                 for (let i = particles.length - 1; i >= 0; i--) {
                     const p = particles[i];
                     const p = particles[i];
                     p.remaining -= delta;
                     p.remain -= dt;
                     if (p.remaining <= 0) { particles.splice(i, 1); continue; }
                     if (p.remain <= 0) { particles.splice(i, 1); continue; }
                     p.x += p.vx * delta;
                     p.x += p.vx * dt;
                     p.y += p.vy * delta;
                     p.y += p.vy * dt;
                     p.vy -= 18 * delta;
                    p.vx += (p.type === 'smoke' ? -4 : direction * 15) * dt;
                     if (p.kind !== 'smoke') continue;
                     p.vy -= 11 * dt;
                     const opacity = Math.min(1, p.remaining / .35) * .42;
                    p.angle += p.spin * dt;
                     const size = p.radius * (1 + (1 - p.remaining / p.life) * .8);
                     if (p.type !== 'smoke' && p.type !== 'ash') continue;
                     ctx.globalAlpha = opacity;
                     const age = 1 - p.remain / p.life;
                     ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2);
                     const size = p.size * (1 + age * (p.type === 'smoke' ? 1.7 : .6));
                     ctx.globalAlpha = Math.min(1, p.remain * 2) *
                        (p.type === 'smoke' ? .52 : .35);
                     ctx.drawImage(SPRITES[p.type], p.x - size, p.y - size, 2 * size, 2 * size);
                 }
                 }
                 ctx.globalAlpha = 1;
                 ctx.restore();


                 // The irregular, continuously moving combustion front.
                 // Broken filament cracks penetrating the unburnt page.
                ctx.beginPath();
                // They flicker independently instead of making a tidy wall.
                pts.forEach(function (p, i) {
                if (burn) {
                    if (i === 0) ctx.moveTo(p[0], p[1]);
                    ctx.save();
                    else ctx.lineTo(p[0], p[1]);
                    for (let y = 38; y < height; y += 103) {
                 });
                        const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
                        ctx.lineWidth = 1.6;
                        ctx.strokeStyle = 'rgba(38,6,4,.8)';
                        ctx.beginPath();
                        ctx.moveTo(a - 8, y);
                        ctx.lineTo(a - 35, y - 13 * skew);
                        ctx.lineTo(a - 59, y + 12);
                        ctx.lineTo(a - 93 - 20 * skew, y - 19);
                        ctx.lineTo(a - 127, y - 6);
                        ctx.stroke();
                        ctx.lineWidth = .7;
                        ctx.strokeStyle = 'rgba(255,99,23,.66)';
                        ctx.stroke();
                        ctx.beginPath();
                        ctx.moveTo(a - 61, y + 12);
                        ctx.lineTo(a - 77, y + 31);
                        ctx.lineTo(a - 91, y + 40);
                        ctx.stroke();
                    }
                    ctx.restore();
                 }
 
                // Blooming outer corona, molten core, then white-hot ignition.
                ctx.save();
                 ctx.lineJoin = 'round';
                 ctx.lineJoin = 'round';
                 ctx.lineCap = 'round';
                 ctx.lineCap = 'round';
                 ctx.shadowColor = burn ? '#ff3905' : '#b1eaff';
                 ctx.shadowColor = burn ? '#fb2509' : '#86e6ff';
                 ctx.shadowBlur = burn ? 48 : 55;
                 ctx.shadowBlur = burn ? 79 : 88;
                 ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)';
                trace(0);
                ctx.strokeStyle = burn ? 'rgba(242,35,6,.6)' : 'rgba(74,168,255,.65)';
                ctx.lineWidth = burn ? 65 : 73;
                ctx.stroke();
                ctx.shadowBlur = 39;
                 ctx.strokeStyle = burn ? 'rgba(255,110,10,.88)' : 'rgba(130,223,255,.91)';
                 ctx.lineWidth = burn ? 29 : 35;
                 ctx.lineWidth = burn ? 29 : 35;
                 ctx.stroke();
                 ctx.stroke();
                 ctx.shadowBlur = 21;
                 ctx.shadowBlur = 18;
                 ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff';
                 ctx.strokeStyle = burn ? '#ffe564' : '#e5fbff';
                 ctx.lineWidth = burn ? 9 : 11;
                 ctx.lineWidth = 9;
                 ctx.stroke();
                 ctx.stroke();
                 ctx.shadowBlur = 11;
                 ctx.shadowBlur = 9;
                 ctx.strokeStyle = '#fff9e2';
                 ctx.strokeStyle = '#fffdf0';
                 ctx.lineWidth = 2.5;
                 ctx.lineWidth = 3;
                 ctx.stroke();
                 ctx.stroke();
                 ctx.shadowBlur = 0;
                 ctx.restore();
 
                // Aggressive curling tongues. Flame tips tear leftwards into
                // old Kuranoka; on the way back, electric shards tear right.
                ctx.save();
                ctx.globalCompositeOperation = 'lighter';
                for (let y = -60; y < height + 60; y += burn ? 17 : 26) {
                    const x = edge(y);
                    const flicker = Math.sin(now * .015 + y * .093);
                    const lift = (burn ? 55 : 68) +
                        (burn ? 65 : 43) * Math.abs(Math.sin(y * .043 - now * .009)) +
                        18 * (flicker + 1);
                    const lean = (burn ? -1 : 1) * lift;
 
                    ctx.fillStyle = burn ? 'rgba(255,53,8,.44)' : 'rgba(31,138,255,.33)';
                    ctx.beginPath();
                    ctx.moveTo(x - lean * .05, y + 19);
                    ctx.bezierCurveTo(x + lean * .14, y - 18,
                        x + lean * .88, y - lift * .47, x + lean, y - lift);
                    ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
                        x - lean * .13, y + 13, x + 16, y + 27);
                    ctx.closePath();
                    ctx.fill();
 
                    ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
                    ctx.beginPath();
                    ctx.moveTo(x + 4, y + 11);
                    ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
                        x + lean * .70, y - lift * .67);
                    ctx.quadraticCurveTo(x - 6, y - lift * .21, x + 7, y + 11);
                    ctx.closePath();
                    ctx.fill();
                }
                ctx.restore();


                 // Actual flame tongues curling into the page that is burning.
                 // Impacts regularly burst along the travelling front.
                 // Layered Bezier shapes prevent this from looking like a flat wipe.
                 burstClock += dt;
                 if (burn) {
                if (burstClock > (burn ? .18 : .26) && progress > .05 && progress < .95) {
                    burstClock = 0;
                    const y = rand(0, height);
                    shockwaves.push({x: edge(y), y, age: 0, max: rand(52, 115)});
                    for (let j = 0; j < (burn ? 18 : 12); j++) addParticle(edge(y), y);
                }
                 for (let i = shockwaves.length - 1; i >= 0; i--) {
                    const s = shockwaves[i];
                    s.age += dt;
                    if (s.age > .52) { shockwaves.splice(i, 1); continue; }
                    const t = s.age / .52;
                     ctx.save();
                     ctx.save();
                     ctx.globalCompositeOperation = 'lighter';
                     ctx.globalCompositeOperation = 'lighter';
                     for (let y = -20; y < height + 45; y += 31) {
                     ctx.globalAlpha = (1 - t) * .67;
                        const x = edgeX(y);
                    ctx.strokeStyle = burn ? '#ffce75' : '#d3ffff';
                        const flicker = Math.sin(now * .014 + y * .19);
                    ctx.lineWidth = 4 * (1 - t) + .3;
                        const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1));
                    ctx.shadowBlur = 32;
                        const lean = 15 + 12 * Math.sin(now * .009 + y * .075);
                    ctx.shadowColor = burn ? '#ff3f05' : '#72d5ff';
                        ctx.fillStyle = 'rgba(255,77,5,.39)';
                    ctx.beginPath();
                        ctx.beginPath();
                    ctx.ellipse(s.x, s.y, s.max * t, s.max * t * .7, 0, 0, Math.PI * 2);
                        ctx.moveTo(x + 13, y + 15);
                    ctx.stroke();
                        ctx.bezierCurveTo(x - 17, y - 2, x - lean - 25, y - lift * .62, x - lean, y - lift);
                        ctx.bezierCurveTo(x - 2, y - lift * .56, x + 11, y - 13, x + 17, y + 15);
                        ctx.closePath();
                        ctx.fill();
 
                        ctx.fillStyle = 'rgba(255,220,91,.43)';
                        ctx.beginPath();
                        ctx.moveTo(x + 3, y + 7);
                        ctx.quadraticCurveTo(x - 10, y - lift * .28, x - lean * .6, y - lift * .68);
                        ctx.quadraticCurveTo(x + 3, y - lift * .3, x + 7, y + 7);
                        ctx.closePath();
                        ctx.fill();
                    }
                     ctx.restore();
                     ctx.restore();
                 }
                 }


                // Live flame tongues / sparks, not an animated screenshot.
                 const density = width < 700 ? 13 : 29;
                 const amount = Math.max(14, Math.min(34, Math.round(height / 41)));
                 for (let i = 0; i < density; i++) {
                 for (let i = 0; i < amount; i++) {
                     const y = rand(0, height);
                     const y = rand(0, height);
                     spawn(edgeX(y), y);
                     addParticle(edge(y), y);
                 }
                 }


                ctx.save();
                 ctx.globalCompositeOperation = 'lighter';
                 ctx.globalCompositeOperation = 'lighter';
                 for (let i = 0; i < particles.length; i++) {
                 particles.forEach(p => {
                    const p = particles[i];
                     if (p.type === 'smoke' || p.type === 'ash') return;
                     if (p.kind === 'smoke') continue;
                     const age = 1 - p.remain / p.life;
                     const stage = 1 - p.remaining / p.life;
                     const fade = Math.min(1, p.remain * 3) * (1 - age * .65);
                     const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5);
                     ctx.globalAlpha = fade * (p.type === 'spark' ? .97 : .72);
                    ctx.save();
                     ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8);
                     ctx.translate(p.x, p.y);
                     ctx.translate(p.x, p.y);
                     ctx.rotate(p.angle);
                     ctx.rotate(p.angle);
                     const sprite = p.kind === 'blue' ? SPRITES.blue :
                     const xsize = p.size * (p.type === 'fire' ? .8 : 1);
                        p.kind === 'ember' ? SPRITES.ember : SPRITES.flame;
                     const ysize = p.size * (p.type === 'fire' ? 1.7 : 1);
                    const sx = p.radius * (p.kind === 'flame' ? .9 : 1);
                     ctx.drawImage(SPRITES[p.type], -xsize, -ysize, xsize * 2, ysize * 2);
                     const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1);
                     ctx.rotate(-p.angle);
                     ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2);
                    ctx.translate(-p.x, -p.y);
                     ctx.restore();
                 });
                 }
                 ctx.restore();
                ctx.globalAlpha = 1;
                 ctx.globalCompositeOperation = 'source-over';


                 if (raw < 1) {
                 if (progress < 1) {
                     requestAnimationFrame(render);
                     requestAnimationFrame(paint);
                 } else {
                 } else {
                     done = true;
                     // The iframe is fully visible BEFORE the old document
                     preview.style.clipPath = 'none';
                    // navigates. No handoff cover, no blue/red solid flash.
                     setTimeout(resolve, 110);
                    iframe.style.clipPath = 'inset(0)';
                     canvas.style.opacity = '0';
                     resolve();
                 }
                 }
             }
             }
             requestAnimationFrame(render);
             requestAnimationFrame(paint);
         });
         });
     }
     }


     function handoff() {
     async function begin() {
         // A very short opaque frame hides the last real document navigation.
        if (!warmPromise) createPreviewEarly();
         // The preview is already the actual target URL, not mock Wiki text.
        button.classList.add('kkpt-loading');
         const cover = document.createElement('div');
        button.querySelector('.kkpt-sub').textContent = 'PREPARING THE OTHER SIDE...';
         cover.id = 'kkpt-handoff';
 
        if (!burn) cover.className = 'kkpt-sky';
         // Usually instantaneous: the iframe and both wallpapers began loading
         document.body.append(cover);
         // when the page itself was opened, not when this button was clicked.
         requestAnimationFrame(function () {
         const ready = await Promise.race([warmPromise, pause(12000).then(() => false)]);
            cover.style.opacity = '1';
         if (!ready || !loaded) {
            setTimeout(function () { window.location.assign(targetUrl); }, 200);
            button.classList.remove('kkpt-loading');
        });
            button.querySelector('.kkpt-sub').textContent = '预览未就绪 · 点击普通跳转';
            // Failure path is a normal link, not a fake blue page reveal.
            button.dataset.kkptFallback = '1';
            busy = false;
            return;
        }
 
        button.classList.remove('kkpt-loading');
         button.classList.add('kkpt-running');
         button.querySelector('.kkpt-sub').textContent = burn ? 'THE WORLD IS BURNING' : 'BREAK THROUGH THE DARK';
        await runAnimation();
 
        // Let the compositor present the REAL destination iframe for a few
        // frames. On navigation, native cross-document View Transition takes
        // its old snapshot from this already-revealed destination.
        button.classList.add('kkpt-vanish');
        await frame();
        await frame();
        await pause(90);
        window.location.assign(targetUrl);
     }
     }


     async function begin() {
     function makeButton() {
         const preview = await preparePreview();
        if (!document.body || document.getElementById('kkpt-toggle')) return;
         await runAnimation(preview);
        button = document.createElement('a');
         handoff();
        button.id = 'kkpt-toggle';
        button.href = targetUrl;
        button.setAttribute('aria-label', burn ? '燃尽当前世界,进入 Kuronata' : '破晓,返回 Kuranoka');
 
        const icon = document.createElement('span');
        icon.className = 'kkpt-icon';
        icon.setAttribute('aria-hidden', 'true');
        icon.textContent = burn ? '✦' : '☀';
         const label = document.createElement('span');
         label.className = 'kkpt-text';
        const title = document.createElement('span');
        title.textContent = target.toUpperCase();
         const sub = document.createElement('span');
        sub.className = 'kkpt-sub';
        sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        label.append(title, sub);
        button.append(icon, label);
        document.body.append(button);
 
        // Preload starts right now, not on click.
        createPreviewEarly();
 
        button.addEventListener('click', event => {
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
                event.metaKey || event.shiftKey || event.altKey) return;
            if (button.dataset.kkptFallback === '1') return; // normal navigation
            event.preventDefault();
            if (busy) return;
            busy = true;
 
            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                window.location.assign(targetUrl);
                return;
            }
            begin().catch(err => {
                console.error('[KuranokaPageTransition] V3 error:', err);
                // Unexpected errors go to the real page, never a blank cover.
                window.location.assign(targetUrl);
            });
        });
     }
     }


第346行: 第570行:
         makeButton();
         makeButton();
     }
     }
    // Back/forward cache can revive a page with the iframe still fully open.
    // Restore the original scene when users return with the Back button.
    window.addEventListener('pageshow', event => {
        if (!event.persisted) return;
        document.getElementById('kkpt-canvas')?.remove();
        if (iframe) iframe.style.clipPath = startClip;
        if (button) {
            button.classList.remove('kkpt-vanish', 'kkpt-running', 'kkpt-loading');
            const subtitle = button.querySelector('.kkpt-sub');
            if (subtitle) subtitle.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        }
        busy = false;
    });
}());
}());

2026年10月23日 (五) 11:59的版本

/* =================================================================
   KURANOKA PAGE TRANSITION V3 — ASHES / REBIRTH
   Paste into MediaWiki:KuranokaPageTransition.js (replace V2).
   Load ONLY on wiki pages Kuranoka and Kuronata via Citizen.js.
   Needs the matching KuranokaPageTransition.css. No libraries.
   ================================================================= */
(function () {
    'use strict';

    // The destination iframe loads this script too. NEVER nest previews.
    if (window !== window.top || window.__kkptV3) return;
    window.__kkptV3 = true;
    if (!window.mw || !mw.config) return;
    if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return;

    const page = mw.config.get('wgPageName');
    if (page !== 'Kuranoka' && page !== 'Kuronata') return;

    const burn = page === 'Kuranoka';
    const target = burn ? 'Kuronata' : 'Kuranoka';
    const targetUrl = '/wiki/' + encodeURIComponent(target);
    const DURATION = burn ? 3900 : 3200;
    const startClip = burn ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
    const warmedImages = new Map();
    const rand = (min, max) => min + Math.random() * (max - min);
    const smooth = t => t * t * (3 - 2 * t);
    const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
    const frame = () => new Promise(resolve => requestAnimationFrame(resolve));

    let iframe = null;
    let warmPromise = null;
    let loaded = false;
    let busy = false;
    let button = null;

    // Look at actual CSS in BOTH documents. This finds Background template
    // body backgrounds as well as CSS pseudo-element backdrops.
    function findBackgroundUrls(doc) {
        const urls = new Set();
        const nodes = [doc.documentElement, doc.body];
        const selectors = [
            '#mw-content-text', '.mw-parser-output', '.citizen-page-container',
            '.citizen-body-container', '.mw-page-container',
            '.background-theme', '[class*="background"]', '[id*="background"]',
            '[style*="background"]', '[data-background]'
        ];

        selectors.forEach(selector => {
            try {
                doc.querySelectorAll(selector).forEach((node, index) => {
                    if (index < 45 && !nodes.includes(node)) nodes.push(node);
                });
            } catch (e) { /* selector may not exist in an older skin */ }
        });

        function add(value) {
            if (!value || typeof value !== 'string') return;
            const urlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
            let match;
            let hasCssUrl = false;
            while ((match = urlPattern.exec(value))) {
                hasCssUrl = true;
                if (match[2]) {
                    try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
                }
            }
            if (!hasCssUrl && /^(https?:\/\/|\/|\.\/|\.\.\/).+\.(?:png|jpe?g|webp|avif|gif|svg)(?:[?#].*)?$/i.test(value)) {
                try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
            }
        }

        nodes.forEach(node => {
            if (!node) return;
            try {
                const win = doc.defaultView;
                [null, '::before', '::after'].forEach(pseudo => {
                    const style = win.getComputedStyle(node, pseudo);
                    if (style) {
                        add(style.backgroundImage);
                        add(style.maskImage);
                        add(style.getPropertyValue('--background-image'));
                        add(style.getPropertyValue('--bg-image'));
                    }
                });
            } catch (e) { /* ignore unsupported pseudo styles */ }

            // MediaWiki Background templates often expose URLs as data-*.
            if (node.dataset) Object.entries(node.dataset).forEach(([key, value]) => {
                if (/background|wallpaper|image|backdrop|bg/i.test(key)) add(value);
            });
        });

        return [...urls];
    }

    function decodeBackground(url) {
        if (warmedImages.has(url)) return warmedImages.get(url);
        const image = new Image();
        image.decoding = 'async';
        image.fetchPriority = 'high';
        image.src = url;
        const promise = new Promise(resolve => {
            if (image.complete) return resolve(image.naturalWidth > 0);
            image.onload = () => resolve(true);
            image.onerror = () => resolve(false);
        }).then(async success => {
            if (success && image.decode) {
                try { await image.decode(); } catch (e) { /* already loaded */ }
            }
            return success;
        });
        // Keep an Image object alive so the browser can reuse its decoded data.
        warmedImages.set(url, promise);
        if (!window.__kkptImagePool) window.__kkptImagePool = [];
        window.__kkptImagePool.push(image);
        return promise;
    }

    async function warmBackgrounds(doc) {
        const urls = findBackgroundUrls(doc);
        if (urls.length) await Promise.all(urls.map(decodeBackground));
        return urls.length;
    }

    function createPreviewEarly() {
        if (warmPromise) return warmPromise;

        iframe = document.createElement('iframe');
        iframe.id = 'kkpt-preview';
        iframe.title = target + ' — 预载画面';
        iframe.tabIndex = -1;
        iframe.setAttribute('aria-hidden', 'true');
        iframe.setAttribute('loading', 'eager');
        iframe.style.clipPath = startClip;

        warmPromise = new Promise(resolve => {
            let settled = false;
            function done(ok) {
                if (settled) return;
                settled = true;
                loaded = ok;
                resolve(ok);
            }

            iframe.addEventListener('load', async function () {
                try {
                    const doc = iframe.contentDocument;
                    if (!doc || !doc.querySelector('#mw-content-text')) return done(false);
                    // Allow background JavaScript / async MediaWiki styles to run.
                    await pause(200);
                    const fonts = doc.fonts && doc.fonts.ready;
                    if (fonts) await Promise.race([fonts.catch(() => {}), pause(1300)]);
                    await warmBackgrounds(doc);
                    await pause(170);
                    await warmBackgrounds(doc); // catches late body/background CSS
                    iframe.contentWindow.scrollTo(0, 0);
                    done(true);
                } catch (err) {
                    console.warn('[KuranokaPageTransition] Unable to pre-render the next page:', err);
                    done(false);
                }
            }, { once: true });
            iframe.addEventListener('error', () => done(false), { once: true });
            document.body.appendChild(iframe);
            iframe.src = targetUrl;
        });

        // Warm the current page as well, in case an incoming navigation needs it.
        warmBackgrounds(document).catch(() => {});
        setTimeout(() => warmBackgrounds(document).catch(() => {}), 500);
        return warmPromise;
    }

    function makeSprite(stops) {
        const canvas = document.createElement('canvas');
        canvas.width = canvas.height = 96;
        const c = canvas.getContext('2d');
        const g = c.createRadialGradient(48, 48, 0, 48, 48, 48);
        stops.forEach(stop => g.addColorStop(stop[0], stop[1]));
        c.fillStyle = g;
        c.fillRect(0, 0, 96, 96);
        return canvas;
    }

    const SPRITES = {
        fire: makeSprite([[0, '#ffffff'], [.11, '#fff6b3'], [.35, '#ffb71e'], [.68, '#f7330988'], [1, '#a3000000']]),
        spark: makeSprite([[0, '#ffffff'], [.16, '#fff3b8'], [.48, '#ff7900bb'], [1, '#ff230000']]),
        smoke: makeSprite([[0, '#170c0de0'], [.36, '#201b20a1'], [.72, '#302b3260'], [1, '#11111100']]),
        ash: makeSprite([[0, '#d2b2a9'], [.28, '#897471bb'], [1, '#55555500']]),
        sky: makeSprite([[0, '#ffffff'], [.16, '#eeffff'], [.44, '#79d5ffbb'], [1, '#3c92ff00']])
    };

    function runAnimation() {
        return new Promise(resolve => {
            const canvas = document.createElement('canvas');
            canvas.id = 'kkpt-canvas';
            canvas.setAttribute('aria-hidden', 'true');
            document.body.append(canvas);
            const ctx = canvas.getContext('2d', { alpha: true });
            if (!ctx) { canvas.remove(); resolve(); return; }

            let width = 0, height = 0;
            function resize() {
                width = window.innerWidth;
                height = window.innerHeight;
                const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.6);
                canvas.width = Math.round(width * pixelRatio);
                canvas.height = Math.round(height * pixelRatio);
                ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
            }
            resize();

            const particles = [];
            const shockwaves = [];
            const maxParticles = width < 700 ? 580 : 1100;
            const start = performance.now();
            let previous = start;
            let burstClock = 0;

            function addParticle(x, y) {
                if (particles.length >= maxParticles) return;
                const r = Math.random();
                const type = burn
                    ? (r < .49 ? 'fire' : r < .70 ? 'spark' : r < .90 ? 'smoke' : 'ash')
                    : (r < .68 ? 'sky' : r < .85 ? 'spark' : 'smoke');
                const isSmoke = type === 'smoke';
                const isDust = type === 'ash';
                const life = isSmoke ? rand(.9, 1.85) : isDust ? rand(.8, 2.3) : rand(.35, 1.05);
                particles.push({
                    type, x: x + rand(-24, 24), y: y + rand(-22, 22),
                    vx: burn ? rand(-255, 96) : rand(-86, 210),
                    vy: isSmoke ? rand(-115, -25) : isDust ? rand(-95, 25) : rand(-280, -52),
                    size: isSmoke ? rand(38, 102) : isDust ? rand(3, 11) :
                        type === 'spark' ? rand(3, 10) : rand(14, 42),
                    life, remain: life, angle: rand(-Math.PI, Math.PI), spin: rand(-1.2, 1.2)
                });
            }

            function paint(now) {
                const dt = Math.min(.05, Math.max(.001, (now - previous) / 1000));
                previous = now;
                const progress = Math.min(1, (now - start) / DURATION);
                const ease = smooth(progress);
                const wobble = Math.pow(Math.sin(Math.PI * progress), .7);
                const direction = burn ? -1 : 1;
                const center = burn
                    ? width + 120 - (width + 260) * ease
                    : -120 + (width + 260) * ease;

                function edge(y) {
                    return center + wobble * (
                        32 * Math.sin(y * .012 + now * .0037) +
                        21 * Math.sin(y * .035 - now * .0061) +
                        12 * Math.sin(y * .079 + now * .0107) +
                         7 * Math.sin(y * .156 - now * .014)
                    );
                }

                const pts = [];
                const count = Math.max(35, Math.ceil(height / 17));
                for (let i = 0; i <= count; i++) {
                    const y = height * i / count;
                    pts.push([edge(y), y]);
                }
                const pathToEdge = pts.slice().reverse().map(p =>
                    p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px'
                ).join(',');
                iframe.style.clipPath = burn
                    ? 'polygon(100% 0,100% 100%,' + pathToEdge + ')'
                    : 'polygon(0 0,0 100%,' + pathToEdge + ')';

                ctx.clearRect(0, 0, width, height);

                // Flare spreading over the whole screen makes this feel like
                // the world's light changes, rather than a moving divider.
                const flash = .025 * (1 + Math.sin(now * .048)) +
                    .07 * Math.pow(Math.max(0, Math.sin(now * .009)), 12);
                ctx.fillStyle = burn
                    ? 'rgba(108,13,5,' + (flash * wobble).toFixed(3) + ')'
                    : 'rgba(48,135,205,' + (flash * wobble).toFixed(3) + ')';
                ctx.fillRect(0, 0, width, height);

                function trace(offset) {
                    ctx.beginPath();
                    pts.forEach((p, i) => {
                        if (i === 0) ctx.moveTo(p[0] + offset, p[1]);
                        else ctx.lineTo(p[0] + offset, p[1]);
                    });
                }

                // A broad charred-paper zone is consumed BEFORE the bright core.
                // Its gradient is on the remaining old page side of the flame.
                ctx.save();
                const x0 = burn ? -133 : 133;
                const x1 = burn ? 18 : -18;
                const dark = ctx.createLinearGradient(center + x0, 0, center + x1, 0);
                if (burn) {
                    dark.addColorStop(0, '#130d0d00');
                    dark.addColorStop(.35, '#150c0eaa');
                    dark.addColorStop(.70, '#10080ce9');
                    dark.addColorStop(.91, '#48170df5');
                    dark.addColorStop(1, '#fa42156a');
                } else {
                    dark.addColorStop(0, '#0b1f3100');
                    dark.addColorStop(.35, '#052744aa');
                    dark.addColorStop(.7, '#041c35e9');
                    dark.addColorStop(.91, '#215b8bf5');
                    dark.addColorStop(1, '#8ce8ff77');
                }
                ctx.beginPath();
                pts.forEach((p, i) => {
                    const jitter = 10 * Math.sin(p[1] * .11 + now * .007);
                    const x = p[0] + x0 + jitter;
                    if (i === 0) ctx.moveTo(x, p[1]);
                    else ctx.lineTo(x, p[1]);
                });
                pts.slice().reverse().forEach(p => ctx.lineTo(p[0] + x1, p[1]));
                ctx.closePath();
                ctx.fillStyle = dark;
                ctx.fill();
                ctx.restore();

                // Smoke is below the light-emitting flames.
                ctx.save();
                for (let i = particles.length - 1; i >= 0; i--) {
                    const p = particles[i];
                    p.remain -= dt;
                    if (p.remain <= 0) { particles.splice(i, 1); continue; }
                    p.x += p.vx * dt;
                    p.y += p.vy * dt;
                    p.vx += (p.type === 'smoke' ? -4 : direction * 15) * dt;
                    p.vy -= 11 * dt;
                    p.angle += p.spin * dt;
                    if (p.type !== 'smoke' && p.type !== 'ash') continue;
                    const age = 1 - p.remain / p.life;
                    const size = p.size * (1 + age * (p.type === 'smoke' ? 1.7 : .6));
                    ctx.globalAlpha = Math.min(1, p.remain * 2) *
                        (p.type === 'smoke' ? .52 : .35);
                    ctx.drawImage(SPRITES[p.type], p.x - size, p.y - size, 2 * size, 2 * size);
                }
                ctx.restore();

                // Broken filament cracks penetrating the unburnt page.
                // They flicker independently instead of making a tidy wall.
                if (burn) {
                    ctx.save();
                    for (let y = 38; y < height; y += 103) {
                        const a = edge(y), skew = Math.sin(y * .13 + now * .0017);
                        ctx.lineWidth = 1.6;
                        ctx.strokeStyle = 'rgba(38,6,4,.8)';
                        ctx.beginPath();
                        ctx.moveTo(a - 8, y);
                        ctx.lineTo(a - 35, y - 13 * skew);
                        ctx.lineTo(a - 59, y + 12);
                        ctx.lineTo(a - 93 - 20 * skew, y - 19);
                        ctx.lineTo(a - 127, y - 6);
                        ctx.stroke();
                        ctx.lineWidth = .7;
                        ctx.strokeStyle = 'rgba(255,99,23,.66)';
                        ctx.stroke();
                        ctx.beginPath();
                        ctx.moveTo(a - 61, y + 12);
                        ctx.lineTo(a - 77, y + 31);
                        ctx.lineTo(a - 91, y + 40);
                        ctx.stroke();
                    }
                    ctx.restore();
                }

                // Blooming outer corona, molten core, then white-hot ignition.
                ctx.save();
                ctx.lineJoin = 'round';
                ctx.lineCap = 'round';
                ctx.shadowColor = burn ? '#fb2509' : '#86e6ff';
                ctx.shadowBlur = burn ? 79 : 88;
                trace(0);
                ctx.strokeStyle = burn ? 'rgba(242,35,6,.6)' : 'rgba(74,168,255,.65)';
                ctx.lineWidth = burn ? 65 : 73;
                ctx.stroke();
                ctx.shadowBlur = 39;
                ctx.strokeStyle = burn ? 'rgba(255,110,10,.88)' : 'rgba(130,223,255,.91)';
                ctx.lineWidth = burn ? 29 : 35;
                ctx.stroke();
                ctx.shadowBlur = 18;
                ctx.strokeStyle = burn ? '#ffe564' : '#e5fbff';
                ctx.lineWidth = 9;
                ctx.stroke();
                ctx.shadowBlur = 9;
                ctx.strokeStyle = '#fffdf0';
                ctx.lineWidth = 3;
                ctx.stroke();
                ctx.restore();

                // Aggressive curling tongues. Flame tips tear leftwards into
                // old Kuranoka; on the way back, electric shards tear right.
                ctx.save();
                ctx.globalCompositeOperation = 'lighter';
                for (let y = -60; y < height + 60; y += burn ? 17 : 26) {
                    const x = edge(y);
                    const flicker = Math.sin(now * .015 + y * .093);
                    const lift = (burn ? 55 : 68) +
                        (burn ? 65 : 43) * Math.abs(Math.sin(y * .043 - now * .009)) +
                        18 * (flicker + 1);
                    const lean = (burn ? -1 : 1) * lift;

                    ctx.fillStyle = burn ? 'rgba(255,53,8,.44)' : 'rgba(31,138,255,.33)';
                    ctx.beginPath();
                    ctx.moveTo(x - lean * .05, y + 19);
                    ctx.bezierCurveTo(x + lean * .14, y - 18,
                        x + lean * .88, y - lift * .47, x + lean, y - lift);
                    ctx.bezierCurveTo(x + lean * .35, y - lift * .53,
                        x - lean * .13, y + 13, x + 16, y + 27);
                    ctx.closePath();
                    ctx.fill();

                    ctx.fillStyle = burn ? 'rgba(255,231,89,.47)' : 'rgba(222,250,255,.52)';
                    ctx.beginPath();
                    ctx.moveTo(x + 4, y + 11);
                    ctx.quadraticCurveTo(x + lean * .32, y - lift * .31,
                        x + lean * .70, y - lift * .67);
                    ctx.quadraticCurveTo(x - 6, y - lift * .21, x + 7, y + 11);
                    ctx.closePath();
                    ctx.fill();
                }
                ctx.restore();

                // Impacts regularly burst along the travelling front.
                burstClock += dt;
                if (burstClock > (burn ? .18 : .26) && progress > .05 && progress < .95) {
                    burstClock = 0;
                    const y = rand(0, height);
                    shockwaves.push({x: edge(y), y, age: 0, max: rand(52, 115)});
                    for (let j = 0; j < (burn ? 18 : 12); j++) addParticle(edge(y), y);
                }
                for (let i = shockwaves.length - 1; i >= 0; i--) {
                    const s = shockwaves[i];
                    s.age += dt;
                    if (s.age > .52) { shockwaves.splice(i, 1); continue; }
                    const t = s.age / .52;
                    ctx.save();
                    ctx.globalCompositeOperation = 'lighter';
                    ctx.globalAlpha = (1 - t) * .67;
                    ctx.strokeStyle = burn ? '#ffce75' : '#d3ffff';
                    ctx.lineWidth = 4 * (1 - t) + .3;
                    ctx.shadowBlur = 32;
                    ctx.shadowColor = burn ? '#ff3f05' : '#72d5ff';
                    ctx.beginPath();
                    ctx.ellipse(s.x, s.y, s.max * t, s.max * t * .7, 0, 0, Math.PI * 2);
                    ctx.stroke();
                    ctx.restore();
                }

                const density = width < 700 ? 13 : 29;
                for (let i = 0; i < density; i++) {
                    const y = rand(0, height);
                    addParticle(edge(y), y);
                }

                ctx.save();
                ctx.globalCompositeOperation = 'lighter';
                particles.forEach(p => {
                    if (p.type === 'smoke' || p.type === 'ash') return;
                    const age = 1 - p.remain / p.life;
                    const fade = Math.min(1, p.remain * 3) * (1 - age * .65);
                    ctx.globalAlpha = fade * (p.type === 'spark' ? .97 : .72);
                    ctx.translate(p.x, p.y);
                    ctx.rotate(p.angle);
                    const xsize = p.size * (p.type === 'fire' ? .8 : 1);
                    const ysize = p.size * (p.type === 'fire' ? 1.7 : 1);
                    ctx.drawImage(SPRITES[p.type], -xsize, -ysize, xsize * 2, ysize * 2);
                    ctx.rotate(-p.angle);
                    ctx.translate(-p.x, -p.y);
                });
                ctx.restore();

                if (progress < 1) {
                    requestAnimationFrame(paint);
                } else {
                    // The iframe is fully visible BEFORE the old document
                    // navigates. No handoff cover, no blue/red solid flash.
                    iframe.style.clipPath = 'inset(0)';
                    canvas.style.opacity = '0';
                    resolve();
                }
            }
            requestAnimationFrame(paint);
        });
    }

    async function begin() {
        if (!warmPromise) createPreviewEarly();
        button.classList.add('kkpt-loading');
        button.querySelector('.kkpt-sub').textContent = 'PREPARING THE OTHER SIDE...';

        // Usually instantaneous: the iframe and both wallpapers began loading
        // when the page itself was opened, not when this button was clicked.
        const ready = await Promise.race([warmPromise, pause(12000).then(() => false)]);
        if (!ready || !loaded) {
            button.classList.remove('kkpt-loading');
            button.querySelector('.kkpt-sub').textContent = '预览未就绪 · 点击普通跳转';
            // Failure path is a normal link, not a fake blue page reveal.
            button.dataset.kkptFallback = '1';
            busy = false;
            return;
        }

        button.classList.remove('kkpt-loading');
        button.classList.add('kkpt-running');
        button.querySelector('.kkpt-sub').textContent = burn ? 'THE WORLD IS BURNING' : 'BREAK THROUGH THE DARK';
        await runAnimation();

        // Let the compositor present the REAL destination iframe for a few
        // frames. On navigation, native cross-document View Transition takes
        // its old snapshot from this already-revealed destination.
        button.classList.add('kkpt-vanish');
        await frame();
        await frame();
        await pause(90);
        window.location.assign(targetUrl);
    }

    function makeButton() {
        if (!document.body || document.getElementById('kkpt-toggle')) return;
        button = document.createElement('a');
        button.id = 'kkpt-toggle';
        button.href = targetUrl;
        button.setAttribute('aria-label', burn ? '燃尽当前世界,进入 Kuronata' : '破晓,返回 Kuranoka');

        const icon = document.createElement('span');
        icon.className = 'kkpt-icon';
        icon.setAttribute('aria-hidden', 'true');
        icon.textContent = burn ? '✦' : '☀';
        const label = document.createElement('span');
        label.className = 'kkpt-text';
        const title = document.createElement('span');
        title.textContent = target.toUpperCase();
        const sub = document.createElement('span');
        sub.className = 'kkpt-sub';
        sub.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        label.append(title, sub);
        button.append(icon, label);
        document.body.append(button);

        // Preload starts right now, not on click.
        createPreviewEarly();

        button.addEventListener('click', event => {
            if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
                event.metaKey || event.shiftKey || event.altKey) return;
            if (button.dataset.kkptFallback === '1') return; // normal navigation
            event.preventDefault();
            if (busy) return;
            busy = true;

            if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
                window.location.assign(targetUrl);
                return;
            }
            begin().catch(err => {
                console.error('[KuranokaPageTransition] V3 error:', err);
                // Unexpected errors go to the real page, never a blank cover.
                window.location.assign(targetUrl);
            });
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', makeButton, { once: true });
    } else {
        makeButton();
    }

    // Back/forward cache can revive a page with the iframe still fully open.
    // Restore the original scene when users return with the Back button.
    window.addEventListener('pageshow', event => {
        if (!event.persisted) return;
        document.getElementById('kkpt-canvas')?.remove();
        if (iframe) iframe.style.clipPath = startClip;
        if (button) {
            button.classList.remove('kkpt-vanish', 'kkpt-running', 'kkpt-loading');
            const subtitle = button.querySelector('.kkpt-sub');
            if (subtitle) subtitle.textContent = burn ? 'BURN THE WORLD' : 'RETURN TO THE SKY';
        }
        busy = false;
    });
}());