Created
March 10, 2026 01:21
-
-
Save EncodeTheCode/3d34f56cea79240300397cf49ee6b39a 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 — 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