MediaWiki:KuranokaPageTransition.js:修订间差异
MediaWiki界面页面
更多操作
无编辑摘要 |
无编辑摘要 |
||
| (未显示同一用户的2个中间版本) | |||
| 第1行: | 第1行: | ||
/* ============================================================ | /* ============================================================ | ||
Kuranoka / Kuronata — Page Transition | Kuranoka / Kuronata — Page Transition V18 | ||
V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments. | V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments. | ||
V18 smooths the mask, uses sampled PADsynth and stabilizes scroll, | |||
with capped particle work and infrequent spatial glitches. | |||
Only this page-scoped JS/CSS; never modify Citizen or loader.js. | Only this page-scoped JS/CSS; never modify Citizen or loader.js. | ||
Two living same-origin Wiki documents; no reload for toggles. | Two living same-origin Wiki documents; no reload for toggles. | ||
| 第25行: | 第27行: | ||
'skin-citizen-light']; | 'skin-citizen-light']; | ||
let savedTheme=themeNames.filter(c=>root.classList.contains(c)); | let savedTheme=themeNames.filter(c=>root.classList.contains(c)); | ||
let enforceFramePreference=false; | |||
if(window!==window.top && basePage==='Kuranoka'){ | |||
// Iframes are independent documents. Restore the parent's ORIGINAL | |||
// reader preference, never the forced dark state during a swap. | |||
try { | |||
const inherited=window.top.__kkptReaderTheme; | |||
if(Array.isArray(inherited) && | |||
inherited.every(c=>themeNames.includes(c))){ | |||
savedTheme=inherited.slice(); | |||
enforceFramePreference=true; | |||
} | |||
}catch(_){} | |||
} else if(window===window.top) { | |||
window.__kkptReaderTheme=savedTheme.slice(); | |||
} | |||
let wantsNight=basePage==='Kuronata'; | let wantsNight=basePage==='Kuronata'; | ||
let changingTheme=false; | let changingTheme=false; | ||
| 第47行: | 第64行: | ||
// Follow later preference changes while Kuranoka is visible. | // Follow later preference changes while Kuranoka is visible. | ||
// Only Kuronata needs an enforced appearance. | // Only Kuronata needs an enforced appearance. | ||
if(wantsNight)syncTheme(); | if(wantsNight||enforceFramePreference)syncTheme(); | ||
else savedTheme=themeNames.filter(c=>root.classList.contains(c)); | else savedTheme=themeNames.filter(c=>root.classList.contains(c)); | ||
}).observe(root,{ | }).observe(root,{ | ||
attributes:true,attributeFilter:['class'] | attributes:true,attributeFilter:['class'] | ||
}); | }); | ||
// V17: the dark-world extras run in *both* the visible document and | |||
// its same-origin preview iframe. They never alter other Wiki pages. | |||
function installKuronataAfterglow() { | |||
if(basePage!=='Kuronata'||window.__kkptDarkExtrasReady)return; | |||
window.__kkptDarkExtrasReady=true; | |||
const prefersReduced=window.matchMedia('(prefers-reduced-motion: reduce)'); | |||
const decorateSpoilers=()=>{ | |||
// Copy only plain-text spoilers into the two glitch slices. | |||
// Do not duplicate nested links or markup, and never reflow text. | |||
document.querySelectorAll('span.heimu').forEach(node=>{ | |||
if(node.hasAttribute('data-kkpt-glitch')||node.children.length)return; | |||
const label=(node.textContent||'').replace(/\s+/g,' ').trim(); | |||
if(label&&label.length<=240) | |||
node.setAttribute('data-kkpt-glitch',label); | |||
}); | |||
}; | |||
const createAmbience=()=>{ | |||
if(prefersReduced.matches||document.getElementById('kkpt-afterglow')||!document.body)return; | |||
const overlay=document.createElement('div'); | |||
overlay.id='kkpt-afterglow'; | |||
overlay.setAttribute('aria-hidden','true'); | |||
const el=(type,i)=>{ | |||
const part=document.createElement('span'); | |||
part.className=type==='soot'?'kkpt-soot-cell':'kkpt-ash-cell'; | |||
const smoke=type==='soot'; | |||
// All sprites are static layers with compositor-only | |||
// transform/opacity animation. No persistent canvas or RAF. | |||
part.style.left=(smoke?Math.random()*101:Math.random()*100)+'%'; | |||
part.style.top=(smoke?37+Math.random()*75:15+Math.random()*94)+'%'; | |||
part.style.setProperty('--size',(smoke?155+Math.random()*175:2+Math.random()*4).toFixed(1)+'px'); | |||
part.style.setProperty('--delay',(-Math.random()*(smoke?29:18)).toFixed(2)+'s'); | |||
part.style.setProperty('--duration',(smoke?18+Math.random()*14:8+Math.random()*11).toFixed(2)+'s'); | |||
part.style.setProperty('--wind',((Math.random()-.5)*(smoke?120:230)).toFixed(1)+'px'); | |||
return part; | |||
}; | |||
for(let i=0;i<10;i++)overlay.append(el('soot',i)); | |||
for(let i=0;i<30;i++)overlay.append(el('ash',i)); | |||
document.body.append(overlay); | |||
}; | |||
const init=()=>{ | |||
decorateSpoilers(); | |||
createAmbience(); | |||
mw.hook('wikipage.content').add(decorateSpoilers); | |||
}; | |||
if(document.readyState==='loading') | |||
document.addEventListener('DOMContentLoaded',init,{once:true}); | |||
else init(); | |||
// One brief spatial fault roughly every 14-36 seconds, never a | |||
// full-screen white strobe. Pause while the iframe is hidden. | |||
const pulse=()=>{ | |||
const inactive=document.hidden||prefersReduced.matches|| | |||
root.classList.contains('kkpt-frame-inactive')|| | |||
root.classList.contains('kkpt-world-suspended')|| | |||
(window!==window.top&&window.frameElement?.getAttribute('aria-hidden')==='true'); | |||
if(!inactive){ | |||
root.style.setProperty('--kkpt-rift-y',(14+Math.random()*70).toFixed(1)+'%'); | |||
root.classList.add('kkpt-rift'); | |||
window.setTimeout(()=>root.classList.remove('kkpt-rift'),390); | |||
} | |||
window.setTimeout(pulse,5000+Math.random()*15000); | |||
}; | |||
window.setTimeout(pulse,5000+Math.random()*15000); | |||
} | |||
installKuronataAfterglow(); | |||
// The nested preview only applies its own theme; no recursive iframe. | // The nested preview only applies its own theme; no recursive iframe. | ||
if (window!==window.top || window.__kkptV12) return; | if (window!==window.top || window.__kkptV12) return; | ||
window.__kkptV12=true; | window.__kkptV12=true; | ||
window.__kkptVersion=' | window.__kkptVersion='18.0'; | ||
// Easter eggs + Infobox gear are the default. No floating pill. | // Easter eggs + Infobox gear are the default. No floating pill. | ||
| 第74行: | 第158行: | ||
const DAWN_VOLUME = .20; | const DAWN_VOLUME = .20; | ||
let soundContext=null,fireBuffer=null,activeSound=null; | let soundContext=null,fireBuffer=null,activeSound=null; | ||
// Public-domain PADsynth recording, ~8.4s / 120KB. Change this | |||
// to a same-origin Wiki-upload URL if external media are blocked. | |||
const REBIRTH_PAD_URL= | |||
'https://upload.wikimedia.org/wikipedia/commons/e/ed/Padsynth_example_synth_piano.ogg'; | |||
let padBytesPromise=null,padDecodePromise=null,padBuffer=null; | |||
function preloadRebirthPad(){ | |||
if(padBytesPromise)return padBytesPromise; | |||
padBytesPromise=fetch(REBIRTH_PAD_URL,{mode:'cors',cache:'force-cache'}) | |||
.then(resp=>{ | |||
if(!resp.ok)throw new Error('PAD HTTP '+resp.status); | |||
return resp.arrayBuffer(); | |||
}) | |||
.catch(err=>{ | |||
console.debug('[KKPT] PAD file unavailable:',err); | |||
return null; | |||
}); | |||
return padBytesPromise; | |||
} | |||
function decodeRebirthPad(ctx){ | |||
if(padBuffer)return Promise.resolve(padBuffer); | |||
if(!padDecodePromise){ | |||
padDecodePromise=preloadRebirthPad() | |||
.then(bytes=>bytes?ctx.decodeAudioData(bytes.slice(0)):null) | |||
.then(result=>(padBuffer=result)) | |||
.catch(err=>{ | |||
console.debug('[KKPT] Cannot decode PAD:',err); | |||
return null; | |||
}); | |||
} | |||
return padDecodePromise; | |||
} | |||
// A plain media element can still play the recording when a site's | |||
// CORS policy disallows reading the file into Web Audio. | |||
function playRebirthPadFallback(durationMs){ | |||
const sound=new Audio(REBIRTH_PAD_URL); | |||
sound.volume=0; | |||
sound.play().then(()=>{ | |||
const begin=performance.now(); | |||
const timer=setInterval(()=>{ | |||
const t=Math.min(1,(performance.now()-begin)/durationMs); | |||
sound.volume=Math.max(0,Math.min(DAWN_VOLUME, | |||
DAWN_VOLUME*Math.min(t/.27,(1-t)/.26))); | |||
if(t>=1){clearInterval(timer);sound.pause();sound.src='';} | |||
},80); | |||
}).catch(err=>console.debug('[KKPT] PAD playback unavailable:',err)); | |||
} | |||
function primeSound(){ | function primeSound(){ | ||
if(!ENABLE_TRANSITION_AUDIO)return; | if(!ENABLE_TRANSITION_AUDIO)return; | ||
| 第81行: | 第213行: | ||
if(!soundContext)soundContext=new AudioEngine(); | if(!soundContext)soundContext=new AudioEngine(); | ||
if(soundContext.state==='suspended')soundContext.resume().catch(()=>{}); | if(soundContext.state==='suspended')soundContext.resume().catch(()=>{}); | ||
decodeRebirthPad(soundContext); | |||
}catch(err){console.debug('[KKPT] Audio unavailable:',err);} | }catch(err){console.debug('[KKPT] Audio unavailable:',err);} | ||
} | } | ||
| 第139行: | 第272行: | ||
src.start(start);src.stop(start+span+.12); | src.start(start);src.stop(start+span+.12); | ||
}else{ | }else{ | ||
// | // A REAL recorded PADsynth sample; no generated wind-chimes. | ||
decodeRebirthPad(ctx).then(buffer=>{ | |||
if(!buffer){playRebirthPadFallback(durationMs);return;} | |||
const at=Math.max(start,ctx.currentTime+.020); | |||
if(at>start+span-.55)return; | |||
const player=ctx.createBufferSource(); | |||
player.buffer=buffer; | |||
player.connect(master); | |||
const | player.start(at,Math.max(0,at-start)+.95); | ||
player.stop(start+span); | |||
}).catch(()=>{}); | |||
} | } | ||
setTimeout(()=>{ | setTimeout(()=>{ | ||
| 第348行: | 第465行: | ||
const view=doc.defaultView; | const view=doc.defaultView; | ||
const y=host===box?rect.top+23:rect.top+rect.height*.5; | const y=host===box?rect.top+23:rect.top+rect.height*.5; | ||
anchor.hidden=rect.width<120 | anchor.hidden=rect.width<120; | ||
if(anchor.hidden)return; | if(anchor.hidden)return; | ||
anchor.style.left=Math.round(rect.right-49)+'px'; | // Absolute document coords; scroll is handled by the browser. | ||
anchor.style.top=Math.round(y-21)+'px'; | anchor.style.left=Math.round(rect.right-49+view.scrollX)+'px'; | ||
anchor.style.top=Math.round(y-21+view.scrollY)+'px'; | |||
} | } | ||
function updateGear(){ | function updateGear(){ | ||
| 第359行: | 第476行: | ||
doc.defaultView.requestAnimationFrame(positionGear); | doc.defaultView.requestAnimationFrame(positionGear); | ||
} | } | ||
doc.defaultView.addEventListener('resize',updateGear,{passive:true}); | doc.defaultView.addEventListener('resize',updateGear,{passive:true}); | ||
if(doc.defaultView.ResizeObserver){ | |||
const observer=new doc.defaultView.ResizeObserver(updateGear); | |||
observer.observe(host); | |||
if(host!==box)observer.observe(box); | |||
} | |||
updateGear(); | updateGear(); | ||
} | } | ||
| 第522行: | 第643行: | ||
} | } | ||
// Shared, low-resolution atmospherics: return of V14's dark smoke, | |||
// long-lived torn paper, shockwaves, blue-white petals and pollen. | |||
// Only the combustion CURTAIN is replaced by the bright V2 engine. | |||
let atmosphereAssetCache=null; | |||
function createAtmosphere(canvas) { | |||
const ctx = canvas.getContext('2d', {alpha:true}); | |||
const rand = n => { | |||
const x = Math.sin(n*127.1+311.7)*43758.5453123; | |||
return x-Math.floor(x); | |||
}; | |||
const clamp01 = n => Math.max(0,Math.min(1,n)); | |||
function valueNoise(x,y,seed) { | |||
const ix=Math.floor(x), iy=Math.floor(y); | |||
let fx=x-ix,fy=y-iy; | |||
fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy); | |||
const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1); | |||
const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx; | |||
const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx; | |||
return a*(1-fy)+b*fy; | |||
} | |||
function smokeTexture(seed,dark) { | |||
const tex=document.createElement('canvas'); | |||
tex.width=tex.height=112; | |||
const g=tex.getContext('2d'); | |||
const data=g.createImageData(112,112); | |||
for(let y=0;y<112;y++) for(let x=0;x<112;x++){ | |||
const u=(x-56)/56,v=(y-56)/56; | |||
const curl=valueNoise(u*2.9+11,v*2.9+1,seed); | |||
const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7); | |||
const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5); | |||
const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13); | |||
const envelope=clamp01((1.05-radius)/.56); | |||
const low=valueNoise(u*4.1+4,v*4.1-9,seed+2); | |||
const high=valueNoise(u*11.4-3,v*11.4+6,seed+5); | |||
const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9); | |||
const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)* | |||
envelope*envelope; | |||
const i=(y*112+x)*4; | |||
const variation=low*28+high*15; | |||
if(dark){ | |||
data.data[i]=5+variation*.37; | |||
data.data[i+1]=5+variation*.33; | |||
data.data[i+2]=8+variation*.38; | |||
}else{ | |||
data.data[i]=44+variation; | |||
data.data[i+1]=91+variation; | |||
data.data[i+2]=125+variation; | |||
} | |||
data.data[i+3]=Math.round(240*density); | |||
} | |||
g.putImageData(data,0,0); | |||
return tex; | |||
} | |||
const sootSprites=atmosphereAssetCache?.sootSprites || [0,1,2,3].map(n=>smokeTexture(n+17,true)); | |||
const mistSprites=atmosphereAssetCache?.mistSprites || [0,1,2,3].map(n=>smokeTexture(n+51,false)); | |||
// V2-inspired white-hot additive sprites, but with noise-eroded | |||
// silhouettes instead of repetitive smooth, elliptical balls. | |||
function glowTexture(seed,mode) { | |||
const tex=document.createElement('canvas'); | |||
tex.width=96;tex.height=128; | |||
const g=tex.getContext('2d'); | |||
const gradient=g.createRadialGradient(48,68,0,48,68,64); | |||
const colors=mode==='fire' | |||
? [[0,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'], | |||
[.55,'#ff841bcc'],[.80,'#bc290749'],[1,'#6e030000']] | |||
: mode==='blue' | |||
? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'], | |||
[.68,'#5298ff99'],[1,'#345bf000']] | |||
: [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'], | |||
[1,'#ff380000']]; | |||
colors.forEach(([p,c])=>gradient.addColorStop(p,c)); | |||
g.fillStyle=gradient;g.fillRect(0,0,96,128); | |||
const img=g.getImageData(0,0,96,128); | |||
for(let y=0;y<128;y++)for(let x=0;x<96;x++){ | |||
const u=(x-48)/48,v=(y-64)/64; | |||
const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18); | |||
const fiber=valueNoise(u*11+3,v*8+17,seed+41); | |||
const shifted=u+.18*(cloud-.5); | |||
const irregular=clamp01((1.15-Math.abs(shifted))/.52)* | |||
clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42); | |||
const n=.59+.77*cloud+.28*fiber; | |||
const alphaIndex=(y*96+x)*4+3; | |||
img.data[alphaIndex]=Math.round(img.data[alphaIndex]* | |||
irregular*clamp01(n)); | |||
} | |||
g.putImageData(img,0,0); | |||
return tex; | |||
} | |||
const FIRE_GLOW=atmosphereAssetCache?.FIRE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed,'fire')); | |||
const BLUE_GLOW=atmosphereAssetCache?.BLUE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed+7,'blue')); | |||
const EMBER_GLOW=atmosphereAssetCache?.EMBER_GLOW || glowTexture(21,'ember'); | |||
atmosphereAssetCache ||= {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW}; | |||
const combustion=[]; | |||
const residualSoot=[]; | |||
const tornPaper=[]; | |||
let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0; | |||
function advanceCombustion(seconds,progress,dark,elapsed,w,h) { | |||
const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed)); | |||
lastElapsed=elapsed; | |||
const live=progress>.01&&progress<.985; | |||
if(live){ | |||
const flow=(dark?70:96)*Math.min(1.5,h/720)* | |||
(.75+.25*Math.sin(Math.PI*progress)); | |||
spawnRemainder+=dt*flow; | |||
const births=Math.min(65,Math.floor(spawnRemainder)); | |||
spawnRemainder-=births; | |||
for(let j=0;j<births&&combustion.length<1320;j++){ | |||
const y=Math.random()*h; | |||
const edge=edgeAt(y,seconds,progress,w); | |||
const r=Math.random(); | |||
const kind=dark | |||
? r<.55?'flame':r<.90?'ember':'ash' | |||
: r<.37?'glow':r<.81?'leaf':'pollen'; | |||
const small=kind==='ember'||kind==='pollen'; | |||
const soot=kind==='ash'; | |||
const life=dark | |||
? (soot?.7+Math.random()*1.1: | |||
small?.65+Math.random()*1.1:.42+Math.random()*.76) | |||
: 1.15+Math.random()*1.55; | |||
combustion.push({ | |||
kind,x:edge+(dark?1:-1)*(Math.random()*47-8), | |||
y:y+(Math.random()-.5)*25, | |||
vx:(dark?1:-1)*(dark?14+Math.random()*154: | |||
16+Math.random()*69), | |||
vy:-(dark?32+Math.random()*171:12+Math.random()*57), | |||
life,remaining:life, | |||
radius:dark | |||
? (soot?1.2+Math.random()*2.7: | |||
small?2+Math.random()*5:9+Math.random()*19) | |||
: kind==='glow'?26+Math.random()*49: | |||
kind==='leaf'?4+Math.random()*6: | |||
2+Math.random()*3, | |||
angle:(Math.random()-.5)*1.1, | |||
sprite:(Math.random()*4)|0, | |||
seed:Math.random()*6.28 | |||
}); | |||
} | |||
if(dark) { | |||
// Unlike the traveling front, these black smoke plumes | |||
// are born in world-space and remain over burnt paper. | |||
sootRemainder+=dt*(h<680?32:46); | |||
const sootBirths=Math.min(11,Math.floor(sootRemainder)); | |||
sootRemainder-=sootBirths; | |||
for(let i=0;i<sootBirths&&residualSoot.length<(w<700?92:155);i++){ | |||
const y=Math.random()*h; | |||
const life=2.15+Math.random()*1.60; | |||
residualSoot.push({ | |||
x:edgeAt(y,seconds,progress,w)+5+Math.random()*83, | |||
y:y+(Math.random()-.5)*48, | |||
vx:8+Math.random()*39,vy:-18-Math.random()*83, | |||
size:155+Math.random()*195,life,remaining:life, | |||
sprite:(Math.random()*4)|0, | |||
spin:(Math.random()-.5)*.8, | |||
seed:Math.random()*6.28 | |||
}); | |||
} | |||
// Charcoal sheets tear off in irregular polygons, not | |||
// squares or narrow glitch streaks. | |||
shardRemainder+=dt*(h<680?9:15); | |||
const shardBirths=Math.min(5,Math.floor(shardRemainder)); | |||
shardRemainder-=shardBirths; | |||
for(let i=0;i<shardBirths&&tornPaper.length<135;i++){ | |||
const y=Math.random()*h; | |||
const life=.82+Math.random()*1.55; | |||
const points=6+Math.floor(Math.random()*4); | |||
const outline=Array.from({length:points},(_,j)=>({ | |||
a:Math.PI*2*j/points+(Math.random()-.5)*.22, | |||
r:.50+Math.random()*.61 | |||
})); | |||
tornPaper.push({ | |||
x:edgeAt(y,seconds,progress,w)+(Math.random()-.5)*47, | |||
y, vx:34+Math.random()*210, | |||
vy:-170+Math.random()*260, | |||
gravity:90+Math.random()*150, | |||
radius:3+Math.pow(Math.random(),1.6)*15, | |||
angle:Math.random()*6.28, | |||
spin:(Math.random()-.5)*7, | |||
outline,life,remaining:life, | |||
seed:Math.random()*6.28 | |||
}); | |||
} | |||
} | |||
} | |||
for(let i=combustion.length-1;i>=0;i--){ | |||
const p=combustion[i]; | |||
p.remaining-=dt; | |||
if(p.remaining<=0){combustion.splice(i,1);continue;} | |||
p.x+=(p.vx+Math.sin(elapsed*(dark?5:1.6)+p.seed)* | |||
(dark?22:24))*dt; | |||
p.y+=p.vy*dt; | |||
p.vy-=(dark?17:7)*dt; | |||
} | |||
for(let i=residualSoot.length-1;i>=0;i--){ | |||
const p=residualSoot[i]; | |||
p.remaining-=dt; | |||
if(p.remaining<=0){residualSoot.splice(i,1);continue;} | |||
p.x+=(p.vx+Math.sin(elapsed*2+p.seed)*27)*dt; | |||
p.y+=p.vy*dt; | |||
p.size+=32*dt; | |||
} | |||
for(let i=tornPaper.length-1;i>=0;i--){ | |||
const p=tornPaper[i]; | |||
p.remaining-=dt; | |||
if(p.remaining<=0){tornPaper.splice(i,1);continue;} | |||
p.x+=(p.vx+Math.sin(elapsed*4+p.seed)*42)*dt; | |||
p.y+=p.vy*dt; | |||
p.vy+=p.gravity*dt; | |||
p.angle+=p.spin*dt; | |||
} | |||
} | |||
const particles=Array.from({length:innerWidth<700?40:62},(_,i)=>({ | |||
seed:rand(i*4+71),height:rand(i*5+13), | |||
lifetime:.5+rand(i*8+37)*.7, | |||
phase:rand(i*9+21),sprite:i%4, | |||
scale:.72+rand(i*7+9)*1.25, | |||
bend:rand(i*15+23)*6.28 | |||
})); | |||
const motes=Array.from({length:innerWidth<700?37:72},(_,i)=>({ | |||
y:rand(i*3+17),phase:rand(i*9+43), | |||
speed:85+rand(i*12+5)*260, | |||
drift:75+rand(i*15+2)*170, | |||
length:3+rand(i*17+7)*11 | |||
})); | |||
const shockDebris=Array.from({length:innerWidth<700?32:66},(_,i)=>({ | |||
angle:rand(i*17+131)*Math.PI*2, | |||
velocity:190+rand(i*11+51)*900, | |||
seed:rand(i*5+79), | |||
length:6+rand(i*7+17)*26 | |||
})); | |||
function pressureWave(x,y,radius,power,dark,time) { | |||
if(power<=.005)return; | |||
const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75); | |||
if(dark){ | |||
core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')'); | |||
core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')'); | |||
core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')'); | |||
core.addColorStop(1,'rgba(30,0,0,0)'); | |||
}else{ | |||
core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')'); | |||
core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')'); | |||
core.addColorStop(1,'rgba(0,29,100,0)'); | |||
} | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.fillStyle=core; | |||
ctx.fillRect(x-radius,y-radius,radius*2,radius*2); | |||
ctx.globalCompositeOperation='source-over'; | |||
} | |||
return function drawAtmosphere(seconds,progress,dark,elapsed,cells) { | |||
const w=innerWidth,h=innerHeight; | |||
const scale=w<700?.43:.55; | |||
const bw=Math.max(1,Math.round(w*scale)); | |||
const bh=Math.max(1,Math.round(h*scale)); | |||
if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;} | |||
ctx.setTransform(scale,0,0,scale,0,0); | |||
ctx.clearRect(0,0,w,h); | |||
const envelope=Math.sin(Math.PI*progress); | |||
const fade=clamp01(progress/.075)*clamp01((1-progress)/.065); | |||
advanceCombustion(seconds,progress,dark,elapsed,w,h); | |||
const sprites=dark?sootSprites:mistSprites; | |||
// New-world side: red fire trails to the right when burning, | |||
// blue dawn trails to the left when returning. | |||
const direction=dark?1:-1; | |||
// Broken smoke banks with different depths and sizes. The old | |||
// aligned rows created an artificial horizontal fog ribbon. | |||
ctx.globalCompositeOperation='source-over'; | |||
const bands=Math.max(8,Math.ceil(h/(dark?116:91))); | |||
for(let i=0;i<bands;i++){ | |||
const baseY=(i+.15+rand(i*11+39)*.70)*h/bands; | |||
const y=baseY+31*Math.sin(seconds*1.6+i*1.47); | |||
const x=edgeAt(y,seconds,progress,w)+direction* | |||
(dark?76+rand(i*19+6)*260:120)+ | |||
Math.sin(seconds*.93+i*1.3)*37; | |||
const width=dark?170+rand(i*29+12)*220:250; | |||
const height=dark?165+rand(i*7+5)*170:245; | |||
ctx.save(); | |||
ctx.globalAlpha=(dark?.34:.21)*fade*(.55+.45*envelope); | |||
ctx.translate(x,y); | |||
ctx.rotate(.27*Math.sin(i*2.1+seconds*.9)); | |||
ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height); | |||
ctx.restore(); | |||
} | |||
// Independent turbulent billows peel away and rise. | |||
for(const item of particles){ | |||
const age=((elapsed/item.lifetime+item.phase)%1); | |||
const baseY=item.height*h; | |||
const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15); | |||
if(y< -230||y>h+230)continue; | |||
const frontier=edgeAt(baseY,seconds,progress,w); | |||
const x=frontier+direction*(age*(dark?225:135)+ | |||
30*Math.sin(age*6.3+item.bend)); | |||
const size=(dark?174:145)*item.scale*(.68+age*1.9); | |||
const opacity=(dark?.43:.26)*Math.sin(Math.PI*age)*fade* | |||
(.55+.45*envelope); | |||
if(opacity<=.005)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(x,y); | |||
ctx.rotate((item.bend-3.14)*.14+age*.85*direction); | |||
ctx.scale(1.18,.84); | |||
ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size); | |||
ctx.restore(); | |||
} | |||
// Residual devastation: coal-black plumes stay pinned to their | |||
// original burn positions, expanding long after the tear passes. | |||
// Composited *before* fire, so the hot core cuts through smoke. | |||
if(dark){ | |||
ctx.save(); | |||
ctx.globalCompositeOperation='source-over'; | |||
for(const p of residualSoot){ | |||
const age=1-p.remaining/p.life; | |||
const opacity=clamp01(age/.14)* | |||
Math.pow(1-age,.49)*(.66+.24*Math.sin(p.seed*3)**2)*fade; | |||
if(opacity<.009)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(p.x,p.y); | |||
ctx.rotate(p.spin+age*.33*Math.sin(p.seed)); | |||
ctx.scale(1.20,.92); | |||
const s=p.size*(1+age*.68); | |||
ctx.drawImage(sootSprites[p.sprite],-s/2,-s/2,s,s); | |||
ctx.restore(); | |||
} | |||
ctx.restore(); | |||
}else{ | |||
// The inverse is a slow, organic unfurling: broad milky | |||
// radiance behind the rebuilt sky, not a cold explosion. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
for(let n=0;n<3;n++){ | |||
const yy=(n+.43)*h/3+Math.sin(seconds*.8+n*2)*30; | |||
const xx=edgeAt(yy,seconds,progress,w)- | |||
(88+55*Math.sin(n*2.1+seconds*.7)); | |||
const radius=145+65*Math.sin(seconds*.7+n*1.7)**2; | |||
const bloom=ctx.createRadialGradient(xx,yy,8,xx,yy,radius); | |||
bloom.addColorStop(0,'rgba(250,255,255,.10)'); | |||
bloom.addColorStop(.33,'rgba(164,237,255,.07)'); | |||
bloom.addColorStop(.74,'rgba(117,198,255,.04)'); | |||
bloom.addColorStop(1,'rgba(117,198,255,0)'); | |||
ctx.globalAlpha=fade; | |||
ctx.fillStyle=bloom; | |||
ctx.fillRect(xx-radius,yy-radius,radius*2,radius*2); | |||
} | |||
ctx.restore(); | |||
} | |||
// The impact happens at ignition and again as the rupture reaches | |||
// the middle of the screen; pulses are intentionally fleeting. | |||
for(const moment of (dark?[.10,.48]:[])){ | |||
const age=(progress-moment)/.125; | |||
if(age<0||age>1)continue; | |||
const intensity=(1-age)*(1-age)*fade; | |||
// A single-frame-ish exposure bloom punches through | |||
// the entire page; the tone is red or icy blue by direction. | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.fillStyle=dark | |||
? 'rgba(255,81,26,'+(.095*intensity)+')' | |||
: 'rgba(150,219,255,'+(.080*intensity)+')'; | |||
ctx.fillRect(0,0,w,h); | |||
ctx.globalCompositeOperation='source-over'; | |||
const x=edgeAt(h*.51,seconds,progress,w); | |||
const y=h*.51; | |||
const radius=95+age*Math.max(w,h)*.9; | |||
pressureWave(x,y,radius,intensity,dark,seconds); | |||
ctx.globalAlpha=(dark?.20:.28)*intensity; | |||
ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age); | |||
ctx.strokeStyle=dark?'#ffb36c':'#b5eaff'; | |||
ctx.beginPath(); | |||
for(let i=0;i<=110;i++){ | |||
const a=i/110*Math.PI*2; | |||
const jag=1+.03*Math.sin(a*29+seconds*12)+ | |||
.05*Math.sin(a*13-seconds*5); | |||
const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag; | |||
if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry); | |||
} | |||
ctx.stroke(); | |||
ctx.globalAlpha=1; | |||
// Only combustion throws straight, fiery projectile trails. | |||
// Return-to-blue has no streaks or sparks whatsoever. | |||
if(dark){ | |||
ctx.globalCompositeOperation='screen'; | |||
for(const bit of shockDebris){ | |||
const f=age*(.5+bit.seed*.5); | |||
const travel=bit.velocity*f; | |||
const px=x+Math.cos(bit.angle)*travel; | |||
const py=y+Math.sin(bit.angle)*travel; | |||
const vx=Math.cos(bit.angle)*bit.length*(1-age); | |||
const vy=Math.sin(bit.angle)*bit.length*(1-age); | |||
ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7); | |||
ctx.strokeStyle=dark?'#ffdd9b':'#e0faff'; | |||
ctx.lineWidth=.6+bit.seed*1.3; | |||
ctx.beginPath(); | |||
ctx.moveTo(px,py); | |||
ctx.lineTo(px-vx,py-vy); | |||
ctx.stroke(); | |||
} | |||
} | |||
ctx.globalCompositeOperation='source-over'; | |||
ctx.globalAlpha=1; | |||
} | |||
// Real flame tongues break OUT of the burn front into the sky. | |||
// They are thin, tapered Bezier ribbons with an unstable pulse, | |||
// rather than round glowing particles or a uniform bright line. | |||
if(dark){ | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
const count=Math.ceil(h/17); | |||
for(let j=0;j<count;j++){ | |||
const seed=rand(j*13+167); | |||
const y=(j+seed*.78)*11.5-5; | |||
const x=edgeAt(y,seconds,progress,w); | |||
if(x < -90 || x > w+90)continue; | |||
const ripple=Math.sin(seconds*(3.4+rand(j*11+4)*2.4)+ | |||
j*2.09+seed*3.1); | |||
const heat=clamp01(.58+ripple*.34+ | |||
.16*Math.sin(j*1.17-seconds*7.1)); | |||
if(heat<.34)continue; | |||
const length=(22+rand(j*23+19)*75)*(.5+heat*.94); | |||
const bend=.18+rand(j*17+42)*.66; | |||
const tipX=x-length*(.76+.19*seed); | |||
const tipY=y-length*bend; | |||
const thick=(3.2+rand(j*31+11)*10.3)*(.5+heat); | |||
const gradient=ctx.createLinearGradient(x,y,tipX,tipY); | |||
gradient.addColorStop(0,'rgba(255,255,242,.99)'); | |||
gradient.addColorStop(.28,'rgba(255,224,84,.96)'); | |||
gradient.addColorStop(.59,'rgba(255,76,10,.64)'); | |||
gradient.addColorStop(1,'rgba(182,17,6,0)'); | |||
ctx.beginPath(); | |||
ctx.moveTo(x+2,y+thick*.5); | |||
ctx.bezierCurveTo( | |||
x-length*.22,y-thick*.25, | |||
tipX+length*.36,tipY+thick*.68,tipX,tipY); | |||
ctx.bezierCurveTo( | |||
tipX+length*.32,tipY-thick*.22, | |||
x-length*.17,y-thick*.74, | |||
x+2,y-thick*.65); | |||
ctx.closePath(); | |||
ctx.globalAlpha=Math.min(1,heat*1.08)*fade; | |||
ctx.fillStyle=gradient; | |||
ctx.shadowBlur=0; | |||
ctx.fill(); | |||
if(heat>.66){ | |||
ctx.globalAlpha=(heat-.54)*1.40*fade; | |||
ctx.strokeStyle='#fffce0';ctx.lineWidth=.9+heat*1.1; | |||
ctx.beginPath();ctx.moveTo(x,y); | |||
ctx.quadraticCurveTo(x-length*.3,y-length*.11, | |||
tipX+length*.19,tipY+thick*.18); | |||
ctx.stroke(); | |||
} | |||
} | |||
ctx.restore(); | |||
} | |||
// Carbonized paper has a fine, ragged edge. The charcoal layer | |||
// persists continuously, but the white-hot core does NOT. | |||
ctx.save(); | |||
ctx.beginPath(); | |||
for(let y=-12,i=0;y<=h+12;y+=3.6,i++){ | |||
const x=edgeAt(y,seconds,progress,w); | |||
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y); | |||
} | |||
ctx.lineCap='round';ctx.lineJoin='round'; | |||
if(dark){ | |||
ctx.strokeStyle='rgba(9,5,7,.81)'; | |||
ctx.lineWidth=18;ctx.shadowBlur=0; | |||
ctx.stroke(); | |||
ctx.strokeStyle='rgba(210,45,9,.53)'; | |||
ctx.lineWidth=6; | |||
ctx.stroke(); | |||
}else{ | |||
ctx.strokeStyle='rgba(27,95,190,.22)'; | |||
ctx.lineWidth=27;ctx.shadowColor='#56bdff';ctx.shadowBlur=40; | |||
ctx.stroke(); | |||
ctx.strokeStyle='rgba(132,225,255,.51)'; | |||
ctx.lineWidth=11;ctx.shadowColor='#8aeaff';ctx.shadowBlur=30; | |||
ctx.stroke(); | |||
ctx.strokeStyle='rgba(249,255,255,.65)'; | |||
ctx.lineWidth=2.7;ctx.shadowBlur=11; | |||
ctx.stroke(); | |||
} | |||
ctx.restore(); | |||
if(dark){ | |||
// Intermittent ignition cells: varying width/color/intensity | |||
// make the bright edge resemble burning fibres, not neon. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.lineCap='round'; | |||
for(let y=-8;y<h+8;y+=5.8){ | |||
const intensity=clamp01(.58+ | |||
.29*Math.sin(y*.054+seconds*3.7)+ | |||
.21*Math.sin(y*.147-seconds*8.9)+ | |||
.13*Math.sin(y*.337+seconds*16.4)); | |||
if(intensity<.47)continue; | |||
const x=edgeAt(y,seconds,progress,w); | |||
const nextX=edgeAt(y+5.8,seconds,progress,w); | |||
ctx.globalAlpha=intensity*fade; | |||
ctx.lineWidth=1.4+intensity*4.6; | |||
ctx.shadowBlur=0; | |||
ctx.strokeStyle=intensity>.75?'#fff8d7': | |||
intensity>.58?'#ffd369':'#ff831b'; | |||
ctx.beginPath();ctx.moveTo(x,y); | |||
ctx.lineTo(nextX,y+5.8);ctx.stroke(); | |||
} | |||
ctx.restore(); | |||
// Fragmented ignition on real microscopic perforations. | |||
// Missing arc sections stop them resembling punched circles. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
ctx.shadowBlur=0; | |||
for (let i=0; i<cells.length; i++) { | |||
const c=cells[i]; | |||
ctx.globalAlpha=(.42+.34*c.heat)*fade; | |||
ctx.strokeStyle=c.heat>.73?'#fff6bf': | |||
c.heat>.38?'#ffd166':'#ff621c'; | |||
ctx.lineWidth=.8+Math.min(2.6,c.radius*.24); | |||
const n=c.points.length; | |||
ctx.beginPath(); | |||
for (let j=0;j<n;j++) { | |||
if ((j+i*3)%5>2)continue; | |||
const a=c.points[j],b=c.points[(j+1)%n]; | |||
ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]); | |||
} | |||
ctx.stroke(); | |||
} | |||
ctx.restore(); | |||
// Small carbon flecks stick to the still-blue paper just | |||
// ahead of the bite; no large rectangular glitch debris. | |||
ctx.save(); | |||
for(let j=0;j<Math.ceil(h/10);j++){ | |||
const y=(j+rand(j*5+61)*.8)*10; | |||
const x=edgeAt(y,seconds,progress,w)- | |||
(5+rand(j*17+38)*24); | |||
const size=.5+rand(j*29+11)*2.5; | |||
const visible=.25+.59*rand(j*13+7); | |||
ctx.globalAlpha=visible*fade; | |||
ctx.fillStyle='#150b0a'; | |||
ctx.beginPath(); | |||
ctx.ellipse(x,y,size*.71,size,rand(j*7+31)*3.14, | |||
0,Math.PI*2); | |||
ctx.fill(); | |||
} | |||
ctx.restore(); | |||
} | |||
// The key property V3-V6 lost: particles remain at their | |||
// birth position while the front advances. A wide, incandescent | |||
// WAKE builds behind the tearing page instead of following | |||
// it as one thin, plastic-looking stripe. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='lighter'; | |||
for(const p of combustion){ | |||
if(p.kind==='ash'||p.kind==='leaf'||p.kind==='pollen')continue; | |||
const age=1-p.remaining/p.life; | |||
const opacity=clamp01(p.remaining/.22)* | |||
clamp01(age/.08)*(1-age*.30)* | |||
(p.kind==='ember'?.74:p.kind==='glow'?.13:.66)*fade* | |||
(dark?Math.exp(-Math.max(0,p.x- | |||
edgeAt(p.y,seconds,progress,w))/70):1); | |||
if(opacity<.012)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(p.x,p.y); | |||
ctx.rotate(p.angle+Math.sin(elapsed*4+p.seed)*.13); | |||
const large=p.kind==='flame'; | |||
const sx=p.radius*(p.kind==='glow'?1.7:large?.96:1.32)*(1+age*.32); | |||
const sy=p.radius*(p.kind==='glow'?1.4:large?1.78:1.35)*(1+age*.57); | |||
const sprite=p.kind==='flame'?FIRE_GLOW[p.sprite]: | |||
p.kind==='glow'?BLUE_GLOW[p.sprite]:EMBER_GLOW; | |||
ctx.drawImage(sprite,-sx,-sy,sx*2,sy*2); | |||
ctx.restore(); | |||
} | |||
ctx.restore(); | |||
// Rebirth uses soft, rotating, organic leaf/petal silhouettes, | |||
// minute circular pollen and spacious white-blue bokeh. No | |||
// rectangular ions, no warm textures and NO glowing streaks. | |||
if(!dark){ | |||
ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
for(const p of combustion){ | |||
if(p.kind!=='leaf'&&p.kind!=='pollen')continue; | |||
const age=1-p.remaining/p.life; | |||
const opacity=clamp01(age/.17)*clamp01(p.remaining/.55)* | |||
(p.kind==='leaf'?.47:.28)*fade; | |||
if(opacity<.012)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(p.x+11*Math.sin(elapsed*1.6+p.seed),p.y); | |||
ctx.rotate(p.angle+age*1.65*Math.sin(p.seed*2.5)); | |||
if(p.kind==='leaf'){ | |||
const r=p.radius; | |||
ctx.shadowBlur=13; | |||
ctx.shadowColor='#bbf4ff'; | |||
ctx.fillStyle=p.sprite%2?'#e6ffff':'#ffffff'; | |||
ctx.beginPath(); | |||
ctx.moveTo(0,-r*1.65); | |||
ctx.bezierCurveTo(r*.97,-r*.48,r*.85,r*.60,0,r*1.35); | |||
ctx.bezierCurveTo(-r*.92,r*.28,-r*.94,-r*.57,0,-r*1.65); | |||
ctx.fill(); | |||
ctx.globalAlpha=opacity*.38; | |||
ctx.strokeStyle='#70c9ea'; | |||
ctx.lineWidth=.65; | |||
ctx.beginPath();ctx.moveTo(0,-r*1.15); | |||
ctx.quadraticCurveTo(-r*.17,0,0,r*.95);ctx.stroke(); | |||
}else{ | |||
const r=p.radius; | |||
const halo=ctx.createRadialGradient(0,0,0,0,0,r*3); | |||
halo.addColorStop(0,'rgba(255,255,255,.78)'); | |||
halo.addColorStop(.25,'rgba(225,250,255,.40)'); | |||
halo.addColorStop(1,'rgba(150,215,255,0)'); | |||
ctx.fillStyle=halo; | |||
ctx.beginPath();ctx.arc(0,0,r*3,0,Math.PI*2);ctx.fill(); | |||
} | |||
ctx.restore(); | |||
} | |||
ctx.restore(); | |||
} | |||
// Minute black flecks, never rectangular shards on the blue side. | |||
ctx.save(); | |||
ctx.globalCompositeOperation='source-over'; | |||
for(const p of combustion){ | |||
if(p.kind!=='ash')continue; | |||
ctx.globalAlpha=clamp01(p.remaining/.25)*.7*fade; | |||
ctx.fillStyle='#190d10'; | |||
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(elapsed*2+p.angle); | |||
ctx.fillRect(-p.radius*.5,-p.radius*.5,p.radius,p.radius*1.8); | |||
ctx.restore(); | |||
} | |||
ctx.restore(); | |||
// Irregular burnt page sections tumble away and reveal their | |||
// blackened underside, with an occasional incandescent rim. | |||
if(dark){ | |||
ctx.save(); | |||
ctx.globalCompositeOperation='source-over'; | |||
for(const p of tornPaper){ | |||
const age=1-p.remaining/p.life; | |||
const opacity=clamp01(age/.08)*clamp01(p.remaining/.4)*fade; | |||
if(opacity<.01)continue; | |||
ctx.save(); | |||
ctx.globalAlpha=opacity; | |||
ctx.translate(p.x,p.y); | |||
ctx.rotate(p.angle); | |||
ctx.beginPath(); | |||
p.outline.forEach((v,i)=>{ | |||
const px=Math.cos(v.a)*v.r*p.radius; | |||
const py=Math.sin(v.a)*v.r*p.radius*.74; | |||
if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py); | |||
}); | |||
ctx.closePath(); | |||
ctx.shadowBlur=7; | |||
ctx.shadowColor='#090506'; | |||
ctx.fillStyle=p.seed>3?'#190d11':'#302025'; | |||
ctx.fill(); | |||
ctx.shadowBlur=0; | |||
ctx.lineWidth=.8+Math.sin(p.seed)**2*.8; | |||
ctx.strokeStyle=age<.48?'rgba(255,98,34,.29)': | |||
'rgba(49,20,19,.47)'; | |||
ctx.stroke(); | |||
// Thin blue-white unburnt fibers still visible on a | |||
// few ripped faces, before the back chars completely. | |||
if(p.seed>4.5&&age<.52){ | |||
ctx.globalAlpha*=.45*(1-age); | |||
ctx.strokeStyle='#c5e7f7'; | |||
ctx.beginPath(); | |||
ctx.moveTo(-p.radius*.42,-p.radius*.16); | |||
ctx.quadraticCurveTo(0,p.radius*.25,p.radius*.28,0); | |||
ctx.stroke(); | |||
} | |||
ctx.restore(); | |||
} | |||
ctx.restore(); | |||
} | |||
// Sharp, directional embers / crystalline fragments, not blobs. | |||
if(dark){ctx.save(); | |||
ctx.globalCompositeOperation='screen'; | |||
for(const mote of motes){ | |||
const age=(elapsed*.74+mote.phase)%1; | |||
const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9; | |||
const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift; | |||
if(x<-25||x>w+25||yy<-25||yy>h+25)continue; | |||
const opacity=(1-age)*fade*.89; | |||
ctx.globalAlpha=opacity; | |||
ctx.strokeStyle='#ffb45d'; | |||
ctx.lineWidth=.8; | |||
ctx.beginPath(); | |||
ctx.moveTo(x,yy); | |||
ctx.lineTo(x-direction*mote.length*.4,yy+mote.length); | |||
ctx.stroke(); | |||
} | |||
ctx.restore();} | |||
}; | |||
} | |||
function prewarmAtmosphere() { | |||
if(atmosphereAssetCache)return; | |||
const prepare=()=>{ | |||
if(!atmosphereAssetCache) | |||
createAtmosphere(document.createElement('canvas')); | |||
}; | |||
if('requestIdleCallback' in window) | |||
requestIdleCallback(prepare,{timeout:6000}); | |||
else setTimeout(prepare,1400); | |||
} | |||
let atmosphereEdge=null; | |||
function edgeAt(y,seconds,progress,width){ | |||
return atmosphereEdge | |||
? atmosphereEdge(y) | |||
: makeEdge(progress,seconds,Boolean(window.__kkptVisualDark),width)(y); | |||
} | |||
// Original V2's moving flame sprites and three bright contours, rebuilt | // Original V2's moving flame sprites and three bright contours, rebuilt | ||
| 第570行: | 第1,398行: | ||
function clipFrame(edge) { | function clipFrame(edge) { | ||
const w=innerWidth,h=innerHeight; | const w=innerWidth,h=innerHeight; | ||
const rows=Math.max( | const rows=Math.max(24,Math.ceil(h/30)); | ||
const pts=[]; | const pts=[]; | ||
for(let i=rows;i>=0;i--) { | for(let i=rows;i>=0;i--) { | ||
| 第581行: | 第1,409行: | ||
} | } | ||
function canvasFire(canvas,dark,duration,done) { | function canvasFire(canvas,dark,duration,drawAtmosphere,done) { | ||
const ctx=canvas.getContext('2d',{alpha:true,desynchronized:true}); | const ctx=dark&&canvas | ||
if(!ctx){done();return;} | ? canvas.getContext('2d',{alpha:true,desynchronized:true}) | ||
const art=makeSprites(); | : null; | ||
if(dark&&!ctx){done();return;} | |||
const art=dark?makeSprites():null; | |||
const w=innerWidth,h=innerHeight; | const w=innerWidth,h=innerHeight; | ||
const scale=Math.min(window.devicePixelRatio||1,w<650?. | if(ctx){ | ||
const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80); | |||
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 rand=(a,b)=>a+Math.random()*(b-a); | ||
const mobile=w<650; | const mobile=w<650; | ||
const maxCount=mobile? | const maxCount=mobile?160:250; | ||
const p=[]; | const p=[]; | ||
const accum={flame:0,ember | const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0}; | ||
const rates=dark | const rates=dark | ||
? {flame:mobile? | ? {flame:mobile?80:105,ember:mobile?72:96, | ||
shard:mobile?26:42} | |||
: {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50}; | : {sky:mobile?50:80,mist:mobile?13:20,pollen:mobile?27:50}; | ||
const started=performance.now(); | const started=performance.now(); | ||
| 第719行: | 第1,551行: | ||
ctx.globalAlpha=fade; | ctx.globalAlpha=fade; | ||
ctx.shadowColor='#ff3903'; | ctx.shadowColor='#ff3903'; | ||
ctx.shadowBlur= | ctx.shadowBlur=25; | ||
ctx.strokeStyle='rgba(255,55,5,.82)'; | ctx.strokeStyle='rgba(255,55,5,.82)'; | ||
ctx.lineWidth=43; | ctx.lineWidth=43; | ||
ctx.stroke(); | ctx.stroke(); | ||
ctx.shadowBlur= | ctx.shadowBlur=16; | ||
ctx.strokeStyle='#ff9b17'; | ctx.strokeStyle='#ff9b17'; | ||
ctx.lineWidth=20; | ctx.lineWidth=20; | ||
ctx.stroke(); | ctx.stroke(); | ||
ctx.shadowBlur= | ctx.shadowBlur=7; | ||
ctx.strokeStyle='#ffe45e'; | ctx.strokeStyle='#ffe45e'; | ||
ctx.lineWidth=9; | ctx.lineWidth=9; | ||
| 第824行: | 第1,656行: | ||
} | } | ||
function paint(now) { | function paint(now) { | ||
if(now-lastPaint<(mobile? | const t=Math.min(1,(now-started)/duration); | ||
const seconds=now/1000; | |||
const edge=makeEdge(t,seconds,dark,w); | |||
// Update the actual Wiki page reveal at display refresh rate. | |||
// The costly flame/smoke pass has an independent 30fps budget. | |||
atmosphereEdge=edge; | |||
window.__kkptVisualDark=dark; | |||
clipFrame(edge); | |||
if(now-lastPaint<(mobile?35:32) && t<1){ | |||
requestAnimationFrame(paint);return; | requestAnimationFrame(paint);return; | ||
} | } | ||
const dt=Math.min(. | const dt=Math.min(.070,(now-previous)/1000); | ||
previous=now;lastPaint=now; | previous=now;lastPaint=now; | ||
const fade=Math.min(1,t/.055,(1-t)/.045); | const fade=Math.min(1,t/.055,(1-t)/.045); | ||
// Restore V14's broad smoke banks, long-lived char, pressure | |||
advance(dt); | // waves and delicate blue leaves. V2 only paints the FIRE. | ||
drawAtmosphere(seconds,t,dark,(now-started)/1000,[]); | |||
if(dark){ | |||
advance(dt); | |||
if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade)); | |||
ctx.clearRect(0,0,w,h); | |||
drawShards(fade); | |||
drawCore(edge,seconds,t,fade); | |||
drawBrightParticles(fade); | |||
} | |||
if(t<1)requestAnimationFrame(paint); | if(t<1)requestAnimationFrame(paint); | ||
else done(); | else done(); | ||
| 第850行: | 第1,690行: | ||
active=nextActive; | active=nextActive; | ||
frame.classList.toggle('kkpt-active',active); | frame.classList.toggle('kkpt-active',active); | ||
document.body.classList.toggle('kkpt-other-active',active); | |||
frame.style.clipPath=active?'inset(0)':hideClip; | frame.style.clipPath=active?'inset(0)':hideClip; | ||
frame.style.pointerEvents=''; | frame.style.pointerEvents=''; | ||
| 第856行: | 第1,697行: | ||
document.title=active?otherTitle:baseTitle; | document.title=active?otherTitle:baseTitle; | ||
showTheme((active?otherPage:basePage)==='Kuronata'); | showTheme((active?otherPage:basePage)==='Kuronata'); | ||
// Stop the invisible Kuronata ambience in either document. | |||
root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active); | |||
try{ | |||
frame.contentDocument?.documentElement.classList.toggle( | |||
'kkpt-frame-inactive',!active); | |||
}catch(_){} | |||
updateButton(); | updateButton(); | ||
} | } | ||
| 第872行: | 第1,719行: | ||
const dark=(nextActive?otherPage:basePage)==='Kuronata'; | const dark=(nextActive?otherPage:basePage)==='Kuronata'; | ||
labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING'; | labelSub.textContent=dark?'THE SKY IS BURNING':'DAWN IS BREAKING'; | ||
// When returning to the blue page, restore its light palette | |||
// BEFORE unmasking it. The dark iframe still fully covers it. | |||
if(!dark)showTheme(false); | |||
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 atmosphere=document.createElement('canvas'); | ||
canvas.id='kkpt-vfx'; | atmosphere.id='kkpt-atmosphere'; | ||
atmosphere.setAttribute('aria-hidden','true'); | |||
document.body.append(atmosphere); | |||
const duration=dark?3450: | const drawAtmosphere=createAtmosphere(atmosphere); | ||
const canvas=dark?document.createElement('canvas'):null; | |||
if(canvas){ | |||
canvas.id='kkpt-vfx'; | |||
canvas.setAttribute('aria-hidden','true'); | |||
document.body.append(canvas); | |||
} | |||
const duration=dark?3450:3100; | |||
transitionSound(dark,duration); | transitionSound(dark,duration); | ||
await new Promise(resolve=>canvasFire(canvas,dark,duration,resolve)); | await new Promise(resolve=>canvasFire( | ||
canvas,dark,duration,drawAtmosphere,resolve)); | |||
setDisplayed(nextActive); | setDisplayed(nextActive); | ||
canvas.style.opacity='0'; | if(canvas)canvas.style.opacity='0'; | ||
atmosphere.style.opacity='0'; | |||
await sleep(160); | await sleep(160); | ||
canvas.remove(); | canvas?.remove(); | ||
atmosphere.remove(); | |||
animating=false; | animating=false; | ||
button.classList.remove('kkpt-busy','kkpt-quiet'); | button.classList.remove('kkpt-busy','kkpt-quiet'); | ||
| 第905行: | 第1,765行: | ||
console.error('[KuranokaPageTransition] V12:',err); | console.error('[KuranokaPageTransition] V12:',err); | ||
document.getElementById('kkpt-vfx')?.remove(); | document.getElementById('kkpt-vfx')?.remove(); | ||
document.getElementById('kkpt-atmosphere')?.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); | ||
| 第930行: | 第1,791行: | ||
mountFateGear(document,otherPage); | mountFateGear(document,otherPage); | ||
warmFrame(); | warmFrame(); | ||
prewarmAtmosphere(); | |||
preloadRebirthPad(); | |||
button.addEventListener('click', event => { | button.addEventListener('click', event => { | ||
| 第962行: | 第1,825行: | ||
animating=false; | animating=false; | ||
document.getElementById('kkpt-vfx')?.remove(); | document.getElementById('kkpt-vfx')?.remove(); | ||
document.getElementById('kkpt-atmosphere')?.remove(); | |||
frame.style.filter = ''; | frame.style.filter = ''; | ||
setDisplayed(pathPage() === otherPage); | setDisplayed(pathPage() === otherPage); | ||
2026年10月23日 (五) 17:17的最新版本
/* ============================================================
Kuranoka / Kuronata — Page Transition V18
V2-inspired REAL CANVAS FIRE: bright flames, smoke, fragments.
V18 smooths the mask, uses sampled PADsynth and stabilizes scroll,
with capped particle work and infrequent spatial glitches.
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 enforceFramePreference=false;
if(window!==window.top && basePage==='Kuranoka'){
// Iframes are independent documents. Restore the parent's ORIGINAL
// reader preference, never the forced dark state during a swap.
try {
const inherited=window.top.__kkptReaderTheme;
if(Array.isArray(inherited) &&
inherited.every(c=>themeNames.includes(c))){
savedTheme=inherited.slice();
enforceFramePreference=true;
}
}catch(_){}
} else if(window===window.top) {
window.__kkptReaderTheme=savedTheme.slice();
}
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||enforceFramePreference)syncTheme();
else savedTheme=themeNames.filter(c=>root.classList.contains(c));
}).observe(root,{
attributes:true,attributeFilter:['class']
});
// V17: the dark-world extras run in *both* the visible document and
// its same-origin preview iframe. They never alter other Wiki pages.
function installKuronataAfterglow() {
if(basePage!=='Kuronata'||window.__kkptDarkExtrasReady)return;
window.__kkptDarkExtrasReady=true;
const prefersReduced=window.matchMedia('(prefers-reduced-motion: reduce)');
const decorateSpoilers=()=>{
// Copy only plain-text spoilers into the two glitch slices.
// Do not duplicate nested links or markup, and never reflow text.
document.querySelectorAll('span.heimu').forEach(node=>{
if(node.hasAttribute('data-kkpt-glitch')||node.children.length)return;
const label=(node.textContent||'').replace(/\s+/g,' ').trim();
if(label&&label.length<=240)
node.setAttribute('data-kkpt-glitch',label);
});
};
const createAmbience=()=>{
if(prefersReduced.matches||document.getElementById('kkpt-afterglow')||!document.body)return;
const overlay=document.createElement('div');
overlay.id='kkpt-afterglow';
overlay.setAttribute('aria-hidden','true');
const el=(type,i)=>{
const part=document.createElement('span');
part.className=type==='soot'?'kkpt-soot-cell':'kkpt-ash-cell';
const smoke=type==='soot';
// All sprites are static layers with compositor-only
// transform/opacity animation. No persistent canvas or RAF.
part.style.left=(smoke?Math.random()*101:Math.random()*100)+'%';
part.style.top=(smoke?37+Math.random()*75:15+Math.random()*94)+'%';
part.style.setProperty('--size',(smoke?155+Math.random()*175:2+Math.random()*4).toFixed(1)+'px');
part.style.setProperty('--delay',(-Math.random()*(smoke?29:18)).toFixed(2)+'s');
part.style.setProperty('--duration',(smoke?18+Math.random()*14:8+Math.random()*11).toFixed(2)+'s');
part.style.setProperty('--wind',((Math.random()-.5)*(smoke?120:230)).toFixed(1)+'px');
return part;
};
for(let i=0;i<10;i++)overlay.append(el('soot',i));
for(let i=0;i<30;i++)overlay.append(el('ash',i));
document.body.append(overlay);
};
const init=()=>{
decorateSpoilers();
createAmbience();
mw.hook('wikipage.content').add(decorateSpoilers);
};
if(document.readyState==='loading')
document.addEventListener('DOMContentLoaded',init,{once:true});
else init();
// One brief spatial fault roughly every 14-36 seconds, never a
// full-screen white strobe. Pause while the iframe is hidden.
const pulse=()=>{
const inactive=document.hidden||prefersReduced.matches||
root.classList.contains('kkpt-frame-inactive')||
root.classList.contains('kkpt-world-suspended')||
(window!==window.top&&window.frameElement?.getAttribute('aria-hidden')==='true');
if(!inactive){
root.style.setProperty('--kkpt-rift-y',(14+Math.random()*70).toFixed(1)+'%');
root.classList.add('kkpt-rift');
window.setTimeout(()=>root.classList.remove('kkpt-rift'),390);
}
window.setTimeout(pulse,5000+Math.random()*15000);
};
window.setTimeout(pulse,5000+Math.random()*15000);
}
installKuronataAfterglow();
// The nested preview only applies its own theme; no recursive iframe.
if (window!==window.top || window.__kkptV12) return;
window.__kkptV12=true;
window.__kkptVersion='18.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;
// Public-domain PADsynth recording, ~8.4s / 120KB. Change this
// to a same-origin Wiki-upload URL if external media are blocked.
const REBIRTH_PAD_URL=
'https://upload.wikimedia.org/wikipedia/commons/e/ed/Padsynth_example_synth_piano.ogg';
let padBytesPromise=null,padDecodePromise=null,padBuffer=null;
function preloadRebirthPad(){
if(padBytesPromise)return padBytesPromise;
padBytesPromise=fetch(REBIRTH_PAD_URL,{mode:'cors',cache:'force-cache'})
.then(resp=>{
if(!resp.ok)throw new Error('PAD HTTP '+resp.status);
return resp.arrayBuffer();
})
.catch(err=>{
console.debug('[KKPT] PAD file unavailable:',err);
return null;
});
return padBytesPromise;
}
function decodeRebirthPad(ctx){
if(padBuffer)return Promise.resolve(padBuffer);
if(!padDecodePromise){
padDecodePromise=preloadRebirthPad()
.then(bytes=>bytes?ctx.decodeAudioData(bytes.slice(0)):null)
.then(result=>(padBuffer=result))
.catch(err=>{
console.debug('[KKPT] Cannot decode PAD:',err);
return null;
});
}
return padDecodePromise;
}
// A plain media element can still play the recording when a site's
// CORS policy disallows reading the file into Web Audio.
function playRebirthPadFallback(durationMs){
const sound=new Audio(REBIRTH_PAD_URL);
sound.volume=0;
sound.play().then(()=>{
const begin=performance.now();
const timer=setInterval(()=>{
const t=Math.min(1,(performance.now()-begin)/durationMs);
sound.volume=Math.max(0,Math.min(DAWN_VOLUME,
DAWN_VOLUME*Math.min(t/.27,(1-t)/.26)));
if(t>=1){clearInterval(timer);sound.pause();sound.src='';}
},80);
}).catch(err=>console.debug('[KKPT] PAD playback unavailable:',err));
}
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(()=>{});
decodeRebirthPad(soundContext);
}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{
// A REAL recorded PADsynth sample; no generated wind-chimes.
decodeRebirthPad(ctx).then(buffer=>{
if(!buffer){playRebirthPadFallback(durationMs);return;}
const at=Math.max(start,ctx.currentTime+.020);
if(at>start+span-.55)return;
const player=ctx.createBufferSource();
player.buffer=buffer;
player.connect(master);
player.start(at,Math.max(0,at-start)+.95);
player.stop(start+span);
}).catch(()=>{});
}
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;
if(anchor.hidden)return;
// Absolute document coords; scroll is handled by the browser.
anchor.style.left=Math.round(rect.right-49+view.scrollX)+'px';
anchor.style.top=Math.round(y-21+view.scrollY)+'px';
}
function updateGear(){
if(pendingPosition)return;
pendingPosition=true;
doc.defaultView.requestAnimationFrame(positionGear);
}
doc.defaultView.addEventListener('resize',updateGear,{passive:true});
if(doc.defaultView.ResizeObserver){
const observer=new doc.defaultView.ResizeObserver(updateGear);
observer.observe(host);
if(host!==box)observer.observe(box);
}
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;
}
// Shared, low-resolution atmospherics: return of V14's dark smoke,
// long-lived torn paper, shockwaves, blue-white petals and pollen.
// Only the combustion CURTAIN is replaced by the bright V2 engine.
let atmosphereAssetCache=null;
function createAtmosphere(canvas) {
const ctx = canvas.getContext('2d', {alpha:true});
const rand = n => {
const x = Math.sin(n*127.1+311.7)*43758.5453123;
return x-Math.floor(x);
};
const clamp01 = n => Math.max(0,Math.min(1,n));
function valueNoise(x,y,seed) {
const ix=Math.floor(x), iy=Math.floor(y);
let fx=x-ix,fy=y-iy;
fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy);
const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1);
const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx;
const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx;
return a*(1-fy)+b*fy;
}
function smokeTexture(seed,dark) {
const tex=document.createElement('canvas');
tex.width=tex.height=112;
const g=tex.getContext('2d');
const data=g.createImageData(112,112);
for(let y=0;y<112;y++) for(let x=0;x<112;x++){
const u=(x-56)/56,v=(y-56)/56;
const curl=valueNoise(u*2.9+11,v*2.9+1,seed);
const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7);
const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5);
const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13);
const envelope=clamp01((1.05-radius)/.56);
const low=valueNoise(u*4.1+4,v*4.1-9,seed+2);
const high=valueNoise(u*11.4-3,v*11.4+6,seed+5);
const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9);
const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)*
envelope*envelope;
const i=(y*112+x)*4;
const variation=low*28+high*15;
if(dark){
data.data[i]=5+variation*.37;
data.data[i+1]=5+variation*.33;
data.data[i+2]=8+variation*.38;
}else{
data.data[i]=44+variation;
data.data[i+1]=91+variation;
data.data[i+2]=125+variation;
}
data.data[i+3]=Math.round(240*density);
}
g.putImageData(data,0,0);
return tex;
}
const sootSprites=atmosphereAssetCache?.sootSprites || [0,1,2,3].map(n=>smokeTexture(n+17,true));
const mistSprites=atmosphereAssetCache?.mistSprites || [0,1,2,3].map(n=>smokeTexture(n+51,false));
// V2-inspired white-hot additive sprites, but with noise-eroded
// silhouettes instead of repetitive smooth, elliptical balls.
function glowTexture(seed,mode) {
const tex=document.createElement('canvas');
tex.width=96;tex.height=128;
const g=tex.getContext('2d');
const gradient=g.createRadialGradient(48,68,0,48,68,64);
const colors=mode==='fire'
? [[0,'#fffef1'],[.14,'#fff4bb'],[.34,'#ffd04d'],
[.55,'#ff841bcc'],[.80,'#bc290749'],[1,'#6e030000']]
: mode==='blue'
? [[0,'#ffffff'],[.19,'#f1ffff'],[.40,'#a2efff'],
[.68,'#5298ff99'],[1,'#345bf000']]
: [[0,'#ffffff'],[.19,'#fff8cd'],[.49,'#ff9b28cc'],
[1,'#ff380000']];
colors.forEach(([p,c])=>gradient.addColorStop(p,c));
g.fillStyle=gradient;g.fillRect(0,0,96,128);
const img=g.getImageData(0,0,96,128);
for(let y=0;y<128;y++)for(let x=0;x<96;x++){
const u=(x-48)/48,v=(y-64)/64;
const cloud=valueNoise(u*4+seed*1.9,v*4+seed*.7,seed+18);
const fiber=valueNoise(u*11+3,v*8+17,seed+41);
const shifted=u+.18*(cloud-.5);
const irregular=clamp01((1.15-Math.abs(shifted))/.52)*
clamp01((1.05-Math.abs(v+.12*(fiber-.5)))/.42);
const n=.59+.77*cloud+.28*fiber;
const alphaIndex=(y*96+x)*4+3;
img.data[alphaIndex]=Math.round(img.data[alphaIndex]*
irregular*clamp01(n));
}
g.putImageData(img,0,0);
return tex;
}
const FIRE_GLOW=atmosphereAssetCache?.FIRE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed,'fire'));
const BLUE_GLOW=atmosphereAssetCache?.BLUE_GLOW || [1,2,3,4].map(seed=>glowTexture(seed+7,'blue'));
const EMBER_GLOW=atmosphereAssetCache?.EMBER_GLOW || glowTexture(21,'ember');
atmosphereAssetCache ||= {sootSprites,mistSprites,FIRE_GLOW,BLUE_GLOW,EMBER_GLOW};
const combustion=[];
const residualSoot=[];
const tornPaper=[];
let lastElapsed=0,spawnRemainder=0,sootRemainder=0,shardRemainder=0;
function advanceCombustion(seconds,progress,dark,elapsed,w,h) {
const dt=Math.min(.065,Math.max(0,elapsed-lastElapsed));
lastElapsed=elapsed;
const live=progress>.01&&progress<.985;
if(live){
const flow=(dark?70:96)*Math.min(1.5,h/720)*
(.75+.25*Math.sin(Math.PI*progress));
spawnRemainder+=dt*flow;
const births=Math.min(65,Math.floor(spawnRemainder));
spawnRemainder-=births;
for(let j=0;j<births&&combustion.length<1320;j++){
const y=Math.random()*h;
const edge=edgeAt(y,seconds,progress,w);
const r=Math.random();
const kind=dark
? r<.55?'flame':r<.90?'ember':'ash'
: r<.37?'glow':r<.81?'leaf':'pollen';
const small=kind==='ember'||kind==='pollen';
const soot=kind==='ash';
const life=dark
? (soot?.7+Math.random()*1.1:
small?.65+Math.random()*1.1:.42+Math.random()*.76)
: 1.15+Math.random()*1.55;
combustion.push({
kind,x:edge+(dark?1:-1)*(Math.random()*47-8),
y:y+(Math.random()-.5)*25,
vx:(dark?1:-1)*(dark?14+Math.random()*154:
16+Math.random()*69),
vy:-(dark?32+Math.random()*171:12+Math.random()*57),
life,remaining:life,
radius:dark
? (soot?1.2+Math.random()*2.7:
small?2+Math.random()*5:9+Math.random()*19)
: kind==='glow'?26+Math.random()*49:
kind==='leaf'?4+Math.random()*6:
2+Math.random()*3,
angle:(Math.random()-.5)*1.1,
sprite:(Math.random()*4)|0,
seed:Math.random()*6.28
});
}
if(dark) {
// Unlike the traveling front, these black smoke plumes
// are born in world-space and remain over burnt paper.
sootRemainder+=dt*(h<680?32:46);
const sootBirths=Math.min(11,Math.floor(sootRemainder));
sootRemainder-=sootBirths;
for(let i=0;i<sootBirths&&residualSoot.length<(w<700?92:155);i++){
const y=Math.random()*h;
const life=2.15+Math.random()*1.60;
residualSoot.push({
x:edgeAt(y,seconds,progress,w)+5+Math.random()*83,
y:y+(Math.random()-.5)*48,
vx:8+Math.random()*39,vy:-18-Math.random()*83,
size:155+Math.random()*195,life,remaining:life,
sprite:(Math.random()*4)|0,
spin:(Math.random()-.5)*.8,
seed:Math.random()*6.28
});
}
// Charcoal sheets tear off in irregular polygons, not
// squares or narrow glitch streaks.
shardRemainder+=dt*(h<680?9:15);
const shardBirths=Math.min(5,Math.floor(shardRemainder));
shardRemainder-=shardBirths;
for(let i=0;i<shardBirths&&tornPaper.length<135;i++){
const y=Math.random()*h;
const life=.82+Math.random()*1.55;
const points=6+Math.floor(Math.random()*4);
const outline=Array.from({length:points},(_,j)=>({
a:Math.PI*2*j/points+(Math.random()-.5)*.22,
r:.50+Math.random()*.61
}));
tornPaper.push({
x:edgeAt(y,seconds,progress,w)+(Math.random()-.5)*47,
y, vx:34+Math.random()*210,
vy:-170+Math.random()*260,
gravity:90+Math.random()*150,
radius:3+Math.pow(Math.random(),1.6)*15,
angle:Math.random()*6.28,
spin:(Math.random()-.5)*7,
outline,life,remaining:life,
seed:Math.random()*6.28
});
}
}
}
for(let i=combustion.length-1;i>=0;i--){
const p=combustion[i];
p.remaining-=dt;
if(p.remaining<=0){combustion.splice(i,1);continue;}
p.x+=(p.vx+Math.sin(elapsed*(dark?5:1.6)+p.seed)*
(dark?22:24))*dt;
p.y+=p.vy*dt;
p.vy-=(dark?17:7)*dt;
}
for(let i=residualSoot.length-1;i>=0;i--){
const p=residualSoot[i];
p.remaining-=dt;
if(p.remaining<=0){residualSoot.splice(i,1);continue;}
p.x+=(p.vx+Math.sin(elapsed*2+p.seed)*27)*dt;
p.y+=p.vy*dt;
p.size+=32*dt;
}
for(let i=tornPaper.length-1;i>=0;i--){
const p=tornPaper[i];
p.remaining-=dt;
if(p.remaining<=0){tornPaper.splice(i,1);continue;}
p.x+=(p.vx+Math.sin(elapsed*4+p.seed)*42)*dt;
p.y+=p.vy*dt;
p.vy+=p.gravity*dt;
p.angle+=p.spin*dt;
}
}
const particles=Array.from({length:innerWidth<700?40:62},(_,i)=>({
seed:rand(i*4+71),height:rand(i*5+13),
lifetime:.5+rand(i*8+37)*.7,
phase:rand(i*9+21),sprite:i%4,
scale:.72+rand(i*7+9)*1.25,
bend:rand(i*15+23)*6.28
}));
const motes=Array.from({length:innerWidth<700?37:72},(_,i)=>({
y:rand(i*3+17),phase:rand(i*9+43),
speed:85+rand(i*12+5)*260,
drift:75+rand(i*15+2)*170,
length:3+rand(i*17+7)*11
}));
const shockDebris=Array.from({length:innerWidth<700?32:66},(_,i)=>({
angle:rand(i*17+131)*Math.PI*2,
velocity:190+rand(i*11+51)*900,
seed:rand(i*5+79),
length:6+rand(i*7+17)*26
}));
function pressureWave(x,y,radius,power,dark,time) {
if(power<=.005)return;
const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75);
if(dark){
core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')');
core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')');
core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')');
core.addColorStop(1,'rgba(30,0,0,0)');
}else{
core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')');
core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')');
core.addColorStop(1,'rgba(0,29,100,0)');
}
ctx.globalCompositeOperation='screen';
ctx.fillStyle=core;
ctx.fillRect(x-radius,y-radius,radius*2,radius*2);
ctx.globalCompositeOperation='source-over';
}
return function drawAtmosphere(seconds,progress,dark,elapsed,cells) {
const w=innerWidth,h=innerHeight;
const scale=w<700?.43:.55;
const bw=Math.max(1,Math.round(w*scale));
const bh=Math.max(1,Math.round(h*scale));
if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;}
ctx.setTransform(scale,0,0,scale,0,0);
ctx.clearRect(0,0,w,h);
const envelope=Math.sin(Math.PI*progress);
const fade=clamp01(progress/.075)*clamp01((1-progress)/.065);
advanceCombustion(seconds,progress,dark,elapsed,w,h);
const sprites=dark?sootSprites:mistSprites;
// New-world side: red fire trails to the right when burning,
// blue dawn trails to the left when returning.
const direction=dark?1:-1;
// Broken smoke banks with different depths and sizes. The old
// aligned rows created an artificial horizontal fog ribbon.
ctx.globalCompositeOperation='source-over';
const bands=Math.max(8,Math.ceil(h/(dark?116:91)));
for(let i=0;i<bands;i++){
const baseY=(i+.15+rand(i*11+39)*.70)*h/bands;
const y=baseY+31*Math.sin(seconds*1.6+i*1.47);
const x=edgeAt(y,seconds,progress,w)+direction*
(dark?76+rand(i*19+6)*260:120)+
Math.sin(seconds*.93+i*1.3)*37;
const width=dark?170+rand(i*29+12)*220:250;
const height=dark?165+rand(i*7+5)*170:245;
ctx.save();
ctx.globalAlpha=(dark?.34:.21)*fade*(.55+.45*envelope);
ctx.translate(x,y);
ctx.rotate(.27*Math.sin(i*2.1+seconds*.9));
ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height);
ctx.restore();
}
// Independent turbulent billows peel away and rise.
for(const item of particles){
const age=((elapsed/item.lifetime+item.phase)%1);
const baseY=item.height*h;
const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15);
if(y< -230||y>h+230)continue;
const frontier=edgeAt(baseY,seconds,progress,w);
const x=frontier+direction*(age*(dark?225:135)+
30*Math.sin(age*6.3+item.bend));
const size=(dark?174:145)*item.scale*(.68+age*1.9);
const opacity=(dark?.43:.26)*Math.sin(Math.PI*age)*fade*
(.55+.45*envelope);
if(opacity<=.005)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(x,y);
ctx.rotate((item.bend-3.14)*.14+age*.85*direction);
ctx.scale(1.18,.84);
ctx.drawImage(sprites[item.sprite],-size/2,-size/2,size,size);
ctx.restore();
}
// Residual devastation: coal-black plumes stay pinned to their
// original burn positions, expanding long after the tear passes.
// Composited *before* fire, so the hot core cuts through smoke.
if(dark){
ctx.save();
ctx.globalCompositeOperation='source-over';
for(const p of residualSoot){
const age=1-p.remaining/p.life;
const opacity=clamp01(age/.14)*
Math.pow(1-age,.49)*(.66+.24*Math.sin(p.seed*3)**2)*fade;
if(opacity<.009)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(p.x,p.y);
ctx.rotate(p.spin+age*.33*Math.sin(p.seed));
ctx.scale(1.20,.92);
const s=p.size*(1+age*.68);
ctx.drawImage(sootSprites[p.sprite],-s/2,-s/2,s,s);
ctx.restore();
}
ctx.restore();
}else{
// The inverse is a slow, organic unfurling: broad milky
// radiance behind the rebuilt sky, not a cold explosion.
ctx.save();
ctx.globalCompositeOperation='screen';
for(let n=0;n<3;n++){
const yy=(n+.43)*h/3+Math.sin(seconds*.8+n*2)*30;
const xx=edgeAt(yy,seconds,progress,w)-
(88+55*Math.sin(n*2.1+seconds*.7));
const radius=145+65*Math.sin(seconds*.7+n*1.7)**2;
const bloom=ctx.createRadialGradient(xx,yy,8,xx,yy,radius);
bloom.addColorStop(0,'rgba(250,255,255,.10)');
bloom.addColorStop(.33,'rgba(164,237,255,.07)');
bloom.addColorStop(.74,'rgba(117,198,255,.04)');
bloom.addColorStop(1,'rgba(117,198,255,0)');
ctx.globalAlpha=fade;
ctx.fillStyle=bloom;
ctx.fillRect(xx-radius,yy-radius,radius*2,radius*2);
}
ctx.restore();
}
// The impact happens at ignition and again as the rupture reaches
// the middle of the screen; pulses are intentionally fleeting.
for(const moment of (dark?[.10,.48]:[])){
const age=(progress-moment)/.125;
if(age<0||age>1)continue;
const intensity=(1-age)*(1-age)*fade;
// A single-frame-ish exposure bloom punches through
// the entire page; the tone is red or icy blue by direction.
ctx.globalCompositeOperation='screen';
ctx.fillStyle=dark
? 'rgba(255,81,26,'+(.095*intensity)+')'
: 'rgba(150,219,255,'+(.080*intensity)+')';
ctx.fillRect(0,0,w,h);
ctx.globalCompositeOperation='source-over';
const x=edgeAt(h*.51,seconds,progress,w);
const y=h*.51;
const radius=95+age*Math.max(w,h)*.9;
pressureWave(x,y,radius,intensity,dark,seconds);
ctx.globalAlpha=(dark?.20:.28)*intensity;
ctx.lineWidth=(dark?2.5:2)*Math.max(.4,1-age);
ctx.strokeStyle=dark?'#ffb36c':'#b5eaff';
ctx.beginPath();
for(let i=0;i<=110;i++){
const a=i/110*Math.PI*2;
const jag=1+.03*Math.sin(a*29+seconds*12)+
.05*Math.sin(a*13-seconds*5);
const rx=Math.cos(a)*radius*jag,ry=Math.sin(a)*radius*jag;
if(i===0)ctx.moveTo(x+rx,y+ry);else ctx.lineTo(x+rx,y+ry);
}
ctx.stroke();
ctx.globalAlpha=1;
// Only combustion throws straight, fiery projectile trails.
// Return-to-blue has no streaks or sparks whatsoever.
if(dark){
ctx.globalCompositeOperation='screen';
for(const bit of shockDebris){
const f=age*(.5+bit.seed*.5);
const travel=bit.velocity*f;
const px=x+Math.cos(bit.angle)*travel;
const py=y+Math.sin(bit.angle)*travel;
const vx=Math.cos(bit.angle)*bit.length*(1-age);
const vy=Math.sin(bit.angle)*bit.length*(1-age);
ctx.globalAlpha=(dark?.82:.68)*intensity*(.3+bit.seed*.7);
ctx.strokeStyle=dark?'#ffdd9b':'#e0faff';
ctx.lineWidth=.6+bit.seed*1.3;
ctx.beginPath();
ctx.moveTo(px,py);
ctx.lineTo(px-vx,py-vy);
ctx.stroke();
}
}
ctx.globalCompositeOperation='source-over';
ctx.globalAlpha=1;
}
// Real flame tongues break OUT of the burn front into the sky.
// They are thin, tapered Bezier ribbons with an unstable pulse,
// rather than round glowing particles or a uniform bright line.
if(dark){
ctx.save();
ctx.globalCompositeOperation='screen';
const count=Math.ceil(h/17);
for(let j=0;j<count;j++){
const seed=rand(j*13+167);
const y=(j+seed*.78)*11.5-5;
const x=edgeAt(y,seconds,progress,w);
if(x < -90 || x > w+90)continue;
const ripple=Math.sin(seconds*(3.4+rand(j*11+4)*2.4)+
j*2.09+seed*3.1);
const heat=clamp01(.58+ripple*.34+
.16*Math.sin(j*1.17-seconds*7.1));
if(heat<.34)continue;
const length=(22+rand(j*23+19)*75)*(.5+heat*.94);
const bend=.18+rand(j*17+42)*.66;
const tipX=x-length*(.76+.19*seed);
const tipY=y-length*bend;
const thick=(3.2+rand(j*31+11)*10.3)*(.5+heat);
const gradient=ctx.createLinearGradient(x,y,tipX,tipY);
gradient.addColorStop(0,'rgba(255,255,242,.99)');
gradient.addColorStop(.28,'rgba(255,224,84,.96)');
gradient.addColorStop(.59,'rgba(255,76,10,.64)');
gradient.addColorStop(1,'rgba(182,17,6,0)');
ctx.beginPath();
ctx.moveTo(x+2,y+thick*.5);
ctx.bezierCurveTo(
x-length*.22,y-thick*.25,
tipX+length*.36,tipY+thick*.68,tipX,tipY);
ctx.bezierCurveTo(
tipX+length*.32,tipY-thick*.22,
x-length*.17,y-thick*.74,
x+2,y-thick*.65);
ctx.closePath();
ctx.globalAlpha=Math.min(1,heat*1.08)*fade;
ctx.fillStyle=gradient;
ctx.shadowBlur=0;
ctx.fill();
if(heat>.66){
ctx.globalAlpha=(heat-.54)*1.40*fade;
ctx.strokeStyle='#fffce0';ctx.lineWidth=.9+heat*1.1;
ctx.beginPath();ctx.moveTo(x,y);
ctx.quadraticCurveTo(x-length*.3,y-length*.11,
tipX+length*.19,tipY+thick*.18);
ctx.stroke();
}
}
ctx.restore();
}
// Carbonized paper has a fine, ragged edge. The charcoal layer
// persists continuously, but the white-hot core does NOT.
ctx.save();
ctx.beginPath();
for(let y=-12,i=0;y<=h+12;y+=3.6,i++){
const x=edgeAt(y,seconds,progress,w);
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
}
ctx.lineCap='round';ctx.lineJoin='round';
if(dark){
ctx.strokeStyle='rgba(9,5,7,.81)';
ctx.lineWidth=18;ctx.shadowBlur=0;
ctx.stroke();
ctx.strokeStyle='rgba(210,45,9,.53)';
ctx.lineWidth=6;
ctx.stroke();
}else{
ctx.strokeStyle='rgba(27,95,190,.22)';
ctx.lineWidth=27;ctx.shadowColor='#56bdff';ctx.shadowBlur=40;
ctx.stroke();
ctx.strokeStyle='rgba(132,225,255,.51)';
ctx.lineWidth=11;ctx.shadowColor='#8aeaff';ctx.shadowBlur=30;
ctx.stroke();
ctx.strokeStyle='rgba(249,255,255,.65)';
ctx.lineWidth=2.7;ctx.shadowBlur=11;
ctx.stroke();
}
ctx.restore();
if(dark){
// Intermittent ignition cells: varying width/color/intensity
// make the bright edge resemble burning fibres, not neon.
ctx.save();
ctx.globalCompositeOperation='screen';
ctx.lineCap='round';
for(let y=-8;y<h+8;y+=5.8){
const intensity=clamp01(.58+
.29*Math.sin(y*.054+seconds*3.7)+
.21*Math.sin(y*.147-seconds*8.9)+
.13*Math.sin(y*.337+seconds*16.4));
if(intensity<.47)continue;
const x=edgeAt(y,seconds,progress,w);
const nextX=edgeAt(y+5.8,seconds,progress,w);
ctx.globalAlpha=intensity*fade;
ctx.lineWidth=1.4+intensity*4.6;
ctx.shadowBlur=0;
ctx.strokeStyle=intensity>.75?'#fff8d7':
intensity>.58?'#ffd369':'#ff831b';
ctx.beginPath();ctx.moveTo(x,y);
ctx.lineTo(nextX,y+5.8);ctx.stroke();
}
ctx.restore();
// Fragmented ignition on real microscopic perforations.
// Missing arc sections stop them resembling punched circles.
ctx.save();
ctx.globalCompositeOperation='screen';
ctx.shadowBlur=0;
for (let i=0; i<cells.length; i++) {
const c=cells[i];
ctx.globalAlpha=(.42+.34*c.heat)*fade;
ctx.strokeStyle=c.heat>.73?'#fff6bf':
c.heat>.38?'#ffd166':'#ff621c';
ctx.lineWidth=.8+Math.min(2.6,c.radius*.24);
const n=c.points.length;
ctx.beginPath();
for (let j=0;j<n;j++) {
if ((j+i*3)%5>2)continue;
const a=c.points[j],b=c.points[(j+1)%n];
ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
}
ctx.stroke();
}
ctx.restore();
// Small carbon flecks stick to the still-blue paper just
// ahead of the bite; no large rectangular glitch debris.
ctx.save();
for(let j=0;j<Math.ceil(h/10);j++){
const y=(j+rand(j*5+61)*.8)*10;
const x=edgeAt(y,seconds,progress,w)-
(5+rand(j*17+38)*24);
const size=.5+rand(j*29+11)*2.5;
const visible=.25+.59*rand(j*13+7);
ctx.globalAlpha=visible*fade;
ctx.fillStyle='#150b0a';
ctx.beginPath();
ctx.ellipse(x,y,size*.71,size,rand(j*7+31)*3.14,
0,Math.PI*2);
ctx.fill();
}
ctx.restore();
}
// The key property V3-V6 lost: particles remain at their
// birth position while the front advances. A wide, incandescent
// WAKE builds behind the tearing page instead of following
// it as one thin, plastic-looking stripe.
ctx.save();
ctx.globalCompositeOperation='lighter';
for(const p of combustion){
if(p.kind==='ash'||p.kind==='leaf'||p.kind==='pollen')continue;
const age=1-p.remaining/p.life;
const opacity=clamp01(p.remaining/.22)*
clamp01(age/.08)*(1-age*.30)*
(p.kind==='ember'?.74:p.kind==='glow'?.13:.66)*fade*
(dark?Math.exp(-Math.max(0,p.x-
edgeAt(p.y,seconds,progress,w))/70):1);
if(opacity<.012)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(p.x,p.y);
ctx.rotate(p.angle+Math.sin(elapsed*4+p.seed)*.13);
const large=p.kind==='flame';
const sx=p.radius*(p.kind==='glow'?1.7:large?.96:1.32)*(1+age*.32);
const sy=p.radius*(p.kind==='glow'?1.4:large?1.78:1.35)*(1+age*.57);
const sprite=p.kind==='flame'?FIRE_GLOW[p.sprite]:
p.kind==='glow'?BLUE_GLOW[p.sprite]:EMBER_GLOW;
ctx.drawImage(sprite,-sx,-sy,sx*2,sy*2);
ctx.restore();
}
ctx.restore();
// Rebirth uses soft, rotating, organic leaf/petal silhouettes,
// minute circular pollen and spacious white-blue bokeh. No
// rectangular ions, no warm textures and NO glowing streaks.
if(!dark){
ctx.save();
ctx.globalCompositeOperation='screen';
for(const p of combustion){
if(p.kind!=='leaf'&&p.kind!=='pollen')continue;
const age=1-p.remaining/p.life;
const opacity=clamp01(age/.17)*clamp01(p.remaining/.55)*
(p.kind==='leaf'?.47:.28)*fade;
if(opacity<.012)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(p.x+11*Math.sin(elapsed*1.6+p.seed),p.y);
ctx.rotate(p.angle+age*1.65*Math.sin(p.seed*2.5));
if(p.kind==='leaf'){
const r=p.radius;
ctx.shadowBlur=13;
ctx.shadowColor='#bbf4ff';
ctx.fillStyle=p.sprite%2?'#e6ffff':'#ffffff';
ctx.beginPath();
ctx.moveTo(0,-r*1.65);
ctx.bezierCurveTo(r*.97,-r*.48,r*.85,r*.60,0,r*1.35);
ctx.bezierCurveTo(-r*.92,r*.28,-r*.94,-r*.57,0,-r*1.65);
ctx.fill();
ctx.globalAlpha=opacity*.38;
ctx.strokeStyle='#70c9ea';
ctx.lineWidth=.65;
ctx.beginPath();ctx.moveTo(0,-r*1.15);
ctx.quadraticCurveTo(-r*.17,0,0,r*.95);ctx.stroke();
}else{
const r=p.radius;
const halo=ctx.createRadialGradient(0,0,0,0,0,r*3);
halo.addColorStop(0,'rgba(255,255,255,.78)');
halo.addColorStop(.25,'rgba(225,250,255,.40)');
halo.addColorStop(1,'rgba(150,215,255,0)');
ctx.fillStyle=halo;
ctx.beginPath();ctx.arc(0,0,r*3,0,Math.PI*2);ctx.fill();
}
ctx.restore();
}
ctx.restore();
}
// Minute black flecks, never rectangular shards on the blue side.
ctx.save();
ctx.globalCompositeOperation='source-over';
for(const p of combustion){
if(p.kind!=='ash')continue;
ctx.globalAlpha=clamp01(p.remaining/.25)*.7*fade;
ctx.fillStyle='#190d10';
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(elapsed*2+p.angle);
ctx.fillRect(-p.radius*.5,-p.radius*.5,p.radius,p.radius*1.8);
ctx.restore();
}
ctx.restore();
// Irregular burnt page sections tumble away and reveal their
// blackened underside, with an occasional incandescent rim.
if(dark){
ctx.save();
ctx.globalCompositeOperation='source-over';
for(const p of tornPaper){
const age=1-p.remaining/p.life;
const opacity=clamp01(age/.08)*clamp01(p.remaining/.4)*fade;
if(opacity<.01)continue;
ctx.save();
ctx.globalAlpha=opacity;
ctx.translate(p.x,p.y);
ctx.rotate(p.angle);
ctx.beginPath();
p.outline.forEach((v,i)=>{
const px=Math.cos(v.a)*v.r*p.radius;
const py=Math.sin(v.a)*v.r*p.radius*.74;
if(i===0)ctx.moveTo(px,py);else ctx.lineTo(px,py);
});
ctx.closePath();
ctx.shadowBlur=7;
ctx.shadowColor='#090506';
ctx.fillStyle=p.seed>3?'#190d11':'#302025';
ctx.fill();
ctx.shadowBlur=0;
ctx.lineWidth=.8+Math.sin(p.seed)**2*.8;
ctx.strokeStyle=age<.48?'rgba(255,98,34,.29)':
'rgba(49,20,19,.47)';
ctx.stroke();
// Thin blue-white unburnt fibers still visible on a
// few ripped faces, before the back chars completely.
if(p.seed>4.5&&age<.52){
ctx.globalAlpha*=.45*(1-age);
ctx.strokeStyle='#c5e7f7';
ctx.beginPath();
ctx.moveTo(-p.radius*.42,-p.radius*.16);
ctx.quadraticCurveTo(0,p.radius*.25,p.radius*.28,0);
ctx.stroke();
}
ctx.restore();
}
ctx.restore();
}
// Sharp, directional embers / crystalline fragments, not blobs.
if(dark){ctx.save();
ctx.globalCompositeOperation='screen';
for(const mote of motes){
const age=(elapsed*.74+mote.phase)%1;
const yy=mote.y*h-age*mote.speed+Math.sin(age*12+mote.phase*8)*9;
const x=edgeAt(yy,seconds,progress,w)+direction*age*mote.drift;
if(x<-25||x>w+25||yy<-25||yy>h+25)continue;
const opacity=(1-age)*fade*.89;
ctx.globalAlpha=opacity;
ctx.strokeStyle='#ffb45d';
ctx.lineWidth=.8;
ctx.beginPath();
ctx.moveTo(x,yy);
ctx.lineTo(x-direction*mote.length*.4,yy+mote.length);
ctx.stroke();
}
ctx.restore();}
};
}
function prewarmAtmosphere() {
if(atmosphereAssetCache)return;
const prepare=()=>{
if(!atmosphereAssetCache)
createAtmosphere(document.createElement('canvas'));
};
if('requestIdleCallback' in window)
requestIdleCallback(prepare,{timeout:6000});
else setTimeout(prepare,1400);
}
let atmosphereEdge=null;
function edgeAt(y,seconds,progress,width){
return atmosphereEdge
? atmosphereEdge(y)
: makeEdge(progress,seconds,Boolean(window.__kkptVisualDark),width)(y);
}
// 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(24,Math.ceil(h/30));
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,drawAtmosphere,done) {
const ctx=dark&&canvas
? canvas.getContext('2d',{alpha:true,desynchronized:true})
: null;
if(dark&&!ctx){done();return;}
const art=dark?makeSprites():null;
const w=innerWidth,h=innerHeight;
if(ctx){
const scale=Math.min(window.devicePixelRatio||1,w<650?.69:.80);
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?160:250;
const p=[];
const accum={flame:0,ember:0,shard:0,sky:0,mist:0,pollen:0};
const rates=dark
? {flame:mobile?80:105,ember:mobile?72:96,
shard:mobile?26:42}
: {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=25;
ctx.strokeStyle='rgba(255,55,5,.82)';
ctx.lineWidth=43;
ctx.stroke();
ctx.shadowBlur=16;
ctx.strokeStyle='#ff9b17';
ctx.lineWidth=20;
ctx.stroke();
ctx.shadowBlur=7;
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) {
const t=Math.min(1,(now-started)/duration);
const seconds=now/1000;
const edge=makeEdge(t,seconds,dark,w);
// Update the actual Wiki page reveal at display refresh rate.
// The costly flame/smoke pass has an independent 30fps budget.
atmosphereEdge=edge;
window.__kkptVisualDark=dark;
clipFrame(edge);
if(now-lastPaint<(mobile?35:32) && t<1){
requestAnimationFrame(paint);return;
}
const dt=Math.min(.070,(now-previous)/1000);
previous=now;lastPaint=now;
const fade=Math.min(1,t/.055,(1-t)/.045);
// Restore V14's broad smoke banks, long-lived char, pressure
// waves and delicate blue leaves. V2 only paints the FIRE.
drawAtmosphere(seconds,t,dark,(now-started)/1000,[]);
if(dark){
advance(dt);
if(t<1)spawnAtRate(dt,edge,seconds,Math.max(0,fade));
ctx.clearRect(0,0,w,h);
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);
document.body.classList.toggle('kkpt-other-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');
// Stop the invisible Kuronata ambience in either document.
root.classList.toggle('kkpt-world-suspended',basePage==='Kuronata'&&active);
try{
frame.contentDocument?.documentElement.classList.toggle(
'kkpt-frame-inactive',!active);
}catch(_){}
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';
// When returning to the blue page, restore its light palette
// BEFORE unmasking it. The dark iframe still fully covers it.
if(!dark)showTheme(false);
frame.classList.remove('kkpt-active');
frame.style.pointerEvents='none';
frame.style.clipPath=active?'inset(0)':hideClip;
const atmosphere=document.createElement('canvas');
atmosphere.id='kkpt-atmosphere';
atmosphere.setAttribute('aria-hidden','true');
document.body.append(atmosphere);
const drawAtmosphere=createAtmosphere(atmosphere);
const canvas=dark?document.createElement('canvas'):null;
if(canvas){
canvas.id='kkpt-vfx';
canvas.setAttribute('aria-hidden','true');
document.body.append(canvas);
}
const duration=dark?3450:3100;
transitionSound(dark,duration);
await new Promise(resolve=>canvasFire(
canvas,dark,duration,drawAtmosphere,resolve));
setDisplayed(nextActive);
if(canvas)canvas.style.opacity='0';
atmosphere.style.opacity='0';
await sleep(160);
canvas?.remove();
atmosphere.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();
document.getElementById('kkpt-atmosphere')?.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();
prewarmAtmosphere();
preloadRebirthPad();
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();
document.getElementById('kkpt-atmosphere')?.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();
}
}());