Skip to content

Instantly share code, notes, and snippets.

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

  • Save EncodeTheCode/3d34f56cea79240300397cf49ee6b39a to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/3d34f56cea79240300397cf49ee6b39a 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 + Circular Menu)</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;
}
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;
}
/* rotating 3D ring */
.menu3d{
position:absolute;
inset:0;
transform-style:preserve-3d;
transition: transform 700ms cubic-bezier(.2,.9,.26,1);
pointer-events:none;
}
/* menu item */
.menu-item{
position:absolute;
left:50%;
top:50%;
transform-origin:center center;
text-align:center;
pointer-events:none;
will-change:transform,opacity,filter;
}
/* label style (requested: inner lime gradient + blur + radius) */
.label{
display:inline-block;
font-weight:900;
font-size: var(--label-size);
line-height:1;
padding:8px 18px;
border-radius:18px;
position:relative;
color:transparent;
-webkit-background-clip:text;
background-clip:text;
background-image: linear-gradient(180deg,#c7ff45 0%, #8ecc23 100%);
text-shadow:
0 0 28px rgba(140,255,120,0.28),
0 6px 14px rgba(0,0,0,0.6);
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.5));
transform-origin:center center;
transition: transform 420ms cubic-bezier(.23,.9,.31,1), opacity 260ms;
}
/* outer rounded glow/border (blurred) */
.label::before{
content:"";
position:absolute;
left:-10px; right:-10px; top:-6px; bottom:-6px;
border-radius:22px;
z-index:-1;
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(0,0,0,0.02));
filter: blur(10px) saturate(1.05);
opacity:0.9;
}
/* the big dot separator */
.dot {
display:inline-block;
width:var(--dot-size);
height:var(--dot-size);
margin-left:16px;
border-radius:50%;
vertical-align:middle;
transform:translateY(-6px);
background: radial-gradient(circle at 35% 30%, #fff2c8, #ff7d3a 55%, #c93a1a 100%);
box-shadow: 0 8px 22px rgba(0,0,0,0.6);
}
/* active/inactive/behind states */
.menu-item.front .label{
transform: translateZ(40px) scale(var(--label-active-scale));
opacity:1;
z-index:30;
text-shadow: 0 0 36px rgba(220,255,150,0.45), 0 6px 20px rgba(0,0,0,0.6);
}
.menu-item.side .label{
transform: translateZ(-40px) scale(var(--label-inactive-scale));
opacity:0.28;
z-index:15;
filter: brightness(.9) saturate(.9);
}
.menu-item.back .label{
transform: translateZ(-120px) scale(.72);
opacity:0.16;
z-index:5;
filter: blur(0.6px) brightness(.7);
}
/* center star image (png support) */
.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;
/* fallback look if no png provided */
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:40;
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; }
}
</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
- canvas-based "pearlescent" blobs with blur
- larger bumps, palette teal/blue/cyan/sky blue
- subtle slow motion by offsetting blob positions
============================ */
(function(){
const canvas = document.getElementById('bgCanvas');
const ctx = canvas.getContext('2d');
let W = canvas.width = innerWidth;
let H = canvas.height = innerHeight;
// user-tweakable parameters
const BLOBS = 36; // number of large bumps
const BLOB_SIZE = Math.max(160, Math.min(420, Math.round(Math.max(W,H) * 0.12))); // bigger bumps
const PALETTE = [
[6,128,120], // teal green
[8,92,168], // deep sky blue
[24,200,190], // cyanish
[56,168,220], // sky blue
[14,94,110] // darker teal
];
// generate blobs with random base positions and hues
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
});
}
// draw single frame
function draw(){
// slight fade for motion trails
ctx.clearRect(0,0,W,H);
// draw many blurred circles using filter
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// use blur filter for large pearly bumps
ctx.filter = 'blur(24px)'; // strong blur to create pearlescent lumps
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();
// subtle overlay of lighter specular spots (simulate pearlescent sheen)
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();
// gentle final color overlay for mood (slight vignette)
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();
}
// animate positions -> move slowly and bounce/wrap
let perf = 0;
function step(ts){
perf = ts;
// update blob positions a little
for(let b of blobs){
b.x += b.vx * 0.6; b.y += b.vy * 0.6;
// micro oscillation with time to create "lap" feeling
b.x += Math.sin((perf*0.00015) + b.r) * 0.08;
b.y += Math.cos((perf*0.00012) + b.r) * 0.06;
// wrap edges so blobs seamlessly move
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);
}
// handle resize
function onResize(){
W = canvas.width = innerWidth;
H = canvas.height = innerHeight;
}
addEventListener('resize', onResize);
requestAnimationFrame(step);
})();
/* ============================
Circular menu logic
- ring continuously rotates left
- slider controls speed (deg/sec)
- left/right snaps to prev/next when pressed
- only front label fully visible
- supports star PNG replacement
============================ */
$(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;
let radius = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 380;
// runtime rotation state
let currentRotation = 0; // degrees; positive means rotateY(angle)
let speedDeg = Number($('#speedRange').val()); // deg/sec; negative rotates left
const speedElem = $('#speedVal');
speedElem.text(speedDeg);
// build items
mainLabels.forEach((label, i) => {
const $it = $(`
<div class="menu-item" data-i="${i}">
<div class="label">${label}</div>
<span class="dot" aria-hidden="true"></span>
</div>
`);
menu3d.append($it);
});
function layoutStatic(){
// compute radius from CSS variable or wrapper width
const wrapperW = $('.menu-wrapper').width();
radius = Math.max(220, Math.round(wrapperW * 0.44));
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
const angle = i * angleStep;
// place each item in a circle (rotateY(angle) translateZ(radius))
$(this).css('transform', `rotateY(${angle}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%)`);
});
}
layoutStatic();
$(window).on('resize', function(){ layoutStatic(); });
// compute front index from currentRotation
function frontIndexFromRotation(rot){
// we want index such that rotation ≈ -index * angleStep (so item at 0deg faces viewer)
const normalized = ((-rot % 360)+360)%360; // 0..360
let idx = Math.round(normalized/angleStep) % n;
idx = (idx + n) % n;
return idx;
}
// update classes & detail text
let lastFront = -1;
function updateVisuals(){
const front = frontIndexFromRotation(currentRotation);
if(front !== lastFront){
lastFront = front;
// update detail main/sub
$('#detailMain').text(capitalize(mainLabels[front]));
if(mainLabels[front] === 'games'){
// show first game if not set
$('#detailSub').text(gamesList[currentGameIndex]);
} else {
$('#detailSub').text('');
}
}
// set front/side/back classes based on angular distance
menu3d.find('.menu-item').each(function(){
const i = Number($(this).attr('data-i'));
// find angular distance (circular)
let diff = Math.abs(i - front);
diff = Math.min(diff, n - diff);
$(this).removeClass('front side back');
if(i === front) $(this).addClass('front');
else if(diff === 1) $(this).addClass('side');
else $(this).addClass('back');
});
// apply the ring rotation transform
menu3d.css('transform', `translateZ(-${radius}px) rotateY(${currentRotation}deg)`);
}
// continuous animation loop
let lastTS = performance.now();
function animateLoop(ts){
const dt = (ts - lastTS) / 1000;
lastTS = ts;
// integrate rotation
currentRotation += speedDeg * dt;
// keep within bounds to avoid overflow
currentRotation = ((currentRotation % 360)+360) % 360;
updateVisuals();
requestAnimationFrame(animateLoop);
}
requestAnimationFrame(animateLoop);
// slider control
$('#speedRange').on('input change', function(){
speedDeg = Number($(this).val());
$('#speedVal').text(speedDeg);
});
// snapping animation helper (jQuery animate on a dummy object)
function snapToIndex(targetIndex, duration=560){
// targetRotation such that -targetIndex * angleStep === rotation (mod 360)
// we choose the closest equivalent rotation to currentRotation to animate shortest path
const targetAngle = -targetIndex * angleStep;
// choose the nearest equivalent by adding k*360 to be close to currentRotation
let k = Math.round((currentRotation - targetAngle) / 360);
let target = targetAngle + 360 * k;
// if distance large, adjust
const dist = target - currentRotation;
// animate currentRotation to target using jQuery
$({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();
}
});
}
// keyboard navigation
let currentGameIndex = 0;
function handleLeftRight(isRight){
// when Games is front, repeated LR should cycle games
const front = frontIndexFromRotation(currentRotation);
if(mainLabels[front] === 'games'){
// cycle inner games list (Right => next)
currentGameIndex = (currentGameIndex + (isRight?1:-1) + gamesList.length) % gamesList.length;
// animate sub text fade
$('#detailSub').stop(true).fadeOut(80, function(){
$(this).text(gamesList[currentGameIndex]).fadeIn(240);
});
// small visual nudge of star
$('#starImg').stop(true).animate({opacity:0.92},150).animate({opacity:1},380);
} else {
// snap to next main label (Right => +1 index)
const front = frontIndexFromRotation(currentRotation);
const targetIdx = (front + (isRight?1:-1) + n) % n;
snapToIndex(targetIdx, 520);
// reset inner game index
currentGameIndex = 0;
}
}
$(window).on('keydown', function(e){
if(e.which === 37){ // left
e.preventDefault();
handleLeftRight(false);
} else if(e.which === 39){ // right
e.preventDefault();
handleLeftRight(true);
} else if(e.which === 13){ // enter toggle selected visual
e.preventDefault();
$('.menu-item.front .label').toggleClass('selected');
// brief pop effect
$('.menu-item.front .label').animate({opacity:0.86},120).animate({opacity:1},260);
} else if(e.which === 27) { // esc reset
snapToIndex(0,600);
}
});
// helper
function capitalize(s){ return s.charAt(0).toUpperCase()+s.slice(1); }
// init: set initial detail sub (games front by default)
currentGameIndex = 0;
$('#detailMain').text(capitalize(mainLabels[0]));
$('#detailSub').text(gamesList[currentGameIndex]);
// initial visual arrangement
updateVisuals();
// star PNG upload support
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>` )}`;
// try to load local file starfish.png automatically (if present in same folder), else fallback svg
fetch('starfish.png', {method:'HEAD'}).then(r=>{
if(r.ok){ starImg.src = 'starfish.png'; } else { starImg.src = defaultSVG; }
}).catch(_=>{
starImg.src = defaultSVG;
});
// file input
$('#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 around + slow orbit of star image
let starAngle = 0;
(function starSpinLoop(){
starAngle += 0.03; // degrees per frame ~ subtle
$('#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