Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created March 10, 2026 01:43
Show Gist options
  • Select an option

  • Save EncodeTheCode/6aba855452ac133bacbea90e0ad6ac7e to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/6aba855452ac133bacbea90e0ad6ac7e to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>PS1 Demo-One — Underwater Menu (Pearl Noise + Curved 3D Ribbons)</title>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
:root{
--menu-radius: 380px; /* visual radius of the ring */
--label-size: 48px; /* requested 48px */
--label-active-scale: 1.22;
--label-inactive-scale: 0.86;
--dot-size: 30px;
--bg-blur: 18px;
--ribbon-thickness: 72px;
}
html,body{
height:100%;
margin:0;
font-family: "Trebuchet MS", Arial, sans-serif;
background: #00121a; /* dark fallback */
overflow:hidden;
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
}
/* full viewport canvas background */
#bgCanvas{
position:fixed;
inset:0;
width:100%;
height:100%;
display:block;
z-index:0;
filter: blur(0px);
background: linear-gradient(#042833,#082a40 30%, #042a3d 80%);
}
/* stage sits on top */
.stage{
position:relative;
z-index:2;
width:100%;
height:100%;
display:flex;
align-items:center;
justify-content:center;
perspective:1100px;
}
/* UI container */
.ui{
width:92%;
max-width:1280px;
height:86%;
display:flex;
align-items:center;
justify-content:center;
position:relative;
pointer-events:none;
}
/* central area for the ring */
.menu-wrapper{
width:860px;
height:460px;
position:relative;
transform-style:preserve-3d;
pointer-events:none;
}
/* NOTE: we no longer rotate a single container; we compute transforms per-item */
.menu3d{
position:absolute;
inset:0;
transform-style:preserve-3d;
pointer-events:none;
z-index:40; /* ensure menu sits above star and bg */
}
/* menu item wrapper (each will contain an SVG "ribbon") */
.menu-item{
position:absolute;
left:50%;
top:50%;
transform-origin:center center;
text-align:center;
pointer-events:none;
will-change:transform,opacity,filter;
backface-visibility:visible;
transform-style:preserve-3d;
display:block;
transition: opacity 260ms, transform 360ms;
}
/* SVG ribbon sizing — big enough to avoid clipping */
svg.ribbon{
width:720px;
height:240px;
overflow:visible;
display:block;
pointer-events:none;
}
/* text inside the SVG */
svg.ribbon text{
font-weight:900;
font-size:var(--label-size);
dominant-baseline:middle;
text-anchor:middle;
pointer-events:none;
}
/* ribbon fill visible and shadow for pop */
svg.ribbon .ribbon-shape{
filter: drop-shadow(0 12px 14px rgba(0,0,0,0.55));
transition: filter 220ms, opacity 220ms;
}
/* dot styling inside SVG */
svg.ribbon .dot {
transition: transform 260ms, opacity 260ms;
}
/* visual emphasis states */
.menu-item.front{ opacity:1; z-index:120; }
.menu-item.side{ opacity:0.38; z-index:60; }
.menu-item.back{ opacity:0.16; z-index:20; }
/* center star image (png support) stays behind the ribbons */
.star-wrap{
position:absolute;
left:50%;
top:50%;
transform:translate(-50%,-50%);
width:420px;
height:420px;
pointer-events:none;
z-index:12; /* sits between background and menu items visually */
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 24px rgba(0,0,0,0.55));
}
/* small UI controls (speed slider & file input) */
.controls{
position:fixed;
right:16px;
top:16px;
z-index:60;
background: rgba(0,0,0,0.28);
padding:10px 12px;
border-radius:10px;
color:#dfffe5;
font-size:13px;
backdrop-filter: blur(6px);
pointer-events:auto;
}
.controls input[type="range"]{ width:160px; display:block; margin-top:6px;}
.controls label{ display:block; font-weight:700; font-size:13px; margin-bottom:6px;}
.detail{
position:absolute;
bottom:6%;
left:50%;
transform:translateX(-50%);
z-index:60;
font-weight:700;
color:#eaffc8;
text-shadow: 0 2px 8px rgba(0,0,0,0.6);
font-size:20px;
pointer-events:none;
}
.help {
position:fixed; left:16px; bottom:16px; z-index:60;
color:rgba(255,255,255,0.78);
font-size:12px;
pointer-events:auto;
background:rgba(0,0,0,0.24);
padding:8px 10px; border-radius:8px;
}
@media (max-width:900px){
.menu-wrapper{ width:92%; height:360px;}
.star-wrap{ width:320px; height:320px;}
:root{ --menu-radius: 260px; --label-size:36px; --ribbon-thickness:56px; }
}
</style>
</head>
<body>
<!-- background canvas for pearl noise -->
<canvas id="bgCanvas"></canvas>
<div class="stage">
<div class="ui">
<div class="menu-wrapper">
<!-- 3D ring container -->
<div class="menu3d" id="menu3d"></div>
<!-- star (PNG-support) -->
<div class="star-wrap" id="starWrap" aria-hidden="true">
<img id="starImg" src="" alt="starfish placeholder"/>
</div>
<!-- detail area -->
<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>
<!-- controls -->
<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 animated background
(unchanged)
============================ */
(function(){
const canvas = document.getElementById('bgCanvas');
const ctx = canvas.getContext('2d');
let W = canvas.width = innerWidth;
let H = canvas.height = innerHeight;
const BLOBS = 36;
const BLOB_SIZE = Math.max(160, Math.min(420, Math.round(Math.max(W,H) * 0.12)));
const PALETTE = [
[6,128,120],[8,92,168],[24,200,190],[56,168,220],[14,94,110]
];
const blobs = [];
for(let i=0;i<BLOBS;i++){
blobs.push({
x: Math.random()*W,
y: Math.random()*H,
r: BLOB_SIZE * (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.24 + Math.random()*0.38
});
}
let perf = 0;
function draw(){
ctx.clearRect(0,0,W,H);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.filter = 'blur(24px)';
for(let 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(let 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);
}
function onResize(){
W = canvas.width = innerWidth;
H = canvas.height = innerHeight;
}
addEventListener('resize', onResize);
requestAnimationFrame(step);
})();
/* ============================
Circular menu logic with curved SVG ribbons
- Each menu item now contains an SVG "ribbon" (pill + text + dot)
- We compute per-frame transforms so each ribbon rides the globe and is billboarded + tilted,
producing a correct 3D bending/tilt in perspective.
- Continuous rotation controlled by slider; snapping animates currentRotation; keyboard works.
============================ */
$(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;
// radius and 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; // degrees (0 => index 0 front)
let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg);
let lastTS = performance.now();
let currentGameIndex = 0;
// Create an SVG ribbon: a curved pill path, text on center arc, and a dot at the arc end
// viewBox is centered at 0,0 so transforms are straightforward
function createRibbonSVG(id, labelText, arcAngleDeg, R){
const half = arcAngleDeg/2;
const a1 = -half * Math.PI/180;
const a2 = half * Math.PI/180;
// radii for outside and inside of the pill
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;
// coordinates for outer arc endpoints
const x1o = outR * Math.sin(a1), y1o = -outR * Math.cos(a1);
const x2o = outR * Math.sin(a2), y2o = -outR * Math.cos(a2);
// coordinates for inner arc endpoints (reverse direction)
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.toFixed(3)} ${y1o.toFixed(3)}`,
`A ${outR.toFixed(3)} ${outR.toFixed(3)} 0 ${laf} 1 ${x2o.toFixed(3)} ${y2o.toFixed(3)}`,
`L ${x2i.toFixed(3)} ${y2i.toFixed(3)}`,
`A ${inR.toFixed(3)} ${inR.toFixed(3)} 0 ${laf} 0 ${x1i.toFixed(3)} ${y1i.toFixed(3)}`,
'Z'
].join(' ');
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.toFixed(3)} ${ym1.toFixed(3)} A ${midR.toFixed(3)} ${midR.toFixed(3)} 0 ${laf} 1 ${xm2.toFixed(3)} ${ym2.toFixed(3)}`;
// dot position on outer edge near x2o,y2o
const dotOut = outR + (ribbonThickness * 0.06);
const dotX = dotOut * Math.sin(a2), dotY = -dotOut * Math.cos(a2);
// gradient id unique
const gradId = `grad_${id}_${Math.round(Math.random()*10000)}`;
// viewBox extents centered at 0
const margin = Math.ceil(ribbonThickness*1.6 + 40);
const extent = Math.ceil(outR + margin);
const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`;
const svg = `
<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="#c7ff45"/>
<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="tpath_${id}" d="${textPathD}" fill="none" stroke="none"/>
<text>
<textPath href="#tpath_${id}" startOffset="50%" text-anchor="middle" dominant-baseline="middle" style="font-family:'Trebuchet MS', Arial, sans-serif; font-weight:900; font-size:${getComputedStyle(document.documentElement).getPropertyValue('--label-size')};">
<tspan fill="url(#${gradId})">${labelText}</tspan>
</textPath>
</text>
<circle class="dot" cx="${dotX.toFixed(2)}" cy="${dotY.toFixed(2)}" r="${(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dot-size'))/2)}" fill="url(#${gradId})" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
</svg>
`;
return svg;
}
// Build items using the original mainLabels array, replacing div.label + .dot with SVG ribbon
function buildItems(){
menu3d.empty();
for(let i=0;i<n;i++){
const lab = mainLabels[i];
const approxAngle = Math.min(82, Math.max(26, lab.length * 7.2));
const $item = $(`<div class="menu-item" data-i="${i}" data-arc="${approxAngle}"></div>`);
$item.html(createRibbonSVG(i, lab, approxAngle, radius));
menu3d.append($item);
}
// initial layout after build
updateVisuals(); // positions & classes
}
// On resize, recalc radius & rebuild so arcs remain correct
function onResize(){
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', onResize);
// compute which index is front given currentRotation
function frontIndexFromRotation(rot){
const normalized = ((-rot % 360)+360)%360;
let idx = Math.round(normalized/angleStep) % n;
idx = (idx + n) % n;
return idx;
}
// update visuals: set per-item transforms so each ribbon rides globe & tilts/curves in perspective
let lastFront = -1;
function updateVisuals(){
// for each menu-item compute effective angle and set transform
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
const baseAngle = i * angleStep;
const eff = baseAngle + currentRotation; // degrees
const effRad = eff * Math.PI / 180;
// the ribbon should tilt to follow the sphere surface: compute tilt using sin(eff)
const maxTilt = 20; // degrees of tilt at edges; tune for stronger/softer curvature
const curveDeg = Math.sin(effRad) * maxTilt;
// Transform pipeline:
// 1) rotateY(eff) to place the ribbon around the ring
// 2) translateZ(radius) to push it out to the ring radius
// 3) translateX(-50%) translateY(-50%) to center the SVG (because its viewBox centered at 0)
// 4) rotateY(-eff) billboard so the SVG faces the camera
// 5) rotateX(curveDeg) tilt the ribbon so it follows globe curvature in perspective
const t = `rotateY(${eff}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%) rotateY(${-eff}deg) rotateX(${curveDeg}deg)`;
$(this).css('transform', t);
});
// manage front/side/back classes and detail text
const front = frontIndexFromRotation(currentRotation);
if(front !== lastFront){
lastFront = front;
$('#detailMain').text(capitalize(mainLabels[front]));
if(mainLabels[front] === 'games'){
$('#detailSub').text(gamesList[currentGameIndex]);
} else {
$('#detailSub').text('');
}
}
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');
});
}
// continuous animation loop (integrates currentRotation)
function animateLoop(ts){
const dt = (ts - lastTS) / 1000;
lastTS = ts;
currentRotation += speedDeg * dt;
currentRotation = ((currentRotation % 360)+360)%360;
updateVisuals();
requestAnimationFrame(animateLoop);
}
// snapping helper: animate currentRotation to target 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;
updateVisuals();
},
complete: function(){
currentRotation = ((target % 360)+360)%360;
updateVisuals();
}
});
}
// build initial items and start animation
onResize(); // builds items and positions them for the initial radius
lastTS = performance.now();
requestAnimationFrame(animateLoop);
// slider control
$('#speedRange').on('input change', function(){
speedDeg = Number($(this).val());
$('#speedVal').text(speedDeg);
});
// keyboard nav (same behavior as your version)
function handleLeftRight(isRight){
const front = frontIndexFromRotation(currentRotation);
if(mainLabels[front] === 'games'){
// cycle inner 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();
// Enter toggles selected visual (a quick pop)
$('.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);
}
});
// helper
function capitalize(s){ return s.charAt(0).toUpperCase()+s.slice(1); }
// initial detail text
currentGameIndex = 0;
$('#detailMain').text(capitalize(mainLabels[0]));
$('#detailSub').text(gamesList[currentGameIndex]);
// star PNG upload support (unchanged)
const starImg = document.getElementById('starImg');
const defaultSVG = `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 = defaultSVG; }
}).catch(_=>{
starImg.src = defaultSVG;
});
$('#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 spin of star
let starAngle = 0;
(function starSpinLoop(){
starAngle += 0.03;
$('#starImg').css('transform', `rotate(${starAngle}deg) scale(1)`);
requestAnimationFrame(starSpinLoop);
})();
// reset button
$('#resetBtn').on('click', function(){
snapToIndex(0,600);
$('#speedRange').val(-6).trigger('change');
});
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment