MediaWiki:KuranokaPageTransition.js:修订间差异
MediaWiki界面页面
更多操作
无编辑摘要 |
无编辑摘要 |
||
| (未显示同一用户的1个中间版本) | |||
| 第1行: | 第1行: | ||
/* ================================================================= | /* ==================================================================== | ||
KURANOKA | KURANOKA / KURONATA V4 — EMBER / AURORA | ||
MediaWiki:KuranokaPageTransition.js | |||
Same-origin iframe + history.pushState: NO full reload on toggles. | |||
Smooth SVG-path clipping + WebGL procedural flames / cold lightning. | |||
================================================================= */ | Backgrounds are prewarmed on page load. Zero external dependencies. | ||
==================================================================== */ | |||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
// The | // The preview is a complete MediaWiki page. It must NOT spawn another. | ||
if (window !== window.top || window. | if (window !== window.top || window.__kkptV4) return; | ||
if (!window.mw || !mw.config) return; | if (!window.mw || !mw.config) return; | ||
const | // The URL is the source of truth if a skin/extension restored a page | ||
if ( | // 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.__kkptV4 = true; | |||
const | window.__kkptVersion = '4.0'; | ||
const | |||
const | const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka'; | ||
const | const baseUrl = location.href; | ||
const | 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 smooth = t => t * t * (3 - 2 * t); | ||
const | const imagePool = new Map(); | ||
let | let active = false; // false: original top-level doc; true: otherPage iframe | ||
let | let animating = false; | ||
let | let ready = false; | ||
let | let loading = null; | ||
let button = | let button, labelTitle, labelSub, labelIcon, frame; | ||
let otherTitle = otherPage + ' - Blockland Wiki'; | |||
function whichPage() { return active ? otherPage : basePage; } | |||
function destination() { return active ? basePage : otherPage; } | |||
function | function isDark(page) { return page === 'Kuronata'; } | ||
const | 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'); | |||
} | |||
// 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) { | function add(value) { | ||
if (!value | if (!value) return; | ||
const | const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi; | ||
let match; | let match; | ||
while ((match = pattern.exec(value))) { | |||
while ((match = | try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ } | ||
} | } | ||
if ( | if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) { | ||
try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ } | try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ } | ||
} | } | ||
} | } | ||
selector.forEach(el => { | |||
if (!el) return; | |||
if (! | |||
try { | try { | ||
[null, '::before', '::after'].forEach(pseudo => { | [null, '::before', '::after'].forEach(pseudo => { | ||
const style = | const style = doc.defaultView.getComputedStyle(el, pseudo); | ||
if (style) | 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]; | return [...urls]; | ||
} | } | ||
function | function warmImage(url) { | ||
if ( | if (imagePool.has(url)) return imagePool.get(url).promise; | ||
const image = new Image(); | const image = new Image(); | ||
const promise = new Promise(resolve => { | 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; | return promise; | ||
} | } | ||
async function warmBackgrounds(doc) { | async function warmBackgrounds(doc) { | ||
const urls = | const urls = backgroundUrls(doc); | ||
await Promise.allSettled(urls.map(warmImage)); | |||
} | } | ||
function | function wireFrame() { | ||
if ( | try { | ||
const doc = frame.contentDocument; | |||
if (!doc) return; | |||
otherTitle = doc.title || otherTitle; | |||
// 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 || event.defaultPrevented || event.button !== 0 || | |||
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return; | |||
const el = event.target instanceof doc.defaultView.Element | |||
? event.target.closest('a[href]') : null; | |||
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 settled = false; | ||
function | let preparing = false; | ||
let poll = null; | |||
function finish(ok, reason) { | |||
if (settled) return; | if (settled) return; | ||
settled = true; | settled = true; | ||
clearInterval(poll); | |||
ready = ok; | |||
if (!ok) console.warn('[KuranokaPageTransition] Preview:', reason); | |||
resolve(ok); | resolve(ok); | ||
} | } | ||
async function inspect() { | |||
if (settled || preparing) return; | |||
try { | try { | ||
const doc = | const doc = frame.contentDocument; | ||
if (!doc || !doc.querySelector('#mw-content-text')) return | 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 | await sleep(120); | ||
await | await Promise.race([warmBackgrounds(doc), sleep(1300)]); | ||
frame.contentWindow.scrollTo(0, 0); | |||
wireFrame(); | |||
finish(true); | |||
} catch (err) { | } catch (err) { finish(false, String(err)); } | ||
} | |||
frame.addEventListener('load', () => { | |||
if (!settled && !preparing) inspect(); | |||
}); | |||
frame.addEventListener('error', () => finish(false,'iframe 网络加载错误')); | |||
document.body. | document.body.append(frame); | ||
poll = setInterval(inspect, 130); | |||
setTimeout(() => { if (!settled && !preparing) finish(false,'18 秒仍未找到目标正文'); }, 18000); | |||
}); | }); | ||
warmBackgrounds(document).catch(() => {}); | warmBackgrounds(document).catch(() => {}); | ||
setTimeout(() => warmBackgrounds(document).catch(() => {}), | setTimeout(() => warmBackgrounds(document).catch(() => {}), 700); | ||
return | return loading; | ||
} | } | ||
function | // This edge is used by BOTH the DOM clip and the shader. | ||
const | function edgeAt(y, seconds, progress, width) { | ||
const eased = smooth(progress); | |||
const | const toDark = window.__kkptVisualDark; | ||
const | 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)) | |||
); | |||
} | } | ||
const | 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) { | |||
// Fluid, upward-drifting fBm and domain warping. | |||
vec2 q = vec2(p.y*.012-t*.44, oldSide*.017-t*.68); | |||
float warp1 = fbm(q+vec2(0.0,t*.4)); | |||
float warp2 = fbm(q*2.3+warp1*2.2); | |||
float curl = fbm(q*1.35+vec2(warp2*2.8,-warp1*1.7)); | |||
float domain = mix(warp1,warp2,.5); | |||
float tongues = 140.0*clamp((domain*.76+curl*.53),0.0,1.0); | |||
float fire = clamp((tongues-oldSide)/68.0,0.0,1.0); | |||
fire *= smoothstep(-55.0,8.0,oldSide); | |||
float core = exp(-abs(d)/9.0); | |||
float outer = exp(-abs(d)/48.0); | |||
vec2 sq = vec2(p.y*.010+t*.14,oldSide*.013-t*.24); | |||
float sootNoise = fbm(sq+fbm(sq*2.0)); | |||
float soot = (1.0-smoothstep(45.0,270.0,oldSide))* | |||
smoothstep(-28.0,17.0,oldSide)*(sootNoise*.75+.20); | |||
vec3 sootColor = mix(vec3(.028,.015,.016),vec3(.14,.040,.027),sootNoise); | |||
float heat = clamp(fire*.85+core*.6,0.0,1.0); | |||
vec3 fireColor = mix(vec3(.36,.015,.008),vec3(1.0,.20,.025),smoothstep(.15,.58,heat)); | |||
fireColor = mix(fireColor,vec3(1.0,.79,.21),smoothstep(.55,.88,heat)); | |||
fireColor = mix(fireColor,vec3(1.0,.97,.74),smoothstep(.85,1.0,heat)); | |||
col = mix(sootColor,fireColor,clamp(fire*1.10+core,0.0,1.0)); | |||
alpha = clamp(soot*.58+fire*.90+outer*.19+core*.64,0.0,.96); | |||
// Fine orange embers, not giant elliptical blobs. | |||
vec2 cell = floor((p+vec2(t*27.0,-t*100.0))/13.0); | |||
vec2 uv = fract((p+vec2(t*27.0,-t*100.0))/13.0)-.5; | |||
float rand = random2(cell); | |||
float sparks = (1.0-smoothstep(0.02,.17,length(uv)))* | |||
step(.987,rand)*exp(-abs(d)/210.0); | |||
col = mix(col,vec3(1.0,.78,.30),sparks); | |||
p | alpha = max(alpha,sparks*.93); | ||
} else { | |||
// The return is ELECTRIC AURORA / COLD MIST, NOT BLUE 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 mist = exp(-abs(d)/115.0)*(cloud*.7+.13); | |||
float ion = exp(-abs(d)/26.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(.012,.10,.24),vec3(.20,.64,1.0),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*.70+ion*.36+arc*.95+branches*.61,0.0,.89); | |||
} | |||
// 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', { | |||
const | 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'); | |||
gl.enable(gl.BLEND); | |||
gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA); | |||
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); | |||
}; | |||
} | |||
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); | |||
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); | |||
draw(seconds,t,destinationDark); | |||
if (t < 1) requestAnimationFrame(tick); | |||
else resolve(); | |||
const | |||
if ( | |||
} | } | ||
requestAnimationFrame( | requestAnimationFrame(tick); | ||
}); | }); | ||
setDisplayed(nextActive); | |||
fxCanvas.style.opacity = '0'; | |||
await sleep(190); | |||
fxCanvas.remove(); | |||
frame.style.pointerEvents = ''; | |||
animating = false; | |||
button.classList.remove('kkpt-busy','kkpt-quiet'); | |||
// No location.assign here: the rendered target stays alive. | |||
history.pushState({ kkptV4: true, active }, '', active ? otherUrl.href : baseUrl); | |||
button.classList.remove('kkpt- | |||
} | } | ||
function | function createButton() { | ||
if (!document.body || document.getElementById('kkpt-toggle')) return; | if (!document.body || document.getElementById('kkpt-toggle')) return; | ||
button = document.createElement('a'); | button=document.createElement('a'); | ||
button.id = 'kkpt-toggle'; | 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'); | |||
const | labelSub.className='kkpt-sub'; | ||
text.append(labelTitle,labelSub); | |||
button.append(labelIcon,text); | |||
button.append( | |||
document.body.append(button); | document.body.append(button); | ||
updateButton(); | |||
warmFrame(); | |||
button.addEventListener('click', event => { | button.addEventListener('click', event => { | ||
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || | if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || | ||
event.metaKey || event. | event.metaKey || event.altKey || event.shiftKey) return; | ||
event.preventDefault(); | event.preventDefault(); | ||
if ( | if (animating) return; | ||
if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) { | |||
setDisplayed(!active); | |||
if ( | history.pushState({kkptV4:true,active},'',active?otherUrl.href:baseUrl); | ||
return; | return; | ||
} | } | ||
animateSwap(!active).catch(err => { | |||
console.error('[KuranokaPageTransition] | console.error('[KuranokaPageTransition] V4:',err); | ||
window.location.assign(active ? baseUrl : otherUrl.href); | |||
window.location.assign( | |||
}); | }); | ||
}); | }); | ||
} | } | ||
// 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(); | |||
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日 (五) 12:28的版本
/* ====================================================================
KURANOKA / KURONATA V4 — EMBER / AURORA
MediaWiki:KuranokaPageTransition.js
Same-origin iframe + history.pushState: NO full reload on toggles.
Smooth SVG-path clipping + WebGL procedural flames / cold lightning.
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.__kkptV4) 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.__kkptV4 = true;
window.__kkptVersion = '4.0';
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');
}
// 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;
// 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 || event.defaultPrevented || event.button !== 0 ||
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
const el = event.target instanceof doc.defaultView.Element
? event.target.closest('a[href]') : null;
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) {
// Fluid, upward-drifting fBm and domain warping.
vec2 q = vec2(p.y*.012-t*.44, oldSide*.017-t*.68);
float warp1 = fbm(q+vec2(0.0,t*.4));
float warp2 = fbm(q*2.3+warp1*2.2);
float curl = fbm(q*1.35+vec2(warp2*2.8,-warp1*1.7));
float domain = mix(warp1,warp2,.5);
float tongues = 140.0*clamp((domain*.76+curl*.53),0.0,1.0);
float fire = clamp((tongues-oldSide)/68.0,0.0,1.0);
fire *= smoothstep(-55.0,8.0,oldSide);
float core = exp(-abs(d)/9.0);
float outer = exp(-abs(d)/48.0);
vec2 sq = vec2(p.y*.010+t*.14,oldSide*.013-t*.24);
float sootNoise = fbm(sq+fbm(sq*2.0));
float soot = (1.0-smoothstep(45.0,270.0,oldSide))*
smoothstep(-28.0,17.0,oldSide)*(sootNoise*.75+.20);
vec3 sootColor = mix(vec3(.028,.015,.016),vec3(.14,.040,.027),sootNoise);
float heat = clamp(fire*.85+core*.6,0.0,1.0);
vec3 fireColor = mix(vec3(.36,.015,.008),vec3(1.0,.20,.025),smoothstep(.15,.58,heat));
fireColor = mix(fireColor,vec3(1.0,.79,.21),smoothstep(.55,.88,heat));
fireColor = mix(fireColor,vec3(1.0,.97,.74),smoothstep(.85,1.0,heat));
col = mix(sootColor,fireColor,clamp(fire*1.10+core,0.0,1.0));
alpha = clamp(soot*.58+fire*.90+outer*.19+core*.64,0.0,.96);
// Fine orange embers, not giant elliptical blobs.
vec2 cell = floor((p+vec2(t*27.0,-t*100.0))/13.0);
vec2 uv = fract((p+vec2(t*27.0,-t*100.0))/13.0)-.5;
float rand = random2(cell);
float sparks = (1.0-smoothstep(0.02,.17,length(uv)))*
step(.987,rand)*exp(-abs(d)/210.0);
col = mix(col,vec3(1.0,.78,.30),sparks);
alpha = max(alpha,sparks*.93);
} else {
// The return is ELECTRIC AURORA / COLD MIST, NOT BLUE 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 mist = exp(-abs(d)/115.0)*(cloud*.7+.13);
float ion = exp(-abs(d)/26.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(.012,.10,.24),vec3(.20,.64,1.0),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*.70+ion*.36+arc*.95+branches*.61,0.0,.89);
}
// 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');
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
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);
};
}
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);
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);
draw(seconds,t,destinationDark);
if (t < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
setDisplayed(nextActive);
fxCanvas.style.opacity = '0';
await sleep(190);
fxCanvas.remove();
frame.style.pointerEvents = '';
animating = false;
button.classList.remove('kkpt-busy','kkpt-quiet');
// No location.assign here: the rendered target stays alive.
history.pushState({ kkptV4: true, active }, '', active ? 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);
updateButton();
warmFrame();
button.addEventListener('click', event => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey ||
event.metaKey || event.altKey || event.shiftKey) return;
event.preventDefault();
if (animating) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches && ready) {
setDisplayed(!active);
history.pushState({kkptV4:true,active},'',active?otherUrl.href:baseUrl);
return;
}
animateSwap(!active).catch(err => {
console.error('[KuranokaPageTransition] V4:',err);
window.location.assign(active ? baseUrl : otherUrl.href);
});
});
}
// 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();
setDisplayed(pathPage() === otherPage);
button?.classList.remove('kkpt-busy','kkpt-quiet');
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded',createButton,{once:true});
} else {
createButton();
}
}());