|
|
| 第1行: |
第1行: |
| /* =============================================================== | | /* Kuranoka Page Transition v2 — standalone page-scoped stylesheet. |
| Kuranoka Page Transition v2 — REAL CANVAS FIRE
| | REPLACE MediaWiki:KuranokaPageTransition.css with this file. |
| REPLACE MediaWiki:KuranokaPageTransition.js with this entire file. | | No @view-transition and NO Citizen.css changes required. */ |
| No global styles. No external JavaScript dependencies. | |
| =============================================================== */
| |
| (function () {
| |
| 'use strict';
| |
|
| |
|
| if (window.__kkptV2 || window !== window.top) return; | | #kkpt-toggle { |
| window.__kkptV2 = true; | | --kkpt-accent: #95dfff; |
| if (!window.mw || !mw.config) return; | | position: fixed; |
| if (mw.config.get('wgNamespaceNumber') !== 0 || mw.config.get('wgAction') !== 'view') return; | | right: clamp(14px, 2.5vw, 32px); |
| | bottom: clamp(16px, 3vh, 34px); |
| | z-index: 2147481000; |
| | display: flex; |
| | align-items: center; |
| | gap: 11px; |
| | max-width: calc(100vw - 32px); |
| | padding: 12px 17px; |
| | border: 1px solid var(--kkpt-accent); |
| | border-radius: 999px; |
| | background: rgba(9, 27, 46, .84); |
| | color: #ecfaff !important; |
| | font: 700 13px/1.3 system-ui, sans-serif; |
| | letter-spacing: .04em; |
| | text-decoration: none !important; |
| | box-shadow: 0 8px 28px #071b3055, 0 0 20px #5cc8ff33; |
| | -webkit-backdrop-filter: blur(14px); |
| | backdrop-filter: blur(14px); |
| | transition: transform .2s, box-shadow .2s, opacity .2s; |
| | } |
| | body.page-Kuronata #kkpt-toggle { |
| | --kkpt-accent: #ff667c; |
| | background: rgba(55, 6, 17, .89); |
| | color: #fff4f5 !important; |
| | box-shadow: 0 8px 28px #21030877, 0 0 24px #e41f4438; |
| | } |
| | #kkpt-toggle:hover, #kkpt-toggle:focus-visible { |
| | transform: translateY(-3px); |
| | box-shadow: 0 8px 35px #0006, 0 0 27px var(--kkpt-accent); |
| | } |
| | #kkpt-toggle:focus-visible { outline: 2px solid var(--kkpt-accent); outline-offset: 4px; } |
| | #kkpt-toggle.kkpt-loading { opacity: .7; pointer-events: none; } |
| | #kkpt-toggle .kkpt-icon { color: var(--kkpt-accent); font-size: 21px; } |
| | #kkpt-toggle .kkpt-text { display: flex; flex-direction: column; gap: 3px; } |
| | #kkpt-toggle .kkpt-sub { font-size: 10px; font-weight: 500; opacity: .72; } |
|
| |
|
| const page = mw.config.get('wgPageName');
| | /* The target Wiki page is visible through an animated polygon mask. */ |
| if (page !== 'Kuranoka' && page !== 'Kuronata') return; | | #kkpt-preview { |
| | position: fixed !important; |
| | inset: 0 !important; |
| | width: 100vw !important; |
| | height: 100vh !important; |
| | margin: 0 !important; |
| | padding: 0 !important; |
| | border: 0 !important; |
| | z-index: 2147481100 !important; |
| | pointer-events: none !important; |
| | display: block !important; |
| | background: #3b0613; |
| | will-change: clip-path; |
| | contain: paint; |
| | } |
| | #kkpt-preview.kkpt-sky { background: #bce9ff; } |
|
| |
|
| const burn = page === 'Kuranoka'; | | #kkpt-canvas { |
| const target = burn ? 'Kuronata' : 'Kuranoka'; | | position: fixed !important; |
| const targetUrl = '/wiki/' + encodeURIComponent(target); | | inset: 0 !important; |
| const DURATION = burn ? 3450 : 2850; | | width: 100vw !important; |
| let busy = false; | | height: 100vh !important; |
| | z-index: 2147481200 !important; |
| | pointer-events: none !important; |
| | } |
|
| |
|
| function makeButton() { | | #kkpt-handoff { |
| if (!document.body || document.getElementById('kkpt-toggle')) return;
| | position: fixed !important; |
| const button = document.createElement('a');
| | inset: 0 !important; |
| button.id = 'kkpt-toggle';
| | z-index: 2147481300 !important; |
| button.href = targetUrl;
| | pointer-events: all !important; |
| button.setAttribute('aria-label', burn ? '焚毁当前世界,切换到 Kuronata' : '破晓,返回 Kuranoka');
| | opacity: 0; |
| | transition: opacity .16s linear; |
| | background: #39050f; |
| | } |
| | #kkpt-handoff.kkpt-sky { |
| | background: #e5f7ff; |
| | } |
|
| |
|
| const icon = document.createElement('span');
| | @media (max-width: 600px) { |
| icon.className = 'kkpt-icon';
| | #kkpt-toggle { padding: 10px 13px; gap: 8px; } |
| icon.setAttribute('aria-hidden', 'true');
| | #kkpt-toggle .kkpt-sub { display: none; } |
| icon.textContent = burn ? '✦' : '☀';
| | } |
| | | @media print { |
| const label = document.createElement('span');
| | #kkpt-toggle, #kkpt-preview, #kkpt-canvas, #kkpt-handoff { display: none !important; } |
| 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();
| |
| }
| |
| }());
| |