MediaWiki:KuranokaPageTransition.js
MediaWiki界面页面
更多操作
注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。
- Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
- Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
- Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/* ===============================================================
Kuranoka Page Transition v2 — REAL CANVAS FIRE
REPLACE MediaWiki:KuranokaPageTransition.js with this entire file.
No global styles. No external JavaScript dependencies.
=============================================================== */
(function () {
'use strict';
if (window.__kkptV2 || window !== window.top) return;
window.__kkptV2 = 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 ? 3450 : 2850;
let busy = false;
function makeButton() {
if (!document.body || document.getElementById('kkpt-toggle')) return;
const 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);
button.addEventListener('click', function (event) {
// 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) {
window.location.assign(targetUrl);
return;
}
begin().catch(function (err) {
console.error('[KuranokaPageTransition] Transition failed:', err);
window.location.assign(targetUrl);
});
});
}
// Use an actual same-origin rendering of the destination as the layer
// that shows through the burnt holes. If framing is blocked, fall back
// to a solid atmospheric color while retaining the fire animation.
function preparePreview() {
return new Promise(function (resolve) {
const iframe = document.createElement('iframe');
iframe.id = 'kkpt-preview';
iframe.className = burn ? 'kkpt-blood' : 'kkpt-sky';
iframe.title = 'Transition preview';
iframe.tabIndex = -1;
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;
const finish = function (success) {
if (completed) return;
completed = true;
clearTimeout(timeout);
if (success) {
try { iframe.contentWindow.scrollTo(0, 0); } catch (e) { /* ignore */ }
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', function () {
let valid = false;
try {
valid = Boolean(iframe.contentDocument &&
iframe.contentDocument.querySelector('#mw-content-text'));
} catch (e) { valid = false; }
finish(valid);
}, { once: true });
iframe.addEventListener('error', function () { finish(false); }, { once: true });
document.body.append(iframe);
iframe.src = targetUrl;
});
}
function createSprite(stops) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 96;
const c = canvas.getContext('2d');
const gradient = c.createRadialGradient(48, 48, 0, 48, 48, 48);
stops.forEach(function (stop) { gradient.addColorStop(stop[0], stop[1]); });
c.fillStyle = gradient;
c.fillRect(0, 0, 96, 96);
return canvas;
}
const SPRITES = {
flame: createSprite([[0, '#fffadc'], [.14, '#ffeb75'], [.43, '#ff7a17'], [.72, '#eb230b88'], [1, '#9a000000']]),
ember: createSprite([[0, '#ffffff'], [.18, '#fff5af'], [.52, '#ff8500aa'], [1, '#ff330000']]),
smoke: createSprite([[0, '#160b0acc'], [.4, '#24171977'], [1, '#11111100']]),
blue: createSprite([[0, '#ffffff'], [.18, '#e6ffff'], [.47, '#75ccffad'], [1, '#55b2ff00']])
};
function runAnimation(preview) {
return new Promise(function (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;
let height = 0;
let ratio = 1;
function size() {
width = window.innerWidth;
height = window.innerHeight;
ratio = Math.min(window.devicePixelRatio || 1, 1.7);
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
size();
const particles = [];
const rand = function (a, b) { return a + Math.random() * (b - a); };
const smooth = function (x) { return x * x * (3 - 2 * x); };
let previous = performance.now();
let start = previous;
let done = false;
function spawn(x, y) {
if (particles.length > 1300) return;
const r = Math.random();
const kind = burn ? (r < .64 ? 'flame' : r < .84 ? 'ember' : 'smoke') : 'blue';
const life = kind === 'smoke' ? rand(.6, 1.4) : kind === 'ember' ? rand(.42, 1.1) : rand(.28, .75);
particles.push({
kind: kind,
x: x + rand(-13, 13),
y: y + rand(-14, 14),
vx: rand(burn ? -125 : -55, burn ? 55 : 85),
vy: rand(-185, -35),
life: life,
remaining: life,
radius: kind === 'smoke' ? rand(32, 85) : kind === 'ember' ? rand(3, 12) : rand(16, 47),
angle: rand(-.4, .4)
});
}
function render(now) {
if (done) return;
const delta = Math.min(.05, Math.max(0, (now - previous) / 1000));
previous = now;
const raw = Math.min(1, (now - start) / DURATION);
const eased = smooth(raw);
const center = burn
? (width + 100) - (width + 200) * eased
: -100 + (width + 200) * eased;
const waviness = Math.sin(Math.PI * raw);
function edgeX(y) {
return center + waviness * (
16 * Math.sin(y * .023 + now * .0055) +
9 * Math.sin(y * .063 - now * .009) +
5 * Math.sin(y * .13 + now * .012)
);
}
const pts = [];
const count = Math.max(28, Math.ceil(height / 25));
for (let i = 0; i <= count; i++) {
const y = height * i / count;
pts.push([edgeX(y), y]);
}
const reverse = pts.slice().reverse().map(function (p) {
return p[0].toFixed(1) + 'px ' + p[1].toFixed(1) + 'px';
}).join(',');
preview.style.clipPath = burn
? 'polygon(100% 0,100% 100%,' + reverse + ')'
: 'polygon(0 0,0 100%,' + reverse + ')';
ctx.clearRect(0, 0, width, height);
// Smoke first, so bright fire and sparks appear in front.
ctx.globalCompositeOperation = 'source-over';
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.remaining -= delta;
if (p.remaining <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * delta;
p.y += p.vy * delta;
p.vy -= 18 * delta;
if (p.kind !== 'smoke') continue;
const opacity = Math.min(1, p.remaining / .35) * .42;
const size = p.radius * (1 + (1 - p.remaining / p.life) * .8);
ctx.globalAlpha = opacity;
ctx.drawImage(SPRITES.smoke, p.x - size, p.y - size, size * 2, size * 2);
}
ctx.globalAlpha = 1;
// The irregular, continuously moving combustion front.
ctx.beginPath();
pts.forEach(function (p, i) {
if (i === 0) ctx.moveTo(p[0], p[1]);
else ctx.lineTo(p[0], p[1]);
});
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowColor = burn ? '#ff3905' : '#b1eaff';
ctx.shadowBlur = burn ? 48 : 55;
ctx.strokeStyle = burn ? 'rgba(255,41,5,.82)' : 'rgba(160,227,255,.72)';
ctx.lineWidth = burn ? 29 : 35;
ctx.stroke();
ctx.shadowBlur = 21;
ctx.strokeStyle = burn ? '#ff9d1c' : '#e5fbff';
ctx.lineWidth = burn ? 9 : 11;
ctx.stroke();
ctx.shadowBlur = 11;
ctx.strokeStyle = '#fff9e2';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.shadowBlur = 0;
// Actual flame tongues curling into the page that is burning.
// Layered Bezier shapes prevent this from looking like a flat wipe.
if (burn) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (let y = -20; y < height + 45; y += 31) {
const x = edgeX(y);
const flicker = Math.sin(now * .014 + y * .19);
const lift = 24 + 29 * (1 + flicker) + 22 * Math.abs(Math.sin(now * .006 - y * .1));
const lean = 15 + 12 * Math.sin(now * .009 + y * .075);
ctx.fillStyle = 'rgba(255,77,5,.39)';
ctx.beginPath();
ctx.moveTo(x + 13, y + 15);
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();
}
// Live flame tongues / sparks, not an animated screenshot.
const amount = Math.max(14, Math.min(34, Math.round(height / 41)));
for (let i = 0; i < amount; i++) {
const y = rand(0, height);
spawn(edgeX(y), y);
}
ctx.globalCompositeOperation = 'lighter';
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (p.kind === 'smoke') continue;
const stage = 1 - p.remaining / p.life;
const fade = Math.min(1, p.remaining * 3) * (1 - stage * .5);
ctx.save();
ctx.globalAlpha = fade * (p.kind === 'ember' ? .85 : .8);
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
const sprite = p.kind === 'blue' ? SPRITES.blue :
p.kind === 'ember' ? SPRITES.ember : SPRITES.flame;
const sx = p.radius * (p.kind === 'flame' ? .9 : 1);
const sy = p.radius * (p.kind === 'flame' ? 1.7 : 1);
ctx.drawImage(sprite, -sx, -sy, sx * 2, sy * 2);
ctx.restore();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
if (raw < 1) {
requestAnimationFrame(render);
} else {
done = true;
preview.style.clipPath = 'none';
setTimeout(resolve, 110);
}
}
requestAnimationFrame(render);
});
}
function handoff() {
// A very short opaque frame hides the last real document navigation.
// The preview is already the actual target URL, not mock Wiki text.
const cover = document.createElement('div');
cover.id = 'kkpt-handoff';
if (!burn) cover.className = 'kkpt-sky';
document.body.append(cover);
requestAnimationFrame(function () {
cover.style.opacity = '1';
setTimeout(function () { window.location.assign(targetUrl); }, 200);
});
}
async function begin() {
const preview = await preparePreview();
await runAnimation(preview);
handoff();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', makeButton, { once: true });
} else {
makeButton();
}
}());