From 088fb7616087b59f4ae9b5adffd005c9eb671e30 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 12 Apr 2026 00:51:38 -0700 Subject: [PATCH] Feature/new homepage (#9) * half working main menu * MVP homepage, need to make small fixes * Final homescreen --- index.html | 2 +- src/features/leaderboard/useTopHighscore.ts | 9 +- src/index.css | 6 + src/pages/HomePage.module.css | 1046 +++++++++---------- src/pages/HomePage.tsx | 502 +++++++-- 5 files changed, 930 insertions(+), 635 deletions(-) diff --git a/index.html b/index.html index 5c4bff9..a257854 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ /> - VIBESTEP + HACK HACK REVOLUTION
diff --git a/src/features/leaderboard/useTopHighscore.ts b/src/features/leaderboard/useTopHighscore.ts index e0f5373..07b3bd9 100644 --- a/src/features/leaderboard/useTopHighscore.ts +++ b/src/features/leaderboard/useTopHighscore.ts @@ -6,6 +6,7 @@ import { supabase } from '../../lib/supabaseClient' */ export function useTopHighscore() { const [topScore, setTopScore] = useState(null) + const [topName, setTopName] = useState(null) const [loading, setLoading] = useState(true) useEffect(() => { @@ -14,7 +15,7 @@ export function useTopHighscore() { async function load() { const { data, error } = await supabase .from('player_stats') - .select('highscore') + .select('highscore, username') .order('highscore', { ascending: false }) .limit(1) @@ -22,9 +23,11 @@ export function useTopHighscore() { if (error || !data?.length) { setTopScore(null) + setTopName(null) } else { - const row = data[0] as { highscore: number } + const row = data[0] as { highscore: number; username?: string } setTopScore(typeof row.highscore === 'number' ? row.highscore : null) + setTopName(typeof row.username === 'string' ? row.username : null) } setLoading(false) } @@ -35,5 +38,5 @@ export function useTopHighscore() { } }, []) - return { topScore, loading } + return { topScore, topName, loading } } diff --git a/src/index.css b/src/index.css index d2b674b..3437a37 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Exo+2:wght@400;600;700&family=Orbitron:wght@700;900&display=swap'); + @font-face { font-family: 'JustDance'; src: url('./assets/fonts/just-dance/JustDance-Regular.otf') format('opentype'); @@ -39,6 +41,10 @@ --line-height-tight: 1.1; --line-height-normal: 1.4; + + --color-neon-pink: #ff4ecb; + --color-neon-cyan: #00c8ff; + --color-neon-gold: #ffd200; } html { diff --git a/src/pages/HomePage.module.css b/src/pages/HomePage.module.css index 14fcf07..2eca3ef 100644 --- a/src/pages/HomePage.module.css +++ b/src/pages/HomePage.module.css @@ -1,75 +1,445 @@ -.root { - min-height: 100vh; - min-height: 100dvh; - box-sizing: border-box; - overflow: hidden; -} - -/* Home hub — visual language aligned with SongSelectScreen (arcade purple, grid, scanlines). */ - .page { - /* Shared with EndOfGamePage.module.css / song-select arcade panels */ - --home-bg: #120820; - --menu-accent: #d946ef; - --menu-highlight: #f0abfc; - --home-grid-line: rgb(255 255 255 / 0.06); - --home-cyan: #22d3ee; - --home-cyan-glow: rgb(34 211 238 / 0.45); - --home-magenta: #d946ef; - --home-magenta-soft: rgb(217 70 239 / 0.35); - --home-pink-glow: #f0abfc; - --home-orange: #fb923c; - --home-panel: rgb(0 0 0 / 0.42); - --home-font-arcade: 'Press Start 2P', ui-monospace, monospace; + --hud-pink: var(--color-neon-pink); + --hud-cyan: var(--color-neon-cyan); + --hud-gold: var(--color-neon-gold); + --hud-panel: rgb(0 0 0 / 0.45); position: relative; - box-sizing: border-box; display: flex; flex-direction: column; width: 100%; - max-width: 100%; min-height: 100vh; min-height: 100dvh; - padding: - max(clamp(0.5rem, 1.5vw, 1rem), env(safe-area-inset-top, 0px)) - max(clamp(0.5rem, 1.8vw, 1rem), env(safe-area-inset-right, 0px)) - max(clamp(0.5rem, 1.2vw, 0.85rem), env(safe-area-inset-bottom, 0px)) - max(clamp(0.5rem, 1.8vw, 1rem), env(safe-area-inset-left, 0px)); - overflow-x: hidden; - overflow-y: auto; - background-color: var(--home-bg); + overflow: hidden; color: #fff; - font-family: var(--home-font-arcade); + background: transparent; } -.page::before { +.layout { + position: relative; + z-index: 12; + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + min-height: 100vh; + min-height: 100dvh; + padding: 32px 40px; + box-sizing: border-box; +} + +.mainRow { + position: relative; + z-index: 12; + display: flex; + flex: 1; + align-items: stretch; + gap: clamp(18px, 4vw, 43px); + min-height: 0; +} + +.leftPanel { + width: 410px; + min-width: 410px; + display: flex; + flex-direction: column; + gap: 22px; +} + +.titleBlock { + display: flex; + flex-direction: column; + gap: 7px; +} + +.gameTitle { + margin: 0; + font-family: 'Orbitron', var(--font-family-display); + font-weight: 900; + font-size: clamp(25px, 4vw, 42px); + line-height: 1.02; + letter-spacing: 0.08em; + color: #fff; + text-shadow: 0 0 25px rgb(255 78 203 / 0.9); +} + +.backdrop { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 0; + background: + radial-gradient(ellipse 80% 60% at 50% 0%, rgb(255 78 203 / 0.16), transparent 55%), + radial-gradient(ellipse 70% 45% at 50% 100%, rgb(0 200 255 / 0.14), transparent 62%), + linear-gradient(180deg, rgb(7 7 20 / 0.94), rgb(8 6 22 / 0.88) 48%, rgb(14 8 28 / 0.96)); +} + +.gameSubtitle { + margin: 0; + font-family: 'Exo 2', var(--font-family-ui); + font-size: 18px; + font-weight: 600; + letter-spacing: 0.47em; + text-transform: uppercase; + color: var(--hud-pink); +} + +.menuWrap { + position: relative; +} + +.menu { + display: flex; + flex-direction: column; +} + +.menuTriangle { + position: absolute; + left: -27px; + z-index: 2; + width: 0; + height: 0; + border-top: 13px solid transparent; + border-bottom: 13px solid transparent; + border-left: 20px solid var(--hud-pink); + transform: translateY(-50%); + filter: drop-shadow(0 0 14px rgb(255 78 203 / 0.9)); + transition: top 0.2s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.menuItem { + display: flex; + align-items: center; + width: 100%; + margin: 0; + margin-bottom: 10px; + padding: 20px 25px 20px 18px; + border: 2px solid rgb(255 78 203 / 0.15); + background: rgb(0 0 0 / 0.45); + backdrop-filter: blur(12px); + text-align: left; + color: #fff; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; +} + +.menuItemSelected { + border-color: rgb(255 78 203 / 0.5); + background: rgb(255 78 203 / 0.15); +} + +.menuItemFlash { + background: rgb(255 78 203 / 0.32); +} + +.menuBadge { + width: 54px; + height: 54px; + flex: 0 0 54px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'Orbitron', var(--font-family-display); + font-size: 25px; + font-weight: 700; +} + +.badgeStart { + background: rgb(255 78 203 / 0.2); + color: var(--hud-pink); + border: 2px solid rgb(255 78 203 / 0.35); +} + +.badgeLeaderboard { + background: rgb(255 210 0 / 0.15); + color: var(--hud-gold); + border: 2px solid rgb(255 210 0 / 0.35); +} + +.badgeCredits { + background: rgb(0 200 255 / 0.17); + color: var(--hud-cyan); + border: 2px solid rgb(0 200 255 / 0.35); +} + +.menuBadge svg { + width: 24px; + height: 24px; +} + +.menuText { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + margin-left: 18px; + margin-right: 14px; +} + +.menuLabel { + font-family: 'Orbitron', var(--font-family-display); + font-size: 23px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #fff; +} + +.menuItemSelected .menuLabel { + color: var(--hud-pink); +} + +.menuSubLabel { + font-family: 'Exo 2', var(--font-family-ui); + font-size: 18px; + font-weight: 400; + letter-spacing: 0.14em; + color: rgb(255 255 255 / 0.62); +} + +.menuHint { + font-family: 'Exo 2', var(--font-family-ui); + font-size: 18px; + letter-spacing: 0.14em; + color: rgb(255 255 255 / 0.36); +} + +.menuItemSelected .menuHint { + color: rgb(255 255 255 / 0.6); +} + +.navHint { + display: flex; + align-items: center; + gap: 11px; + margin-top: 7px; +} + +.keyBadge { + padding: 4px 13px; + border-radius: 5px; + border: 2px solid rgb(255 255 255 / 0.28); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'Exo 2', var(--font-family-ui); + font-size: 20px; + color: rgb(255 255 255 / 0.66); +} + +.navHintText { + font-family: 'Exo 2', var(--font-family-ui); + font-size: 18px; + letter-spacing: 0.11em; + color: rgb(255 255 255 / 0.46); +} + +.eqRow { + display: flex; + align-items: flex-end; + gap: 5px; + height: 43px; +} + +.eqBar { + width: 11px; + border-radius: 3px 3px 0 0; + height: var(--eq-low); + animation: eqBounce var(--eq-duration) ease-in-out var(--eq-delay) infinite alternate; +} + +.eqBar:nth-child(7n + 1) { + background: #ff4ecb; +} + +.eqBar:nth-child(7n + 2) { + background: #ff8b3d; +} + +.eqBar:nth-child(7n + 3) { + background: #ffd200; +} + +.eqBar:nth-child(7n + 4) { + background: #5fef9c; +} + +.eqBar:nth-child(7n + 5) { + background: #00c8ff; +} + +.eqBar:nth-child(7n + 6) { + background: #8e5cff; +} + +.eqBar:nth-child(7n + 7) { + background: #ff4ecb; +} + +.centerPanel { + position: relative; + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + align-items: flex-end; + justify-content: center; +} + +.poseBadge { + position: absolute; + top: 0; + right: 0; + display: inline-flex; + align-items: center; + gap: 11px; + padding: 5px 14px; + border: 2px solid rgb(0 200 255 / 0.22); + border-radius: 5px; + background: rgb(0 0 0 / 0.45); + backdrop-filter: blur(12px); +} + +.poseDot { + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--hud-cyan); + animation: posePulse 0.95s ease-in-out infinite; +} + +.poseLabel { + font-family: 'Exo 2', var(--font-family-ui); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.31em; + text-transform: uppercase; + color: var(--hud-cyan); +} + +.avatarCanvas { + display: block; + width: min(360px, 35vw); + max-width: 360px; + height: auto; + margin-bottom: 7px; +} + +.rightPanel { + width: 274px; + min-width: 274px; + display: flex; + justify-content: flex-end; +} + +.scoreCard { + position: relative; + width: 100%; + align-self: flex-start; + padding: 22px 25px; + border-radius: 7px; + border: 2px solid rgb(0 200 255 / 0.22); + background: rgb(0 0 0 / 0.45); + backdrop-filter: blur(12px); + text-align: right; + overflow: hidden; +} + +.scoreCard::after { content: ''; position: absolute; - inset: 0; - z-index: 0; - background-image: - linear-gradient(to right, var(--home-grid-line) 1px, transparent 1px), - linear-gradient(to bottom, var(--home-grid-line) 1px, transparent 1px); - background-size: 64px 64px; - pointer-events: none; + top: 0; + left: -100%; + width: 100%; + height: 2px; + background: linear-gradient(90deg, transparent, #00c8ff, transparent); + animation: scoreScan 2.5s linear infinite; } -.vignette { +.scoreEyebrow { + margin: 0; + font-family: 'Exo 2', var(--font-family-ui); + font-size: 16px; + font-weight: 600; + letter-spacing: 0.54em; + text-transform: uppercase; + color: var(--hud-cyan); +} + +.scoreTitle { + margin: 11px 0 7px; + font-family: 'Orbitron', var(--font-family-display); + font-size: 16px; + letter-spacing: 0.36em; + color: rgb(255 255 255 / 0.38); +} + +.scoreValue { + margin: 0; + font-family: 'Orbitron', var(--font-family-display); + font-size: 36px; + font-weight: 900; + letter-spacing: 0.18em; + color: #fff; + text-shadow: 0 0 22px rgb(0 200 255 / 0.85); +} + +.scorePlayer { + margin: 11px 0 0; + font-family: 'Exo 2', var(--font-family-ui); + font-size: 18px; + font-weight: 700; + color: var(--hud-gold); +} + +.corner { position: absolute; - inset: 0; - z-index: 0; + width: 40px; + height: 40px; + border-style: solid; + border-color: var(--hud-pink); + opacity: 0.55; pointer-events: none; - background: radial-gradient(ellipse 90% 70% at 50% 18%, rgb(0 0 0 / 0.2) 0%, transparent 55%); + z-index: 20; } -/* Full-bleed perspective floor (same look as before); sits above page grid bg, under UI */ -.floorLayer { +.cornerTopLeft { + top: 25px; + left: 25px; + border-width: 4px 0 0 4px; +} + +.cornerTopRight { + top: 25px; + right: 25px; + border-width: 4px 4px 0 0; +} + +.cornerBottomLeft { + bottom: 25px; + left: 25px; + border-width: 0 0 4px 4px; +} + +.cornerBottomRight { + right: 25px; + bottom: 25px; + border-width: 0 4px 4px 0; +} + +.floor { position: absolute; left: 0; right: 0; bottom: 0; - height: min(58dvh, 58%); - z-index: 1; + height: 30vh; + perspective: 300px; + perspective-origin: 50% 0%; + z-index: 2; pointer-events: none; overflow: hidden; } @@ -77,260 +447,50 @@ .scanlines { position: absolute; inset: 0; - z-index: 2; + z-index: 1; pointer-events: none; - opacity: 0.32; background: repeating-linear-gradient( 0deg, transparent, - transparent 2px, - rgb(0 0 0 / 0.08) 2px, - rgb(0 0 0 / 0.08) 4px + transparent 3px, + rgb(0 0 0 / 0.045) 3px, + rgb(0 0 0 / 0.045) 4px ); } -.grid { - position: relative; - z-index: 3; - flex: 1; - min-height: 0; - display: grid; - width: 100%; - /* Wider menu column; right column is mostly stage + perspective floor */ - grid-template-columns: minmax(260px, min(520px, 50vw)) minmax(0, 1fr); - grid-template-rows: auto 1fr; - gap: clamp(0.55rem, 1.5vw, 1.1rem); - align-items: stretch; -} - -.titleBlock { - grid-column: 1; - grid-row: 1; - align-self: end; - padding-bottom: 0.45rem; -} - -.gameTitle { - margin: 0; - font-size: clamp(1.05rem, 2.4vw + 0.85rem, 2.65rem); - line-height: 1.2; - letter-spacing: 0.28em; - text-transform: uppercase; - color: #fff; - text-shadow: - 0 0 20px rgb(255 255 255 / 0.25), - 0 0 36px var(--home-magenta-soft), - 0 0 48px rgb(34 211 238 / 0.2); - animation: homeTitlePulse 2.8s ease-in-out infinite alternate; -} - -@keyframes homeTitlePulse { - from { - text-shadow: - 0 0 16px rgb(255 255 255 / 0.2), - 0 0 28px var(--home-magenta-soft); - } - to { - text-shadow: - 0 0 28px rgb(240 171 252 / 0.45), - 0 0 52px rgb(34 211 238 / 0.35); - } -} - -.hiScore { - grid-column: 2; - grid-row: 1; - justify-self: end; - align-self: start; - padding: 0.65rem 1rem 0.75rem; - border: 2px solid rgb(255 255 255 / 0.22); - border-radius: 4px; - background: var(--home-panel); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 0.06), - 0 0 24px var(--home-magenta-soft), - 0 4px 20px rgb(0 0 0 / 0.45); - text-decoration: none; - color: inherit; - cursor: pointer; - transition: filter 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; -} - -.hiScore:hover { - filter: brightness(1.12); - border-color: rgb(240 171 252 / 0.45); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 0.08), - 0 0 28px rgb(217 70 239 / 0.45), - 0 4px 22px rgb(0 0 0 / 0.5); -} - -.hiScore:focus-visible { - outline: 2px solid var(--menu-highlight); - outline-offset: 3px; -} - -.hiScoreLabel { - font-size: 0.45rem; - letter-spacing: 0.24em; - color: rgb(255 255 255 / 0.55); - margin-bottom: 0.45rem; -} - -.hiScoreValue { - font-size: clamp(0.75rem, 1.2vw + 0.35rem, 1.1rem); - letter-spacing: 0.06em; - color: var(--home-pink-glow); - text-shadow: 0 0 18px var(--home-magenta-soft); -} - -/* Menu shell: fixed vertical footprint (~50–60% viewport); buttons flex to fill it */ -.menu { - grid-column: 1; - grid-row: 2; - align-self: start; - display: flex; - flex-direction: column; - gap: clamp(8px, 1.2dvh, 12px); - width: 100%; - height: clamp(50dvh, 56dvh, 60dvh); - max-height: 60dvh; - min-height: 0; - box-sizing: border-box; - padding: clamp(12px, 1.6dvh, 16px) clamp(14px, 2vw, 20px); - border: 1px solid; - border-radius: 6px; - background: rgb(0 0 0 / 0.32); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 0.05), - 0 4px 20px rgb(0 0 0 / 0.28); -} - -.menuItem { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - margin: 0; - flex: 1 1 0; - min-height: 0; - padding: clamp(10px, 1.4dvh, 16px) clamp(18px, 2.6vw, 30px); - box-sizing: border-box; - border: 1px solid rgb(255 255 255 / 0.22); - border-radius: 8px; - background: rgb(0 0 0 / 0.28); - color: #ffffff; - font-size: clamp(1rem, 1.8vw + 0.55rem, 1.55rem); - font-weight: var(--font-weight-bold); - line-height: 1.12; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; - box-shadow: none; - transition: - border-color 0.18s ease, - background 0.18s ease, - filter 0.2s ease; -} - -.menuItem:not(.menuItemSelected):hover { - border-color: rgb(240 171 252 / 0.55); - background: rgb(217 70 239 / 0.28); - color: #ffffff; -} - -.menuItem:not(.menuItemSelected):active { - transform: scale(0.992); -} - -.menuItemSelected { - border: 1px solid transparent; - border-radius: 8px; - box-shadow: 0 6px 22px rgb(0 0 0 / 0.35); -} - -.menuItemSelected:hover { - filter: brightness(1.08); -} - -.menuItemSelected:active { - transform: scale(0.992); -} - -/* Caret out of flex flow so labels stay visually centered in the full button */ -.menuCaret { +.floorInner { position: absolute; - left: clamp(14px, 2.4vw, 26px); - top: 50%; - width: 1.1em; - font-size: 0.95em; - line-height: 1; - opacity: 0; - transform: translateY(-50%) translateX(-6px); - color: currentColor; + bottom: 0; + left: -50%; + right: -50%; + height: 100%; pointer-events: none; - transition: opacity 0.15s ease, transform 0.15s ease; -} - -.menuItemSelected .menuCaret { - opacity: 1; - transform: translateY(-50%) translateX(0); - animation: homeCaretNudge 0.75s ease-in-out infinite; -} - -@keyframes homeCaretNudge { - 0%, - 100% { - transform: translateY(-50%) translateX(0); - opacity: 1; - } - 50% { - transform: translateY(-50%) translateX(5px); - opacity: 0.65; - } -} - -.menuLabel { - display: block; - width: 100%; - max-width: 100%; - padding-inline: clamp(1.25rem, 3vw, 2rem); - box-sizing: border-box; - text-align: center; - transform-origin: center center; - transition: transform 0.22s ease; -} - -.menuItem:hover .menuLabel { - transform: scale(1.07); -} - -.menuItem:active .menuLabel { - transform: scale(1.03); -} - -/* Stage: avatar only; full-width floor lives in .floorLayer behind the grid */ -.stage { - grid-column: 2; - grid-row: 2; - position: relative; - min-height: 0; - overflow: visible; - border-radius: 4px; - background: transparent; -} - -.floorWrap { - position: absolute; - inset: 0; - pointer-events: none; - perspective: 780px; - perspective-origin: 50% 100%; + transform: rotateX(35deg); + transform-origin: bottom center; + background-color: rgb(20 10 38 / 0.84); + background-image: + linear-gradient(90deg, rgb(255 78 203 / 0.25) 1px, transparent 1px), + linear-gradient(0deg, rgb(255 78 203 / 0.25) 1px, transparent 1px); + background-size: 8vw 8vw; overflow: hidden; } -.floorVanish { +.floorInner::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + z-index: 4; + background: linear-gradient(90deg, transparent, #ff4ecb, #00c8ff, #ff4ecb, transparent); + box-shadow: + 0 0 18px 4px rgb(255 78 203 / 0.6), + 0 0 40px 8px rgb(255 78 203 / 0.25); +} + +.floorInner::after { + content: ''; position: absolute; inset: 0; z-index: 3; @@ -344,23 +504,10 @@ ); } -.floorPerspective { - position: absolute; - z-index: 1; - left: 50%; - bottom: 0; - width: 265%; - max-width: none; - height: 125%; - transform: translateX(-50%) rotateX(74deg); - transform-origin: center bottom; - transform-style: preserve-3d; -} - .floorGlow { position: absolute; inset: 0; - z-index: 0; + z-index: 1; background: radial-gradient(ellipse 70% 45% at 50% 100%, rgb(217 70 239 / 0.35), transparent 62%), radial-gradient(ellipse 50% 35% at 50% 88%, rgb(34 211 238 / 0.12), transparent 55%); @@ -370,6 +517,7 @@ .floorTiles { position: absolute; inset: 0; + z-index: 2; display: grid; gap: 4px; padding: 3% 2% 0; @@ -389,137 +537,6 @@ animation: homeTileGlow 1.8s ease-in-out infinite; } -.avatarDock { - position: absolute; - z-index: 4; - left: 50%; - bottom: clamp(10dvh, 12vw, 16dvh); - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 0.4rem; - width: min(200px, 42vw); - max-width: 210px; - padding: 0.45rem 0.5rem 0.55rem; - border: 1px solid rgb(255 255 255 / 0.2); - border-radius: 4px; - background: rgb(0 0 0 / 0.55); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 0.06), - 0 0 20px rgb(34 211 238 / 0.12), - 0 12px 28px rgb(0 0 0 / 0.5); -} - -.avatarFrame { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - gap: 0.28rem; - width: 100%; - padding: 0.35rem 0.25rem 0.15rem; - border: 1px dashed rgb(34 211 238 / 0.32); - border-radius: 6px; - background: radial-gradient(ellipse 80% 90% at 50% 100%, rgb(217 70 239 / 0.14), transparent 62%); -} - -/* Taller “figure” reads as standing on the floor plane */ -.avatarSilhouette { - width: min(52px, 14vw); - height: min(92px, 24vw); - border-radius: 42% 42% 40% 40% / 48% 48% 52% 52%; - background: - radial-gradient(ellipse 55% 35% at 50% 22%, rgb(255 255 255 / 0.18), transparent 52%), - linear-gradient(175deg, rgb(34 211 238 / 0.28), rgb(217 70 239 / 0.16)); - box-shadow: - 0 10px 22px rgb(0 0 0 / 0.45), - 0 0 18px rgb(34 211 238 / 0.2), - inset 0 -12px 18px rgb(0 0 0 / 0.35); - animation: homeAvatarPulse 2.4s ease-in-out infinite; -} - -@keyframes homeAvatarPulse { - 0%, - 100% { - transform: scale(1); - box-shadow: - 0 10px 22px rgb(0 0 0 / 0.45), - 0 0 18px rgb(34 211 238 / 0.2), - inset 0 -12px 18px rgb(0 0 0 / 0.35); - } - 50% { - transform: scale(1.03); - box-shadow: - 0 12px 26px rgb(0 0 0 / 0.5), - 0 0 26px rgb(240 171 252 / 0.35), - inset 0 -12px 18px rgb(0 0 0 / 0.35); - } -} - -.avatarCaption { - margin: 0; - font-size: 0.4rem; - letter-spacing: 0.18em; - color: var(--home-cyan); - text-shadow: 0 0 12px var(--home-cyan-glow); -} - -.avatarHint { - margin: 0; - max-width: 11rem; - font-size: 0.32rem; - line-height: 1.45; - letter-spacing: 0.05em; - color: rgb(255 255 255 / 0.42); - text-align: center; -} - -/* D-pad shell for future MediaPipe direction highlights */ -.dPad { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(3, 18px); - gap: 3px; - width: 100%; - max-width: 96px; - margin: 0 auto; - opacity: 0.85; -} - -.dPadCell { - border-radius: 2px; - border: 1px solid rgb(255 255 255 / 0.12); - background: rgb(0 0 0 / 0.35); - box-shadow: inset 0 0 12px rgb(34 211 238 / 0.06); -} - -.dPadCell[data-dir='up'] { - grid-column: 2; - grid-row: 1; -} - -.dPadCell[data-dir='left'] { - grid-column: 1; - grid-row: 2; -} - -.dPadCell[data-dir='center'] { - grid-column: 2; - grid-row: 2; - border-color: rgb(217 70 239 / 0.25); -} - -.dPadCell[data-dir='right'] { - grid-column: 3; - grid-row: 2; -} - -.dPadCell[data-dir='down'] { - grid-column: 2; - grid-row: 3; -} - @keyframes homeTileGlow { 0%, 100% { @@ -534,119 +551,98 @@ } } -.keyHint { - position: relative; - z-index: 4; - flex-shrink: 0; - margin: 0.35rem auto 0; - max-width: 42rem; - padding: 0.45rem 0.85rem; - border-radius: 999px; - border: 1px solid rgb(255 255 255 / 0.14); - background: rgb(0 0 0 / 0.5); - font-size: 0.38rem; - letter-spacing: 0.08em; - color: rgb(255 255 255 / 0.55); - text-align: center; +@keyframes eqBounce { + from { + height: var(--eq-low); + } + to { + height: var(--eq-high); + } } -.keyHintKeys { - color: var(--home-cyan); - text-shadow: 0 0 10px var(--home-cyan-glow); +@keyframes scoreScan { + from { + left: -100%; + } + to { + left: 100%; + } } -/* ——— Responsive: stack for narrow viewports ——— */ -@media (max-width: 820px) { - .grid { - grid-template-columns: 1fr auto; - grid-template-rows: auto auto minmax(min(46dvh, 320px), 1fr); +@keyframes posePulse { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.7; + } + 50% { + transform: scale(1.2); + opacity: 1; + } +} + +@media (max-width: 960px) { + .mainRow { + gap: 22px; } - .titleBlock { - grid-column: 1; - grid-row: 1; - align-self: end; + .leftPanel { + width: 378px; + min-width: 378px; } - .hiScore { - grid-column: 2; - grid-row: 1; - justify-self: end; - align-self: start; + .rightPanel { + width: 252px; + min-width: 252px; + } +} + +@media (max-width: 760px) { + .layout { + padding-bottom: max(14px, env(safe-area-inset-bottom, 0px)); } - .menu { - grid-column: 1 / -1; - grid-row: 2; + .mainRow { flex-direction: column; - height: clamp(48dvh, 54dvh, 58dvh); - max-height: 58dvh; + align-items: stretch; + gap: 18px; } - .menuItem { - flex: 1 1 0; - min-height: 0; - justify-content: center; - } - - .stage { - grid-column: 1 / -1; - grid-row: 3; - min-height: min(50dvh, 400px); - } - - .floorLayer .floorPerspective { - width: 310%; - height: 130%; - transform: translateX(-50%) rotateX(78deg); - } - - .avatarDock { - bottom: clamp(8dvh, 10vw, 14dvh); - } - - .dPad { - align-self: center; - margin: 0; - } -} - -@media (max-width: 520px) { - .menu { - height: clamp(46dvh, 52dvh, 56dvh); - max-height: 56dvh; - } - - .menuItem { - min-width: 0; + .leftPanel, + .rightPanel { width: 100%; - font-size: clamp(0.92rem, 4.2vw + 0.35rem, 1.35rem); + min-width: 0; } - .stage { - min-height: min(46dvh, 340px); + .rightPanel { + justify-content: flex-start; } - .floorLayer .floorPerspective { - width: 340%; + .scoreCard { + width: min(396px, 100%); } + + .centerPanel { + min-height: 378px; + justify-content: flex-end; + } + + .poseBadge { + right: auto; + left: 0; + } + } @media (prefers-reduced-motion: reduce) { - .gameTitle, - .menuItemSelected .menuCaret, - .avatarSilhouette, - .floorTile { + .eqBar, + .floorTile, + .scoreCard::after, + .poseDot { animation: none; } - .menuItem:hover .menuLabel, - .menuItem:active .menuLabel { - transform: none; - } - - .menuItem:not(.menuItemSelected):active, - .menuItemSelected:active { - transform: none; + .menuTriangle { + transition: none; } } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b9da957..540803b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,17 +1,206 @@ -import { type CSSProperties, useMemo } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { lighten, withAlpha } from '../features/song-select/color-utils' -import { ROUTE_PATHS } from '../app/route-paths' +import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' import { playSound, SFX_BUTTON_DEFAULT, SFX_BUTTON_START } from '../lib/sounds' import { useTopHighscore } from '../features/leaderboard/useTopHighscore' -import { MAIN_MENU_ENTRIES } from '../features/home-main-menu/main-menu-config' -import { useHomeMainMenu } from '../features/home-main-menu/useHomeMainMenu' +import { MAIN_MENU_ENTRIES, type MainMenuId } from '../features/home-main-menu/main-menu-config' import styles from './HomePage.module.css' -/** Same accent chain as EndOfGamePage + SongInfoBar SELECT. */ -const MENU_ACCENT_HEX = '#d946ef' +type JointKey = + | 'nose' + | 'lSh' + | 'rSh' + | 'lEl' + | 'rEl' + | 'lWr' + | 'rWr' + | 'lHp' + | 'rHp' + | 'lKn' + | 'rKn' + | 'lAn' + | 'rAn' + +type JointPoint = [number, number] +type Skeleton = Record + +type MenuMeta = { + label: string + subLabel: string + badge: string + badgeClass: string +} + +type PoseResults = { + poseLandmarks?: Array<{ x: number; y: number }> +} + +const BASE_SKELETON: Skeleton = { + nose: [0.5, 0.16], + lSh: [0.42, 0.26], + rSh: [0.58, 0.26], + lEl: [0.36, 0.36], + rEl: [0.64, 0.36], + lWr: [0.33, 0.5], + rWr: [0.67, 0.5], + lHp: [0.45, 0.48], + rHp: [0.55, 0.48], + lKn: [0.45, 0.66], + rKn: [0.56, 0.66], + lAn: [0.44, 0.86], + rAn: [0.57, 0.86], +} + +const MENU_META: Record = { + start: { label: 'START', subLabel: 'Begin Session', badge: '▶', badgeClass: styles.badgeStart }, + leaderboards: { + label: 'LEADERBOARD', + subLabel: 'Top Movers', + badge: '★', + badgeClass: styles.badgeLeaderboard, + }, + credits: { label: 'CREDITS', subLabel: 'Team + Tech', badge: '◇', badgeClass: styles.badgeCredits }, +} + +function cloneSkeleton(input: Skeleton): Skeleton { + return { + nose: [...input.nose], + lSh: [...input.lSh], + rSh: [...input.rSh], + lEl: [...input.lEl], + rEl: [...input.rEl], + lWr: [...input.lWr], + rWr: [...input.rWr], + lHp: [...input.lHp], + rHp: [...input.rHp], + lKn: [...input.lKn], + rKn: [...input.rKn], + lAn: [...input.lAn], + rAn: [...input.rAn], + } +} + +function animateIdleSkeleton(target: Skeleton, t: number) { + const swing = Math.sin(t) + const sway = Math.sin(t * 0.5) * 0.016 + const bob = Math.abs(Math.sin(t * 0.8)) * 0.011 + const rightPhase = t + Math.PI * 0.65 + + target.nose = [BASE_SKELETON.nose[0] + sway, BASE_SKELETON.nose[1] - bob] + target.lSh = [BASE_SKELETON.lSh[0] + sway, BASE_SKELETON.lSh[1] - bob] + target.rSh = [BASE_SKELETON.rSh[0] + sway, BASE_SKELETON.rSh[1] - bob] + target.lHp = [BASE_SKELETON.lHp[0] + sway, BASE_SKELETON.lHp[1] - bob] + target.rHp = [BASE_SKELETON.rHp[0] + sway, BASE_SKELETON.rHp[1] - bob] + + target.lEl = [BASE_SKELETON.lEl[0] + swing * 0.024 + sway, BASE_SKELETON.lEl[1] + swing * 0.038 - bob] + target.rEl = [ + BASE_SKELETON.rEl[0] + Math.sin(rightPhase) * 0.024 + sway, + BASE_SKELETON.rEl[1] + Math.sin(rightPhase) * 0.038 - bob, + ] + + target.lWr = [ + BASE_SKELETON.lWr[0] + Math.sin(t + 0.35) * 0.06 + sway, + BASE_SKELETON.lWr[1] + swing * 0.05 - bob, + ] + target.rWr = [ + BASE_SKELETON.rWr[0] + Math.sin(rightPhase + 0.35) * 0.06 + sway, + BASE_SKELETON.rWr[1] + Math.sin(rightPhase) * 0.05 - bob, + ] + + target.lKn = [BASE_SKELETON.lKn[0] + sway * 0.65, BASE_SKELETON.lKn[1] - bob * 0.5] + target.rKn = [BASE_SKELETON.rKn[0] + sway * 0.65, BASE_SKELETON.rKn[1] - bob * 0.5] + target.lAn = [BASE_SKELETON.lAn[0] + sway * 0.45, BASE_SKELETON.lAn[1] - bob * 0.35] + target.rAn = [BASE_SKELETON.rAn[0] + sway * 0.45, BASE_SKELETON.rAn[1] - bob * 0.35] +} + +function drawSkeleton(ctx: CanvasRenderingContext2D, skeleton: Skeleton, width: number, height: number) { + const p = (key: JointKey): JointPoint => [skeleton[key][0] * width, skeleton[key][1] * height] + const [lShX, lShY] = p('lSh') + const [rShX, rShY] = p('rSh') + const [lHpX, lHpY] = p('lHp') + const [rHpX, rHpY] = p('rHp') + const midSh: JointPoint = [(lShX + rShX) / 2, (lShY + rShY) / 2] + const midHp: JointPoint = [(lHpX + rHpX) / 2, (lHpY + rHpY) / 2] + const [noseX, noseY] = p('nose') + + const line = (a: JointPoint, b: JointPoint, color: string, widthPx: number, alpha = 1) => { + ctx.save() + ctx.strokeStyle = color + ctx.globalAlpha = alpha + ctx.lineWidth = widthPx + ctx.lineCap = 'round' + ctx.beginPath() + ctx.moveTo(a[0], a[1]) + ctx.lineTo(b[0], b[1]) + ctx.stroke() + ctx.restore() + } + + const dot = (point: JointPoint, radius: number, fill: string, alpha = 1) => { + ctx.save() + ctx.globalAlpha = alpha + ctx.fillStyle = fill + ctx.beginPath() + ctx.arc(point[0], point[1], radius, 0, Math.PI * 2) + ctx.fill() + ctx.restore() + } + + ctx.clearRect(0, 0, width, height) + + ctx.save() + ctx.globalAlpha = 0.22 + ctx.filter = 'blur(9px)' + line(p('lSh'), p('lEl'), '#ff4ecb', 18) + line(p('lEl'), p('lWr'), '#ff4ecb', 18) + line(p('rSh'), p('rEl'), '#00c8ff', 18) + line(p('rEl'), p('rWr'), '#00c8ff', 18) + ctx.restore() + + line(p('lSh'), p('lEl'), '#ff4ecb', 4.5) + line(p('lEl'), p('lWr'), '#ff4ecb', 3.6) + line(p('rSh'), p('rEl'), '#00c8ff', 4.5) + line(p('rEl'), p('rWr'), '#00c8ff', 3.6) + + line(midSh, midHp, '#ffffff', 3.6, 0.65) + line(p('lSh'), p('rSh'), '#ffffff', 4.5, 0.88) + line(p('lHp'), p('rHp'), '#ffffff', 3.6, 0.74) + line(p('lSh'), p('lHp'), '#ffffff', 2.7, 0.18) + line(p('rSh'), p('rHp'), '#ffffff', 2.7, 0.18) + + line(p('lHp'), p('lKn'), 'rgba(255,255,255,0.22)', 4) + line(p('lKn'), p('lAn'), 'rgba(255,255,255,0.22)', 3.6) + line(p('rHp'), p('rKn'), 'rgba(255,255,255,0.22)', 4) + line(p('rKn'), p('rAn'), 'rgba(255,255,255,0.22)', 3.6) + + ctx.save() + ctx.strokeStyle = '#ffffff' + ctx.lineWidth = 3.6 + ctx.fillStyle = 'rgba(255,255,255,0.07)' + ctx.beginPath() + ctx.arc(noseX, noseY + 18, 23, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + ctx.beginPath() + ctx.arc(noseX, noseY + 18, 20, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() + + dot(p('lSh'), 7.2, '#ffffff') + dot(p('rSh'), 7.2, '#ffffff') + dot(p('lHp'), 7.2, '#ffffff') + dot(p('rHp'), 7.2, '#ffffff') + dot(p('lKn'), 5.4, '#ffffff', 0.3) + dot(p('rKn'), 5.4, '#ffffff', 0.3) + dot(p('lAn'), 5.4, '#ffffff', 0.3) + dot(p('rAn'), 5.4, '#ffffff', 0.3) + dot(p('lEl'), 9, '#ff4ecb') + dot(p('rEl'), 9, '#00c8ff') + dot(p('lWr'), 10.8, '#ff4ecb') + dot(p('rWr'), 10.8, '#00c8ff') + dot(p('lWr'), 18, 'rgba(255,78,203,0.2)') + dot(p('rWr'), 18, 'rgba(0,200,255,0.2)') +} -/** Floor grid cell count (columns × rows) for the perspective dance floor. */ function useDanceFloorGrid() { return useMemo(() => { if (typeof window === 'undefined') return { cols: 18, rows: 8 } @@ -27,122 +216,223 @@ function useDanceFloorGrid() { */ export function HomePage() { const navigate = useNavigate() - const { selectedIndex, setSelectedIndex } = useHomeMainMenu() - const { topScore, loading: hiScoreLoading } = useTopHighscore() + const [selectedIndex, setSelectedIndex] = useState(0) + const [flashIndex, setFlashIndex] = useState(null) + const [indicatorTop, setIndicatorTop] = useState(0) + const menuRef = useRef(null) + const menuItemRefs = useRef>([]) + const canvasRef = useRef(null) + const skeletonRef = useRef(cloneSkeleton(BASE_SKELETON)) + const hasLivePoseRef = useRef(false) + const rafRef = useRef(null) + const timeRef = useRef(0) + const { topScore, topName: topNameFromDB, loading: hiScoreLoading } = useTopHighscore() const { cols: floorCols, rows: floorRows } = useDanceFloorGrid() const floorTileCount = floorCols * floorRows - const menuPanelStyle = useMemo( - () => - ({ - backgroundColor: withAlpha(MENU_ACCENT_HEX, 0.22), - borderColor: withAlpha(MENU_ACCENT_HEX, 0.55), - }) as CSSProperties, - [], + // Rainbow EQ bars removed + + const updateIndicator = useCallback(() => { + const wrapper = menuRef.current + const selected = menuItemRefs.current[selectedIndex] + if (!wrapper || !selected) return + const wrapperRect = wrapper.getBoundingClientRect() + const itemRect = selected.getBoundingClientRect() + setIndicatorTop(itemRect.top - wrapperRect.top + itemRect.height / 2) + }, [selectedIndex]) + + const activateSelection = useCallback( + (index: number) => { + const item = MAIN_MENU_ENTRIES[index] + if (!item) return + playSound(index === 0 ? SFX_BUTTON_START : SFX_BUTTON_DEFAULT) + setFlashIndex(index) + window.setTimeout(() => { + setFlashIndex(null) + navigate(item.to) + }, 110) + }, + [navigate], ) - const selectedMenuItemStyle = useMemo( - () => - ({ - backgroundColor: lighten(MENU_ACCENT_HEX, 0.38), - color: MENU_ACCENT_HEX, - }) as CSSProperties, - [], - ) + useEffect(() => { + updateIndicator() + }, [updateIndicator]) + + useEffect(() => { + const onResize = () => updateIndicator() + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [updateIndicator]) + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex((prev) => (prev + 1) % MAIN_MENU_ENTRIES.length) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex((prev) => (prev - 1 + MAIN_MENU_ENTRIES.length) % MAIN_MENU_ENTRIES.length) + return + } + if (event.key === 'Enter') { + event.preventDefault() + activateSelection(selectedIndex) + } + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [activateSelection, selectedIndex]) + + useEffect(() => { + const onPoseResults = (results: PoseResults) => { + const lm = results.poseLandmarks + if (!lm || lm.length < 29) return + hasLivePoseRef.current = true + skeletonRef.current.nose = [lm[0].x, lm[0].y] + skeletonRef.current.lSh = [lm[11].x, lm[11].y] + skeletonRef.current.rSh = [lm[12].x, lm[12].y] + skeletonRef.current.lEl = [lm[13].x, lm[13].y] + skeletonRef.current.rEl = [lm[14].x, lm[14].y] + skeletonRef.current.lWr = [lm[15].x, lm[15].y] + skeletonRef.current.rWr = [lm[16].x, lm[16].y] + skeletonRef.current.lHp = [lm[23].x, lm[23].y] + skeletonRef.current.rHp = [lm[24].x, lm[24].y] + skeletonRef.current.lKn = [lm[25].x, lm[25].y] + skeletonRef.current.rKn = [lm[26].x, lm[26].y] + skeletonRef.current.lAn = [lm[27].x, lm[27].y] + skeletonRef.current.rAn = [lm[28].x, lm[28].y] + } + + const host = window as Window & { onGrooveQuestPoseResults?: (results: PoseResults) => void } + host.onGrooveQuestPoseResults = onPoseResults + + return () => { + if (host.onGrooveQuestPoseResults === onPoseResults) { + delete host.onGrooveQuestPoseResults + } + } + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const tick = () => { + if (!hasLivePoseRef.current) { + timeRef.current += 0.028 + animateIdleSkeleton(skeletonRef.current, timeRef.current) + } + drawSkeleton(ctx, skeletonRef.current, canvas.width, canvas.height) + rafRef.current = window.requestAnimationFrame(tick) + } + + rafRef.current = window.requestAnimationFrame(tick) + return () => { + if (rafRef.current != null) { + window.cancelAnimationFrame(rafRef.current) + } + } + }, []) return (
-
-
-
+
+
+
-
-
- {Array.from({ length: floorTileCount }, (_, i) => ( -
- ))} -
+
+ {Array.from({ length: floorTileCount }, (_, i) => ( +
+ ))}
-
+
+
+
+
-
-
-

VIBESTEP

-
+
+
+ + +
+ {/* Mediapipe ready badge removed */} +
-
-
-

- UP / DOWN move · ENTER select -

+ + +
) -} \ No newline at end of file +}