MediaWiki:KuranokaPageTransition.js:修订间差异
MediaWiki界面页面
更多操作
创建页面,内容为“→================================================================ KURANOKA / KURONATA — PAGE TRANSITION 1.0 Paste this file to MediaWiki:PageTransition.js. Loaded by MediaWiki:Citizen.js (see installation guide). ================================================================: (function () { 'use strict'; if (window.__kkptLoaded) return; window.__kkptLoaded = true; if (!window.mw || !mw.config) return; if (mw.config.get(…” |
无编辑摘要 |
||
| (未显示同一用户的5个中间版本) | |||
| 第1行: | 第1行: | ||
/* ================================================================ | /* ==================================================================== | ||
KURANOKA / KURONATA — | KURANOKA / KURONATA V6 — CATACLYSM / DAWN | ||
MediaWiki:KuranokaPageTransition.js | |||
Same-origin iframe + history.pushState: NO full reload on toggles. | |||
================================================================ */ | Irregular tear + WebGL combustion + layered turbulent smoke/impact. | ||
Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]]. | |||
Backgrounds are prewarmed on page load. Zero external dependencies. | |||
==================================================================== */ | |||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
if (window. | // The preview is a complete MediaWiki page. It must NOT spawn another. | ||
window. | if (window !== window.top || window.__kkptV6) return; | ||
if (!window.mw || !mw.config) return; | |||
// The URL is the source of truth if a skin/extension restored a page | |||
// without immediately updating mw.config (soft navigation / bfcache). | |||
const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/); | |||
const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title'); | |||
const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage) | |||
? urlPage : mw.config.get('wgPageName'); | |||
const action = new URLSearchParams(location.search).get('action') || 'view'; | |||
if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return; | |||
window.__kkptV6 = true; | |||
window.__kkptVersion = '6.0'; | |||
// For the easter egg: links inside the Wiki are the default trigger. | |||
// Change to true only if you also want the fixed switch button. | |||
const SHOW_FLOATING_BUTTON = false; | |||
const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka'; | |||
const baseUrl = location.href; | |||
const baseTitle = document.title; | |||
const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href); | |||
const clipSide = basePage === 'Kuranoka' ? 'right' : 'left'; | |||
const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)'; | |||
const sleep = ms => new Promise(r => setTimeout(r, ms)); | |||
const smooth = t => t * t * (3 - 2 * t); | |||
const imagePool = new Map(); | |||
let active = false; // false: original top-level doc; true: otherPage iframe | |||
let animating = false; | |||
let ready = false; | |||
let loading = null; | |||
let button, labelTitle, labelSub, labelIcon, frame; | |||
let otherTitle = otherPage + ' - Blockland Wiki'; | |||
function whichPage() { return active ? otherPage : basePage; } | |||
function destination() { return active ? basePage : otherPage; } | |||
function isDark(page) { return page === 'Kuronata'; } | |||
function pathPage() { | |||
const url = new URL(location.href); | |||
if (url.pathname.startsWith('/wiki/')) { | |||
try { return decodeURIComponent(url.pathname.slice(6)).replace(/_/g, ' '); } | |||
catch (e) { return ''; } | |||
} | |||
return url.searchParams.get('title') || ''; | |||
} | |||
function updateButton() { | |||
if (!button) return; | |||
const to = destination(); | |||
button.href = to === basePage ? baseUrl : otherUrl.href; | |||
labelTitle.textContent = to.toUpperCase(); | |||
labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY'; | |||
labelIcon.textContent = isDark(to) ? '✦' : '❄'; | |||
button.classList.toggle('kkpt-dark', isDark(whichPage())); | |||
button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka'); | |||
} | |||
// Accept the two standard MediaWiki link forms, while rejecting | |||
// edit/action links, external sites and similarly named articles. | |||
function wikiLinkPage(anchor, doc) { | |||
try { | |||
const url = new URL(anchor.href, doc.baseURI); | |||
if (url.origin !== location.origin) return null; | |||
const action = url.searchParams.get('action'); | |||
if (action && action !== 'view') return null; | |||
let title = url.searchParams.get('title'); | |||
if (!title) { | |||
const matched = url.pathname.match(/^\/wiki\/([^/?#]+)\/?$/); | |||
if (matched) title = decodeURIComponent(matched[1]); | |||
} | |||
title = (title || '').replace(/_/g, ' '); | |||
return title === 'Kuranoka' || title === 'Kuronata' ? title : null; | |||
} catch (_) { | |||
return null; | |||
} | |||
} | |||
function decoratePortalLinks(doc, targetPage) { | |||
// Use only the article: sidebar, edit tools and site-wide links | |||
// must retain their usual navigation behavior. | |||
const content = doc.querySelector('#mw-content-text,.mw-parser-output'); | |||
if (!content) return; | |||
content.querySelectorAll('a[href]').forEach(anchor => { | |||
if (wikiLinkPage(anchor, doc) !== targetPage) return; | |||
anchor.classList.add('kkpt-portal-link'); | |||
anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata'); | |||
anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka'); | |||
const caption = (anchor.textContent || '').trim(); | |||
if (caption) anchor.dataset.kkptText = caption.slice(0, 120); | |||
anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage); | |||
}); | |||
} | |||
function watchPortalLinks(doc, targetPage) { | |||
decoratePortalLinks(doc, targetPage); | |||
if (doc.__kkptPortalObserver) return; | |||
const root = doc.querySelector('#mw-content-text,.mw-parser-output'); | |||
if (!root) return; | |||
const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage)); | |||
observer.observe(root, {childList:true,subtree:true}); | |||
doc.__kkptPortalObserver = observer; | |||
} | |||
function isPlainClick(event) { | |||
return !event.defaultPrevented && event.button === 0 && | |||
!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; | |||
} | |||
function findAnchor(event, doc) { | |||
const target = event.target; | |||
return target && target.nodeType === 1 && | |||
typeof target.closest === 'function' | |||
? target.closest('a[href]') : null; | |||
} | |||
function portalClick(event, doc, targetPage, nextActive) { | |||
if (!isPlainClick(event)) return false; | |||
const anchor = findAnchor(event, doc); | |||
if (!anchor || (anchor.target && anchor.target !== '_self') || | |||
anchor.hasAttribute('download') || | |||
wikiLinkPage(anchor, doc) !== targetPage) return false; | |||
event.preventDefault(); | |||
requestSwap(nextActive); | |||
return true; | |||
} | |||
// Read backgrounds after background-template code populates --bg-image. | |||
function backgroundUrls(doc) { | |||
const urls = new Set(); | |||
const selector = [doc.documentElement, doc.body]; | |||
try { | |||
doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container') | |||
.forEach((el, i) => { if (i < 65) selector.push(el); }); | |||
} catch (e) { /* use html/body */ } | |||
function add(value) { | |||
if (!value) return; | |||
const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi; | |||
let match; | |||
while ((match = pattern.exec(value))) { | |||
try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ } | |||
} | |||
if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) { | |||
try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ } | |||
} | |||
} | |||
selector.forEach(el => { | |||
if (!el) return; | |||
try { | |||
[null, '::before', '::after'].forEach(pseudo => { | |||
const style = doc.defaultView.getComputedStyle(el, pseudo); | |||
if (!style) return; | |||
add(style.backgroundImage); | |||
add(style.getPropertyValue('--bg-image')); | |||
add(style.getPropertyValue('--background-image')); | |||
}); | |||
if (el.dataset) add(el.dataset.bgImage); | |||
} catch (e) { /* style not yet ready */ } | |||
}); | |||
return [...urls]; | |||
} | |||
function warmImage(url) { | |||
if (imagePool.has(url)) return imagePool.get(url).promise; | |||
const image = new Image(); | |||
const promise = new Promise(resolve => { | |||
let finished = false; | |||
const timeout = setTimeout(() => done(false), 5000); | |||
function done(ok) { | |||
if (finished) return; | |||
finished = true; | |||
clearTimeout(timeout); | |||
resolve(ok); | |||
} | |||
image.onload = () => done(true); | |||
image.onerror = () => done(false); | |||
image.src = url; | |||
if (image.complete) done(image.naturalWidth > 0); | |||
}).then(ok => ok && image.decode | |||
? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)]) | |||
: ok); | |||
imagePool.set(url, { image, promise }); | |||
return promise; | |||
} | |||
async function warmBackgrounds(doc) { | |||
const urls = backgroundUrls(doc); | |||
await Promise.allSettled(urls.map(warmImage)); | |||
} | |||
function wireFrame() { | |||
try { | |||
const doc = frame.contentDocument; | |||
if (!doc) return; | |||
otherTitle = doc.title || otherTitle; | |||
watchPortalLinks(doc, basePage); | |||
if (doc.__kkptFrameBridge) return; | |||
doc.__kkptFrameBridge = true; | |||
// Non-anchor navigation should leave the iframe and open normally | |||
// in the top window, so the browser's URL doesn't become stale. | |||
doc.addEventListener('click', event => { | |||
if (!active || !isPlainClick(event)) return; | |||
if (portalClick(event, doc, basePage, false)) return; | |||
const el = findAnchor(event, doc); | |||
if (!el || (el.target && el.target !== '_self')) return; | |||
const href = el.href; | |||
if (!href || href.startsWith('javascript:')) return; | |||
const next = new URL(href, doc.baseURI); | |||
// Same-document fragments stay inside the iframe. | |||
const currentFrame = new URL(frame.contentWindow.location.href); | |||
if (next.origin === currentFrame.origin && | |||
next.pathname === currentFrame.pathname && | |||
next.search === currentFrame.search && next.hash) { | |||
history.replaceState(history.state, '', next.href); | |||
return; | |||
} | |||
event.preventDefault(); | |||
window.location.assign(next.href); | |||
}, true); | |||
doc.addEventListener('submit', event => { | |||
if (active && event.target instanceof doc.defaultView.HTMLFormElement) { | |||
event.target.target = '_top'; | |||
} | |||
}, true); | |||
} catch (err) { | |||
console.warn('[KuranokaPageTransition] Frame link bridge unavailable:', err); | |||
} | |||
} | |||
function warmFrame() { | |||
if (loading) return loading; | |||
frame = document.createElement('iframe'); | |||
frame.id = 'kkpt-preview'; | |||
frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop | |||
frame.title = otherPage + ' 预载页面'; | |||
frame.tabIndex = -1; | |||
frame.setAttribute('aria-hidden', 'true'); | |||
frame.setAttribute('loading', 'eager'); | |||
frame.style.clipPath = hideClip; | |||
loading = new Promise(resolve => { | |||
let settled = false; | |||
let preparing = false; | |||
let poll = null; | |||
function finish(ok, reason) { | |||
if (settled) return; | |||
settled = true; | |||
clearInterval(poll); | |||
ready = ok; | |||
if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason); | |||
resolve(ok); | |||
} | |||
async function inspect() { | |||
if (settled || preparing) return; | |||
try { | |||
const doc = frame.contentDocument; | |||
if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return; | |||
preparing = true; | |||
clearInterval(poll); | |||
await sleep(320); // Background.js / wiki style scripts | |||
await Promise.race([warmBackgrounds(doc), sleep(3600)]); | |||
await sleep(120); | |||
await Promise.race([warmBackgrounds(doc), sleep(1300)]); | |||
frame.contentWindow.scrollTo(0, 0); | |||
wireFrame(); | |||
finish(true); | |||
} catch (err) { finish(false, String(err)); } | |||
} | |||
frame.addEventListener('load', () => { | |||
if (!settled && !preparing) inspect(); | |||
}); | |||
frame.addEventListener('error', () => finish(false,'iframe 网络加载错误')); | |||
document.body.append(frame); | |||
poll = setInterval(inspect, 130); | |||
setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000); | |||
}); | |||
warmBackgrounds(document).catch(() => {}); | |||
setTimeout(() => warmBackgrounds(document).catch(() => {}), 700); | |||
return loading; | |||
} | |||
// This edge is used by BOTH the DOM clip and the shader. | |||
function edgeAt(y, seconds, progress, width) { | |||
const eased = smooth(progress); | |||
const toDark = window.__kkptVisualDark; | |||
const center = toDark | |||
? width + 110 - (width + 220) * eased | |||
: -110 + (width + 220) * eased; | |||
const wave = Math.sin(Math.PI * progress); | |||
return center + wave * ( | |||
28 * Math.sin(y * .012 + seconds * 3.1) + | |||
17 * Math.sin(y * .041 - seconds * 5.7) + | |||
7 * Math.sin(y * .10 + seconds * 10.0) + | |||
11 * Math.sin(y * .073 + seconds * 6.13 + 2 * Math.sin(y * .017 - seconds * 2.3)) | |||
); | |||
} | |||
function clipFrame(seconds, progress) { | |||
const width = innerWidth, height = innerHeight; | |||
const pts = []; | |||
const count = Math.max(35, Math.ceil(height / 11)); | |||
for (let i = 0; i <= count; i++) { | |||
const y = height * i / count; | |||
pts.push([edgeAt(y, seconds, progress, width), y]); | |||
} | |||
const supportsPath = CSS.supports('clip-path', 'path("M 0 0 L 8 0 L 8 8 Z")'); | |||
if (supportsPath) { | |||
const startX = clipSide === 'right' ? width + 4 : -4; | |||
let d = `M ${startX} -4 L ${startX} ${height + 4} L ${pts[count][0].toFixed(1)} ${height}`; | |||
// Quadratic smoothing eliminates the geometric triangular teeth. | |||
for (let i = count - 1; i >= 0; i--) { | |||
const point = pts[i]; | |||
const previous = pts[Math.max(0, i - 1)]; | |||
const mx = (point[0] + previous[0]) * .5; | |||
const my = (point[1] + previous[1]) * .5; | |||
d += ` Q ${point[0].toFixed(1)} ${point[1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`; | |||
} | |||
d += ` L ${pts[0][0].toFixed(1)} 0 Z`; | |||
frame.style.clipPath = `path("${d}")`; | |||
} else { | |||
const points = pts.slice().reverse().map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(','); | |||
frame.style.clipPath = clipSide === 'right' | |||
? `polygon(100% 0,100% 100%,${points})` | |||
: `polygon(0 0,0 100%,${points})`; | |||
} | |||
} | |||
// Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies. | |||
const VERTEX = ` | |||
attribute vec2 aPosition; | |||
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); } | |||
`; | |||
const FRAGMENT = ` | |||
precision highp float; | |||
uniform vec2 uSize; | |||
uniform vec2 uBuffer; | |||
uniform float uTime; | |||
uniform float uProgress; | |||
uniform float uDark; | |||
float random2(vec2 p) { | |||
return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); | |||
} | |||
float noise(vec2 p) { | |||
vec2 i = floor(p), f = fract(p); | |||
vec2 u = f*f*(3.0-2.0*f); | |||
return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x), | |||
mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y); | |||
} | |||
float fbm(vec2 p) { | |||
float result = 0.0, amp = 0.5; | |||
for (int i = 0; i < 5; i++) { | |||
result += amp * noise(p); | |||
p = mat2(1.6,-1.2,1.2,1.6) * p; | |||
amp *= 0.5; | |||
} | |||
return result; | |||
} | |||
float smoothProgress(float t) { return t*t*(3.0-2.0*t); } | |||
float edge(float y) { | |||
float w = uSize.x; | |||
float t = smoothProgress(uProgress); | |||
float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t; | |||
float wave = sin(3.14159265*uProgress); | |||
return c+wave*(28.0*sin(y*.012+uTime*3.1)+ | |||
17.0*sin(y*.041-uTime*5.7)+7.0*sin(y*.10+uTime*10.0)+ | |||
11.0*sin(y*.073+uTime*6.13+2.0*sin(y*.017-uTime*2.3))); | |||
} | |||
void main() { | |||
vec2 p = vec2(gl_FragCoord.x*uSize.x/uBuffer.x, | |||
uSize.y-gl_FragCoord.y*uSize.y/uBuffer.y); | |||
float d = p.x-edge(p.y); | |||
float oldSide = uDark > .5 ? -d : d; | |||
float t = uTime; | |||
float impact = sin(3.14159265*uProgress); | |||
vec3 col = vec3(0.0); | |||
float alpha = 0.0; | |||
if (uDark > .5) { | |||
// Deep smoke blankets a 350px belt BEHIND the incandescent | |||
// edge. Multiple warped noise scales break up smooth bands. | |||
vec2 sq = vec2(p.y*.007-t*.29,oldSide*.006+t*.12); | |||
vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)), | |||
fbm(sq*1.9+vec2(0.0,-t*.21))); | |||
float smokeCloud = fbm(sq*2.0+warp*2.2); | |||
float smokeDetail = fbm(sq*5.1-warp*.8); | |||
float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34); | |||
// A thick, drifting smoke WALL. The lighter old world | |||
// goes visibly dark before the red page consumes it. | |||
float soot = smoothstep(-110.0,-5.0,oldSide)* | |||
(1.0-smoothstep(260.0,555.0,oldSide))* | |||
(.55+.58*billow); | |||
vec3 sootColor = mix(vec3(.011,.012,.022),vec3(.18,.055,.041),smokeCloud); | |||
// High-frequency fBm produces uneven, flame-like tongues, | |||
// with a broad ember-red zone instead of a white rope. | |||
vec2 q = vec2(p.y*.014-t*.60,oldSide*.015-t*.43); | |||
float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25)); | |||
float shredded = fbm(q*3.6+vec2(t*.24,-t*.34)); | |||
float lengthOfTongue = 55.0+215.0*turbulence+65.0*shredded; | |||
float raggedEdge = 1.0-smoothstep(lengthOfTongue-62.0,lengthOfTongue+15.0,oldSide); | |||
float flame = raggedEdge*smoothstep(-75.0,7.0,oldSide)* | |||
smoothstep(.21,.68,turbulence+.23*shredded); | |||
float deepEmber = exp(-max(0.0,oldSide)/86.0); | |||
float core = exp(-abs(d)/12.0); | |||
float outer = exp(-abs(d)/78.0); | |||
float whiteCore = core*max(.2,shredded); | |||
float heat = clamp(flame*.83+core*.69,0.0,1.0); | |||
vec3 fireColor = mix(vec3(.30,.011,.009),vec3(1.0,.105,.016),smoothstep(.10,.57,heat)); | |||
fireColor = mix(fireColor,vec3(1.0,.47,.048),smoothstep(.43,.85,heat)); | |||
fireColor = mix(fireColor,vec3(1.0,.93,.63),smoothstep(.80,1.0,whiteCore)); | |||
col = mix(sootColor,fireColor,clamp(flame*.95+core+deepEmber*.20,0.0,1.0)); | |||
alpha = clamp(soot*.93+flame*.92+outer*.32+core*.68,0.0,.99); | |||
// Sharp micron-scale cinders; the real ember trails live | |||
// on the separate high-resolution atmosphere canvas. | |||
vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0); | |||
vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5; | |||
float sparks = (1.0-smoothstep(.015,.12,length(uv)))* | |||
step(.991,random2(cell))*exp(-abs(d)/240.0); | |||
col = mix(col,vec3(1.0,.78,.23),sparks); | |||
alpha = max(alpha,sparks*.97); | |||
} else { | |||
// Returning to blue is ELECTRIC AURORA / COLD MIST, NEVER FIRE. | |||
vec2 q = vec2(p.y*.009-t*.25,oldSide*.013+t*.18); | |||
float cloud = fbm(q+vec2(fbm(q*1.7),-t*.25)); | |||
float finer = fbm(q*3.1+vec2(-t*.15,t*.19)); | |||
float mist = exp(-abs(d)/208.0)*(cloud*.7+finer*.18+.23); | |||
float ion = exp(-abs(d)/37.0); | |||
float arcWobble = 25.0*sin(p.y*.043-t*6.0)+ | |||
10.0*sin(p.y*.14+t*13.0)+5.0*sin(p.y*.37-t*19.0); | |||
float arc = exp(-abs(d+arcWobble)/2.7)* | |||
(.7+.3*sin(p.y*.065+t*7.0)); | |||
float branches = exp(-abs(d+52.0*sin(p.y*.018+t*2.4))/1.9)* | |||
smoothstep(.63,.92,noise(vec2(floor(p.y/54.0),floor(t*9.0)))); | |||
col = mix(vec3(.008,.07,.19),vec3(.15,.52,.96),cloud+.27); | |||
col += vec3(.23,.45,.60)*ion; | |||
col = mix(col,vec3(.90,1.0,1.0),clamp(arc*.93+branches*.55,0.0,1.0)); | |||
alpha = clamp(mist*.85+ion*.43+arc*.97+branches*.65,0.0,.91); | |||
} | |||
// No long-lasting screen tint after the burn has finished. | |||
alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) * | |||
(1.0-smoothstep(.94,1.0,uProgress)); | |||
gl_FragColor = vec4(col,alpha); | |||
} | |||
`; | |||
function createRenderer(canvas) { | |||
const gl = canvas.getContext('webgl', { | |||
alpha: true, antialias: false, depth: false, stencil: false, | |||
preserveDrawingBuffer: false, premultipliedAlpha: false | |||
}); | |||
if (!gl) return null; | |||
function compile(type, source) { | |||
const shader = gl.createShader(type); | |||
gl.shaderSource(shader, source); | |||
gl.compileShader(shader); | |||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |||
console.error('[KKPT WebGL] Shader:', gl.getShaderInfoLog(shader)); | |||
return null; | |||
} | |||
return shader; | |||
} | |||
const vs = compile(gl.VERTEX_SHADER, VERTEX); | |||
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT); | |||
if (!vs || !fs) return null; | |||
const program = gl.createProgram(); | |||
gl.attachShader(program, vs); | |||
gl.attachShader(program, fs); | |||
gl.linkProgram(program); | |||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { | |||
console.error('[KKPT WebGL] Program:', gl.getProgramInfoLog(program)); | |||
return null; | |||
} | |||
gl.useProgram(program); | |||
const buffer = gl.createBuffer(); | |||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); | |||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW); | |||
const pos = gl.getAttribLocation(program,'aPosition'); | |||
gl.enableVertexAttribArray(pos); | |||
gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0); | |||
const sizeUniform = gl.getUniformLocation(program,'uSize'); | |||
const bufferUniform = gl.getUniformLocation(program,'uBuffer'); | |||
const timeUniform = gl.getUniformLocation(program,'uTime'); | |||
const progressUniform = gl.getUniformLocation(program,'uProgress'); | |||
const modeUniform = gl.getUniformLocation(program,'uDark'); | |||
// This is a single fullscreen pass to a transparent canvas. | |||
// Blending here would multiply alpha twice and wash out the fire. | |||
gl.disable(gl.BLEND); | |||
return function draw(seconds,progress,dark) { | |||
const w=innerWidth,h=innerHeight; | |||
// Keep a stable GPU budget on high-DPI/mobile displays. | |||
const scale = w < 700 ? .68 : .84; | |||
const bw=Math.round(w*scale),bh=Math.round(h*scale); | |||
if (canvas.width!==bw || canvas.height!==bh) { | |||
canvas.width=bw;canvas.height=bh;gl.viewport(0,0,bw,bh); | |||
} | |||
gl.clearColor(0,0,0,0); | |||
gl.clear(gl.COLOR_BUFFER_BIT); | |||
gl.uniform2f(sizeUniform,w,h); | |||
gl.uniform2f(bufferUniform,bw,bh); | |||
gl.uniform1f(timeUniform,seconds); | |||
gl.uniform1f(progressUniform,progress); | |||
gl.uniform1f(modeUniform,dark?1:0); | |||
gl.drawArrays(gl.TRIANGLES,0,6); | |||
}; | |||
} | |||
if (! | function createFallback(canvas) { | ||
const ctx = canvas.getContext('2d'); | |||
return function draw(seconds,progress,dark) { | |||
const w=innerWidth,h=innerHeight; | |||
if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;} | |||
ctx.clearRect(0,0,w,h); | |||
const x = edgeAt(h/2,seconds,progress,w); | |||
const gradient = ctx.createLinearGradient(x-115,0,x+115,0); | |||
if (dark) { | |||
gradient.addColorStop(0,'#180d0d00'); | |||
gradient.addColorStop(.40,'#67180677'); | |||
gradient.addColorStop(.50,'#ffe186cc'); | |||
gradient.addColorStop(.62,'#ef3f0955'); | |||
gradient.addColorStop(1,'#180d0d00'); | |||
} else { | |||
gradient.addColorStop(0,'#5abcf600'); | |||
gradient.addColorStop(.46,'#7cdbff77'); | |||
gradient.addColorStop(.5,'#ffffffbb'); | |||
gradient.addColorStop(.56,'#63c9ff77'); | |||
gradient.addColorStop(1,'#5abcf600'); | |||
} | |||
ctx.fillStyle=gradient;ctx.fillRect(x-115,0,230,h); | |||
}; | |||
} | |||
// A second canvas adds real, textured soot / rolling vapor and violent | |||
// short-lived pressure waves. It is deliberately independent of WebGL, | |||
// so even browsers with GPU disabled still get the smoke and impact. | |||
function createAtmosphere(canvas) { | |||
const ctx = canvas.getContext('2d', {alpha:true}); | |||
const rand = n => { | |||
const x = Math.sin(n*127.1+311.7)*43758.5453123; | |||
return x-Math.floor(x); | |||
}; | |||
const clamp01 = n => Math.max(0,Math.min(1,n)); | |||
function valueNoise(x,y,seed) { | |||
const ix=Math.floor(x), iy=Math.floor(y); | |||
let fx=x-ix,fy=y-iy; | |||
fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy); | |||
const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1); | |||
const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx; | |||
const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx; | |||
return a*(1-fy)+b*fy; | |||
} | |||
function smokeTexture(seed,dark) { | |||
const tex=document.createElement('canvas'); | |||
tex.width=tex.height=112; | |||
const g=tex.getContext('2d'); | |||
const data=g.createImageData(112,112); | |||
for(let y=0;y<112;y++) for(let x=0;x<112;x++){ | |||
const u=(x-56)/56,v=(y-56)/56; | |||
const curl=valueNoise(u*2.9+11,v*2.9+1,seed); | |||
const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7); | |||
const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5); | |||
const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13); | |||
const envelope=clamp01((1.05-radius)/.56); | |||
const low=valueNoise(u*4.1+4,v*4.1-9,seed+2); | |||
const high=valueNoise(u*11.4-3,v*11.4+6,seed+5); | |||
const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9); | |||
const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)* | |||
envelope*envelope; | |||
const i=(y*112+x)*4; | |||
const variation=low*28+high*15; | |||
if(dark){ | |||
data.data[i]=18+variation; | |||
data.data[i+1]=15+variation*.58; | |||
data.data[i+2]=19+variation*.48; | |||
}else{ | |||
data.data[i]=44+variation; | |||
data.data[i+1]=91+variation; | |||
data.data[i+2]=125+variation; | |||
} | |||
data.data[i+3]=Math.round(240*density); | |||
} | |||
g.putImageData(data,0,0); | |||
return tex; | |||
} | |||
const sootSprites=[0,1,2,3].map(n=>smokeTexture(n+17,true)); | |||
const mistSprites=[0,1,2,3].map(n=>smokeTexture(n+51,false)); | |||
const particles=Array.from({length:innerWidth<700?72:124},(_,i)=>({ | |||
seed:rand(i*4+71),height:rand(i*5+13), | |||
lifetime:.5+rand(i*8+37)*.7, | |||
phase:rand(i*9+21),sprite:i%4, | |||
scale:.72+rand(i*7+9)*1.25, | |||
bend:rand(i*15+23)*6.28 | |||
})); | |||
const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({ | |||
y:rand(i*3+17),phase:rand(i*9+43), | |||
speed:85+rand(i*12+5)*260, | |||
drift:75+rand(i*15+2)*170, | |||
length:3+rand(i*17+7)*11 | |||
})); | |||
const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({ | |||
angle:rand(i*17+131)*Math.PI*2, | |||
velocity:190+rand(i*11+51)*900, | |||
seed:rand(i*5+79), | |||
length:6+rand(i*7+17)*26 | |||
})); | |||
function pressureWave(x,y,radius,power,dark,time) { | |||
if(power<=.005)return; | |||
const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75); | |||
if(dark){ | |||
core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')'); | |||
core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')'); | |||
core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')'); | |||
core.addColorStop(1,'rgba(30,0,0,0)'); | |||
}else{ | |||
core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')'); | |||
core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')'); | |||
core.addColorStop(1,'rgba(0,29,100,0)'); | |||
} | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.fillStyle=core; | |||
ctx.fillRect(x-radius,y-radius,radius*2,radius*2); | |||
ctx.globalCompositeOperation='source-over'; | |||
} | |||
return function drawAtmosphere(seconds,progress,dark,elapsed) { | |||
const w=innerWidth,h=innerHeight; | |||
const scale=w<700?.67:.79; | |||
const bw=Math.max(1,Math.round(w*scale)); | |||
const bh=Math.max(1,Math.round(h*scale)); | |||
if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;} | |||
ctx.setTransform(scale,0,0,scale,0,0); | |||
ctx.clearRect(0,0,w,h); | |||
const envelope=Math.sin(Math.PI*progress); | |||
const fade=clamp01(progress/.075)*clamp01((1-progress)/.065); | |||
const sprites=dark?sootSprites:mistSprites; | |||
const direction=dark?-1:1; | |||
// Continuous textured smoke wall: not isolated oval puffs. | |||
// The base layer makes the plume visible on a pale blue sky. | |||
ctx.globalCompositeOperation='source-over'; | |||
const bands=Math.max(10,Math.ceil(h/(dark?49:73))); | |||
for(let i=0;i<bands;i++){ | |||
const baseY=(i+.5)*h/bands; | |||
const y=baseY+24*Math.sin(seconds*2.5+i*.7); | |||
const x=edgeAt(y,seconds,progress,w)+direction* | |||
(dark?118:72)+Math.sin(seconds*1.2+i*1.3)*36; | |||
const width=(dark?400:280)*(1+.19*Math.sin(i*2.9)); | |||
const height=(dark?315:260); | |||
ctx.save(); | |||
ctx.globalAlpha=(dark?.62:.36)*fade*(.65+.35*envelope); | |||
ctx.translate(x,y); | |||
ctx.rotate(.16*Math.sin(i*2.1+seconds*1.5)); | |||
ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height); | |||
ctx.restore(); | |||
} | |||
// Independent turbulent billows peel away and rise. | |||
for(const item of particles){ | |||
const age=((elapsed/item.lifetime+item.phase)%1); | |||
const baseY=item.height*h; | |||
const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15); | |||
if(y< -230||y>h+230)continue; | |||
const frontier=edgeAt(baseY,seconds,progress,w); | |||
const x=frontier+direction*(age*(dark?225:135)+ | |||
30*Math.sin(age*6.3+item.bend)); | |||
const size=(dark?175:145)*item.scale*(.68+age*1.9); | |||
const opacity=(dark?.85:.42)*Math.sin(Math.PI*age)*fade* | |||
(.55+.45*envelope); | |||
if(opacity<=.005)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(x,y); | |||
ctx.rotate((item.bend-3.14)*.14+age*.85*direction); | |||
ctx.scale(1.18,.84); | |||
ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size); | |||
ctx.restore(); | |||
} | |||
// The impact happens at ignition and again as the rupture reaches | |||
// the middle of the screen; pulses are intentionally fleeting. | |||
for(const moment of (dark?[.10,.48]:[.18,.58])){ | |||
const age=(progress-moment)/.125; | |||
if(age<0||age>1)continue; | |||
const intensity=(1-age)*(1-age)*fade; | |||
// A single-frame-ish exposure bloom punches through | |||
// the entire page; the tone is red or icy blue by direction. | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.fillStyle=dark | |||
? 'rgba(255,81,26,'+(.095*intensity)+')' | |||
: 'rgba(150,219,255,'+(.080*intensity)+')'; | |||
ctx.fillRect(0,0,w,h); | |||
ctx.globalCompositeOperation='source-over'; | |||
const x=edgeAt(h*.51,seconds,progress,w); | |||
const y=h*.51; | |||
const radius=95+age*Math.max(w,h)*.9; | |||
pressureWave(x,y,radius,intensity,dark,seconds); | |||
ctx.globalAlpha=(dark?.20:.28)*intensity; | |||
ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age); | |||
ctx.strokeStyle=dark?'#ffb36c':'#b5eaff'; | |||
ctx.beginPath(); | |||
for(let i=0;i<=110;i++){ | |||
const a=i/110*Math.PI*2; | |||
const jag=1+.03*Math.sin(a*29+seconds*12)+ | |||
.05*Math.sin(a*13-seconds*5); | |||
const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag; | |||
if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry); | |||
} | |||
ctx.stroke(); | |||
ctx.globalAlpha=1; | |||
// Fragment trails explode outward from the rupture point. | |||
// They follow independent directions and speeds; no circles. | |||
ctx.globalCompositeOperation='screen'; | |||
for(const bit of shockDebris){ | |||
const f=age*(.5+bit.seed*.5); | |||
const travel=bit.velocity*f; | |||
const px=x+Math.cos(bit.angle)*travel; | |||
const py=y+Math.sin(bit.angle)*travel; | |||
const vx=Math.cos(bit.angle)*bit.length*(1-age); | |||
const vy=Math.sin(bit.angle)*bit.length*(1-age); | |||
ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7); | |||
ctx.strokeStyle=dark?'#ffdd9b':'#e0faff'; | |||
ctx.lineWidth=.6+bit.seed*1.3; | |||
ctx.beginPath(); | |||
ctx.moveTo(px,py); | |||
ctx.lineTo(px-vx,py-vy); | |||
ctx.stroke(); | |||
} | |||
ctx.globalCompositeOperation='source-over'; | |||
ctx.globalAlpha=1; | |||
} | |||
// Sharp, directional embers / crystalline fragments, not blobs. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
for(const mote of motes){ | |||
const age=(elapsed*(dark?.74:.94)+mote.phase)%1; | |||
const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9; | |||
const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift; | |||
if(x<-25||x>w+25||yy<-25||yy>h+25)continue; | |||
const opacity=(1-age)*fade*(dark?.89:.61); | |||
ctx.globalAlpha=opacity; | |||
ctx.strokeStyle=dark?'#ffb45d':'#b2efff'; | |||
ctx.lineWidth=dark?.8:1.15; | |||
ctx.beginPath(); | |||
ctx.moveTo(x,yy); | |||
ctx.lineTo(x-direction*mote.length*.4,yy+mote.length); | |||
ctx.stroke(); | |||
} | |||
ctx.restore(); | |||
}; | |||
} | |||
function setDisplayed(nextActive) { | |||
active = nextActive; | |||
frame.classList.toggle('kkpt-active', active); | |||
frame.style.clipPath = active ? 'inset(0)' : hideClip; | |||
frame.tabIndex = active ? 0 : -1; | |||
frame.setAttribute('aria-hidden', active ? 'false' : 'true'); | |||
document.title = active ? otherTitle : baseTitle; | |||
updateButton(); | |||
} | |||
function | async function animateSwap(nextActive) { | ||
if ( | if (animating) return; | ||
animating = true; | |||
button.classList.add('kkpt-busy'); | |||
// Warm-up should be done already; prepare if the user clicked early. | |||
labelSub.textContent = 'PREPARING THE OTHER SIDE...'; | |||
if (!loading) warmFrame(); | |||
const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]); | |||
if (!canUseFrame || !ready) { | |||
// No mock background: fall back to the genuine Wiki page. | |||
window.location.assign(nextActive ? otherUrl.href : baseUrl); | |||
return; | |||
: | } | ||
const destinationDark = isDark(nextActive ? otherPage : basePage); | |||
window.__kkptVisualDark = destinationDark; | |||
button.classList.remove('kkpt-quiet'); | |||
labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING'; | |||
// Freeze frame interaction while its silhouette is animated. | |||
frame.classList.remove('kkpt-active'); | |||
frame.style.pointerEvents = 'none'; | |||
frame.style.clipPath = active ? 'inset(0)' : hideClip; | |||
const canvas = document.createElement('canvas'); | |||
canvas.id = 'kkpt-vfx'; | |||
document.body.append(canvas); | |||
const atmosphere = document.createElement('canvas'); | |||
atmosphere.id = 'kkpt-atmosphere'; | |||
document.body.append(atmosphere); | |||
const drawAtmosphere = createAtmosphere(atmosphere); | |||
const blast = document.createElement('div'); | |||
blast.id = 'kkpt-impact'; | |||
blast.className = destinationDark ? 'kkpt-impact-red' : 'kkpt-impact-blue'; | |||
blast.setAttribute('aria-hidden','true'); | |||
document.body.append(blast); | |||
let draw = createRenderer(canvas); | |||
if (!draw) { | |||
canvas.remove(); | |||
const fallbackCanvas = document.createElement('canvas'); | |||
fallbackCanvas.id = 'kkpt-vfx'; | |||
document.body.append(fallbackCanvas); | |||
draw = createFallback(fallbackCanvas); | |||
} | |||
const fxCanvas = document.getElementById('kkpt-vfx'); | |||
const duration = destinationDark ? 3650 : 2950; | |||
const started = performance.now(); | |||
await new Promise(resolve => { | |||
function tick(now) { | |||
const seconds = now / 1000; | |||
const t = Math.min(1,(now-started)/duration); | |||
clipFrame(seconds,t); | |||
drawAtmosphere(seconds,t,destinationDark,(now-started)/1000); | |||
draw(seconds,t,destinationDark); | |||
const first=destinationDark?.10:.18; | |||
const second=destinationDark?.48:.58; | |||
const shockA=Math.exp(-Math.pow((t-first)/.041,2)); | |||
const shockB=Math.exp(-Math.pow((t-second)/.056,2)); | |||
const shock=Math.min(1,shockA*.98+shockB*.80); | |||
blast.style.opacity=String(shock); | |||
blast.style.setProperty('--kkpt-impact-x', | |||
edgeAt(innerHeight*.51,seconds,t,innerWidth).toFixed(1)+'px'); | |||
frame.style.filter=shock>.01 | |||
? 'brightness('+(1+.48*shock).toFixed(3)+') saturate('+ | |||
(1+.25*shock).toFixed(3)+')' | |||
: ''; | |||
if (t < 1) requestAnimationFrame(tick); | |||
else resolve(); | |||
} | |||
requestAnimationFrame(tick); | |||
}); | |||
setDisplayed(nextActive); | |||
fxCanvas.style.opacity = '0'; | |||
atmosphere.style.opacity = '0'; | |||
blast.style.opacity = '0'; | |||
await sleep(190); | |||
fxCanvas.remove(); | |||
atmosphere.remove(); | |||
blast.remove(); | |||
frame.style.filter = ''; | |||
frame.style.pointerEvents = ''; | |||
animating = false; | |||
button.classList.remove('kkpt-busy','kkpt-quiet'); | |||
// No location.assign here: the rendered target stays alive. | |||
history.pushState({ kkptV6: true, active }, '', active ? otherUrl.href : baseUrl); | |||
} | |||
function requestSwap(nextActive) { | |||
if (animating || nextActive === active) return; | |||
if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) { | |||
setDisplayed(nextActive); | |||
history.pushState({kkptV6:true,active},'',active?otherUrl.href:baseUrl); | |||
return; | |||
} | } | ||
animateSwap(nextActive).catch(err => { | |||
console.error('[KuranokaPageTransition] V6:',err); | |||
document.getElementById('kkpt-vfx')?.remove(); | |||
document.getElementById('kkpt-atmosphere')?.remove(); | |||
document.getElementById('kkpt-impact')?.remove(); | |||
if (frame) frame.style.filter = ''; | |||
window.location.assign(nextActive ? otherUrl.href : baseUrl); | |||
}); | |||
} | |||
function createButton() { | |||
if (!document.body || document.getElementById('kkpt-toggle')) return; | |||
button=document.createElement('a'); | |||
button.id='kkpt-toggle'; | |||
labelIcon=document.createElement('span'); | |||
labelIcon.className='kkpt-icon'; | |||
labelIcon.setAttribute('aria-hidden','true'); | |||
const text=document.createElement('span'); | |||
text.className='kkpt-text'; | |||
labelTitle=document.createElement('span'); | |||
labelSub=document.createElement('span'); | |||
labelSub.className='kkpt-sub'; | |||
text.append(labelTitle,labelSub); | |||
button.append(labelIcon,text); | |||
document.body.append(button); | |||
button.hidden=!SHOW_FLOATING_BUTTON; | |||
updateButton(); | |||
watchPortalLinks(document, otherPage); | |||
warmFrame(); | |||
button.addEventListener('click', event => { | |||
if (!isPlainClick(event)) return; | |||
event.preventDefault(); | |||
requestSwap(!active); | |||
}); | |||
} | } | ||
// A plain [[Kuronata|your mysterious text]] now triggers everything. | |||
// The inverse [[Kuranoka|...]] works inside the already-loaded iframe. | |||
document.addEventListener('click', event => { | |||
if (active) return; | |||
portalClick(event, document, otherPage, true); | |||
}, true); | |||
mw.hook('wikipage.content').add(() => { | |||
if (document.readyState !== 'loading') watchPortalLinks(document, otherPage); | |||
}); | |||
// Back and Forward restore the two already-loaded documents instantly. | |||
window.addEventListener('popstate', () => { | |||
if (!frame || !ready || animating) return; | |||
const route = pathPage(); | |||
if (route === basePage || route === otherPage) { | |||
setDisplayed(route === otherPage); | |||
} | |||
}); | |||
window.addEventListener('pageshow', event => { | |||
if (!event.persisted || !frame || !ready) return; | |||
animating=false; | |||
document.getElementById('kkpt-vfx')?.remove(); | |||
document.getElementById('kkpt-atmosphere')?.remove(); | |||
document.getElementById('kkpt-impact')?.remove(); | |||
frame.style.filter = ''; | |||
setDisplayed(pathPage() === otherPage); | |||
button?.classList.remove('kkpt-busy','kkpt-quiet'); | |||
}); | |||
if (document.readyState === 'loading') { | if (document.readyState === 'loading') { | ||
document.addEventListener('DOMContentLoaded', | document.addEventListener('DOMContentLoaded',createButton,{once:true}); | ||
} else { | } else { | ||
createButton(); | |||
} | } | ||
}()); | }()); | ||
2026年10月23日 (五) 13:34的版本
/* ====================================================================
KURANOKA / KURONATA V6 — CATACLYSM / DAWN
MediaWiki:KuranokaPageTransition.js
Same-origin iframe + history.pushState: NO full reload on toggles.
Irregular tear + WebGL combustion + layered turbulent smoke/impact.
Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]].
Backgrounds are prewarmed on page load. Zero external dependencies.
==================================================================== */
(function () {
'use strict';
// The preview is a complete MediaWiki page. It must NOT spawn another.
if (window !== window.top || window.__kkptV6) return;
if (!window.mw || !mw.config) return;
// The URL is the source of truth if a skin/extension restored a page
// without immediately updating mw.config (soft navigation / bfcache).
const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/);
const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title');
const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage)
? urlPage : mw.config.get('wgPageName');
const action = new URLSearchParams(location.search).get('action') || 'view';
if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return;
window.__kkptV6 = true;
window.__kkptVersion = '6.0';
// For the easter egg: links inside the Wiki are the default trigger.
// Change to true only if you also want the fixed switch button.
const SHOW_FLOATING_BUTTON = false;
const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka';
const baseUrl = location.href;
const baseTitle = document.title;
const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href);
const clipSide = basePage === 'Kuranoka' ? 'right' : 'left';
const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const smooth = t => t * t * (3 - 2 * t);
const imagePool = new Map();
let active = false; // false: original top-level doc; true: otherPage iframe
let animating = false;
let ready = false;
let loading = null;
let button, labelTitle, labelSub, labelIcon, frame;
let otherTitle = otherPage + ' - Blockland Wiki';
function whichPage() { return active ? otherPage : basePage; }
function destination() { return active ? basePage : otherPage; }
function isDark(page) { return page === 'Kuronata'; }
function pathPage() {
const url = new URL(location.href);
if (url.pathname.startsWith('/wiki/')) {
try { return decodeURIComponent(url.pathname.slice(6)).replace(/_/g, ' '); }
catch (e) { return ''; }
}
return url.searchParams.get('title') || '';
}
function updateButton() {
if (!button) return;
const to = destination();
button.href = to === basePage ? baseUrl : otherUrl.href;
labelTitle.textContent = to.toUpperCase();
labelSub.textContent = isDark(to) ? 'BURN THE SKY' : 'RECLAIM THE SKY';
labelIcon.textContent = isDark(to) ? '✦' : '❄';
button.classList.toggle('kkpt-dark', isDark(whichPage()));
button.setAttribute('aria-label', isDark(to) ? '燃尽天空,进入 Kuronata' : '驱散黑暗,回到 Kuranoka');
}
// Accept the two standard MediaWiki link forms, while rejecting
// edit/action links, external sites and similarly named articles.
function wikiLinkPage(anchor, doc) {
try {
const url = new URL(anchor.href, doc.baseURI);
if (url.origin !== location.origin) return null;
const action = url.searchParams.get('action');
if (action && action !== 'view') return null;
let title = url.searchParams.get('title');
if (!title) {
const matched = url.pathname.match(/^\/wiki\/([^/?#]+)\/?$/);
if (matched) title = decodeURIComponent(matched[1]);
}
title = (title || '').replace(/_/g, ' ');
return title === 'Kuranoka' || title === 'Kuronata' ? title : null;
} catch (_) {
return null;
}
}
function decoratePortalLinks(doc, targetPage) {
// Use only the article: sidebar, edit tools and site-wide links
// must retain their usual navigation behavior.
const content = doc.querySelector('#mw-content-text,.mw-parser-output');
if (!content) return;
content.querySelectorAll('a[href]').forEach(anchor => {
if (wikiLinkPage(anchor, doc) !== targetPage) return;
anchor.classList.add('kkpt-portal-link');
anchor.classList.toggle('kkpt-portal-red', targetPage === 'Kuronata');
anchor.classList.toggle('kkpt-portal-blue', targetPage === 'Kuranoka');
const caption = (anchor.textContent || '').trim();
if (caption) anchor.dataset.kkptText = caption.slice(0, 120);
anchor.setAttribute('aria-label', caption + ',切换至 ' + targetPage);
});
}
function watchPortalLinks(doc, targetPage) {
decoratePortalLinks(doc, targetPage);
if (doc.__kkptPortalObserver) return;
const root = doc.querySelector('#mw-content-text,.mw-parser-output');
if (!root) return;
const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage));
observer.observe(root, {childList:true,subtree:true});
doc.__kkptPortalObserver = observer;
}
function isPlainClick(event) {
return !event.defaultPrevented && event.button === 0 &&
!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
}
function findAnchor(event, doc) {
const target = event.target;
return target && target.nodeType === 1 &&
typeof target.closest === 'function'
? target.closest('a[href]') : null;
}
function portalClick(event, doc, targetPage, nextActive) {
if (!isPlainClick(event)) return false;
const anchor = findAnchor(event, doc);
if (!anchor || (anchor.target && anchor.target !== '_self') ||
anchor.hasAttribute('download') ||
wikiLinkPage(anchor, doc) !== targetPage) return false;
event.preventDefault();
requestSwap(nextActive);
return true;
}
// Read backgrounds after background-template code populates --bg-image.
function backgroundUrls(doc) {
const urls = new Set();
const selector = [doc.documentElement, doc.body];
try {
doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container')
.forEach((el, i) => { if (i < 65) selector.push(el); });
} catch (e) { /* use html/body */ }
function add(value) {
if (!value) return;
const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
let match;
while ((match = pattern.exec(value))) {
try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ }
}
if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) {
try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ }
}
}
selector.forEach(el => {
if (!el) return;
try {
[null, '::before', '::after'].forEach(pseudo => {
const style = doc.defaultView.getComputedStyle(el, pseudo);
if (!style) return;
add(style.backgroundImage);
add(style.getPropertyValue('--bg-image'));
add(style.getPropertyValue('--background-image'));
});
if (el.dataset) add(el.dataset.bgImage);
} catch (e) { /* style not yet ready */ }
});
return [...urls];
}
function warmImage(url) {
if (imagePool.has(url)) return imagePool.get(url).promise;
const image = new Image();
const promise = new Promise(resolve => {
let finished = false;
const timeout = setTimeout(() => done(false), 5000);
function done(ok) {
if (finished) return;
finished = true;
clearTimeout(timeout);
resolve(ok);
}
image.onload = () => done(true);
image.onerror = () => done(false);
image.src = url;
if (image.complete) done(image.naturalWidth > 0);
}).then(ok => ok && image.decode
? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)])
: ok);
imagePool.set(url, { image, promise });
return promise;
}
async function warmBackgrounds(doc) {
const urls = backgroundUrls(doc);
await Promise.allSettled(urls.map(warmImage));
}
function wireFrame() {
try {
const doc = frame.contentDocument;
if (!doc) return;
otherTitle = doc.title || otherTitle;
watchPortalLinks(doc, basePage);
if (doc.__kkptFrameBridge) return;
doc.__kkptFrameBridge = true;
// Non-anchor navigation should leave the iframe and open normally
// in the top window, so the browser's URL doesn't become stale.
doc.addEventListener('click', event => {
if (!active || !isPlainClick(event)) return;
if (portalClick(event, doc, basePage, false)) return;
const el = findAnchor(event, doc);
if (!el || (el.target && el.target !== '_self')) return;
const href = el.href;
if (!href || href.startsWith('javascript:')) return;
const next = new URL(href, doc.baseURI);
// Same-document fragments stay inside the iframe.
const currentFrame = new URL(frame.contentWindow.location.href);
if (next.origin === currentFrame.origin &&
next.pathname === currentFrame.pathname &&
next.search === currentFrame.search && next.hash) {
history.replaceState(history.state, '', next.href);
return;
}
event.preventDefault();
window.location.assign(next.href);
}, true);
doc.addEventListener('submit', event => {
if (active && event.target instanceof doc.defaultView.HTMLFormElement) {
event.target.target = '_top';
}
}, true);
} catch (err) {
console.warn('[KuranokaPageTransition] Frame link bridge unavailable:', err);
}
}
function warmFrame() {
if (loading) return loading;
frame = document.createElement('iframe');
frame.id = 'kkpt-preview';
frame.src = otherUrl.href; // set BEFORE insertion, avoids extra initial history hop
frame.title = otherPage + ' 预载页面';
frame.tabIndex = -1;
frame.setAttribute('aria-hidden', 'true');
frame.setAttribute('loading', 'eager');
frame.style.clipPath = hideClip;
loading = new Promise(resolve => {
let settled = false;
let preparing = false;
let poll = null;
function finish(ok, reason) {
if (settled) return;
settled = true;
clearInterval(poll);
ready = ok;
if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason);
resolve(ok);
}
async function inspect() {
if (settled || preparing) return;
try {
const doc = frame.contentDocument;
if (!doc || !doc.querySelector('#mw-content-text,.mw-parser-output')) return;
preparing = true;
clearInterval(poll);
await sleep(320); // Background.js / wiki style scripts
await Promise.race([warmBackgrounds(doc), sleep(3600)]);
await sleep(120);
await Promise.race([warmBackgrounds(doc), sleep(1300)]);
frame.contentWindow.scrollTo(0, 0);
wireFrame();
finish(true);
} catch (err) { finish(false, String(err)); }
}
frame.addEventListener('load', () => {
if (!settled && !preparing) inspect();
});
frame.addEventListener('error', () => finish(false,'iframe 网络加载错误'));
document.body.append(frame);
poll = setInterval(inspect, 130);
setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000);
});
warmBackgrounds(document).catch(() => {});
setTimeout(() => warmBackgrounds(document).catch(() => {}), 700);
return loading;
}
// This edge is used by BOTH the DOM clip and the shader.
function edgeAt(y, seconds, progress, width) {
const eased = smooth(progress);
const toDark = window.__kkptVisualDark;
const center = toDark
? width + 110 - (width + 220) * eased
: -110 + (width + 220) * eased;
const wave = Math.sin(Math.PI * progress);
return center + wave * (
28 * Math.sin(y * .012 + seconds * 3.1) +
17 * Math.sin(y * .041 - seconds * 5.7) +
7 * Math.sin(y * .10 + seconds * 10.0) +
11 * Math.sin(y * .073 + seconds * 6.13 + 2 * Math.sin(y * .017 - seconds * 2.3))
);
}
function clipFrame(seconds, progress) {
const width = innerWidth, height = innerHeight;
const pts = [];
const count = Math.max(35, Math.ceil(height / 11));
for (let i = 0; i <= count; i++) {
const y = height * i / count;
pts.push([edgeAt(y, seconds, progress, width), y]);
}
const supportsPath = CSS.supports('clip-path', 'path("M 0 0 L 8 0 L 8 8 Z")');
if (supportsPath) {
const startX = clipSide === 'right' ? width + 4 : -4;
let d = `M ${startX} -4 L ${startX} ${height + 4} L ${pts[count][0].toFixed(1)} ${height}`;
// Quadratic smoothing eliminates the geometric triangular teeth.
for (let i = count - 1; i >= 0; i--) {
const point = pts[i];
const previous = pts[Math.max(0, i - 1)];
const mx = (point[0] + previous[0]) * .5;
const my = (point[1] + previous[1]) * .5;
d += ` Q ${point[0].toFixed(1)} ${point[1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
}
d += ` L ${pts[0][0].toFixed(1)} 0 Z`;
frame.style.clipPath = `path("${d}")`;
} else {
const points = pts.slice().reverse().map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(',');
frame.style.clipPath = clipSide === 'right'
? `polygon(100% 0,100% 100%,${points})`
: `polygon(0 0,0 100%,${points})`;
}
}
// Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies.
const VERTEX = `
attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }
`;
const FRAGMENT = `
precision highp float;
uniform vec2 uSize;
uniform vec2 uBuffer;
uniform float uTime;
uniform float uProgress;
uniform float uDark;
float random2(vec2 p) {
return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x),
mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y);
}
float fbm(vec2 p) {
float result = 0.0, amp = 0.5;
for (int i = 0; i < 5; i++) {
result += amp * noise(p);
p = mat2(1.6,-1.2,1.2,1.6) * p;
amp *= 0.5;
}
return result;
}
float smoothProgress(float t) { return t*t*(3.0-2.0*t); }
float edge(float y) {
float w = uSize.x;
float t = smoothProgress(uProgress);
float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t;
float wave = sin(3.14159265*uProgress);
return c+wave*(28.0*sin(y*.012+uTime*3.1)+
17.0*sin(y*.041-uTime*5.7)+7.0*sin(y*.10+uTime*10.0)+
11.0*sin(y*.073+uTime*6.13+2.0*sin(y*.017-uTime*2.3)));
}
void main() {
vec2 p = vec2(gl_FragCoord.x*uSize.x/uBuffer.x,
uSize.y-gl_FragCoord.y*uSize.y/uBuffer.y);
float d = p.x-edge(p.y);
float oldSide = uDark > .5 ? -d : d;
float t = uTime;
float impact = sin(3.14159265*uProgress);
vec3 col = vec3(0.0);
float alpha = 0.0;
if (uDark > .5) {
// Deep smoke blankets a 350px belt BEHIND the incandescent
// edge. Multiple warped noise scales break up smooth bands.
vec2 sq = vec2(p.y*.007-t*.29,oldSide*.006+t*.12);
vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)),
fbm(sq*1.9+vec2(0.0,-t*.21)));
float smokeCloud = fbm(sq*2.0+warp*2.2);
float smokeDetail = fbm(sq*5.1-warp*.8);
float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34);
// A thick, drifting smoke WALL. The lighter old world
// goes visibly dark before the red page consumes it.
float soot = smoothstep(-110.0,-5.0,oldSide)*
(1.0-smoothstep(260.0,555.0,oldSide))*
(.55+.58*billow);
vec3 sootColor = mix(vec3(.011,.012,.022),vec3(.18,.055,.041),smokeCloud);
// High-frequency fBm produces uneven, flame-like tongues,
// with a broad ember-red zone instead of a white rope.
vec2 q = vec2(p.y*.014-t*.60,oldSide*.015-t*.43);
float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25));
float shredded = fbm(q*3.6+vec2(t*.24,-t*.34));
float lengthOfTongue = 55.0+215.0*turbulence+65.0*shredded;
float raggedEdge = 1.0-smoothstep(lengthOfTongue-62.0,lengthOfTongue+15.0,oldSide);
float flame = raggedEdge*smoothstep(-75.0,7.0,oldSide)*
smoothstep(.21,.68,turbulence+.23*shredded);
float deepEmber = exp(-max(0.0,oldSide)/86.0);
float core = exp(-abs(d)/12.0);
float outer = exp(-abs(d)/78.0);
float whiteCore = core*max(.2,shredded);
float heat = clamp(flame*.83+core*.69,0.0,1.0);
vec3 fireColor = mix(vec3(.30,.011,.009),vec3(1.0,.105,.016),smoothstep(.10,.57,heat));
fireColor = mix(fireColor,vec3(1.0,.47,.048),smoothstep(.43,.85,heat));
fireColor = mix(fireColor,vec3(1.0,.93,.63),smoothstep(.80,1.0,whiteCore));
col = mix(sootColor,fireColor,clamp(flame*.95+core+deepEmber*.20,0.0,1.0));
alpha = clamp(soot*.93+flame*.92+outer*.32+core*.68,0.0,.99);
// Sharp micron-scale cinders; the real ember trails live
// on the separate high-resolution atmosphere canvas.
vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0);
vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5;
float sparks = (1.0-smoothstep(.015,.12,length(uv)))*
step(.991,random2(cell))*exp(-abs(d)/240.0);
col = mix(col,vec3(1.0,.78,.23),sparks);
alpha = max(alpha,sparks*.97);
} else {
// Returning to blue is ELECTRIC AURORA / COLD MIST, NEVER FIRE.
vec2 q = vec2(p.y*.009-t*.25,oldSide*.013+t*.18);
float cloud = fbm(q+vec2(fbm(q*1.7),-t*.25));
float finer = fbm(q*3.1+vec2(-t*.15,t*.19));
float mist = exp(-abs(d)/208.0)*(cloud*.7+finer*.18+.23);
float ion = exp(-abs(d)/37.0);
float arcWobble = 25.0*sin(p.y*.043-t*6.0)+
10.0*sin(p.y*.14+t*13.0)+5.0*sin(p.y*.37-t*19.0);
float arc = exp(-abs(d+arcWobble)/2.7)*
(.7+.3*sin(p.y*.065+t*7.0));
float branches = exp(-abs(d+52.0*sin(p.y*.018+t*2.4))/1.9)*
smoothstep(.63,.92,noise(vec2(floor(p.y/54.0),floor(t*9.0))));
col = mix(vec3(.008,.07,.19),vec3(.15,.52,.96),cloud+.27);
col += vec3(.23,.45,.60)*ion;
col = mix(col,vec3(.90,1.0,1.0),clamp(arc*.93+branches*.55,0.0,1.0));
alpha = clamp(mist*.85+ion*.43+arc*.97+branches*.65,0.0,.91);
}
// No long-lasting screen tint after the burn has finished.
alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) *
(1.0-smoothstep(.94,1.0,uProgress));
gl_FragColor = vec4(col,alpha);
}
`;
function createRenderer(canvas) {
const gl = canvas.getContext('webgl', {
alpha: true, antialias: false, depth: false, stencil: false,
preserveDrawingBuffer: false, premultipliedAlpha: false
});
if (!gl) return null;
function compile(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error('[KKPT WebGL] Shader:', gl.getShaderInfoLog(shader));
return null;
}
return shader;
}
const vs = compile(gl.VERTEX_SHADER, VERTEX);
const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT);
if (!vs || !fs) return null;
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('[KKPT WebGL] Program:', gl.getProgramInfoLog(program));
return null;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program,'aPosition');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
const sizeUniform = gl.getUniformLocation(program,'uSize');
const bufferUniform = gl.getUniformLocation(program,'uBuffer');
const timeUniform = gl.getUniformLocation(program,'uTime');
const progressUniform = gl.getUniformLocation(program,'uProgress');
const modeUniform = gl.getUniformLocation(program,'uDark');
// This is a single fullscreen pass to a transparent canvas.
// Blending here would multiply alpha twice and wash out the fire.
gl.disable(gl.BLEND);
return function draw(seconds,progress,dark) {
const w=innerWidth,h=innerHeight;
// Keep a stable GPU budget on high-DPI/mobile displays.
const scale = w < 700 ? .68 : .84;
const bw=Math.round(w*scale),bh=Math.round(h*scale);
if (canvas.width!==bw || canvas.height!==bh) {
canvas.width=bw;canvas.height=bh;gl.viewport(0,0,bw,bh);
}
gl.clearColor(0,0,0,0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(sizeUniform,w,h);
gl.uniform2f(bufferUniform,bw,bh);
gl.uniform1f(timeUniform,seconds);
gl.uniform1f(progressUniform,progress);
gl.uniform1f(modeUniform,dark?1:0);
gl.drawArrays(gl.TRIANGLES,0,6);
};
}
function createFallback(canvas) {
const ctx = canvas.getContext('2d');
return function draw(seconds,progress,dark) {
const w=innerWidth,h=innerHeight;
if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;}
ctx.clearRect(0,0,w,h);
const x = edgeAt(h/2,seconds,progress,w);
const gradient = ctx.createLinearGradient(x-115,0,x+115,0);
if (dark) {
gradient.addColorStop(0,'#180d0d00');
gradient.addColorStop(.40,'#67180677');
gradient.addColorStop(.50,'#ffe186cc');
gradient.addColorStop(.62,'#ef3f0955');
gradient.addColorStop(1,'#180d0d00');
} else {
gradient.addColorStop(0,'#5abcf600');
gradient.addColorStop(.46,'#7cdbff77');
gradient.addColorStop(.5,'#ffffffbb');
gradient.addColorStop(.56,'#63c9ff77');
gradient.addColorStop(1,'#5abcf600');
}
ctx.fillStyle=gradient;ctx.fillRect(x-115,0,230,h);
};
}
// A second canvas adds real, textured soot / rolling vapor and violent
// short-lived pressure waves. It is deliberately independent of WebGL,
// so even browsers with GPU disabled still get the smoke and impact.
function createAtmosphere(canvas) {
const ctx = canvas.getContext('2d', {alpha:true});
const rand = n => {
const x = Math.sin(n*127.1+311.7)*43758.5453123;
return x-Math.floor(x);
};
const clamp01 = n => Math.max(0,Math.min(1,n));
function valueNoise(x,y,seed) {
const ix=Math.floor(x), iy=Math.floor(y);
let fx=x-ix,fy=y-iy;
fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
return a*(1-fy)+b*fy;
}
function smokeTexture(seed,dark) {
const tex=document.createElement('canvas');
tex.width=tex.height=112;
const g=tex.getContext('2d');
const data=g.createImageData(112,112);
for(let y=0;y<112;y++) for(let x=0;x<112;x++){
const u=(x-56)/56,v=(y-56)/56;
const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
const envelope=clamp01((1.05-radius)/.56);
const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
envelope*envelope;
const i=(y*112+x)*4;
const variation=low*28+high*15;
if(dark){
data.data[i]=18+variation;
data.data[i+1]=15+variation*.58;
data.data[i+2]=19+variation*.48;
}else{
data.data[i]=44+variation;
data.data[i+1]=91+variation;
data.data[i+2]=125+variation;
}
data.data[i+3]=Math.round(240*density);
}
g.putImageData(data,0,0);
return tex;
}
const sootSprites=[0,1,2,3].map(n=>smokeTexture(n+17,true));
const mistSprites=[0,1,2,3].map(n=>smokeTexture(n+51,false));
const particles=Array.from({length:innerWidth<700?72:124},(_,i)=>({
seed:rand(i*4+71),height:rand(i*5+13),
lifetime:.5+rand(i*8+37)*.7,
phase:rand(i*9+21),sprite:i%4,
scale:.72+rand(i*7+9)*1.25,
bend:rand(i*15+23)*6.28
}));
const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({
y:rand(i*3+17),phase:rand(i*9+43),
speed:85+rand(i*12+5)*260,
drift:75+rand(i*15+2)*170,
length:3+rand(i*17+7)*11
}));
const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({
angle:rand(i*17+131)*Math.PI*2,
velocity:190+rand(i*11+51)*900,
seed:rand(i*5+79),
length:6+rand(i*7+17)*26
}));
function pressureWave(x,y,radius,power,dark,time) {
if(power<=.005)return;
const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
if(dark){
core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
core.addColorStop(1,'rgba(30,0,0,0)');
}else{
core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
core.addColorStop(1,'rgba(0,29,100,0)');
}
ctx.globalCompositeOperation='screen';
ctx.fillStyle=core;
ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
ctx.globalCompositeOperation='source-over';
}
return function drawAtmosphere(seconds,progress,dark,elapsed) {
const w=innerWidth,h=innerHeight;
const scale=w<700?.67:.79;
const bw=Math.max(1,Math.round(w*scale));
const bh=Math.max(1,Math.round(h*scale));
if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
ctx.setTransform(scale,0,0,scale,0,0);
ctx.clearRect(0,0,w,h);
const envelope=Math.sin(Math.PI*progress);
const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
const sprites=dark?sootSprites:mistSprites;
const direction=dark?-1:1;
// Continuous textured smoke wall: not isolated oval puffs.
// The base layer makes the plume visible on a pale blue sky.
ctx.globalCompositeOperation='source-over';
const bands=Math.max(10,Math.ceil(h/(dark?49:73)));
for(let i=0;i<bands;i++){
const baseY=(i+.5)*h/bands;
const y=baseY+24*Math.sin(seconds*2.5+i*.7);
const x=edgeAt(y,seconds,progress,w)+direction*
(dark?118:72)+Math.sin(seconds*1.2+i*1.3)*36;
const width=(dark?400:280)*(1+.19*Math.sin(i*2.9));
const height=(dark?315:260);
ctx.save();
ctx.globalAlpha=(dark?.62:.36)*fade*(.65+.35*envelope);
ctx.translate(x,y);
ctx.rotate(.16*Math.sin(i*2.1+seconds*1.5));
ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
ctx.restore();
}
// Independent turbulent billows peel away and rise.
for(const item of particles){
const age=((elapsed/item.lifetime+item.phase)%1);
const baseY=item.height*h;
const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
if(y< -230||y>h+230)continue;
const frontier=edgeAt(baseY,seconds,progress,w);
const x=frontier+direction*(age*(dark?225:135)+
30*Math.sin(age*6.3+item.bend));
const size=(dark?175:145)*item.scale*(.68+age*1.9);
const opacity=(dark?.85:.42)*Math.sin(Math.PI*age)*fade*
(.55+.45*envelope);
if(opacity<=.005)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(x,y);
ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
ctx.scale(1.18,.84);
ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
ctx.restore();
}
// The impact happens at ignition and again as the rupture reaches
// the middle of the screen; pulses are intentionally fleeting.
for(const moment of (dark?[.10,.48]:[.18,.58])){
const age=(progress-moment)/.125;
if(age<0||age>1)continue;
const intensity=(1-age)*(1-age)*fade;
// A single-frame-ish exposure bloom punches through
// the entire page; the tone is red or icy blue by direction.
ctx.globalCompositeOperation='screen';
ctx.fillStyle=dark
? 'rgba(255,81,26,'+(.095*intensity)+')'
: 'rgba(150,219,255,'+(.080*intensity)+')';
ctx.fillRect(0,0,w,h);
ctx.globalCompositeOperation='source-over';
const x=edgeAt(h*.51,seconds,progress,w);
const y=h*.51;
const radius=95+age*Math.max(w,h)*.9;
pressureWave(x,y,radius,intensity,dark,seconds);
ctx.globalAlpha=(dark?.20:.28)*intensity;
ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
ctx.beginPath();
for(let i=0;i<=110;i++){
const a=i/110*Math.PI*2;
const jag=1+.03*Math.sin(a*29+seconds*12)+
.05*Math.sin(a*13-seconds*5);
const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
}
ctx.stroke();
ctx.globalAlpha=1;
// Fragment trails explode outward from the rupture point.
// They follow independent directions and speeds; no circles.
ctx.globalCompositeOperation='screen';
for(const bit of shockDebris){
const f=age*(.5+bit.seed*.5);
const travel=bit.velocity*f;
const px=x+Math.cos(bit.angle)*travel;
const py=y+Math.sin(bit.angle)*travel;
const vx=Math.cos(bit.angle)*bit.length*(1-age);
const vy=Math.sin(bit.angle)*bit.length*(1-age);
ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
ctx.lineWidth=.6+bit.seed*1.3;
ctx.beginPath();
ctx.moveTo(px,py);
ctx.lineTo(px-vx,py-vy);
ctx.stroke();
}
ctx.globalCompositeOperation='source-over';
ctx.globalAlpha=1;
}
// Sharp, directional embers / crystalline fragments, not blobs.
ctx.save();
ctx.globalCompositeOperation='screen';
for(const mote of motes){
const age=(elapsed*(dark?.74:.94)+mote.phase)%1;
const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
const opacity=(1-age)*fade*(dark?.89:.61);
ctx.globalAlpha=opacity;
ctx.strokeStyle=dark?'#ffb45d':'#b2efff';
ctx.lineWidth=dark?.8:1.15;
ctx.beginPath();
ctx.moveTo(x,yy);
ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
ctx.stroke();
}
ctx.restore();
};
}
function setDisplayed(nextActive) {
active = nextActive;
frame.classList.toggle('kkpt-active', active);
frame.style.clipPath = active ? 'inset(0)' : hideClip;
frame.tabIndex = active ? 0 : -1;
frame.setAttribute('aria-hidden', active ? 'false' : 'true');
document.title = active ? otherTitle : baseTitle;
updateButton();
}
async function animateSwap(nextActive) {
if (animating) return;
animating = true;
button.classList.add('kkpt-busy');
// Warm-up should be done already; prepare if the user clicked early.
labelSub.textContent = 'PREPARING THE OTHER SIDE...';
if (!loading) warmFrame();
const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]);
if (!canUseFrame || !ready) {
// No mock background: fall back to the genuine Wiki page.
window.location.assign(nextActive ? otherUrl.href : baseUrl);
return;
}
const destinationDark = isDark(nextActive ? otherPage : basePage);
window.__kkptVisualDark = destinationDark;
button.classList.remove('kkpt-quiet');
labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING';
// Freeze frame interaction while its silhouette is animated.
frame.classList.remove('kkpt-active');
frame.style.pointerEvents = 'none';
frame.style.clipPath = active ? 'inset(0)' : hideClip;
const canvas = document.createElement('canvas');
canvas.id = 'kkpt-vfx';
document.body.append(canvas);
const atmosphere = document.createElement('canvas');
atmosphere.id = 'kkpt-atmosphere';
document.body.append(atmosphere);
const drawAtmosphere = createAtmosphere(atmosphere);
const blast = document.createElement('div');
blast.id = 'kkpt-impact';
blast.className = destinationDark ? 'kkpt-impact-red' : 'kkpt-impact-blue';
blast.setAttribute('aria-hidden','true');
document.body.append(blast);
let draw = createRenderer(canvas);
if (!draw) {
canvas.remove();
const fallbackCanvas = document.createElement('canvas');
fallbackCanvas.id = 'kkpt-vfx';
document.body.append(fallbackCanvas);
draw = createFallback(fallbackCanvas);
}
const fxCanvas = document.getElementById('kkpt-vfx');
const duration = destinationDark ? 3650 : 2950;
const started = performance.now();
await new Promise(resolve => {
function tick(now) {
const seconds = now / 1000;
const t = Math.min(1,(now-started)/duration);
clipFrame(seconds,t);
drawAtmosphere(seconds,t,destinationDark,(now-started)/1000);
draw(seconds,t,destinationDark);
const first=destinationDark?.10:.18;
const second=destinationDark?.48:.58;
const shockA=Math.exp(-Math.pow((t-first)/.041,2));
const shockB=Math.exp(-Math.pow((t-second)/.056,2));
const shock=Math.min(1,shockA*.98+shockB*.80);
blast.style.opacity=String(shock);
blast.style.setProperty('--kkpt-impact-x',
edgeAt(innerHeight*.51,seconds,t,innerWidth).toFixed(1)+'px');
frame.style.filter=shock>.01
? 'brightness('+(1+.48*shock).toFixed(3)+') saturate('+
(1+.25*shock).toFixed(3)+')'
: '';
if (t < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
setDisplayed(nextActive);
fxCanvas.style.opacity = '0';
atmosphere.style.opacity = '0';
blast.style.opacity = '0';
await sleep(190);
fxCanvas.remove();
atmosphere.remove();
blast.remove();
frame.style.filter = '';
frame.style.pointerEvents = '';
animating = false;
button.classList.remove('kkpt-busy','kkpt-quiet');
// No location.assign here: the rendered target stays alive.
history.pushState({ kkptV6: true, active }, '', active ? otherUrl.href : baseUrl);
}
function requestSwap(nextActive) {
if (animating || nextActive === active) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
setDisplayed(nextActive);
history.pushState({kkptV6:true,active},'',active?otherUrl.href:baseUrl);
return;
}
animateSwap(nextActive).catch(err => {
console.error('[KuranokaPageTransition] V6:',err);
document.getElementById('kkpt-vfx')?.remove();
document.getElementById('kkpt-atmosphere')?.remove();
document.getElementById('kkpt-impact')?.remove();
if (frame) frame.style.filter = '';
window.location.assign(nextActive ? otherUrl.href : baseUrl);
});
}
function createButton() {
if (!document.body || document.getElementById('kkpt-toggle')) return;
button=document.createElement('a');
button.id='kkpt-toggle';
labelIcon=document.createElement('span');
labelIcon.className='kkpt-icon';
labelIcon.setAttribute('aria-hidden','true');
const text=document.createElement('span');
text.className='kkpt-text';
labelTitle=document.createElement('span');
labelSub=document.createElement('span');
labelSub.className='kkpt-sub';
text.append(labelTitle,labelSub);
button.append(labelIcon,text);
document.body.append(button);
button.hidden=!SHOW_FLOATING_BUTTON;
updateButton();
watchPortalLinks(document, otherPage);
warmFrame();
button.addEventListener('click', event => {
if (!isPlainClick(event)) return;
event.preventDefault();
requestSwap(!active);
});
}
// A plain [[Kuronata|your mysterious text]] now triggers everything.
// The inverse [[Kuranoka|...]] works inside the already-loaded iframe.
document.addEventListener('click', event => {
if (active) return;
portalClick(event, document, otherPage, true);
}, true);
mw.hook('wikipage.content').add(() => {
if (document.readyState !== 'loading') watchPortalLinks(document, otherPage);
});
// Back and Forward restore the two already-loaded documents instantly.
window.addEventListener('popstate', () => {
if (!frame || !ready || animating) return;
const route = pathPage();
if (route === basePage || route === otherPage) {
setDisplayed(route === otherPage);
}
});
window.addEventListener('pageshow', event => {
if (!event.persisted || !frame || !ready) return;
animating=false;
document.getElementById('kkpt-vfx')?.remove();
document.getElementById('kkpt-atmosphere')?.remove();
document.getElementById('kkpt-impact')?.remove();
frame.style.filter = '';
setDisplayed(pathPage() === otherPage);
button?.classList.remove('kkpt-busy','kkpt-quiet');
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded',createButton,{once:true});
} else {
createButton();
}
}());