Instantly share code, notes, and snippets.
Created
February 27, 2026 20:53
-
Star
1
(1)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save EncodeTheCode/5d45287d561469eae93f9dd9fa2454e7 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>PS Classic Menu — Selected Border & Smooth Nav</title> | |
| <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> | |
| <style> | |
| :root{ --selected-size:200px; } | |
| html,body{height:100%;margin:0;background:radial-gradient(circle at center,#111 0%,#000 100%);color:#eee;font-family:system-ui, -apple-system, "Segoe UI", Roboto, Arial;} | |
| .stage{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;} | |
| .carousel{position:relative;width:900px;height:500px;pointer-events:none;will-change:transform;} | |
| /* Each game is square w/ 0.35em rounded corners */ | |
| .game{ | |
| --size:100px; | |
| position:absolute; left:50%; top:50%; | |
| transform:translate(-50%,-50%); | |
| width:var(--size); height:var(--size); | |
| display:flex; align-items:center; justify-content:center; | |
| pointer-events:auto; cursor:pointer; | |
| border-radius:0.35em; overflow:hidden; | |
| border:1px solid rgba(255,255,255,0.06); | |
| background:linear-gradient(180deg,#151515,#0b0b0f); | |
| box-shadow:0 8px 20px rgba(0,0,0,0.65); | |
| /* Smooth transform/size/opacity transitions so keyboard nav feels natural */ | |
| transition: | |
| transform 420ms cubic-bezier(.25,.8,.25,1), | |
| width 420ms cubic-bezier(.25,.8,.25,1), | |
| height 420ms cubic-bezier(.25,.8,.25,1), | |
| opacity 260ms ease, | |
| box-shadow 260ms ease, | |
| border-color 200ms ease; | |
| will-change: transform; | |
| } | |
| .game img{ | |
| width:100%; height:100%; object-fit:cover; display:block; border-radius:inherit; pointer-events:none; backface-visibility:hidden; | |
| } | |
| /* SELECTED / FRONT styling: exactly 1px solid white border, subtle glow */ | |
| .game.front{ | |
| border:1px solid #fff; /* requested white 1px border */ | |
| box-shadow: | |
| 0 14px 36px rgba(0,0,0,0.6), | |
| 0 0 18px rgba(255,255,255,0.06); | |
| } | |
| .game.single{ z-index:9999!important; } | |
| .game.single img{ width:var(--selected-size); height:var(--selected-size); border-radius:0.35em; box-shadow:0 28px 68px rgba(0,0,0,0.75); } | |
| .controls{position:absolute;top:16px;left:50%;transform:translateX(-50%);color:#ddd;font-size:13px;background:rgba(255,255,255,0.02);padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,0.03);display:flex;gap:12px;align-items:center;pointer-events:auto;} | |
| .arrowBtn{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.04);color:#ddd;padding:8px 10px;border-radius:8px;cursor:pointer;} | |
| .hud{position:absolute;bottom:26px;width:100%;text-align:center;color:#ddd;font-size:14px;pointer-events:none;} | |
| .game-title{position:absolute;bottom:86px;left:50%;transform:translateX(-50%);color:#bfbfbf;font-size:18px;letter-spacing:1.6px;text-transform:uppercase;pointer-events:none;text-align:center;width:min(900px,94vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0.98;} | |
| @media (max-width:900px){ .carousel{width:94vw;height:46vh;} .game-title{font-size:14px;bottom:70px;} } | |
| .game:focus{outline:none;} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="stage"> | |
| <div class="carousel" id="carousel" aria-hidden="false"></div> | |
| <div class="controls" aria-hidden="false"> | |
| <div class="menuLabel" id="menuTitle">Main Menu</div> | |
| <div style="display:flex;gap:10px;align-items:center;"> | |
| <button class="arrowBtn" id="btnLeft" aria-label="Move left">A / ←</button> | |
| <button class="arrowBtn" id="btnRight" aria-label="Move right">D / →</button> | |
| </div> | |
| </div> | |
| <div class="game-title" id="gameTitle" aria-live="polite" aria-atomic="true"></div> | |
| <div class="hud" id="hintText">Press <strong>A</strong>/<strong>D</strong> or ←/→ to rotate, <strong>Enter</strong> to select, <strong>R</strong> to return</div> | |
| </div> | |
| <script> | |
| /* =========================== | |
| Configurable visual vars | |
| =========================== */ | |
| let spacing = 0.75; | |
| let perspectiveTilt = 0.75; | |
| let baseOffset = 40; | |
| let spiralStrength = 1.15; | |
| let boomerangIntensity = 0.9; | |
| let stage1Duration = 520; | |
| let stage2Duration = 820; | |
| /* =========================== | |
| Menu data | |
| =========================== */ | |
| const mainGames = [ | |
| { title: 'Crash Bandicoot', color: '#b2362f' }, | |
| { title: 'Spyro the Dragon', color: '#2f6fb2' }, | |
| { title: 'Tekken 3', color: '#2fb280' }, | |
| { title: 'Final Fantasy VII', color: '#b28b2f' }, | |
| { title: 'Metal Gear Solid', color: '#8a2fb2' }, | |
| { title: 'Ridge Racer', color: '#b22f6f' }, | |
| { title: 'Tombi!', color: '#2fb29e' }, | |
| { title: 'Barbie: Race & Ride', color: '#6fb22f' }, | |
| { title: 'Wipeout 2097', color: '#b24c2f' }, | |
| { title: 'Worms Armageddon', color: '#2fb2b2' }, | |
| { title: 'Tomb Raider 3', color: '#b24ca8' }, | |
| { title: 'Options', color: '#4c6fb2' } | |
| ]; | |
| const optionsGames = [ | |
| { title: 'Display Settings', color: '#3f7fb2' }, | |
| { title: 'Audio Settings', color: '#5fb27f' }, | |
| { title: 'Controls', color: '#b27f3f' }, | |
| { title: 'Network', color: '#b25f9a' }, | |
| { title: 'System Info', color: '#7f8fb2' }, | |
| { title: 'Back', color: '#4c6fb2' } | |
| ]; | |
| /* =========================== | |
| Utility functions | |
| =========================== */ | |
| function escapeForSVG(s){ | |
| return String(s) | |
| .replace(/&/g,'&') | |
| .replace(/</g,'<') | |
| .replace(/>/g,'>') | |
| .replace(/"/g,'"') | |
| .replace(/'/g,'''); | |
| } | |
| function makeSVGData(title,color){ | |
| const safe = escapeForSVG(title); | |
| const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400'><defs><linearGradient id='g' x1='0' x2='1'><stop offset='0' stop-color='${color}'/><stop offset='1' stop-color='#111'/></linearGradient></defs><rect width='100%' height='100%' fill='url(#g)'/><text x='50%' y='50%' font-family='Verdana, Arial, sans-serif' font-size='34' fill='#fff' dominant-baseline='middle' text-anchor='middle'>${safe}</text></svg>`; | |
| return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); | |
| } | |
| function cubicBezierPoint(p0,p1,p2,p3,t){ | |
| const u = 1-t; | |
| const x = u*u*u*p0.x + 3*u*u*t*p1.x + 3*u*t*t*p2.x + t*t*t*p3.x; | |
| const y = u*u*u*p0.y + 3*u*u*t*p1.y + 3*u*t*t*p2.y + t*t*t*p3.y; | |
| return {x,y}; | |
| } | |
| function easeOutCubic(x){ return 1 - Math.pow(1-x,3); } | |
| function signedAngle(raw){ while(raw<=-Math.PI) raw+=2*Math.PI; while(raw>Math.PI) raw-=2*Math.PI; return raw; } | |
| /* =========================== | |
| State & DOM | |
| =========================== */ | |
| const $carousel = $('#carousel'); | |
| let menuData = mainGames.slice(); | |
| let nodes = []; | |
| let N = 0; | |
| let selected = 0; | |
| let currentMenu = 'main'; | |
| let menuStack = []; | |
| let animating = false; | |
| let inSingleView = false; | |
| let savedSelected = null; | |
| /* ---------- Layout math ---------- */ | |
| function getLayoutParams(){ | |
| const rect = $carousel[0].getBoundingClientRect(); | |
| const baseRX = Math.min(rect.width,900) * 0.42; | |
| const baseRY = Math.min(rect.height,500) * 0.34; | |
| const rX = baseRX * spacing; | |
| const rY = baseRY * spacing; | |
| return { rX, rY, width:rect.width, height:rect.height }; | |
| } | |
| function angleStepFor(n){ return (2*Math.PI/n); } | |
| function sizeFromD(d){ if(d<=0.5){ const t=d/0.5; return 110 - (110-80)*t; } else { const t=(d-0.5)/0.5; return 80 - (80-55)*t; } } | |
| /* ---------- Build menu DOM (create nodes once per menu) ---------- */ | |
| function buildMenu(menuArray){ | |
| $carousel.empty(); nodes = []; | |
| menuArray.forEach((g,i)=>{ | |
| const el = document.createElement('div'); | |
| el.className = 'game'; | |
| el.dataset.index = i; | |
| el.setAttribute('role','button'); el.setAttribute('tabindex','0'); | |
| const img = document.createElement('img'); | |
| img.alt = g.title; | |
| img.src = makeSVGData(g.title.replace(/&/g,'&'), g.color || '#444'); | |
| el.appendChild(img); | |
| $carousel.append(el); | |
| nodes.push(el); | |
| }); | |
| N = nodes.length; | |
| nodes.forEach((node,idx)=>{ | |
| node.addEventListener('click', ()=>{ | |
| if(inSingleView){ | |
| restoreFromSingleView().then(()=>{ selected = idx; updatePositions(); updateGameTitle(); }); | |
| return; | |
| } | |
| selected = idx; updatePositions(); updateGameTitle(); | |
| }); | |
| node.addEventListener('keydown', (ev)=>{ | |
| if(ev.key === 'Enter' || ev.key === ' '){ | |
| ev.preventDefault(); | |
| if(!animating && !inSingleView){ | |
| selected = Number(node.dataset.index); | |
| handleEnterOnSelected(); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| /* ---------- Circle layout for index i ---------- */ | |
| function circlePosFor(i, sel, count, params){ | |
| const step = angleStepFor(count); | |
| const raw = signedAngle((i - sel) * step); | |
| const d = Math.abs(raw) / Math.PI; | |
| const size = sizeFromD(d); | |
| const x = Math.sin(raw) * params.rX; | |
| const y = Math.cos(raw) * params.rY * perspectiveTilt + baseOffset; | |
| const scale = size / 110; | |
| return {x,y,scale,raw,d,size}; | |
| } | |
| /* ---------- Update positions with CSS transitions for natural nav ---------- | |
| Also ensures exactly one `.front` item, focuses it, and sets aria-current. | |
| */ | |
| function updatePositions(){ | |
| if(inSingleView) return; | |
| if(!nodes.length) return; | |
| const params = getLayoutParams(); | |
| const step = angleStepFor(N); | |
| let frontIndex = null; | |
| for(let i=0;i<N;i++){ | |
| const node = nodes[i]; | |
| const pos = circlePosFor(i, selected, N, params); | |
| node.style.setProperty('--size', pos.size + 'px'); | |
| node.style.width = pos.size + 'px'; | |
| node.style.height = pos.size + 'px'; | |
| node.style.transform = `translate(-50%,-50%) translate(${pos.x}px, ${pos.y}px) scale(${pos.scale}) rotate(0deg)`; | |
| node.style.zIndex = Math.round((1 - pos.d) * 1000); | |
| node.style.opacity = 1; | |
| // decide front exactly using angle threshold | |
| if(Math.abs(pos.raw) < (step * 0.5)){ | |
| node.classList.add('front'); | |
| node.setAttribute('aria-current','true'); | |
| frontIndex = i; | |
| } else { | |
| node.classList.remove('front'); | |
| node.removeAttribute('aria-current'); | |
| } | |
| } | |
| // If no one fell within threshold (edge cases), explicitly mark selected as front | |
| if(frontIndex === null && nodes[selected]){ | |
| nodes[selected].classList.add('front'); | |
| nodes[selected].setAttribute('aria-current','true'); | |
| frontIndex = selected; | |
| } | |
| // Focus the frontIndex element so keyboard nav always originates from the selected item | |
| try { | |
| if(frontIndex !== null && nodes[frontIndex]) nodes[frontIndex].focus(); | |
| } catch(e){ /* ignore focus errors */ } | |
| updateGameTitle(); | |
| } | |
| /* ---------- Entrance animation ---------- */ | |
| function animateEntrance(durationOverride){ | |
| if(!nodes.length) return; | |
| const params = getLayoutParams(); | |
| const targets = []; | |
| for(let i=0;i<N;i++){ | |
| const pos = circlePosFor(i, selected, N, params); | |
| targets.push(pos); | |
| const node = nodes[i]; | |
| node.style.transform = `translate(-50%,-50%) translate(0px, 0px) scale(0.12) rotate(0deg)`; | |
| node.style.opacity = 0; | |
| node.classList.remove('single'); | |
| } | |
| nodes.forEach(n => $(n).stop(true,true)); | |
| animating = true; | |
| const dur = durationOverride || 900; | |
| $({t:0}).animate({t:1},{duration:dur,easing:'swing',step(now){ | |
| const p = now; const e = easeOutCubic; | |
| for(let i=0;i<N;i++){ | |
| const node = nodes[i], tar = targets[i]; | |
| const spin = (1 - p) * (3 * 2 * Math.PI); | |
| const angle = tar.raw + spin; | |
| const x = Math.sin(angle) * tar.x * e(p); | |
| const y = Math.cos(angle) * tar.y * e(p); | |
| const scale = 0.12 + (tar.scale - 0.12) * e(p); | |
| node.style.transform = `translate(-50%,-50%) translate(${x}px, ${y}px) scale(${scale}) rotate(0deg)`; | |
| node.style.opacity = Math.min(1, p*1.05); | |
| node.style.zIndex = tar.size; | |
| } | |
| },complete(){ | |
| animating = false; updatePositions(); try{ nodes[selected].focus(); }catch(e){} | |
| }}); | |
| } | |
| /* =========================== | |
| Bézier boomerang transition (unchanged) | |
| (full implementation as in previous working build) | |
| =========================== */ | |
| /* Capture start circle states */ | |
| function captureStartStates(){ | |
| const params = getLayoutParams(); | |
| const startStates = []; | |
| for(let i=0;i<N;i++){ | |
| const pos = circlePosFor(i, selected, N, params); | |
| startStates.push({ x:pos.x, y:pos.y, scale:pos.scale, raw:pos.raw, size:pos.size }); | |
| } | |
| return startStates; | |
| } | |
| /* Generate peaks using spiral math + randomness */ | |
| function generatePeaksFor(startStates){ | |
| const params = getLayoutParams(); | |
| return startStates.map((s, idx) => { | |
| const spiralJitter = (Math.random()-0.5) * Math.PI * 0.6; | |
| const angle = s.raw + spiralJitter; | |
| const radius = Math.sqrt(params.rX*params.rX + params.rY*params.rY) * (spiralStrength + Math.random()*0.6); | |
| const spiralRotation = (idx / Math.max(1,N)) * Math.PI * (0.2 + Math.random()*1.6); | |
| const px = Math.sin(angle + spiralRotation) * radius; | |
| const py = Math.cos(angle + spiralRotation) * params.rY * (perspectiveTilt * (1.0 + Math.random()*0.4)) - baseOffset * (0.2 + Math.random()*0.6); | |
| const rot = (Math.random()<0.5? -1:1) * (220 + Math.random()*720); | |
| const scale = Math.max(0.28, s.scale * (0.7 + Math.random()*0.6)); | |
| const ctrlOffset = Math.min(params.rX, params.rY) * (0.25 + Math.random()*0.8); | |
| const p1 = { x: s.x + Math.cos(s.raw + Math.PI/2)*ctrlOffset, y: s.y + Math.sin(s.raw + Math.PI/2)*ctrlOffset }; | |
| const p2 = { x: px + Math.cos(s.raw - Math.PI/2)*ctrlOffset*0.6, y: py + Math.sin(s.raw - Math.PI/2)*ctrlOffset*0.6 }; | |
| return { px, py, rot, scale, p1, p2 }; | |
| }); | |
| } | |
| /* Compute final positions for a menu (without DOM) */ | |
| function computeFinalPositionsForMenu(menuArray, selIndex){ | |
| const params = getLayoutParams(); | |
| const final = []; | |
| const count = menuArray.length; | |
| const step = angleStepFor(count); | |
| for(let i=0;i<count;i++){ | |
| const raw = signedAngle((i - selIndex) * step); | |
| const d = Math.abs(raw) / Math.PI; | |
| const size = sizeFromD(d); | |
| const x = Math.sin(raw) * params.rX; | |
| const y = Math.cos(raw) * params.rY * perspectiveTilt + baseOffset; | |
| const scale = size / 110; | |
| final.push({x,y,scale,raw,d,size}); | |
| } | |
| return final; | |
| } | |
| /* Main transition routine */ | |
| function transitionToMenu(newMenuArray, title, newSelectedIndex = 0){ | |
| if(animating) return; | |
| animating = true; | |
| const startStates = captureStartStates(); | |
| const peaks = generatePeaksFor(startStates); | |
| // Stage 1: start -> peak (cubic bezier outward) | |
| const stage1Dur = Math.round(stage1Duration * (0.9 + Math.random()*0.25)); | |
| const stage1Dfd = $.Deferred(); | |
| $({t:0}).animate({t:1},{duration:stage1Dur,easing:'swing',step(now){ | |
| const p = now; const e = easeOutCubic; | |
| for(let i=0;i<N;i++){ | |
| const node = nodes[i]; | |
| const s = startStates[i]; | |
| const pk = peaks[i]; | |
| const P0 = {x:s.x, y:s.y}; | |
| const P1 = pk.p1; | |
| const P2 = pk.p2; | |
| const P3 = {x:pk.px, y:pk.py}; | |
| const pt = cubicBezierPoint(P0,P1,P2,P3, e(p)); | |
| const rot = pk.rot * (p); | |
| const sc = s.scale + (pk.scale - s.scale) * e(p); | |
| node.style.transform = `translate(-50%,-50%) translate(${pt.x}px, ${pt.y}px) scale(${sc}) rotate(${rot}deg)`; | |
| node.style.opacity = 1 - Math.min(0.6, p*0.9); | |
| node.style.zIndex = Math.round(10000 - Math.abs(pt.x) - Math.abs(pt.y)); | |
| } | |
| }, complete(){ | |
| stage1Dfd.resolve(); | |
| }}); | |
| // After outward stage, swap DOM mid-air and animate back in | |
| stage1Dfd.promise().then(()=>{ | |
| const outwardPositions = peaks.map(pk => ({x:pk.px, y:pk.py, rot:pk.rot, scale:pk.scale, opacity: 0.2 + Math.random()*0.6})); | |
| // Save state so return works | |
| menuStack.push({ menu: currentMenu, data: menuData.slice(), selectedIndex: selected, title: $('#menuTitle').text() }); | |
| currentMenu = (newMenuArray === optionsGames) ? 'options' : 'menu'; | |
| // Build new DOM — start nodes at outward positions | |
| menuData = newMenuArray.slice(); | |
| $carousel.empty(); nodes = []; | |
| menuData.forEach((g,i)=>{ | |
| const el = document.createElement('div'); | |
| el.className = 'game'; | |
| el.dataset.index = i; | |
| el.setAttribute('role','button'); el.setAttribute('tabindex','0'); | |
| const img = document.createElement('img'); | |
| img.alt = g.title; | |
| img.src = makeSVGData(g.title.replace(/&/g,'&'), g.color || '#444'); | |
| el.appendChild(img); | |
| $carousel.append(el); | |
| nodes.push(el); | |
| }); | |
| N = nodes.length; | |
| nodes.forEach((node,idx)=>{ | |
| node.addEventListener('click', ()=>{ | |
| if(inSingleView){ | |
| restoreFromSingleView().then(()=>{ selected = idx; updatePositions(); updateGameTitle(); }); | |
| return; | |
| } | |
| selected = idx; updatePositions(); updateGameTitle(); | |
| }); | |
| node.addEventListener('keydown', (ev)=>{ | |
| if(ev.key === 'Enter' || ev.key === ' '){ | |
| ev.preventDefault(); | |
| if(!animating && !inSingleView){ | |
| selected = Number(node.dataset.index); | |
| handleEnterOnSelected(); | |
| } | |
| } | |
| }); | |
| }); | |
| // position new nodes at outward peaks (cycle if counts mismatch) | |
| for(let i=0;i<N;i++){ | |
| const pos = outwardPositions[i % outwardPositions.length]; | |
| const node = nodes[i]; | |
| node.style.transform = `translate(-50%,-50%) translate(${pos.x}px, ${pos.y}px) scale(${pos.scale}) rotate(${pos.rot}deg)`; | |
| node.style.opacity = pos.opacity; | |
| node.style.width = '60px'; | |
| node.style.height = '60px'; | |
| } | |
| // compute final positions and animate stage2 (peak -> final) along bezier boomerang arcs | |
| const finalPositions = computeFinalPositionsForMenu(menuData, newSelectedIndex); | |
| const stage2Dur = Math.round(stage2Duration * (0.9 + Math.random()*0.2)); | |
| $({t:0}).animate({t:1},{duration:stage2Dur,easing:'swing',step(now){ | |
| const p = now; const e = easeOutCubic; | |
| for(let i=0;i<N;i++){ | |
| const node = nodes[i]; | |
| const peak = outwardPositions[i % outwardPositions.length]; | |
| const fin = finalPositions[i]; | |
| const P0 = {x: peak.x, y: peak.y}; | |
| const angle = Math.atan2(fin.y - peak.y, fin.x - peak.x); | |
| const cpMag = Math.min(200, Math.hypot(peak.x-fin.x, peak.y-fin.y) * (0.4 + Math.random()*0.6)); | |
| const cp1 = { x: peak.x + Math.cos(angle + Math.PI/2) * cpMag * (0.6 + Math.random()*0.8), y: peak.y + Math.sin(angle + Math.PI/2) * cpMag * (0.6 + Math.random()*0.8) }; | |
| const cp2 = { x: fin.x + Math.cos(angle - Math.PI/2) * cpMag * (0.3 + Math.random()*0.8), y: fin.y + Math.sin(angle - Math.PI/2) * cpMag * (0.3 + Math.random()*0.8) }; | |
| const t = e(p); | |
| const pt = cubicBezierPoint(P0, cp1, cp2, {x:fin.x, y:fin.y}, t); | |
| const rot = (peak.rot || 0) * (1 - p * (1.0 + boomerangIntensity*0.2)); | |
| const scale = peak.scale + (fin.scale - peak.scale) * t; | |
| node.style.transform = `translate(-50%,-50%) translate(${pt.x}px, ${pt.y}px) scale(${scale}) rotate(${rot}deg)`; | |
| node.style.opacity = Math.min(1, 0.2 + p*1.4); | |
| node.style.zIndex = Math.round((1 - fin.d) * 1000); | |
| node.style.setProperty('--size', fin.size + 'px'); | |
| node.style.width = fin.size + 'px'; | |
| node.style.height = fin.size + 'px'; | |
| } | |
| }, complete(){ | |
| animating = false; | |
| $('#menuTitle').text(title || 'Menu'); | |
| selected = newSelectedIndex; | |
| updatePositions(); | |
| try{ nodes[selected].focus(); }catch(e){} | |
| }}); | |
| }); | |
| } | |
| /* ---------- Helpers to open Options & return ---------- */ | |
| function openOptions(){ transitionToMenu(optionsGames, 'Options', 0); } | |
| function returnToParent(){ | |
| const prev = menuStack.pop(); | |
| if(!prev){ animating = false; return; } | |
| transitionToMenu(prev.data.slice(), prev.title || 'Main Menu', prev.selectedIndex || 0); | |
| currentMenu = prev.menu; | |
| } | |
| /* ---------- Title update ---------- */ | |
| function updateGameTitle(){ | |
| const el = document.getElementById('gameTitle'); | |
| if(!el) return; | |
| const item = (menuData && menuData[selected]) ? menuData[selected].title : ''; | |
| el.textContent = String(item).replace(/&/g,'&'); | |
| } | |
| /* ---------- Enter / select handling ---------- */ | |
| function handleEnterOnSelected(){ | |
| const item = menuData[selected]; | |
| if(!item) return; | |
| const realTitle = String(item.title).replace(/&/g,'&').trim().toLowerCase(); | |
| if(currentMenu === 'main' && realTitle === 'options'){ openOptions(); return; } | |
| if(currentMenu === 'options' && realTitle === 'back'){ returnToParent(); return; } | |
| enterSelect(); | |
| } | |
| /* ---------- Single item view ---------- */ | |
| function enterSelect(){ | |
| if(animating || inSingleView) return $.Deferred().resolve().promise(); | |
| animating = true; inSingleView = true; savedSelected = selected; | |
| nodes.forEach(n=>$(n).stop(true,true)); | |
| const chosen = nodes[selected]; const img = chosen.querySelector('img'); | |
| const curSize = parseFloat(getComputedStyle(chosen).getPropertyValue('--size')) || 100; | |
| nodes.forEach((node,idx)=>{ if(idx===selected) return; $(node).animate({opacity:0},260).promise().done(()=>{ node.style.pointerEvents='none'; }); }); | |
| const dfd = $.Deferred(); | |
| $({t:0}).animate({t:1},{duration:420,easing:'swing',step(now){ | |
| const p=now; const curScale=(curSize/110); const targetScale=(parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--selected-size'))||200)/110; | |
| const scale = curScale + (targetScale - curScale) * p; | |
| chosen.style.transform = `translate(-50%,-50%) translate(0px,0px) scale(${scale})`; | |
| chosen.style.zIndex = 9999; chosen.style.opacity = 1; | |
| if(img){ const sizePx = (curSize*(1-p)) + ((parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--selected-size'))||200)*p); img.style.width = sizePx + 'px'; img.style.height = sizePx + 'px'; } | |
| },complete(){ chosen.classList.add('single'); animating=false; dfd.resolve(); }}); | |
| return dfd.promise(); | |
| } | |
| function restoreFromSingleView(){ | |
| if(!inSingleView && !animating) return $.Deferred().resolve().promise(); | |
| animating = true; | |
| nodes.forEach(n=>$(n).stop(true,true)); | |
| const promises = nodes.map(node => { | |
| const d = $.Deferred(); | |
| node.style.pointerEvents = 'auto'; | |
| $(node).animate({opacity:1},240, function(){ | |
| const img = node.querySelector('img'); | |
| if(img){ img.style.width=''; img.style.height=''; } | |
| d.resolve(); | |
| }); | |
| return d.promise(); | |
| }); | |
| return $.when(...promises).then(()=>{ nodes.forEach(n=>{ n.classList.remove('single'); n.style.zIndex=''; }); inSingleView=false; animating=false; if(savedSelected!==null){ selected = savedSelected; savedSelected = null; } setTimeout(updatePositions,20); }); | |
| } | |
| /* ---------- Navigation ---------- */ | |
| function moveLeft(){ if(inSingleView){ restoreFromSingleView().then(()=>{ selected = (selected - 1 + N) % N; updatePositions(); }); return; } selected = (selected - 1 + N) % N; updatePositions(); } | |
| function moveRight(){ if(inSingleView){ restoreFromSingleView().then(()=>{ selected = (selected + 1) % N; updatePositions(); }); return; } selected = (selected + 1) % N; updatePositions(); } | |
| /* ---------- Keyboard & UI bindings ---------- */ | |
| $(window).on('keydown', (ev)=>{ | |
| const tag = (document.activeElement && document.activeElement.tagName) || ""; | |
| if(tag === "INPUT" || tag === "TEXTAREA") return; | |
| const k = ev.key; | |
| if(k === 'a' || k === 'A' || k === 'ArrowLeft'){ ev.preventDefault(); moveLeft(); } | |
| else if(k === 'd' || k === 'D' || k === 'ArrowRight'){ ev.preventDefault(); moveRight(); } | |
| else if(k === 'Enter'){ ev.preventDefault(); if(!animating && !inSingleView) handleEnterOnSelected(); } | |
| else if(k === 'r' || k === 'R'){ ev.preventDefault(); if(inSingleView && !animating) restoreFromSingleView(); else if(currentMenu==='options' && !animating) returnToParent(); } | |
| }); | |
| $('#btnLeft').on('click', moveLeft); $('#btnRight').on('click', moveRight); | |
| $(window).on('resize', ()=>{ setTimeout(updatePositions,60); }); | |
| /* ---------- Init ---------- */ | |
| function initMain(){ | |
| menuData = mainGames.slice(); | |
| buildMenu(menuData); | |
| selected = 0; | |
| $('#menuTitle').text('Main Menu'); | |
| setTimeout(()=>{ animateEntrance(900); }, 120); | |
| } | |
| $(function(){ initMain(); }); | |
| /* ---------- Runtime API ---------- */ | |
| window._ps = { | |
| setSpacing: v=>{ spacing = Number(v)||1.0; updatePositions(); }, | |
| setTilt: v=>{ perspectiveTilt = Number(v)||0.75; updatePositions(); }, | |
| setBaseOffset: v=>{ baseOffset = Number(v)||40; updatePositions(); }, | |
| setSpiralStrength: v=>{ spiralStrength = Number(v)||1.15; }, | |
| setBoomerangIntensity: v=>{ boomerangIntensity = Number(v)||0.9; }, | |
| setStageDurations: (s1,s2)=>{ stage1Duration = s1||520; stage2Duration = s2||820; }, | |
| openOptions: ()=>openOptions(), | |
| returnToParent: ()=>returnToParent(), | |
| enterSelect: ()=>enterSelect(), | |
| restoreFromSingleView: ()=>restoreFromSingleView() | |
| }; | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment