Created
March 10, 2026 01:53
-
-
Save EncodeTheCode/ab4c76ab92939442a42bce868e2c56d6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>PS1 Demo-One — Curved 3D Ribbon Menu (fixed text + 3D bend)</title> | |
| <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> | |
| <style> | |
| :root{ | |
| --menu-radius: 380px; | |
| --label-size: 48px; | |
| --dot-size: 32px; | |
| --ribbon-thickness: 72px; | |
| --front-boost: 48px; /* how much the front ribbon comes forward */ | |
| } | |
| html,body{ height:100%; margin:0; background:#00121a; font-family: "Trebuchet MS", Arial, sans-serif; overflow:hidden; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; } | |
| /* pearl background canvas */ | |
| #bgCanvas{ position:fixed; inset:0; width:100%; height:100%; z-index:0; background:linear-gradient(#042833,#082a40 30%, #042a3d 80%); display:block; } | |
| .stage{ position:relative; width:100%; height:100%; display:flex; align-items:center; justify-content:center; perspective:1200px; z-index:2; } | |
| .ui{ width:92%; max-width:1280px; height:86%; display:flex; align-items:center; justify-content:center; position:relative; } | |
| .menu-wrapper{ width:920px; height:520px; position:relative; transform-style:preserve-3d; } | |
| .menu3d{ position:absolute; inset:0; transform-style:preserve-3d; z-index:50; pointer-events:none; } /* keep on top */ | |
| .menu-item{ position:absolute; left:50%; top:50%; transform-origin:center center; pointer-events:none; backface-visibility:visible; transform-style:preserve-3d; transition: transform 420ms cubic-bezier(.22,.95,.3,1), opacity 260ms; will-change:transform,opacity; display:block;} | |
| /* SVG ribbons centered at 0,0 */ | |
| svg.ribbon{ width:760px; height:260px; overflow:visible; display:block; pointer-events:none; } | |
| svg.ribbon .ribbon-shape{ transition: opacity 200ms; filter: drop-shadow(0 12px 16px rgba(0,0,0,0.55)); } | |
| /* very legible text: fill + stroke + paint-order so stroke sits behind fill */ | |
| svg.ribbon text { font-family: "Trebuchet MS", Arial, sans-serif; font-weight:900; font-size:var(--label-size); text-anchor:middle; dominant-baseline:middle; paint-order: stroke fill; stroke: rgba(0,0,0,0.9); stroke-width:2.2; } | |
| svg.ribbon text tspan { fill: #f8ff8a; } /* bright yellow/green fill for strong contrast */ | |
| svg.ribbon .dot { transition: transform 220ms, opacity 220ms; filter: drop-shadow(0 8px 10px rgba(0,0,0,0.5)); } | |
| /* depth states */ | |
| .menu-item.front{ opacity:1; z-index:120; } | |
| .menu-item.side{ opacity:0.42; z-index:70; } | |
| .menu-item.back{ opacity:0.18; z-index:30; filter:brightness(.85); } | |
| /* center star - sits visually at center (behind ribbons in z-order) */ | |
| .star-wrap{ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:420px; height:420px; z-index:40; pointer-events:none; display:flex; align-items:center; justify-content:center; } | |
| #starImg{ max-width:100%; max-height:100%; transform-origin:center center; will-change:transform,opacity; filter: drop-shadow(0 22px 26px rgba(0,0,0,0.55)); opacity:0.98; } | |
| /* controls */ | |
| .controls{ position:fixed; right:16px; top:16px; z-index:150; background: rgba(0,0,0,0.28); padding:10px 12px; border-radius:10px; color:#dfffe5; font-size:13px; pointer-events:auto; } | |
| .controls input[type="range"]{ width:170px; display:block; margin-top:6px; } | |
| .controls label{ display:block; font-weight:700; margin-bottom:6px; } | |
| .detail{ position:absolute; left:50%; bottom:6%; transform:translateX(-50%); z-index:160; color:#eaffc8; text-shadow: 0 2px 8px rgba(0,0,0,0.6); font-weight:700; font-size:20px; pointer-events:none; } | |
| .help{ position:fixed; left:16px; bottom:16px; z-index:150; color:rgba(255,255,255,0.78); background:rgba(0,0,0,0.24); padding:8px 10px; border-radius:8px; font-size:12px; pointer-events:auto; } | |
| @media (max-width:900px){ | |
| svg.ribbon{ width:520px; height:200px; } | |
| :root{ --menu-radius:260px; --label-size:36px; --ribbon-thickness:56px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="bgCanvas"></canvas> | |
| <div class="stage"> | |
| <div class="ui"> | |
| <div class="menu-wrapper"> | |
| <div class="menu3d" id="menu3d"></div> | |
| <div class="star-wrap" id="starWrap" aria-hidden="true"> | |
| <img id="starImg" src="" alt="starfish"/> | |
| </div> | |
| <div class="detail" id="detail"> | |
| <div id="detailMain">Games</div> | |
| <div id="detailSub" style="margin-left:14px; opacity:0.95; font-weight:800; color:#c6ffd8"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <label>Rotation speed (deg/sec, negative = left): <span id="speedVal">-6</span></label> | |
| <input id="speedRange" type="range" min="-60" max="60" value="-6" step="1"/> | |
| <div style="height:8px"></div> | |
| <label>Star PNG (optional)</label> | |
| <input id="starFile" type="file" accept="image/*"/> | |
| <div style="height:8px"></div> | |
| <button id="resetBtn" style="padding:6px 8px; border-radius:6px;">Reset view</button> | |
| </div> | |
| <div class="help"> | |
| Arrow Left / Right — jump previous/next. <br/> | |
| Continuous spin always runs (adjust slider). <br/> | |
| When <strong>Games</strong> is front, the sublabel cycles games on repeated Left/Right. | |
| </div> | |
| <script> | |
| /* --------------------------- | |
| Pearl noise background (same algorithm but exposed variables tuned) | |
| --------------------------- */ | |
| (function(){ | |
| const canvas = document.getElementById('bgCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let W = canvas.width = innerWidth, H = canvas.height = innerHeight; | |
| const PALETTE = [[6,128,120],[8,92,168],[24,200,190],[56,168,220],[14,94,110]]; | |
| const BLOBS = 36; | |
| const blobs = []; | |
| const BLOB_BASE = Math.max(160, Math.round(Math.max(W,H) * 0.12)); | |
| for(let i=0;i<BLOBS;i++){ | |
| blobs.push({ | |
| x: Math.random()*W, | |
| y: Math.random()*H, | |
| r: BLOB_BASE * (0.7 + Math.random()*1.2), | |
| vx: (Math.random()*2-1)*0.06, | |
| vy: (Math.random()*2-1)*0.06, | |
| ci: Math.floor(Math.random()*PALETTE.length), | |
| alpha: 0.22 + Math.random()*0.44 | |
| }); | |
| } | |
| let perf = 0; | |
| function draw(){ | |
| ctx.clearRect(0,0,W,H); | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.filter = 'blur(24px)'; | |
| for(const b of blobs){ | |
| const c = PALETTE[b.ci]; | |
| ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${b.alpha})`; | |
| ctx.beginPath(); | |
| ctx.ellipse(b.x,b.y,b.r,b.r*0.9,0,0,Math.PI*2); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| ctx.save(); ctx.globalCompositeOperation='overlay'; ctx.filter='blur(14px)'; | |
| for(let i=0;i<6;i++){ | |
| const x = (i/6)*W + Math.sin(perf*0.0006 + i)*60; | |
| const y = (Math.cos(perf*0.0005 + i)*80) + H*0.33; | |
| const g = ctx.createRadialGradient(x,y,20,x,y,Math.min(W,H)*0.7); | |
| g.addColorStop(0,'rgba(255,255,255,0.03)'); | |
| g.addColorStop(1,'rgba(255,255,255,0.0)'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0,0,W,H); | |
| } | |
| ctx.restore(); | |
| ctx.save(); | |
| const og = ctx.createLinearGradient(0,0,0,H); | |
| og.addColorStop(0,'rgba(0,10,18,0.06)'); | |
| og.addColorStop(1,'rgba(0,3,8,0.22)'); | |
| ctx.fillStyle = og; | |
| ctx.fillRect(0,0,W,H); | |
| ctx.restore(); | |
| } | |
| function step(ts){ | |
| perf = ts; | |
| for(const b of blobs){ | |
| b.x += b.vx * 0.6; b.y += b.vy * 0.6; | |
| b.x += Math.sin((perf*0.00015) + b.r) * 0.08; | |
| b.y += Math.cos((perf*0.00012) + b.r) * 0.06; | |
| if(b.x < -b.r) b.x = W + b.r; if(b.x > W + b.r) b.x = -b.r; | |
| if(b.y < -b.r) b.y = H + b.r; if(b.y > H + b.r) b.y = -b.r; | |
| } | |
| draw(); | |
| requestAnimationFrame(step); | |
| } | |
| addEventListener('resize', ()=>{ W = canvas.width = innerWidth; H = canvas.height = innerHeight; }); | |
| requestAnimationFrame(step); | |
| })(); | |
| /* --------------------------- | |
| Curved ribbon menu implementation (fixed text + 3D bend) | |
| --------------------------- */ | |
| $(function(){ | |
| const mainLabels = ["games","video","music","memory","network"]; | |
| const gamesList = ["Tomb Raider 3","Tekken 3","Crash Bandicoot","Final Fantasy VII","Metal Gear Solid"]; | |
| const menu3d = $('#menu3d'); | |
| const n = mainLabels.length; | |
| const angleStep = 360 / n; | |
| // runtime state | |
| let radius = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 380; | |
| let ribbonThickness = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ribbon-thickness')) || 72; | |
| let currentRotation = 0; | |
| let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg); | |
| let lastTS = performance.now(); | |
| let currentGameIndex = 0; | |
| // Helper: create ribbon SVG (curved pill). text uses textLength to fit arc. | |
| function createRibbonSVG(id, labelText, arcAngleDeg, R){ | |
| const half = arcAngleDeg / 2; | |
| const a1 = -half * Math.PI/180, a2 = half * Math.PI/180; | |
| const outR = Math.max(20, (R * 0.92) + (ribbonThickness/2)); | |
| const inR = Math.max(6, (R * 0.92) - (ribbonThickness/2)); | |
| const midR = (outR + inR)/2; | |
| // endpoints | |
| const x1o = outR * Math.sin(a1), y1o = -outR * Math.cos(a1); | |
| const x2o = outR * Math.sin(a2), y2o = -outR * Math.cos(a2); | |
| const x2i = inR * Math.sin(a2), y2i = -inR * Math.cos(a2); | |
| const x1i = inR * Math.sin(a1), y1i = -inR * Math.cos(a1); | |
| const laf = arcAngleDeg > 180 ? 1 : 0; | |
| const pathD = [ | |
| `M ${x1o} ${y1o}`, | |
| `A ${outR} ${outR} 0 ${laf} 1 ${x2o} ${y2o}`, | |
| `L ${x2i} ${y2i}`, | |
| `A ${inR} ${inR} 0 ${laf} 0 ${x1i} ${y1i}`, | |
| 'Z' | |
| ].join(' '); | |
| // text center arc | |
| const xm1 = midR * Math.sin(a1), ym1 = -midR * Math.cos(a1); | |
| const xm2 = midR * Math.sin(a2), ym2 = -midR * Math.cos(a2); | |
| const textPathD = `M ${xm1} ${ym1} A ${midR} ${midR} 0 ${laf} 1 ${xm2} ${ym2}`; | |
| // dot position | |
| const dotOut = outR + (ribbonThickness * 0.06); | |
| const dotX = dotOut * Math.sin(a2), dotY = -dotOut * Math.cos(a2); | |
| const gradId = `grad_${id}_${Math.round(Math.random()*9999)}`; | |
| // viewBox extents | |
| const margin = Math.ceil(ribbonThickness*1.6 + 40); | |
| const extent = Math.ceil(outR + margin); | |
| const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`; | |
| // measure approx text length (approx glyph width * letters) so we can set textLength and avoid overflow | |
| // rough estimate: character width = 0.62 * fontSize | |
| const fontSize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--label-size')) || 48; | |
| const approxCharW = fontSize * 0.62; | |
| const textSize = Math.max(1, labelText.length * approxCharW); | |
| // build SVG | |
| return ` | |
| <svg class="ribbon" viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> | |
| <defs> | |
| <linearGradient id="${gradId}" x1="0" x2="1"> | |
| <stop offset="0" stop-color="#e8ff7a"/> | |
| <stop offset="1" stop-color="#8ecc23"/> | |
| </linearGradient> | |
| </defs> | |
| <path class="ribbon-shape" d="${pathD}" fill="url(#${gradId})" opacity="0.995" stroke="rgba(0,0,0,0.08)" stroke-width="1.2" /> | |
| <path id="tp_${id}" d="${textPathD}" fill="none" stroke="none" /> | |
| <text> | |
| <textPath href="#tp_${id}" startOffset="50%" text-anchor="middle" dominant-baseline="middle" lengthAdjust="spacingAndGlyphs" textLength="${Math.min(textSize, midR*1.5)}"> | |
| <tspan>${labelText}</tspan> | |
| </textPath> | |
| </text> | |
| <circle class="dot" cx="${dotX}" cy="${dotY}" r="${(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dot-size'))/2)}" fill="url(#${gradId})" stroke="rgba(0,0,0,0.25)" stroke-width="1.6" /> | |
| </svg> | |
| `; | |
| } | |
| // build menu items (SVG ribbons) | |
| function buildItems(){ | |
| menu3d.empty(); | |
| for(let i=0;i<n;i++){ | |
| const label = mainLabels[i]; | |
| const arc = Math.min(100, Math.max(28, Math.round(label.length * 7.4))); | |
| const $it = $(`<div class="menu-item" data-i="${i}" data-arc="${arc}"></div>`); | |
| $it.html(createRibbonSVG(i, label, arc, radius)); | |
| menu3d.append($it); | |
| } | |
| // ensure initial visuals | |
| updateVisualsImmediate(); | |
| } | |
| // update camera/radius and rebuild ribbons so arcs match radius | |
| function recalcAndBuild(){ | |
| const wrapperW = $('.menu-wrapper').width(); | |
| radius = Math.max(220, Math.round(wrapperW * 0.44)); | |
| ribbonThickness = Math.max(46, Math.round(radius * 0.18)); | |
| document.documentElement.style.setProperty('--ribbon-thickness', ribbonThickness + 'px'); | |
| buildItems(); | |
| } | |
| $(window).on('resize', recalcAndBuild); | |
| // compute front index | |
| function frontIndexFromRotation(rot){ | |
| const normalized = ((-rot % 360)+360)%360; | |
| let idx = Math.round(normalized / angleStep) % n; | |
| idx = (idx + n) % n; | |
| return idx; | |
| } | |
| // immediate update (no smoothing) used after build | |
| function updateVisualsImmediate(){ | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| const baseAngle = i * angleStep; | |
| const eff = baseAngle + currentRotation; | |
| const effRad = eff * Math.PI/180; | |
| // tilt: how much the label tilts up/down along the sphere; use sine so extremes tilt most | |
| const maxTilt = 20; | |
| const curveDeg = Math.sin(effRad) * maxTilt; | |
| // depth scaling (makes front bigger) | |
| const depthFactor = (Math.cos(effRad)+1)/2; // 0 at side/back, 1 at front | |
| const extraFront = (i === frontIndexFromRotation(currentRotation)) ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--front-boost')) : 0; | |
| // place on ring; note: we DO NOT billboard here (we purposely keep ribbon oriented to surface) | |
| const t = `rotateY(${eff}deg) translateZ(${radius + extraFront}px) translateX(-50%) translateY(-50%) rotateX(${curveDeg}deg) scale(${0.78 + 0.45*depthFactor})`; | |
| $(this).css('transform', t); | |
| }); | |
| // classes & detail | |
| const front = frontIndexFromRotation(currentRotation); | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| let d = Math.abs(i - front); d = Math.min(d, n - d); | |
| $(this).removeClass('front side back'); | |
| if(i === front) $(this).addClass('front'); else if(d === 1) $(this).addClass('side'); else $(this).addClass('back'); | |
| }); | |
| $('#detailMain').text(capitalize(mainLabels[front])); | |
| if(mainLabels[front] === 'games') $('#detailSub').text(gamesList[currentGameIndex]); else $('#detailSub').text(''); | |
| } | |
| // per-frame update (smooth continuous rotation) | |
| function updateVisuals(){ | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| const baseAngle = i * angleStep; | |
| const eff = baseAngle + currentRotation; | |
| const effRad = eff * Math.PI/180; | |
| const maxTilt = 20; | |
| const curveDeg = Math.sin(effRad) * maxTilt; | |
| const depthFactor = (Math.cos(effRad)+1)/2; | |
| // bring the very front item slightly more forward to mimic Demo One pop | |
| const frontIndex = frontIndexFromRotation(currentRotation); | |
| const extraFront = (i === frontIndex) ? parseInt(getComputedStyle(document.documentElement).getPropertyValue('--front-boost')) : 0; | |
| // positioned around center star; do not billboard — keep the ribbon oriented on the sphere surface | |
| const scale = (0.78 + 0.45*depthFactor); | |
| const t = `rotateY(${eff}deg) translateZ(${radius + extraFront}px) translateX(-50%) translateY(-50%) rotateX(${curveDeg}deg) scale(${scale})`; | |
| $(this).css('transform', t); | |
| }); | |
| // classes and detail text update | |
| const front = frontIndexFromRotation(currentRotation); | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| let d = Math.abs(i - front); d = Math.min(d, n - d); | |
| $(this).removeClass('front side back'); | |
| if(i === front) $(this).addClass('front'); else if(d === 1) $(this).addClass('side'); else $(this).addClass('back'); | |
| }); | |
| if(lastFront !== front){ | |
| lastFront = front; | |
| $('#detailMain').text(capitalize(mainLabels[front])); | |
| if(mainLabels[front] === 'games') $('#detailSub').text(gamesList[currentGameIndex]); else $('#detailSub').text(''); | |
| } | |
| } | |
| // animation loop | |
| let lastFront = -1; | |
| function rafLoop(ts){ | |
| const dt = (ts - lastTS) / 1000; | |
| lastTS = ts; | |
| currentRotation += speedDeg * dt; | |
| currentRotation = ((currentRotation % 360)+360)%360; | |
| updateVisuals(); | |
| requestAnimationFrame(rafLoop); | |
| } | |
| // snapping helper to animate rotation to a specific index | |
| function snapToIndex(targetIndex, duration=560){ | |
| const targetAngle = -targetIndex * angleStep; | |
| let k = Math.round((currentRotation - targetAngle) / 360); | |
| let target = targetAngle + 360 * k; | |
| $({r: currentRotation}).stop(true).animate({r: target}, { | |
| duration: duration, easing: 'swing', | |
| step: function(now){ currentRotation = ((now%360)+360)%360; }, | |
| complete: function(){ currentRotation = ((target%360)+360)%360; } | |
| }); | |
| } | |
| // keyboard nav & games cycling | |
| function handleLeftRight(isRight){ | |
| const front = frontIndexFromRotation(currentRotation); | |
| if(mainLabels[front] === 'games'){ | |
| currentGameIndex = (currentGameIndex + (isRight?1:-1) + gamesList.length) % gamesList.length; | |
| $('#detailSub').stop(true).fadeOut(80, function(){ $(this).text(gamesList[currentGameIndex]).fadeIn(240); }); | |
| $('#starImg').stop(true).animate({opacity:0.92},150).animate({opacity:1},380); | |
| } else { | |
| const targetIdx = (front + (isRight?1:-1) + n) % n; | |
| snapToIndex(targetIdx, 520); | |
| currentGameIndex = 0; | |
| } | |
| } | |
| $(window).on('keydown', function(e){ | |
| if(e.which === 37){ e.preventDefault(); handleLeftRight(false); } | |
| else if(e.which === 39){ e.preventDefault(); handleLeftRight(true); } | |
| else if(e.which === 13){ e.preventDefault(); $('.menu-item.front').toggleClass('selected'); $('.menu-item.front').animate({opacity:0.86},120).animate({opacity:1},260); } | |
| else if(e.which === 27){ snapToIndex(0,600); } | |
| }); | |
| // controls | |
| $('#speedRange').on('input change', function(){ speedDeg = Number($(this).val()); $('#speedVal').text(speedDeg); }); | |
| $('#resetBtn').on('click', function(){ snapToIndex(0,600); $('#speedRange').val(-6).trigger('change'); }); | |
| // image star loading (tries starfish.png then fallback) | |
| const starImg = document.getElementById('starImg'); | |
| const fallbackSVG = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#ff7a2c"/><stop offset="1" stop-color="#d92c2c"/></linearGradient></defs><path d="M300 40 C350 120 430 140 520 120 C450 200 480 300 420 380 C320 330 260 400 200 520 C120 400 40 320 -20 220 C20 170 40 120 120 80 C200 40 260 10 300 40 Z" fill="url(#g)" opacity="0.98" /></svg>`)}`; | |
| fetch('starfish.png', {method:'HEAD'}).then(r=>{ if(r.ok) starImg.src='starfish.png'; else starImg.src=fallbackSVG; }).catch(_=>{ starImg.src=fallbackSVG; }); | |
| $('#starFile').on('change', function(ev){ | |
| const f = ev.target.files && ev.target.files[0]; if(!f) return; | |
| const reader = new FileReader(); reader.onload = function(e){ starImg.src = e.target.result; }; reader.readAsDataURL(f); | |
| }); | |
| // gentle center star spin (keeps behind ribbons visually) | |
| (function spinStar(){ | |
| let a = 0; | |
| (function loop(){ a += 0.028; $('#starImg').css('transform', `rotate(${a}deg) scale(1)`); requestAnimationFrame(loop); })(); | |
| })(); | |
| // initial build + start | |
| recalcAndBuild(); | |
| lastTS = performance.now(); | |
| requestAnimationFrame(rafLoop); | |
| // helper | |
| function capitalize(s){ return s.charAt(0).toUpperCase() + s.slice(1); } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment