MediaWiki:KuranokaPageTransition.js:修订间差异
MediaWiki界面页面
更多操作
无编辑摘要 |
无编辑摘要 |
||
| 第1行: | 第1行: | ||
/* | /* ============================================================ | ||
Kuranoka / Kuronata — Page Transition V15 | |||
V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments. | |||
Only this page-scoped JS/CSS; never modify Citizen or loader.js. | |||
Two living same-origin Wiki documents; no reload for toggles. | |||
============================================================ */ | |||
(function () { | (function () { | ||
'use strict'; | 'use strict'; | ||
if (!window.mw || !mw.config) return; | if (!window.mw || !mw.config) return; | ||
const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/); | const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/); | ||
const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title'); | const urlPage = pathMatch ? pathMatch[1] | ||
const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage) | : new URLSearchParams(location.search).get('title'); | ||
const basePage = ['Kuranoka','Kuronata'].includes(urlPage) | |||
? urlPage : mw.config.get('wgPageName'); | ? urlPage : mw.config.get('wgPageName'); | ||
const action = new URLSearchParams(location.search).get('action') || 'view'; | const action = new URLSearchParams(location.search).get('action') || 'view'; | ||
if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return; | if (!['Kuranoka','Kuronata'].includes(basePage) || action !== 'view') return; | ||
// Snapshot the user's actual Citizen theme. An entering Kuronata | |||
// is dark only for this document; returning to Kuranoka restores | |||
// exactly the former Day / OS / Night preference classes. | |||
const root=document.documentElement; | |||
const themeNames=['skin-theme-clientpref-day','skin-theme-clientpref-os', | |||
'skin-theme-clientpref-night','skin-theme-clientpref-black', | |||
'skin-citizen-light']; | |||
let savedTheme=themeNames.filter(c=>root.classList.contains(c)); | |||
let wantsNight=basePage==='Kuronata'; | |||
let changingTheme=false; | |||
function syncTheme() { | |||
if (changingTheme) return; | |||
const expected=wantsNight | |||
? ['skin-theme-clientpref-night','kkpt-kuronata-dark'] | |||
: savedTheme; | |||
const forbidden=wantsNight | |||
? themeNames.filter(c=>c!=='skin-theme-clientpref-night') | |||
: ['kkpt-kuronata-dark',...themeNames.filter(c=>!savedTheme.includes(c))]; | |||
if(expected.every(c=>root.classList.contains(c)) && | |||
forbidden.every(c=>!root.classList.contains(c))) return; | |||
changingTheme=true; | |||
root.classList.remove(...themeNames,'kkpt-kuronata-dark'); | |||
root.classList.add(...expected); | |||
changingTheme=false; | |||
} | |||
function showTheme(dark) { wantsNight=dark;syncTheme(); } | |||
syncTheme(); | |||
new MutationObserver(()=>{ | |||
// Follow later preference changes while Kuranoka is visible. | |||
// Only Kuronata needs an enforced appearance. | |||
if(wantsNight)syncTheme(); | |||
else savedTheme=themeNames.filter(c=>root.classList.contains(c)); | |||
}).observe(root,{ | |||
attributes:true,attributeFilter:['class'] | |||
}); | |||
// | // The nested preview only applies its own theme; no recursive iframe. | ||
if (window!==window.top || window.__kkptV12) return; | |||
window.__kkptV12=true; | |||
window.__kkptVersion='15.0'; | |||
const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka'; | // Easter eggs + Infobox gear are the default. No floating pill. | ||
const baseUrl = location.href; | const SHOW_FLOATING_BUTTON=false; | ||
const baseTitle = document.title; | const otherPage=basePage==='Kuranoka'?'Kuronata':'Kuranoka'; | ||
const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href); | const baseUrl=location.href; | ||
const clipSide = basePage === 'Kuranoka' ? 'right' : 'left'; | const baseTitle=document.title; | ||
const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)'; | const otherUrl=new URL('/wiki/'+encodeURIComponent(otherPage),location.href); | ||
const sleep = ms => new Promise(r => setTimeout(r, ms)); | const clipSide=basePage==='Kuranoka'?'right':'left'; | ||
const smooth = t => t * t * (3 - 2 * t); | const hideClip=clipSide==='right'?'inset(0 0 0 100%)':'inset(0 100% 0 0)'; | ||
const imagePool = new Map(); | const sleep=ms=>new Promise(r=>setTimeout(r,ms)); | ||
const smooth=t=>t*t*(3-2*t); | |||
const imagePool=new Map(); | |||
const ENABLE_TRANSITION_AUDIO = true; | const ENABLE_TRANSITION_AUDIO = true; | ||
const FIRE_VOLUME = .30; | const FIRE_VOLUME = .30; | ||
| 第269行: | 第246行: | ||
const root = doc.querySelector('#mw-content-text,.mw-parser-output'); | const root = doc.querySelector('#mw-content-text,.mw-parser-output'); | ||
if (!root) return; | if (!root) return; | ||
const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage)); | let pending=false; | ||
const observer = new MutationObserver(() => { | |||
if(pending)return; | |||
pending=true; | |||
setTimeout(()=>{ | |||
pending=false; | |||
decoratePortalLinks(doc,targetPage); | |||
},120); | |||
}); | |||
observer.observe(root, {childList:true,subtree:true}); | observer.observe(root, {childList:true,subtree:true}); | ||
doc.__kkptPortalObserver = observer; | doc.__kkptPortalObserver = observer; | ||
| 第385行: | 第370行: | ||
try { | try { | ||
doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container') | doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container') | ||
.forEach((el, i) => { if (i < | .forEach((el, i) => { if (i < 18) selector.push(el); }); | ||
} catch (e) { /* use html/body */ } | } catch (e) { /* use html/body */ } | ||
function add(value) { | function add(value) { | ||
| 第537行: | 第522行: | ||
} | } | ||
// | // Original V2's moving flame sprites and three bright contours, rebuilt | ||
// | // with bounded particle counts. No WebGL, no full-screen noise pass, | ||
// | // no 1,000-point polygon or per-frame image-texture upload. | ||
let | let sprites=null; | ||
function | function makeSprites() { | ||
if (sprites) return sprites; | |||
function radial(stops) { | |||
const el=document.createElement('canvas'); | |||
el.width=el.height=96; | |||
const c=el.getContext('2d'); | |||
const g=c.createRadialGradient(48,48,0,48,48,48); | |||
stops.forEach(pair=>g.addColorStop(pair[0],pair[1])); | |||
c.fillStyle=g;c.fillRect(0,0,96,96); | |||
return el; | |||
const | |||
const | |||
return | |||
} | } | ||
sprites={ | |||
flame:radial([[0,'#fffbed'],[.18,'#fff2a0'], | |||
[.43,'#ffb42b'],[.70,'#ff360088'],[1,'#8f050000']]), | |||
ember:radial([[0,'#ffffff'],[.15,'#fff6a8'], | |||
[.44,'#ff9900cc'],[1,'#ef280000']]), | |||
smoke:radial([[0,'#080608dd'],[.30,'#100b10bb'], | |||
[.62,'#160f1677'],[1,'#08050800']]), | |||
sky:radial([[0,'#ffffff'],[.16,'#e6ffff'], | |||
[.46,'#94deffe0'],[1,'#4ab5ff00']]), | |||
mist:radial([[0,'#f2fdffb0'],[.4,'#abdffff0'], | |||
[1,'#a9e7ff00']]) | |||
}; | |||
return sprites; | |||
return | |||
} | } | ||
// | // V2's simple advancing boundary. The reader asked for the | ||
// | // vibrant fire curtain, not a mathematically fractal tearing wire. | ||
function makeEdge(t,seconds,dark,w) { | |||
function | const center=dark | ||
? w+105-(w+210)*smooth(t) | |||
const | : -105+(w+210)*smooth(t); | ||
const wobble=Math.sin(Math.PI*t); | |||
return y=>center+wobble*( | |||
17*Math.sin(y*.023+seconds*5.5)+ | |||
9*Math.sin(y*.063-seconds*8.4)+ | |||
4*Math.sin(y*.13+seconds*12) | |||
); | |||
} | } | ||
// | // Clip the ACTUAL preloaded Wiki frame, precisely where the fire is. | ||
function clipFrame(edge) { | |||
const w=innerWidth,h=innerHeight; | |||
const rows=Math.max(32,Math.ceil(h/22)); | |||
const pts=[]; | |||
for(let i=rows;i>=0;i--) { | |||
const y=h*i/rows; | |||
pts.push(edge(y).toFixed(1)+'px '+y.toFixed(1)+'px'); | |||
function clipFrame( | |||
const | |||
const | |||
} | } | ||
frame.style.clipPath=clipSide==='right' | |||
? 'polygon(100% 0,100% 100%,'+pts.join(',')+')' | |||
: 'polygon(0 0,0 100%,'+pts.join(',')+')'; | |||
} | } | ||
function canvasFire(canvas,dark,duration,done) { | |||
const ctx=canvas.getContext('2d',{alpha:true,desynchronized:true}); | |||
if(!ctx){done();return;} | |||
const art=makeSprites(); | |||
const w=innerWidth,h=innerHeight; | |||
const scale=Math.min(window.devicePixelRatio||1,w<650?.88:1.0); | |||
canvas.width=Math.max(1,Math.round(w*scale)); | |||
canvas.height=Math.max(1,Math.round(h*scale)); | |||
ctx.setTransform(scale,0,0,scale,0,0); | |||
const rand=(a,b)=>a+Math.random()*(b-a); | |||
const mobile=w<650; | |||
const maxCount=mobile?175:340; | |||
const p=[]; | |||
const accum={flame:0,ember:0,smoke:0,shard:0,sky:0,mist:0,pollen:0}; | |||
const rates=dark | |||
? {flame:mobile?85:120,ember:mobile?75:113, | |||
smoke:mobile?28:43,shard:mobile?30:50} | |||
: {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50}; | |||
const started=performance.now(); | |||
let previous=started; | |||
let lastPaint=0; | |||
function spawn(kind,edge,seconds) { | |||
if(p.length>=maxCount)return; | |||
const y=rand(-25,h+25); | |||
const x=edge(y); | |||
const type=kind; | |||
const smoke=type==='smoke'; | |||
const shard=type==='shard'; | |||
const ember=type==='ember'||type==='pollen'; | |||
const blue=type==='sky'||type==='mist'||type==='pollen'; | |||
let radius,life,vx,vy; | |||
if(shard){ | |||
radius=rand(4,18);life=rand(.6,1.65); | |||
vx=rand(-130,65);vy=rand(-125,30); | |||
}else if(smoke){ | |||
radius=rand(32,85);life=rand(1.1,2.5); | |||
vx=rand(-20,65);vy=rand(-88,-22); | |||
}else if(type==='mist'){ | |||
radius=rand(45,100);life=rand(.9,1.8); | |||
vx=rand(-35,45);vy=rand(-75,-15); | |||
}else if(type==='flame'){ | |||
radius=rand(20,57);life=rand(.3,.82); | |||
vx=rand(-100,30);vy=rand(-180,-45); | |||
if ( | |||
} else { | |||
}else{ | }else{ | ||
radius=ember?rand(2.5,9):rand(18,47); | |||
life=ember?rand(.45,1.1):rand(.35,.85); | |||
vx=rand(-95,75);vy=rand(-150,-25); | |||
} | } | ||
p.push({ | |||
type,life,left:life, | |||
x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64), | |||
y:y+rand(-14,14),vx,vy,radius, | |||
angle:rand(-Math.PI,Math.PI),spin:rand(-5,5), | |||
drift:rand(-.65,.65),blue | |||
}); | |||
} | } | ||
function | function spawnAtRate(dt,edge,seconds,strength){ | ||
for(const type in rates){ | |||
accum[type]+=rates[type]*dt*strength; | |||
const n=Math.min(11,Math.floor(accum[type])); | |||
accum[type]-=n; | |||
for(let i=0;i<n;i++)spawn(type,edge,seconds); | |||
const | |||
} | } | ||
} | } | ||
function advance(dt){ | |||
for(let i=p.length-1;i>=0;i--) { | |||
const q=p[i]; | |||
q.left-=dt; | |||
if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;} | |||
q.x+=q.vx*dt; | |||
q.y+=q.vy*dt; | |||
q.vx+=q.drift*dt*35; | |||
q.vy-=10*dt; | |||
q.angle+=q.spin*dt; | |||
} | } | ||
} | } | ||
function spriteDraw(image,x,y,sx,sy,alpha){ | |||
ctx.globalAlpha=alpha; | |||
ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2); | |||
function | |||
} | } | ||
function drawSmoke(fade){ | |||
ctx.globalCompositeOperation='source-over'; | |||
for(const q of p){ | |||
if(q.type!=='smoke'&&q.type!=='mist')continue; | |||
const age=1-q.left/q.life; | |||
const radius=q.radius*(1+age*.98); | |||
const alpha=(q.type==='smoke'?.76:.32)* | |||
Math.min(1,age*5)*Math.min(1,q.left/.55)*fade; | |||
spriteDraw(q.type==='smoke'?art.smoke:art.mist, | |||
q.x,q.y,radius*1.7,radius*1.3,alpha); | |||
} | } | ||
ctx. | ctx.globalAlpha=1; | ||
} | } | ||
function drawShards(fade) { | |||
if(!dark)return; | |||
ctx.save(); | |||
if( | |||
ctx. | |||
ctx.globalCompositeOperation='source-over'; | ctx.globalCompositeOperation='source-over'; | ||
for(const q of p){ | |||
for( | if(q.type!=='shard')continue; | ||
const age=1-q.left/q.life; | |||
const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade; | |||
const | |||
const | |||
ctx.save(); | ctx.save(); | ||
ctx.translate(q.x,q.y); | |||
ctx.rotate(q.angle); | |||
ctx.globalAlpha=a; | |||
const r=q.radius; | |||
const | |||
ctx.beginPath(); | ctx.beginPath(); | ||
ctx.moveTo(-r*.9,-r*.3); | |||
ctx.lineTo(r*.7,-r*.75); | |||
ctx.lineTo(r*.48,r*.42); | |||
ctx.lineTo(-r*.35,r*.8); | |||
ctx.closePath(); | |||
ctx.fillStyle=age<.3?'#371d22':'#140d13'; | |||
ctx.fill(); | |||
ctx.strokeStyle=age<.42?'#f57d3c':'#5a2c2b'; | |||
ctx.lineWidth=age<.42?1.6:.75; | |||
ctx.stroke(); | ctx.stroke(); | ||
ctx.restore(); | ctx.restore(); | ||
} | } | ||
ctx.restore(); | |||
} | |||
function drawCore(edge,seconds,t,fade){ | |||
const waviness=Math.sin(Math.PI*t); | |||
ctx.save(); | ctx.save(); | ||
ctx.beginPath(); | ctx.beginPath(); | ||
for(let | const count=Math.max(35,Math.ceil(h/19)); | ||
const | for(let i=0;i<=count;i++){ | ||
if(i===0)ctx.moveTo( | const y=h*i/count; | ||
if(i===0)ctx.moveTo(edge(y),y); | |||
else ctx.lineTo(edge(y),y); | |||
} | } | ||
ctx.lineCap='round';ctx.lineJoin='round'; | ctx.lineCap='round'; | ||
ctx.lineJoin='round'; | |||
if(dark){ | if(dark){ | ||
ctx.strokeStyle='rgba( | ctx.globalCompositeOperation='lighter'; | ||
ctx.lineWidth= | ctx.globalAlpha=fade; | ||
ctx.shadowColor='#ff3903'; | |||
ctx.shadowBlur=36; | |||
ctx.strokeStyle='rgba(255,55,5,.82)'; | |||
ctx.lineWidth=43; | |||
ctx.stroke(); | ctx.stroke(); | ||
ctx.strokeStyle=' | ctx.shadowBlur=21; | ||
ctx.lineWidth= | ctx.strokeStyle='#ff9b17'; | ||
ctx.lineWidth=20; | |||
ctx.stroke(); | |||
ctx.shadowBlur=9; | |||
ctx.strokeStyle='#ffe45e'; | |||
ctx.lineWidth=9; | |||
ctx.stroke(); | |||
ctx.shadowBlur=4; | |||
ctx.strokeStyle='#fffcde'; | |||
ctx.lineWidth=3.8; | |||
ctx.stroke(); | ctx.stroke(); | ||
}else{ | }else{ | ||
ctx. | ctx.globalAlpha=fade*.64; | ||
ctx. | ctx.shadowColor='#8ccfff'; | ||
ctx.shadowBlur=29; | |||
ctx.strokeStyle='rgba(139,219,255,.68)'; | |||
ctx.lineWidth=28; | |||
ctx.stroke(); | ctx.stroke(); | ||
ctx. | ctx.shadowBlur=10; | ||
ctx. | ctx.strokeStyle='#e7f8ff'; | ||
ctx.lineWidth=9; | |||
ctx.stroke(); | ctx.stroke(); | ||
ctx.strokeStyle=' | ctx.shadowBlur=0; | ||
ctx.lineWidth=2 | ctx.strokeStyle='white'; | ||
ctx.lineWidth=2; | |||
ctx.stroke(); | ctx.stroke(); | ||
} | } | ||
ctx.restore(); | ctx.restore(); | ||
// Dense GOLD / WHITE rolling fire, including on both sides | |||
// of the border. These are the luminous shapes from V2. | |||
if(dark){ | if(dark){ | ||
ctx.save(); | ctx.save(); | ||
ctx.globalCompositeOperation=' | ctx.globalCompositeOperation='lighter'; | ||
for(let y=-70;y<h+80;y+=48){ | |||
for(let y=- | const x=edge(y); | ||
const | const pulse=.65+.35*Math.sin(seconds*8.5+y*.083); | ||
spriteDraw(art.flame,x+rand(-8,30),y,77,70, | |||
fade*(.26+.16*pulse)); | |||
} | } | ||
for(let y=-20;y<h+52;y+=36){ | |||
const x=edge(y); | |||
const flicker=Math.sin(seconds*13+y*.19); | |||
const length=52+30*flicker+17*Math.sin(seconds*6-y*.11); | |||
const lean=15+18*Math.sin(seconds*9+y*.069); | |||
ctx.globalAlpha=.62*fade; | |||
ctx.fillStyle='#ff5705'; | |||
ctx.beginPath(); | |||
ctx.globalAlpha= | ctx.moveTo(x+18,y+19); | ||
ctx. | ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57, | ||
x-lean,y-length); | |||
ctx. | ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19); | ||
ctx.fill(); | |||
ctx.globalAlpha=.58*fade; | |||
ctx.fillStyle='#ffdb58'; | |||
ctx.beginPath(); | |||
ctx.moveTo(x+7,y+8); | |||
ctx.quadraticCurveTo(x-10,y-length*.33, | |||
x-lean*.8,y-length*.72); | |||
ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8); | |||
ctx.fill(); | |||
ctx.globalAlpha=.38*fade; | |||
ctx.fillStyle='#fffced'; | |||
ctx.beginPath(); | ctx.beginPath(); | ||
ctx.moveTo(x+3,y+5); | |||
ctx.quadraticCurveTo(x-5,y-length*.2, | |||
x-lean*.5,y-length*.48); | |||
ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5); | |||
ctx.fill(); | |||
ctx. | |||
} | } | ||
ctx.restore(); | ctx.restore(); | ||
}else{ | |||
ctx.save(); | ctx.save(); | ||
for(let | ctx.globalCompositeOperation='lighter'; | ||
for(let y=-50;y<h+55;y+=88){ | |||
spriteDraw(art.sky,edge(y),y,89,109,.27*fade); | |||
} | } | ||
ctx.restore(); | ctx.restore(); | ||
} | } | ||
} | |||
function drawBrightParticles(fade) { | |||
ctx.save(); | ctx.save(); | ||
ctx.globalCompositeOperation='lighter'; | ctx.globalCompositeOperation='lighter'; | ||
for(const p | for(const q of p){ | ||
if( | if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue; | ||
const age=1- | const age=1-q.left/q.life; | ||
const | const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade; | ||
let artName='flame',sx=q.radius,sy=q.radius; | |||
if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;} | |||
else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;} | |||
else if(q.type==='sky'){artName='sky';sx*=1.1;sy*=1.55;} | |||
if( | else {sx*=.9;sy*=1.65;} | ||
spriteDraw(art[artName],q.x,q.y,sx,sy, | |||
brightness*(q.type==='ember'?.93:.78)); | |||
} | } | ||
ctx.restore(); | ctx.restore(); | ||
ctx.globalAlpha=1; | |||
ctx.globalCompositeOperation='source-over'; | ctx.globalCompositeOperation='source-over'; | ||
} | |||
function paint(now) { | |||
if(now-lastPaint<(mobile?39:31)){ | |||
requestAnimationFrame(paint);return; | |||
} | } | ||
const dt=Math.min(.05,(now-previous)/1000); | |||
previous=now;lastPaint=now; | |||
const t=Math.min(1,(now-started)/duration); | |||
const seconds=now/1000; | |||
const edge=makeEdge(t,seconds,dark,w); | |||
const fade=Math.min(1,t/.055,(1-t)/.045); | |||
clipFrame(edge); | |||
advance(dt); | |||
if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade)); | |||
ctx.clearRect(0,0,w,h); | |||
drawSmoke(fade); | |||
drawShards(fade); | |||
drawCore(edge,seconds,t,fade); | |||
drawBrightParticles(fade); | |||
if(t<1)requestAnimationFrame(paint); | |||
else done(); | |||
} | |||
requestAnimationFrame(paint); | |||
} | } | ||
function setDisplayed(nextActive) { | function setDisplayed(nextActive) { | ||
active = nextActive; | active=nextActive; | ||
frame.classList.toggle('kkpt-active', active); | frame.classList.toggle('kkpt-active',active); | ||
frame.style.clipPath = active ? 'inset(0)' : hideClip; | frame.style.clipPath=active?'inset(0)':hideClip; | ||
frame.tabIndex = active ? 0 : -1; | frame.style.pointerEvents=''; | ||
frame.setAttribute('aria-hidden', active ? 'false' : 'true'); | frame.tabIndex=active?0:-1; | ||
document.title = active ? otherTitle : baseTitle; | frame.setAttribute('aria-hidden',active?'false':'true'); | ||
document.title=active?otherTitle:baseTitle; | |||
showTheme((active?otherPage:basePage)==='Kuronata'); | |||
updateButton(); | updateButton(); | ||
} | } | ||
async function animateSwap(nextActive) { | async function animateSwap(nextActive) { | ||
if (animating) return; | if(animating)return; | ||
animating = true; | animating=true; | ||
button.classList.add('kkpt-busy'); | button.classList.add('kkpt-busy'); | ||
labelSub.textContent='PREPARING THE OTHER SIDE...'; | |||
if(!loading)warmFrame(); | |||
labelSub.textContent = 'PREPARING THE OTHER SIDE...'; | const ok=await Promise.race([loading,sleep(11000).then(()=>false)]); | ||
if (!loading) warmFrame(); | if(!ok||!ready){ | ||
const | window.location.assign(nextActive?otherUrl.href:baseUrl);return; | ||
if (! | |||
window.location.assign(nextActive ? otherUrl.href : baseUrl); | |||
} | } | ||
const | const dark=(nextActive?otherPage:basePage)==='Kuronata'; | ||
labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING'; | |||
labelSub.textContent = | |||
frame.classList.remove('kkpt-active'); | frame.classList.remove('kkpt-active'); | ||
frame.style.pointerEvents = 'none'; | frame.style.pointerEvents='none'; | ||
frame.style.clipPath = active ? 'inset(0)' : hideClip; | frame.style.clipPath=active?'inset(0)':hideClip; | ||
const canvas = document.createElement('canvas'); | const canvas=document.createElement('canvas'); | ||
canvas.id = 'kkpt-vfx'; | canvas.id='kkpt-vfx'; | ||
canvas.setAttribute('aria-hidden','true'); | |||
document.body.append(canvas); | document.body.append(canvas); | ||
const duration=dark?3450:2950; | |||
transitionSound(dark,duration); | |||
await new Promise(resolve=>canvasFire(canvas,dark,duration,resolve)); | |||
const duration = | |||
transitionSound( | |||
await new Promise(resolve => | |||
setDisplayed(nextActive); | setDisplayed(nextActive); | ||
canvas.style.opacity='0'; | |||
await sleep(160); | |||
canvas.remove(); | |||
await sleep( | animating=false; | ||
animating = false; | |||
button.classList.remove('kkpt-busy','kkpt-quiet'); | button.classList.remove('kkpt-busy','kkpt-quiet'); | ||
// No | // The target remains visible in a live iframe. No reload. | ||
history.pushState({ kkptV12: true, active }, '', active ? otherUrl.href : baseUrl); | history.pushState({kkptV12:true,active},'', | ||
active?otherUrl.href:baseUrl); | |||
} | } | ||
| 第1,776行: | 第905行: | ||
console.error('[KuranokaPageTransition] V12:',err); | console.error('[KuranokaPageTransition] V12:',err); | ||
document.getElementById('kkpt-vfx')?.remove(); | document.getElementById('kkpt-vfx')?.remove(); | ||
if (frame) frame.style.filter = ''; | if (frame) frame.style.filter = ''; | ||
window.location.assign(nextActive ? otherUrl.href : baseUrl); | window.location.assign(nextActive ? otherUrl.href : baseUrl); | ||
| 第1,835行: | 第962行: | ||
animating=false; | animating=false; | ||
document.getElementById('kkpt-vfx')?.remove(); | document.getElementById('kkpt-vfx')?.remove(); | ||
frame.style.filter = ''; | frame.style.filter = ''; | ||
setDisplayed(pathPage() === otherPage); | setDisplayed(pathPage() === otherPage); | ||
2026年10月23日 (五) 15:54的版本
/* ============================================================
Kuranoka / Kuronata — Page Transition V15
V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
Only this page-scoped JS/CSS; never modify Citizen or loader.js.
Two living same-origin Wiki documents; no reload for toggles.
============================================================ */
(function () {
'use strict';
if (!window.mw || !mw.config) return;
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;
// Snapshot the user's actual Citizen theme. An entering Kuronata
// is dark only for this document; returning to Kuranoka restores
// exactly the former Day / OS / Night preference classes.
const root=document.documentElement;
const themeNames=['skin-theme-clientpref-day','skin-theme-clientpref-os',
'skin-theme-clientpref-night','skin-theme-clientpref-black',
'skin-citizen-light'];
let savedTheme=themeNames.filter(c=>root.classList.contains(c));
let wantsNight=basePage==='Kuronata';
let changingTheme=false;
function syncTheme() {
if (changingTheme) return;
const expected=wantsNight
? ['skin-theme-clientpref-night','kkpt-kuronata-dark']
: savedTheme;
const forbidden=wantsNight
? themeNames.filter(c=>c!=='skin-theme-clientpref-night')
: ['kkpt-kuronata-dark',...themeNames.filter(c=>!savedTheme.includes(c))];
if(expected.every(c=>root.classList.contains(c)) &&
forbidden.every(c=>!root.classList.contains(c))) return;
changingTheme=true;
root.classList.remove(...themeNames,'kkpt-kuronata-dark');
root.classList.add(...expected);
changingTheme=false;
}
function showTheme(dark) { wantsNight=dark;syncTheme(); }
syncTheme();
new MutationObserver(()=>{
// Follow later preference changes while Kuranoka is visible.
// Only Kuronata needs an enforced appearance.
if(wantsNight)syncTheme();
else savedTheme=themeNames.filter(c=>root.classList.contains(c));
}).observe(root,{
attributes:true,attributeFilter:['class']
});
// The nested preview only applies its own theme; no recursive iframe.
if (window!==window.top || window.__kkptV12) return;
window.__kkptV12=true;
window.__kkptVersion='15.0';
// Easter eggs + Infobox gear are the default. No floating pill.
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();
const ENABLE_TRANSITION_AUDIO = true;
const FIRE_VOLUME = .30;
const DAWN_VOLUME = .20;
let soundContext=null,fireBuffer=null,activeSound=null;
function primeSound(){
if(!ENABLE_TRANSITION_AUDIO)return;
try{
const AudioEngine=window.AudioContext||window.webkitAudioContext;
if(!AudioEngine)return;
if(!soundContext)soundContext=new AudioEngine();
if(soundContext.state==='suspended')soundContext.resume().catch(()=>{});
}catch(err){console.debug('[KKPT] Audio unavailable:',err);}
}
function createFireAudio(ctx){
const length=Math.round(ctx.sampleRate*4.5);
const buffer=ctx.createBuffer(1,length,ctx.sampleRate);
const data=buffer.getChannelData(0);
let low=0,middle=0;
for(let i=0;i<length;i++){
const random=Math.random()*2-1;
low=low*.992+random*.008;
middle=middle*.92+random*.08;
data[i]=Math.max(-1,Math.min(1,
low*3.9+middle*.33+random*.055));
}
// Micro-crackles: impulsive bursts with different decay times.
for(let k=0;k<350;k++){
const at=Math.floor(Math.random()*(length-1500));
const decay=36+Math.random()*330;
const duration=Math.min(1400,Math.ceil(decay*4));
const power=.20+Math.pow(Math.random(),3)*.68;
for(let j=0;j<duration;j++){
const env=Math.exp(-j/decay);
data[at+j]=Math.max(-1,Math.min(1,data[at+j]+
(Math.random()*2-1)*power*env));
}
}
return buffer;
}
function transitionSound(dark,durationMs){
const ctx=soundContext;
if(!ctx||ctx.state!=='running')return;
const start=ctx.currentTime+.018;
const span=durationMs/1000;
if(activeSound){
const old=activeSound.gain;
old.cancelScheduledValues(start);
old.setTargetAtTime(.0001,start,.05);
}
const master=ctx.createGain();
master.gain.setValueAtTime(.0001,start);
master.gain.linearRampToValueAtTime(dark?FIRE_VOLUME:DAWN_VOLUME,
start+(dark?.75:1.10));
master.gain.setValueAtTime(dark?FIRE_VOLUME:DAWN_VOLUME,
start+span-(dark?.88:1.0));
master.gain.exponentialRampToValueAtTime(.0001,start+span);
master.connect(ctx.destination);
activeSound={gain:master};
if(dark){
if(!fireBuffer)fireBuffer=createFireAudio(ctx);
const src=ctx.createBufferSource();
src.buffer=fireBuffer;src.loop=true;
const lowpass=ctx.createBiquadFilter();
lowpass.type='lowpass';lowpass.frequency.value=2050;
const bass=ctx.createBiquadFilter();
bass.type='highpass';bass.frequency.value=55;
src.connect(lowpass);lowpass.connect(bass);bass.connect(master);
src.start(start);src.stop(start+span+.12);
}else{
// Return to blue: a rising E-major wind-chime motif and airy
// breath, with no fire or electrical crackle artifacts.
const notes=[329.63,415.30,493.88,659.25,830.61];
notes.forEach((hz,i)=>{
const at=start+.14+i*.36;
const osc=ctx.createOscillator();
osc.type=i%2?'triangle':'sine';
osc.frequency.setValueAtTime(hz,at);
const note=ctx.createGain();
note.gain.setValueAtTime(.0001,at);
note.gain.exponentialRampToValueAtTime(.20/(1+i*.16),at+.095);
note.gain.exponentialRampToValueAtTime(.0001,
Math.min(start+span-.04,at+1.45));
osc.connect(note);note.connect(master);
osc.start(at);osc.stop(Math.min(start+span,at+1.52));
});
// A soft, swelling harmonic makes the return sound alive and
// spacious without turning it into another impact blast.
const air=ctx.createOscillator();
air.type='sine';air.frequency.setValueAtTime(164.81,start);
air.frequency.linearRampToValueAtTime(246.94,start+span*.82);
const breath=ctx.createGain();
breath.gain.setValueAtTime(.0001,start);
breath.gain.linearRampToValueAtTime(.115,start+1.0);
breath.gain.exponentialRampToValueAtTime(.0001,start+span);
air.connect(breath);breath.connect(master);
air.start(start);air.stop(start+span+.01);
}
setTimeout(()=>{
if(activeSound&&activeSound.gain===master)activeSound=null;
try{master.disconnect();}catch(_){}
},durationMs+350);
}
let active = false; // false: original top-level doc; true: otherPage iframe
let animating = false;
let gearPending = 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(anchor.classList.contains('kkpt-fate-gear'))return;
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;
let pending=false;
const observer = new MutationObserver(() => {
if(pending)return;
pending=true;
setTimeout(()=>{
pending=false;
decoratePortalLinks(doc,targetPage);
},120);
});
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();
primeSound(); // Synchronous click activation unlocks Web Audio.
if(anchor.classList.contains('kkpt-fate-gear')) {
beginFateSwap(anchor,nextActive);
} else {
requestSwap(nextActive);
}
return true;
}
// The Infobox gear begins to turn before the actual world changes.
function beginFateSwap(anchor,nextActive) {
if(animating||gearPending||nextActive===active)return;
gearPending=true;
anchor.classList.add('kkpt-fate-gear-turning');
setTimeout(()=>{
gearPending=false;
requestSwap(nextActive);
},570);
setTimeout(()=>anchor.classList.remove('kkpt-fate-gear-turning'),4800);
}
function fateGearShape() {
const parts=[];
for(let i=0;i<48;i++) {
const a=i/48*Math.PI*2-Math.PI/2;
const r=(i%4===0||i%4===3)?21:16.8;
parts.push((i?'L':'M')+(24+Math.cos(a)*r).toFixed(2)+' '+
(24+Math.sin(a)*r).toFixed(2));
}
return parts.join(' ')+' Z';
}
function mountFateGear(doc,targetPage) {
if(doc.querySelector('.kkpt-fate-gear'))return;
const content=doc.querySelector('#mw-content-text,.mw-parser-output');
if(!content)return;
const selectors='table.infobox,.infobox-character,.portable-infobox,'+
'.infobox,[class*="infobox"]';
const box=[...content.querySelectorAll(selectors)].find(el=>{
const r=el.getBoundingClientRect();
return r.width>175&&r.width<720&&r.height>210;
});
if(!box)return;
const header=box.querySelector('.infobox-title,.infobox-header,'+
'.infobox-above,.infobox-heading,.infobox-head,'+
'.pi-title,caption,th[colspan]');
// Deliberately NEVER append to or restyle a native Infobox node.
// A fixed-position overlay follows its visual rectangle instead.
const host=header&&header.tagName!=='TR'?header:box;
const anchor=doc.createElement('a');
anchor.className='kkpt-fate-gear';
anchor.hidden=true;
anchor.href=new URL('/wiki/'+targetPage,doc.baseURI).href;
anchor.title=targetPage==='Kuronata'
?'转动命运齿轮 · 唤醒 Kuronata':'转动命运齿轮 · 回到 Kuranoka';
anchor.setAttribute('aria-label',anchor.title);
anchor.innerHTML='<svg class="kkpt-fate-gear-svg" viewBox="0 0 48 48" '+
'aria-hidden="true" focusable="false"><path d="'+fateGearShape()+
'"/><circle cx="24" cy="24" r="8.5"/>'+
'<circle cx="24" cy="24" r="3"/></svg>';
doc.body.appendChild(anchor);
let pendingPosition=false;
function positionGear(){
pendingPosition=false;
if(!doc.body.contains(anchor)||!doc.body.contains(host)){
anchor.hidden=true;return;
}
const rect=host.getBoundingClientRect();
const view=doc.defaultView;
const y=host===box?rect.top+23:rect.top+rect.height*.5;
anchor.hidden=rect.width<120||rect.bottom<0||rect.top>view.innerHeight||
y<0||y>view.innerHeight;
if(anchor.hidden)return;
anchor.style.left=Math.round(rect.right-49)+'px';
anchor.style.top=Math.round(y-21)+'px';
}
function updateGear(){
if(pendingPosition)return;
pendingPosition=true;
doc.defaultView.requestAnimationFrame(positionGear);
}
doc.defaultView.addEventListener('scroll',updateGear,{passive:true});
doc.defaultView.addEventListener('resize',updateGear,{passive:true});
updateGear();
}
// 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 < 18) 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);
mountFateGear(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;
}
// Original V2's moving flame sprites and three bright contours, rebuilt
// with bounded particle counts. No WebGL, no full-screen noise pass,
// no 1,000-point polygon or per-frame image-texture upload.
let sprites=null;
function makeSprites() {
if (sprites) return sprites;
function radial(stops) {
const el=document.createElement('canvas');
el.width=el.height=96;
const c=el.getContext('2d');
const g=c.createRadialGradient(48,48,0,48,48,48);
stops.forEach(pair=>g.addColorStop(pair[0],pair[1]));
c.fillStyle=g;c.fillRect(0,0,96,96);
return el;
}
sprites={
flame:radial([[0,'#fffbed'],[.18,'#fff2a0'],
[.43,'#ffb42b'],[.70,'#ff360088'],[1,'#8f050000']]),
ember:radial([[0,'#ffffff'],[.15,'#fff6a8'],
[.44,'#ff9900cc'],[1,'#ef280000']]),
smoke:radial([[0,'#080608dd'],[.30,'#100b10bb'],
[.62,'#160f1677'],[1,'#08050800']]),
sky:radial([[0,'#ffffff'],[.16,'#e6ffff'],
[.46,'#94deffe0'],[1,'#4ab5ff00']]),
mist:radial([[0,'#f2fdffb0'],[.4,'#abdffff0'],
[1,'#a9e7ff00']])
};
return sprites;
}
// V2's simple advancing boundary. The reader asked for the
// vibrant fire curtain, not a mathematically fractal tearing wire.
function makeEdge(t,seconds,dark,w) {
const center=dark
? w+105-(w+210)*smooth(t)
: -105+(w+210)*smooth(t);
const wobble=Math.sin(Math.PI*t);
return y=>center+wobble*(
17*Math.sin(y*.023+seconds*5.5)+
9*Math.sin(y*.063-seconds*8.4)+
4*Math.sin(y*.13+seconds*12)
);
}
// Clip the ACTUAL preloaded Wiki frame, precisely where the fire is.
function clipFrame(edge) {
const w=innerWidth,h=innerHeight;
const rows=Math.max(32,Math.ceil(h/22));
const pts=[];
for(let i=rows;i>=0;i--) {
const y=h*i/rows;
pts.push(edge(y).toFixed(1)+'px '+y.toFixed(1)+'px');
}
frame.style.clipPath=clipSide==='right'
? 'polygon(100% 0,100% 100%,'+pts.join(',')+')'
: 'polygon(0 0,0 100%,'+pts.join(',')+')';
}
function canvasFire(canvas,dark,duration,done) {
const ctx=canvas.getContext('2d',{alpha:true,desynchronized:true});
if(!ctx){done();return;}
const art=makeSprites();
const w=innerWidth,h=innerHeight;
const scale=Math.min(window.devicePixelRatio||1,w<650?.88:1.0);
canvas.width=Math.max(1,Math.round(w*scale));
canvas.height=Math.max(1,Math.round(h*scale));
ctx.setTransform(scale,0,0,scale,0,0);
const rand=(a,b)=>a+Math.random()*(b-a);
const mobile=w<650;
const maxCount=mobile?175:340;
const p=[];
const accum={flame:0,ember:0,smoke:0,shard:0,sky:0,mist:0,pollen:0};
const rates=dark
? {flame:mobile?85:120,ember:mobile?75:113,
smoke:mobile?28:43,shard:mobile?30:50}
: {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50};
const started=performance.now();
let previous=started;
let lastPaint=0;
function spawn(kind,edge,seconds) {
if(p.length>=maxCount)return;
const y=rand(-25,h+25);
const x=edge(y);
const type=kind;
const smoke=type==='smoke';
const shard=type==='shard';
const ember=type==='ember'||type==='pollen';
const blue=type==='sky'||type==='mist'||type==='pollen';
let radius,life,vx,vy;
if(shard){
radius=rand(4,18);life=rand(.6,1.65);
vx=rand(-130,65);vy=rand(-125,30);
}else if(smoke){
radius=rand(32,85);life=rand(1.1,2.5);
vx=rand(-20,65);vy=rand(-88,-22);
}else if(type==='mist'){
radius=rand(45,100);life=rand(.9,1.8);
vx=rand(-35,45);vy=rand(-75,-15);
}else if(type==='flame'){
radius=rand(20,57);life=rand(.3,.82);
vx=rand(-100,30);vy=rand(-180,-45);
}else{
radius=ember?rand(2.5,9):rand(18,47);
life=ember?rand(.45,1.1):rand(.35,.85);
vx=rand(-95,75);vy=rand(-150,-25);
}
p.push({
type,life,left:life,
x:x+rand(smoke?12:shard?-35:-27,smoke?140:shard?36:64),
y:y+rand(-14,14),vx,vy,radius,
angle:rand(-Math.PI,Math.PI),spin:rand(-5,5),
drift:rand(-.65,.65),blue
});
}
function spawnAtRate(dt,edge,seconds,strength){
for(const type in rates){
accum[type]+=rates[type]*dt*strength;
const n=Math.min(11,Math.floor(accum[type]));
accum[type]-=n;
for(let i=0;i<n;i++)spawn(type,edge,seconds);
}
}
function advance(dt){
for(let i=p.length-1;i>=0;i--) {
const q=p[i];
q.left-=dt;
if(q.left<=0){p[i]=p[p.length-1];p.pop();continue;}
q.x+=q.vx*dt;
q.y+=q.vy*dt;
q.vx+=q.drift*dt*35;
q.vy-=10*dt;
q.angle+=q.spin*dt;
}
}
function spriteDraw(image,x,y,sx,sy,alpha){
ctx.globalAlpha=alpha;
ctx.drawImage(image,x-sx,y-sy,sx*2,sy*2);
}
function drawSmoke(fade){
ctx.globalCompositeOperation='source-over';
for(const q of p){
if(q.type!=='smoke'&&q.type!=='mist')continue;
const age=1-q.left/q.life;
const radius=q.radius*(1+age*.98);
const alpha=(q.type==='smoke'?.76:.32)*
Math.min(1,age*5)*Math.min(1,q.left/.55)*fade;
spriteDraw(q.type==='smoke'?art.smoke:art.mist,
q.x,q.y,radius*1.7,radius*1.3,alpha);
}
ctx.globalAlpha=1;
}
function drawShards(fade) {
if(!dark)return;
ctx.save();
ctx.globalCompositeOperation='source-over';
for(const q of p){
if(q.type!=='shard')continue;
const age=1-q.left/q.life;
const a=Math.min(1,q.left/.38)*(.90-age*.28)*fade;
ctx.save();
ctx.translate(q.x,q.y);
ctx.rotate(q.angle);
ctx.globalAlpha=a;
const r=q.radius;
ctx.beginPath();
ctx.moveTo(-r*.9,-r*.3);
ctx.lineTo(r*.7,-r*.75);
ctx.lineTo(r*.48,r*.42);
ctx.lineTo(-r*.35,r*.8);
ctx.closePath();
ctx.fillStyle=age<.3?'#371d22':'#140d13';
ctx.fill();
ctx.strokeStyle=age<.42?'#f57d3c':'#5a2c2b';
ctx.lineWidth=age<.42?1.6:.75;
ctx.stroke();
ctx.restore();
}
ctx.restore();
}
function drawCore(edge,seconds,t,fade){
const waviness=Math.sin(Math.PI*t);
ctx.save();
ctx.beginPath();
const count=Math.max(35,Math.ceil(h/19));
for(let i=0;i<=count;i++){
const y=h*i/count;
if(i===0)ctx.moveTo(edge(y),y);
else ctx.lineTo(edge(y),y);
}
ctx.lineCap='round';
ctx.lineJoin='round';
if(dark){
ctx.globalCompositeOperation='lighter';
ctx.globalAlpha=fade;
ctx.shadowColor='#ff3903';
ctx.shadowBlur=36;
ctx.strokeStyle='rgba(255,55,5,.82)';
ctx.lineWidth=43;
ctx.stroke();
ctx.shadowBlur=21;
ctx.strokeStyle='#ff9b17';
ctx.lineWidth=20;
ctx.stroke();
ctx.shadowBlur=9;
ctx.strokeStyle='#ffe45e';
ctx.lineWidth=9;
ctx.stroke();
ctx.shadowBlur=4;
ctx.strokeStyle='#fffcde';
ctx.lineWidth=3.8;
ctx.stroke();
}else{
ctx.globalAlpha=fade*.64;
ctx.shadowColor='#8ccfff';
ctx.shadowBlur=29;
ctx.strokeStyle='rgba(139,219,255,.68)';
ctx.lineWidth=28;
ctx.stroke();
ctx.shadowBlur=10;
ctx.strokeStyle='#e7f8ff';
ctx.lineWidth=9;
ctx.stroke();
ctx.shadowBlur=0;
ctx.strokeStyle='white';
ctx.lineWidth=2;
ctx.stroke();
}
ctx.restore();
// Dense GOLD / WHITE rolling fire, including on both sides
// of the border. These are the luminous shapes from V2.
if(dark){
ctx.save();
ctx.globalCompositeOperation='lighter';
for(let y=-70;y<h+80;y+=48){
const x=edge(y);
const pulse=.65+.35*Math.sin(seconds*8.5+y*.083);
spriteDraw(art.flame,x+rand(-8,30),y,77,70,
fade*(.26+.16*pulse));
}
for(let y=-20;y<h+52;y+=36){
const x=edge(y);
const flicker=Math.sin(seconds*13+y*.19);
const length=52+30*flicker+17*Math.sin(seconds*6-y*.11);
const lean=15+18*Math.sin(seconds*9+y*.069);
ctx.globalAlpha=.62*fade;
ctx.fillStyle='#ff5705';
ctx.beginPath();
ctx.moveTo(x+18,y+19);
ctx.bezierCurveTo(x-18,y+2,x-lean-24,y-length*.57,
x-lean,y-length);
ctx.bezierCurveTo(x+2,y-length*.57,x+13,y-12,x+18,y+19);
ctx.fill();
ctx.globalAlpha=.58*fade;
ctx.fillStyle='#ffdb58';
ctx.beginPath();
ctx.moveTo(x+7,y+8);
ctx.quadraticCurveTo(x-10,y-length*.33,
x-lean*.8,y-length*.72);
ctx.quadraticCurveTo(x+3,y-length*.30,x+8,y+8);
ctx.fill();
ctx.globalAlpha=.38*fade;
ctx.fillStyle='#fffced';
ctx.beginPath();
ctx.moveTo(x+3,y+5);
ctx.quadraticCurveTo(x-5,y-length*.2,
x-lean*.5,y-length*.48);
ctx.quadraticCurveTo(x+4,y-length*.19,x+5,y+5);
ctx.fill();
}
ctx.restore();
}else{
ctx.save();
ctx.globalCompositeOperation='lighter';
for(let y=-50;y<h+55;y+=88){
spriteDraw(art.sky,edge(y),y,89,109,.27*fade);
}
ctx.restore();
}
}
function drawBrightParticles(fade) {
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const q of p){
if(q.type==='smoke'||q.type==='mist'||q.type==='shard')continue;
const age=1-q.left/q.life;
const brightness=Math.min(1,q.left/.25)*(1-age*.25)*fade;
let artName='flame',sx=q.radius,sy=q.radius;
if(q.type==='ember'){artName='ember';sx*=1.2;sy*=1.2;}
else if(q.type==='pollen'){artName='sky';sx*=1.15;sy*=1.15;}
else if(q.type==='sky'){artName='sky';sx*=1.1;sy*=1.55;}
else {sx*=.9;sy*=1.65;}
spriteDraw(art[artName],q.x,q.y,sx,sy,
brightness*(q.type==='ember'?.93:.78));
}
ctx.restore();
ctx.globalAlpha=1;
ctx.globalCompositeOperation='source-over';
}
function paint(now) {
if(now-lastPaint<(mobile?39:31)){
requestAnimationFrame(paint);return;
}
const dt=Math.min(.05,(now-previous)/1000);
previous=now;lastPaint=now;
const t=Math.min(1,(now-started)/duration);
const seconds=now/1000;
const edge=makeEdge(t,seconds,dark,w);
const fade=Math.min(1,t/.055,(1-t)/.045);
clipFrame(edge);
advance(dt);
if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
ctx.clearRect(0,0,w,h);
drawSmoke(fade);
drawShards(fade);
drawCore(edge,seconds,t,fade);
drawBrightParticles(fade);
if(t<1)requestAnimationFrame(paint);
else done();
}
requestAnimationFrame(paint);
}
function setDisplayed(nextActive) {
active=nextActive;
frame.classList.toggle('kkpt-active',active);
frame.style.clipPath=active?'inset(0)':hideClip;
frame.style.pointerEvents='';
frame.tabIndex=active?0:-1;
frame.setAttribute('aria-hidden',active?'false':'true');
document.title=active?otherTitle:baseTitle;
showTheme((active?otherPage:basePage)==='Kuronata');
updateButton();
}
async function animateSwap(nextActive) {
if(animating)return;
animating=true;
button.classList.add('kkpt-busy');
labelSub.textContent='PREPARING THE OTHER SIDE...';
if(!loading)warmFrame();
const ok=await Promise.race([loading,sleep(11000).then(()=>false)]);
if(!ok||!ready){
window.location.assign(nextActive?otherUrl.href:baseUrl);return;
}
const dark=(nextActive?otherPage:basePage)==='Kuronata';
labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING';
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';
canvas.setAttribute('aria-hidden','true');
document.body.append(canvas);
const duration=dark?3450:2950;
transitionSound(dark,duration);
await new Promise(resolve=>canvasFire(canvas,dark,duration,resolve));
setDisplayed(nextActive);
canvas.style.opacity='0';
await sleep(160);
canvas.remove();
animating=false;
button.classList.remove('kkpt-busy','kkpt-quiet');
// The target remains visible in a live iframe. No reload.
history.pushState({kkptV12: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({kkptV12:true,active},'',active?otherUrl.href:baseUrl);
return;
}
animateSwap(nextActive).catch(err => {
console.error('[KuranokaPageTransition] V12:',err);
document.getElementById('kkpt-vfx')?.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);
mountFateGear(document,otherPage);
warmFrame();
button.addEventListener('click', event => {
if (!isPlainClick(event)) return;
event.preventDefault();
primeSound();
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();
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();
}
}());