跳转到内容
打开/关闭搜索
搜索
打开/关闭菜单
450
条目
805
文件
22
用户
9126
编辑
Blockland Wiki
导航
首页
最近更改
随机页面
MediaWiki帮助
特殊页面
打开/关闭外观设置菜单
无法加载外观设置。请检查您的网络连接并重试。
重试
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。
user-interface-preferences
个人工具
创建账号
登录
查看“︁MediaWiki:KuranokaPageTransition.js”︁的源代码
MediaWiki界面页面
查看
阅读
查看源代码
查看历史
相关页面
系统消息
讨论
更多操作
更多
工具
链入页面
相关更改
页面信息
Cargo数据
←
MediaWiki:KuranokaPageTransition.js
因为以下原因,您没有权限编辑该页面:
您请求的操作仅限属于该用户组的用户执行:
用户
此页面为本wiki上的软件提供界面文本,并受到保护以防止滥用。如欲修改所有wiki的翻译,请访问
translatewiki.net
上的MediaWiki本地化项目。
您无权编辑此JavaScript页面,因为编辑此页面可能会影响所有访问者。
您可以查看和复制此页面的源代码。
/* ==================================================================== KURANOKA / KURONATA V10 — ASH FRONT MediaWiki:KuranokaPageTransition.js Same-origin iframe + history.pushState: NO full reload on toggles. Hard chiaroscuro: preserved sky / incandescent tearing edge / persistent burning wake / residual soot / torn paper / blood-red world. Wiki easter eggs: [[Kuronata|...]] / [[Kuranoka|...]]. Backgrounds are prewarmed on page load. Zero external dependencies. ==================================================================== */ (function () { 'use strict'; // The preview is a complete MediaWiki page. It must NOT spawn another. if (window !== window.top || window.__kkptV10) return; if (!window.mw || !mw.config) return; // The URL is the source of truth if a skin/extension restored a page // without immediately updating mw.config (soft navigation / bfcache). const pathMatch = location.pathname.match(/\/wiki\/(Kuranoka|Kuronata)\/?$/); const urlPage = pathMatch ? pathMatch[1] : new URLSearchParams(location.search).get('title'); const basePage = ['Kuranoka', 'Kuronata'].includes(urlPage) ? urlPage : mw.config.get('wgPageName'); const action = new URLSearchParams(location.search).get('action') || 'view'; if (!['Kuranoka', 'Kuronata'].includes(basePage) || action !== 'view') return; window.__kkptV10 = true; window.__kkptVersion = '10.0'; // For the easter egg: links inside the Wiki are the default trigger. // Change to true only if you also want the fixed switch button. const SHOW_FLOATING_BUTTON = false; const otherPage = basePage === 'Kuranoka' ? 'Kuronata' : 'Kuranoka'; const baseUrl = location.href; const baseTitle = document.title; const otherUrl = new URL('/wiki/' + encodeURIComponent(otherPage), location.href); const clipSide = basePage === 'Kuranoka' ? 'right' : 'left'; const hideClip = clipSide === 'right' ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)'; const sleep = ms => new Promise(r => setTimeout(r, ms)); const smooth = t => t * t * (3 - 2 * t); const imagePool = new Map(); let active = false; // false: original top-level doc; true: otherPage iframe let animating = false; let 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; const observer = new MutationObserver(() => decoratePortalLinks(doc, targetPage)); observer.observe(root, {childList:true,subtree:true}); doc.__kkptPortalObserver = observer; } function isPlainClick(event) { return !event.defaultPrevented && event.button === 0 && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; } function findAnchor(event, doc) { const target = event.target; return target && target.nodeType === 1 && typeof target.closest === 'function' ? target.closest('a[href]') : null; } function portalClick(event, doc, targetPage, nextActive) { if (!isPlainClick(event)) return false; const anchor = findAnchor(event, doc); if (!anchor || (anchor.target && anchor.target !== '_self') || anchor.hasAttribute('download') || wikiLinkPage(anchor, doc) !== targetPage) return false; event.preventDefault(); 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-turning'); setTimeout(()=>{ gearPending=false; requestSwap(nextActive); },570); setTimeout(()=>anchor.classList.remove('kkpt-fate-turning'),4800); } function fateGearShape() { const parts=[]; for(let i=0;i<48;i++) { const a=i/48*Math.PI*2-Math.PI/2; const r=(i%4===0||i%4===3)?21:16.8; parts.push((i?'L':'M')+(24+Math.cos(a)*r).toFixed(2)+' '+ (24+Math.sin(a)*r).toFixed(2)); } return parts.join(' ')+' Z'; } function mountFateGear(doc,targetPage) { if(doc.querySelector('.kkpt-fate-gear'))return; const content=doc.querySelector('#mw-content-text,.mw-parser-output'); if(!content)return; const selectors='table.infobox,.infobox-character,.portable-infobox,'+ '.infobox,[class*="infobox"]'; const box=[...content.querySelectorAll(selectors)].find(el=>{ const r=el.getBoundingClientRect(); return r.width>175&&r.width<720&&r.height>210; }); if(!box)return; const header=box.querySelector('.infobox-title,.infobox-header,'+ '.infobox-above,.infobox-heading,.infobox-head,'+ '.pi-title,caption,th[colspan]'); // Deliberately NEVER append to or restyle a native Infobox node. // A fixed-position overlay follows its visual rectangle instead. const host=header&&header.tagName!=='TR'?header:box; const anchor=doc.createElement('a'); anchor.className='kkpt-fate-gear'; anchor.hidden=true; anchor.href=new URL('/wiki/'+targetPage,doc.baseURI).href; anchor.title=targetPage==='Kuronata' ?'转动命运齿轮 · 唤醒 Kuronata':'转动命运齿轮 · 回到 Kuranoka'; anchor.setAttribute('aria-label',anchor.title); anchor.innerHTML='<svg class="kkpt-fate-gear-svg" viewBox="0 0 48 48" '+ 'aria-hidden="true" focusable="false"><path d="'+fateGearShape()+ '"/><circle cx="24" cy="24" r="8.5"/>'+ '<circle cx="24" cy="24" r="3"/></svg>'; doc.body.appendChild(anchor); let pendingPosition=false; function positionGear(){ pendingPosition=false; if(!doc.body.contains(anchor)||!doc.body.contains(host)){ anchor.hidden=true;return; } const rect=host.getBoundingClientRect(); const view=doc.defaultView; const y=host===box?rect.top+23:rect.top+rect.height*.5; anchor.hidden=rect.width<120||rect.bottom<0||rect.top>view.innerHeight|| y<0||y>view.innerHeight; if(anchor.hidden)return; anchor.style.left=Math.round(rect.right-49)+'px'; anchor.style.top=Math.round(y-21)+'px'; } function updateGear(){ if(pendingPosition)return; pendingPosition=true; doc.defaultView.requestAnimationFrame(positionGear); } doc.defaultView.addEventListener('scroll',updateGear,{passive:true}); doc.defaultView.addEventListener('resize',updateGear,{passive:true}); updateGear(); } // Read backgrounds after background-template code populates --bg-image. function backgroundUrls(doc) { const urls = new Set(); const selector = [doc.documentElement, doc.body]; try { doc.querySelectorAll('.background-theme,[class*="background"],[style*="background"],#mw-content-text,.citizen-page-container') .forEach((el, i) => { if (i < 65) selector.push(el); }); } catch (e) { /* use html/body */ } function add(value) { if (!value) return; const pattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi; let match; while ((match = pattern.exec(value))) { try { urls.add(new URL(match[2], doc.baseURI).href); } catch (e) { /* ignore */ } } if (/^https?:\/\/\S+\.(?:jpg|jpeg|png|webp|avif)(?:\?\S*)?$/i.test(value)) { try { urls.add(new URL(value, doc.baseURI).href); } catch (e) { /* ignore */ } } } selector.forEach(el => { if (!el) return; try { [null, '::before', '::after'].forEach(pseudo => { const style = doc.defaultView.getComputedStyle(el, pseudo); if (!style) return; add(style.backgroundImage); add(style.getPropertyValue('--bg-image')); add(style.getPropertyValue('--background-image')); }); if (el.dataset) add(el.dataset.bgImage); } catch (e) { /* style not yet ready */ } }); return [...urls]; } function warmImage(url) { if (imagePool.has(url)) return imagePool.get(url).promise; const image = new Image(); const promise = new Promise(resolve => { let finished = false; const timeout = setTimeout(() => done(false), 5000); function done(ok) { if (finished) return; finished = true; clearTimeout(timeout); resolve(ok); } image.onload = () => done(true); image.onerror = () => done(false); image.src = url; if (image.complete) done(image.naturalWidth > 0); }).then(ok => ok && image.decode ? Promise.race([image.decode().then(() => true).catch(() => true),sleep(900).then(() => true)]) : ok); imagePool.set(url, { image, promise }); return promise; } async function warmBackgrounds(doc) { const urls = backgroundUrls(doc); await Promise.allSettled(urls.map(warmImage)); } function wireFrame() { try { const doc = frame.contentDocument; if (!doc) return; otherTitle = doc.title || otherTitle; watchPortalLinks(doc, basePage); 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; } // One irregular, multi-speed burn front. Large lobes advance at // genuinely different rates; close-up turbulence erodes the boundary. // This EXACT expression is mirrored in the fragment shader below. function edgeAt(y, seconds, progress, width) { const toDark = window.__kkptVisualDark; const wave = Math.sin(Math.PI * progress); const h=innerHeight; const yn=y/h; const lobes=.63*Math.sin(yn*9.3+seconds*.38+.4)+ .27*Math.sin(yn*22.7-seconds*.28+1.4)+ .10*Math.sin(yn*51.1+seconds*.58); const localProgress=toDark ? Math.max(0,Math.min(1,progress+.285*wave*lobes)) : progress; const eased = smooth(localProgress); const center = toDark ? width + 110 - (width + 220) * eased : -110 + (width + 220) * eased; const turbulence=toDark ? 22*Math.sin(y*.019+seconds*1.8)+ 13*Math.sin(y*.061-seconds*3.6)+ 6*Math.sin(y*.135+seconds*7.9) : 28*Math.sin(y*.012+seconds*3.1)+ 17*Math.sin(y*.041-seconds*5.7)+ 7*Math.sin(y*.10+seconds*10); return center+wave*turbulence; } function clipFrame(seconds, progress) { const width = innerWidth, height = innerHeight; const pts = []; const count = Math.min(240,Math.max(55,Math.ceil(height / 6))); for (let i = 0; i <= count; i++) { const y = height * i / count; pts.push([edgeAt(y, seconds, progress, width), y]); } const supportsPath = CSS.supports('clip-path', 'path("M 0 0 L 8 0 L 8 8 Z")'); if (supportsPath) { const startX = clipSide === 'right' ? width + 4 : -4; let d = `M ${startX} -4 L ${startX} ${height + 4} L ${pts[count][0].toFixed(1)} ${height}`; // Quadratic smoothing eliminates the geometric triangular teeth. for (let i = count - 1; i >= 0; i--) { const point = pts[i]; const previous = pts[Math.max(0, i - 1)]; const mx = (point[0] + previous[0]) * .5; const my = (point[1] + previous[1]) * .5; d += ` Q ${point[0].toFixed(1)} ${point[1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`; } d += ` L ${pts[0][0].toFixed(1)} 0 Z`; frame.style.clipPath = `path("${d}")`; } else { const points = pts.slice().reverse().map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(','); frame.style.clipPath = clipSide === 'right' ? `polygon(100% 0,100% 100%,${points})` : `polygon(0 0,0 100%,${points})`; } } // Tiny fullscreen quad + procedural fBm fire. No sprites or dependencies. const VERTEX = ` attribute vec2 aPosition; void main() { gl_Position = vec4(aPosition, 0.0, 1.0); } `; const FRAGMENT = ` precision highp float; uniform vec2 uSize; uniform vec2 uBuffer; uniform float uTime; uniform float uProgress; uniform float uDark; float random2(vec2 p) { return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453123); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f*f*(3.0-2.0*f); return mix(mix(random2(i),random2(i+vec2(1.0,0.0)),u.x), mix(random2(i+vec2(0.0,1.0)),random2(i+1.0),u.x),u.y); } float fbm(vec2 p) { float result = 0.0, amp = 0.5; for (int i = 0; i < 5; i++) { result += amp * noise(p); p = mat2(1.6,-1.2,1.2,1.6) * p; amp *= 0.5; } return result; } float smoothProgress(float t) { return t*t*(3.0-2.0*t); } float edge(float y) { float w = uSize.x; float wave=sin(3.14159265*uProgress); float yn=y/uSize.y; float lobes=.63*sin(yn*9.3+uTime*.38+.4)+ .27*sin(yn*22.7-uTime*.28+1.4)+ .10*sin(yn*51.1+uTime*.58); float localProgress=uDark>.5 ? clamp(uProgress+.285*wave*lobes,0.0,1.0) : uProgress; float t = smoothProgress(localProgress); float c = uDark > .5 ? w+110.0-(w+220.0)*t : -110.0+(w+220.0)*t; float turbulent=uDark>.5 ? 22.0*sin(y*.019+uTime*1.8)+ 13.0*sin(y*.061-uTime*3.6)+ 6.0*sin(y*.135+uTime*7.9) : 28.0*sin(y*.012+uTime*3.1)+ 17.0*sin(y*.041-uTime*5.7)+ 7.0*sin(y*.10+uTime*10.0); return c+wave*turbulent; } void main() { vec2 p = vec2(gl_FragCoord.x*uSize.x/uBuffer.x, uSize.y-gl_FragCoord.y*uSize.y/uBuffer.y); float d = p.x-edge(p.y); float oldSide = uDark > .5 ? -d : d; float t = uTime; float impact = sin(3.14159265*uProgress); vec3 col = vec3(0.0); float alpha = 0.0; if (uDark > .5) { // V7: intact sky remains clean on the OLD side. // Incandescent wake + char exist ONLY on the newly // revealed Kuronata side, trailing the traveling tear. float wakeSide = -oldSide; vec2 sq = vec2(p.y*.007-t*.29,wakeSide*.006+t*.12); vec2 warp = vec2(fbm(sq*1.5+vec2(t*.13,0.0)), fbm(sq*1.9+vec2(0.0,-t*.21))); float smokeCloud = fbm(sq*2.0+warp*2.2); float smokeDetail = fbm(sq*5.1-warp*.8); float billow = smoothstep(.29,.69,smokeCloud*.88+smokeDetail*.34); float soot = smoothstep(-42.0,24.0,wakeSide)* (1.0-smoothstep(390.0,850.0,wakeSide))* (.47+.53*billow); vec3 sootColor = mix(vec3(.010,.009,.012),vec3(.066,.019,.018),smokeCloud); // Flame remains close to the paper boundary; the far wake // retains soot and charcoal, not wandering bright blobs. vec2 q = vec2(p.y*.015-t*.66,wakeSide*.012-t*.49); float turbulence = fbm(q+vec2(fbm(q*1.6)*2.7,-t*.25)); float shredded = fbm(q*3.9+vec2(t*.25,-t*.32)); float lengthOfTongue = 57.0+65.0*turbulence+33.0*shredded; float raggedEdge = 1.0-smoothstep(lengthOfTongue-35.0,lengthOfTongue+18.0,wakeSide); float flame = raggedEdge*smoothstep(-65.0,22.0,wakeSide)* smoothstep(.24,.73,turbulence+.35*shredded); float core = exp(-abs(d)/7.0); float outer = exp(-abs(d)/42.0); float heat = clamp(flame*.87+core*.93,0.0,1.0); float hotPockets = smoothstep(.49,.78,turbulence*.75+shredded*.55)* exp(-max(0.0,wakeSide)/62.0); vec3 fireColor = mix(vec3(.43,.013,.004),vec3(1.0,.16,.009),smoothstep(.07,.56,heat)); fireColor = mix(fireColor,vec3(1.0,.43,.025),smoothstep(.35,.78,heat)); fireColor = mix(fireColor,vec3(1.0,.98,.75), clamp(hotPockets*.57+core*.78,0.0,.92)); col = mix(sootColor,fireColor,clamp(flame*.81+core*.95,0.0,1.0)); alpha = clamp(soot*.81+flame*.85+outer*.25+core*.67,0.0,.96); // Sharp micron-scale cinders; the real ember trails live // on the separate high-resolution atmosphere canvas. vec2 cell = floor((p+vec2(t*23.0,-t*122.0))/11.0); vec2 uv = fract((p+vec2(t*23.0,-t*122.0))/11.0)-.5; float sparks = (1.0-smoothstep(.015,.12,length(uv)))* step(.991,random2(cell))*exp(-max(0.0,wakeSide)/380.0); col = mix(col,vec3(1.0,.78,.23),sparks); alpha = max(alpha,sparks*.97); } else { // Dawn is a living, spreading atmosphere: diffuse cloud, // breathing alabaster light and soft shafts. NO electric // arcs, ignition lines, flame-shaped sparks or embers. float newlyBlue = -oldSide; vec2 q = vec2(p.y*.007-t*.16,newlyBlue*.008+t*.10); float cloud = fbm(q+vec2(fbm(q*1.6)*1.7,-t*.13)); float billow = fbm(q*2.9+vec2(t*.08,-t*.11)); float erosion = smoothstep(.22,.72,cloud*.76+billow*.37); float settling = smoothstep(-68.0,35.0,newlyBlue)* exp(-max(0.0,newlyBlue)/440.0); float pearlyMist = settling*(.33+.64*erosion); float halo = exp(-pow(d/78.0,2.0)); float innerLight = exp(-pow(d/21.0,2.0)); float shaftDrift=20.0*sin(p.y*.013-t*1.0)+ 9.0*sin(p.y*.039+t*.61); float softRay=exp(-pow((d+shaftDrift+88.0)/81.0,2.0))* (.32+.68*smoothstep(.28,.81,cloud)); col=mix(vec3(.075,.30,.57),vec3(.72,.94,1.0),clamp(cloud*.76+.38,0.0,1.0)); col=mix(col,vec3(.94,.995,1.0), clamp(innerLight*.49+softRay*.20,0.0,.6)); alpha=clamp(pearlyMist*.42+halo*.30+innerLight*.22+ softRay*.14,0.0,.59); } // No long-lasting screen tint after the burn has finished. alpha *= (.35+.65*impact) * smoothstep(0.0,.07,uProgress) * (1.0-smoothstep(.94,1.0,uProgress)); gl_FragColor = vec4(col,alpha); } `; function createRenderer(canvas) { const gl = canvas.getContext('webgl', { alpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, premultipliedAlpha: false }); if (!gl) return null; function compile(type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error('[KKPT WebGL] Shader:', gl.getShaderInfoLog(shader)); return null; } return shader; } const vs = compile(gl.VERTEX_SHADER, VERTEX); const fs = compile(gl.FRAGMENT_SHADER, FRAGMENT); if (!vs || !fs) return null; const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('[KKPT WebGL] Program:', gl.getProgramInfoLog(program)); return null; } gl.useProgram(program); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW); const pos = gl.getAttribLocation(program,'aPosition'); gl.enableVertexAttribArray(pos); gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0); const sizeUniform = gl.getUniformLocation(program,'uSize'); const bufferUniform = gl.getUniformLocation(program,'uBuffer'); const timeUniform = gl.getUniformLocation(program,'uTime'); const progressUniform = gl.getUniformLocation(program,'uProgress'); const modeUniform = gl.getUniformLocation(program,'uDark'); // This is a single fullscreen pass to a transparent canvas. // Blending here would multiply alpha twice and wash out the fire. gl.disable(gl.BLEND); return function draw(seconds,progress,dark) { const w=innerWidth,h=innerHeight; // Keep a stable GPU budget on high-DPI/mobile displays. const scale = w < 700 ? .68 : .84; const bw=Math.round(w*scale),bh=Math.round(h*scale); if (canvas.width!==bw || canvas.height!==bh) { canvas.width=bw;canvas.height=bh;gl.viewport(0,0,bw,bh); } gl.clearColor(0,0,0,0); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform2f(sizeUniform,w,h); gl.uniform2f(bufferUniform,bw,bh); gl.uniform1f(timeUniform,seconds); gl.uniform1f(progressUniform,progress); gl.uniform1f(modeUniform,dark?1:0); gl.drawArrays(gl.TRIANGLES,0,6); }; } function createFallback(canvas) { const ctx = canvas.getContext('2d'); return function draw(seconds,progress,dark) { const w=innerWidth,h=innerHeight; if (canvas.width!==w || canvas.height!==h) {canvas.width=w;canvas.height=h;} ctx.clearRect(0,0,w,h); const x = edgeAt(h/2,seconds,progress,w); const gradient = ctx.createLinearGradient(x-115,0,x+115,0); if (dark) { gradient.addColorStop(0,'#180d0d00'); gradient.addColorStop(.40,'#67180677'); gradient.addColorStop(.50,'#ffe186cc'); gradient.addColorStop(.62,'#ef3f0955'); gradient.addColorStop(1,'#180d0d00'); } else { gradient.addColorStop(0,'#5abcf600'); gradient.addColorStop(.46,'#7cdbff77'); gradient.addColorStop(.5,'#ffffffbb'); gradient.addColorStop(.56,'#63c9ff77'); gradient.addColorStop(1,'#5abcf600'); } ctx.fillStyle=gradient;ctx.fillRect(x-115,0,230,h); }; } // A second canvas adds real, textured soot / rolling vapor and violent // short-lived pressure waves. It is deliberately independent of WebGL, // so even browsers with GPU disabled still get the smoke and impact. function createAtmosphere(canvas) { const ctx = canvas.getContext('2d', {alpha:true}); const rand = n => { const x = Math.sin(n*127.1+311.7)*43758.5453123; return x-Math.floor(x); }; const clamp01 = n => Math.max(0,Math.min(1,n)); function valueNoise(x,y,seed) { const ix=Math.floor(x), iy=Math.floor(y); let fx=x-ix,fy=y-iy; fx=fx*fx*(3-2*fx);fy=fy*fy*(3-2*fy); const h=(a,b)=>rand(a*171.3+b*513.7+seed*89.1); const a=h(ix,iy)*(1-fx)+h(ix+1,iy)*fx; const b=h(ix,iy+1)*(1-fx)+h(ix+1,iy+1)*fx; return a*(1-fy)+b*fy; } function smokeTexture(seed,dark) { const tex=document.createElement('canvas'); tex.width=tex.height=112; const g=tex.getContext('2d'); const data=g.createImageData(112,112); for(let y=0;y<112;y++) for(let x=0;x<112;x++){ const u=(x-56)/56,v=(y-56)/56; const curl=valueNoise(u*2.9+11,v*2.9+1,seed); const curl2=valueNoise(u*3.4+3,v*3.4+7,seed+7); const wx=u+.47*(curl-.5),wy=v+.40*(curl2-.5); const radius=Math.sqrt(wx*wx*.91+wy*wy*1.13); const envelope=clamp01((1.05-radius)/.56); const low=valueNoise(u*4.1+4,v*4.1-9,seed+2); const high=valueNoise(u*11.4-3,v*11.4+6,seed+5); const fibers=valueNoise(u*22.0+1,v*16.0+5,seed+9); const density=clamp01((low*.62+high*.28+fibers*.10-.28)*2.0)* envelope*envelope; const i=(y*112+x)*4; const variation=low*28+high*15; if(dark){ data.data[i]=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=[0,1,2,3].map(n=>smokeTexture(n+17,true)); const 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,'#ffde82'],[.16,'#ffc03d'],[.36,'#ff8019'], [.58,'#f3400bbb'],[.81,'#9312063a'],[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=[1,2,3,4].map(seed=>glowTexture(seed,'fire')); const BLUE_GLOW=[1,2,3,4].map(seed=>glowTexture(seed+7,'blue')); const EMBER_GLOW=glowTexture(21,'ember'); 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?425:115)*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<.61?'flame':r<.91?'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:14+Math.random()*30) : 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?48:94); const sootBirths=Math.min(11,Math.floor(sootRemainder)); sootRemainder-=sootBirths; for(let i=0;i<sootBirths&&residualSoot.length<(w<700?175:360);i++){ const y=Math.random()*h; const life=2.05+Math.random()*2.0; 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:110+Math.random()*175,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?17:27); 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:4+Math.pow(Math.random(),1.6)*23, 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?72:124},(_,i)=>({ seed:rand(i*4+71),height:rand(i*5+13), lifetime:.5+rand(i*8+37)*.7, phase:rand(i*9+21),sprite:i%4, scale:.72+rand(i*7+9)*1.25, bend:rand(i*15+23)*6.28 })); const motes=Array.from({length:innerWidth<700?47:100},(_,i)=>({ y:rand(i*3+17),phase:rand(i*9+43), speed:85+rand(i*12+5)*260, drift:75+rand(i*15+2)*170, length:3+rand(i*17+7)*11 })); const shockDebris=Array.from({length:innerWidth<700?40:85},(_,i)=>({ angle:rand(i*17+131)*Math.PI*2, velocity:190+rand(i*11+51)*900, seed:rand(i*5+79), length:6+rand(i*7+17)*26 })); function pressureWave(x,y,radius,power,dark,time) { if(power<=.005)return; const core=ctx.createRadialGradient(x,y,0,x,y,radius*.75); if(dark){ core.addColorStop(0,'rgba(255,231,169,'+(.20*power)+')'); core.addColorStop(.17,'rgba(253,88,18,'+(.21*power)+')'); core.addColorStop(.60,'rgba(106,16,12,'+(.09*power)+')'); core.addColorStop(1,'rgba(30,0,0,0)'); }else{ core.addColorStop(0,'rgba(237,255,255,'+(.24*power)+')'); core.addColorStop(.26,'rgba(98,193,255,'+(.18*power)+')'); core.addColorStop(1,'rgba(0,29,100,0)'); } ctx.globalCompositeOperation='screen'; ctx.fillStyle=core; ctx.fillRect(x-radius,y-radius,radius*2,radius*2); ctx.globalCompositeOperation='source-over'; } return function drawAtmosphere(seconds,progress,dark,elapsed) { const w=innerWidth,h=innerHeight; const scale=w<700?.67:.79; const bw=Math.max(1,Math.round(w*scale)); const bh=Math.max(1,Math.round(h*scale)); if(canvas.width!==bw||canvas.height!==bh){canvas.width=bw;canvas.height=bh;} ctx.setTransform(scale,0,0,scale,0,0); ctx.clearRect(0,0,w,h); const envelope=Math.sin(Math.PI*progress); const fade=clamp01(progress/.075)*clamp01((1-progress)/.065); 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; // Continuous textured smoke wall: not isolated oval puffs. // The base layer makes the plume visible on a pale blue sky. ctx.globalCompositeOperation='source-over'; const bands=Math.max(10,Math.ceil(h/(dark?49:91))); for(let i=0;i<bands;i++){ const baseY=(i+.5)*h/bands; const y=baseY+24*Math.sin(seconds*2.5+i*.7); const x=edgeAt(y,seconds,progress,w)+direction* (dark?168:120)+Math.sin(seconds*1.2+i*1.3)*36; const width=(dark?270:250)*(1+.19*Math.sin(i*2.9)); const height=(dark?265:245); ctx.save(); ctx.globalAlpha=(dark?.49:.21)*fade*(.65+.35*envelope); ctx.translate(x,y); ctx.rotate(.16*Math.sin(i*2.1+seconds*1.5)); ctx.drawImage(sprites[i%4],-width/2,-height/2,width,height); ctx.restore(); } // Independent turbulent billows peel away and rise. for(const item of particles){ const age=((elapsed/item.lifetime+item.phase)%1); const baseY=item.height*h; const y=baseY-age*(dark?230:128)+(Math.sin(elapsed*2+item.bend)*15); if(y< -230||y>h+230)continue; const frontier=edgeAt(baseY,seconds,progress,w); const x=frontier+direction*(age*(dark?225:135)+ 30*Math.sin(age*6.3+item.bend)); const size=(dark?145:145)*item.scale*(.68+age*1.9); const opacity=(dark?.54:.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)*(.52+.31*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; } // Paper-burn outline: charred edge, hot rim, then white-hot core. // Color/light contrast stays localized; the old sky remains clear. ctx.save(); ctx.beginPath(); for(let y=-12,i=0;y<=h+12;y+=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(10,4,7,.75)'; ctx.lineWidth=51;ctx.shadowColor='#130507';ctx.shadowBlur=40; ctx.stroke(); ctx.strokeStyle='rgba(255,54,4,.87)'; ctx.lineWidth=20;ctx.shadowColor='#ff4c08';ctx.shadowBlur=45; ctx.stroke(); ctx.strokeStyle='rgba(255,192,63,.99)'; ctx.lineWidth=9;ctx.shadowColor='#ffbb35';ctx.shadowBlur=29; ctx.stroke(); ctx.strokeStyle='rgba(255,255,241,1)'; ctx.lineWidth=3.2;ctx.shadowBlur=13; 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(); // 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'?.69:p.kind==='glow'?.13:.79)*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,104,30,.80)': 'rgba(81,32,25,.78)'; 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 setDisplayed(nextActive) { active = nextActive; frame.classList.toggle('kkpt-active', active); frame.style.clipPath = active ? 'inset(0)' : hideClip; frame.tabIndex = active ? 0 : -1; frame.setAttribute('aria-hidden', active ? 'false' : 'true'); document.title = active ? otherTitle : baseTitle; updateButton(); } async function animateSwap(nextActive) { if (animating) return; animating = true; button.classList.add('kkpt-busy'); // Warm-up should be done already; prepare if the user clicked early. labelSub.textContent = 'PREPARING THE OTHER SIDE...'; if (!loading) warmFrame(); const canUseFrame = await Promise.race([loading,sleep(11000).then(() => false)]); if (!canUseFrame || !ready) { // No mock background: fall back to the genuine Wiki page. window.location.assign(nextActive ? otherUrl.href : baseUrl); return; } const destinationDark = isDark(nextActive ? otherPage : basePage); window.__kkptVisualDark = destinationDark; button.classList.remove('kkpt-quiet'); labelSub.textContent = destinationDark ? 'THE SKY IS BURNING' : 'DAWN IS BREAKING'; // Freeze frame interaction while its silhouette is animated. frame.classList.remove('kkpt-active'); frame.style.pointerEvents = 'none'; frame.style.clipPath = active ? 'inset(0)' : hideClip; const canvas = document.createElement('canvas'); canvas.id = 'kkpt-vfx'; document.body.append(canvas); const atmosphere = document.createElement('canvas'); atmosphere.id = 'kkpt-atmosphere'; document.body.append(atmosphere); const drawAtmosphere = createAtmosphere(atmosphere); const blast = document.createElement('div'); blast.id = 'kkpt-impact'; blast.className = destinationDark ? 'kkpt-impact-red' : 'kkpt-impact-blue'; blast.setAttribute('aria-hidden','true'); document.body.append(blast); let draw = createRenderer(canvas); if (!draw) { canvas.remove(); const fallbackCanvas = document.createElement('canvas'); fallbackCanvas.id = 'kkpt-vfx'; document.body.append(fallbackCanvas); draw = createFallback(fallbackCanvas); } const fxCanvas = document.getElementById('kkpt-vfx'); const duration = destinationDark ? 3650 : 3350; const started = performance.now(); await new Promise(resolve => { function tick(now) { const seconds = now / 1000; const t = Math.min(1,(now-started)/duration); clipFrame(seconds,t); drawAtmosphere(seconds,t,destinationDark,(now-started)/1000); draw(seconds,t,destinationDark); const first=destinationDark?.10:.18; const second=destinationDark?.48:.58; const shockA=Math.exp(-Math.pow((t-first)/.041,2)); const shockB=Math.exp(-Math.pow((t-second)/.056,2)); const shock=Math.min(1,shockA*.98+shockB*.80); blast.style.opacity=String(shock*(destinationDark?.60:.09)); blast.style.setProperty('--kkpt-impact-x', edgeAt(innerHeight*.51,seconds,t,innerWidth).toFixed(1)+'px'); frame.style.filter=shock>.01 ? 'brightness('+(1+(destinationDark?.20:.04)*shock).toFixed(3)+ ') saturate('+(1+(destinationDark?.10:.03)*shock).toFixed(3)+')' : ''; if (t < 1) requestAnimationFrame(tick); else resolve(); } requestAnimationFrame(tick); }); setDisplayed(nextActive); fxCanvas.style.opacity = '0'; atmosphere.style.opacity = '0'; blast.style.opacity = '0'; await sleep(190); fxCanvas.remove(); atmosphere.remove(); blast.remove(); frame.style.filter = ''; frame.style.pointerEvents = ''; animating = false; button.classList.remove('kkpt-busy','kkpt-quiet'); // No location.assign here: the rendered target stays alive. history.pushState({ kkptV10: 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({kkptV10:true,active},'',active?otherUrl.href:baseUrl); return; } animateSwap(nextActive).catch(err => { console.error('[KuranokaPageTransition] V10:',err); document.getElementById('kkpt-vfx')?.remove(); document.getElementById('kkpt-atmosphere')?.remove(); document.getElementById('kkpt-impact')?.remove(); if (frame) frame.style.filter = ''; window.location.assign(nextActive ? otherUrl.href : baseUrl); }); } function createButton() { if (!document.body || document.getElementById('kkpt-toggle')) return; button=document.createElement('a'); button.id='kkpt-toggle'; labelIcon=document.createElement('span'); labelIcon.className='kkpt-icon'; labelIcon.setAttribute('aria-hidden','true'); const text=document.createElement('span'); text.className='kkpt-text'; labelTitle=document.createElement('span'); labelSub=document.createElement('span'); labelSub.className='kkpt-sub'; text.append(labelTitle,labelSub); button.append(labelIcon,text); document.body.append(button); button.hidden=!SHOW_FLOATING_BUTTON; updateButton(); watchPortalLinks(document, otherPage); mountFateGear(document,otherPage); warmFrame(); button.addEventListener('click', event => { if (!isPlainClick(event)) return; event.preventDefault(); requestSwap(!active); }); } // A plain [[Kuronata|your mysterious text]] now triggers everything. // The inverse [[Kuranoka|...]] works inside the already-loaded iframe. document.addEventListener('click', event => { if (active) return; portalClick(event, document, otherPage, true); }, true); mw.hook('wikipage.content').add(() => { if (document.readyState !== 'loading') watchPortalLinks(document, otherPage); }); // Back and Forward restore the two already-loaded documents instantly. window.addEventListener('popstate', () => { if (!frame || !ready || animating) return; const route = pathPage(); if (route === basePage || route === otherPage) { setDisplayed(route === otherPage); } }); window.addEventListener('pageshow', event => { if (!event.persisted || !frame || !ready) return; animating=false; document.getElementById('kkpt-vfx')?.remove(); document.getElementById('kkpt-atmosphere')?.remove(); document.getElementById('kkpt-impact')?.remove(); frame.style.filter = ''; setDisplayed(pathPage() === otherPage); button?.classList.remove('kkpt-busy','kkpt-quiet'); }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded',createButton,{once:true}); } else { createButton(); } }());
返回
MediaWiki:KuranokaPageTransition.js
。
查看“︁MediaWiki:KuranokaPageTransition.js”︁的源代码
MediaWiki界面页面