Instantly share code, notes, and snippets.
Created
February 27, 2026 20:50
-
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/61cddffeebff87f0fa09975e49df101b 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 — Fixed Enter + Bézier Boomerang Transitions</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;} | |
| /* Restored smooth nav transitions */ | |
| .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); | |
| 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; | |
| will-change: transform; | |
| } | |
| .game img{width:100%;height:100%;object-fit:cover;display:block;border-radius:inherit;pointer-events:none;backface-visibility:hidden;} | |
| .game.front{border:2px solid rgba(255,255,255,0.06);box-shadow:0 18px 46px rgba(0,0,0,0.6);} | |
| .game.single{z-index:9999!important;} | |
| .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> | |
| /* ------------------------- | |
| Adjustable visual variables | |
| ------------------------- */ | |
| let spacing = 0.75; | |
| let perspectiveTilt = 0.75; // vertical exaggeration | |
| let baseOffset = 40; // lower front item (px) | |
| let spiralStrength = 1.15; // outward peak multiplier | |
| let boomerangIntensity = 0.9; // return overshoot | |
| let stage1Duration = 520; // ms | |
| let stage2Duration = 820; // ms | |
| /* ------------------------- | |
| Menus | |
| ------------------------- */ | |
| 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' } | |
| ]; | |
| /* ------------------------- | |
| Utilities | |
| ------------------------- */ | |
| 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 ---------- */ | |
| function updatePositions(){ | |
| if(inSingleView) return; | |
| if(!nodes.length) return; | |
| const params = getLayoutParams(); | |
| 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; | |
| if(Math.abs(pos.raw) < (angleStepFor(N) * 0.5)) node.classList.add('front'); else node.classList.remove('front'); | |
| } | |
| 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 | |
| =========================== */ | |
| /* 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 (fixed to be robust) ---------- */ | |
| function handleEnterOnSelected(){ | |
| const item = menuData[selected]; | |
| if(!item) return; | |
| const realTitle = String(item.title).replace(/&/g,'&').trim().toLowerCase(); | |
| if(currentMenu === 'main' && realTitle === 'options'){ | |
| // open submenu with transition | |
| openOptions(); | |
| return; | |
| } | |
| if(currentMenu === 'options' && realTitle === 'back'){ | |
| returnToParent(); | |
| return; | |
| } | |
| enterSelect(); | |
| } | |
| /* ---------- Single item view (unchanged) ---------- */ | |
| 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(19000); }, 50); | |
| } | |
| $(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