Feature/new homepage (#9)

* half working main menu

* MVP homepage, need to make small fixes

* Final homescreen
main
Max 2026-04-12 00:51:38 -07:00 committed by GitHub
parent 4a2ae5ec6c
commit 088fb76160
5 changed files with 930 additions and 635 deletions

View File

@ -10,7 +10,7 @@
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VIBESTEP</title>
<title>HACK HACK REVOLUTION</title>
</head>
<body>
<div id="root"></div>

View File

@ -6,6 +6,7 @@ import { supabase } from '../../lib/supabaseClient'
*/
export function useTopHighscore() {
const [topScore, setTopScore] = useState<number | null>(null)
const [topName, setTopName] = useState<string | null>(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 }
}

View File

@ -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 {

File diff suppressed because it is too large Load Diff

View File

@ -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<JointKey, JointPoint>
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<MainMenuId, MenuMeta> = {
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<number | null>(null)
const [indicatorTop, setIndicatorTop] = useState(0)
const menuRef = useRef<HTMLDivElement | null>(null)
const menuItemRefs = useRef<Array<HTMLButtonElement | null>>([])
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const skeletonRef = useRef<Skeleton>(cloneSkeleton(BASE_SKELETON))
const hasLivePoseRef = useRef(false)
const rafRef = useRef<number | null>(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 (
<div className={styles.page}>
<div className={styles.vignette} aria-hidden />
<div className={styles.floorLayer} aria-hidden>
<div className={styles.floorWrap}>
<div className={styles.backdrop} aria-hidden />
<div className={styles.floor} aria-hidden>
<div className={styles.floorInner}>
<div className={styles.floorGlow} />
<div className={styles.floorPerspective}>
<div
className={styles.floorTiles}
style={{
gridTemplateColumns: `repeat(${floorCols}, 1fr)`,
gridTemplateRows: `repeat(${floorRows}, 1fr)`,
}}
>
{Array.from({ length: floorTileCount }, (_, i) => (
<div
key={i}
className={styles.floorTile}
style={{ animationDelay: `${(i % 7) * 0.12}s` }}
/>
))}
</div>
<div
className={styles.floorTiles}
style={{
gridTemplateColumns: `repeat(${floorCols}, 1fr)`,
gridTemplateRows: `repeat(${floorRows}, 1fr)`,
}}
>
{Array.from({ length: floorTileCount }, (_, i) => (
<div
key={i}
className={styles.floorTile}
style={{ animationDelay: `${(i % 7) * 0.12}s` }}
/>
))}
</div>
<div className={styles.floorVanish} />
</div>
</div>
<div className={styles.scanlines} aria-hidden />
<div className={`${styles.corner} ${styles.cornerTopLeft}`} aria-hidden />
<div className={`${styles.corner} ${styles.cornerTopRight}`} aria-hidden />
<div className={`${styles.corner} ${styles.cornerBottomLeft}`} aria-hidden />
<div className={`${styles.corner} ${styles.cornerBottomRight}`} aria-hidden />
<div className={styles.grid}>
<header className={styles.titleBlock}>
<h1 className={styles.gameTitle}>VIBESTEP</h1>
</header>
<div className={styles.layout}>
<main className={styles.mainRow}>
<aside className={styles.leftPanel}>
<header className={styles.titleBlock}>
<h1 className={styles.gameTitle}>HACK HACK REVOLUTION</h1>
</header>
<Link
className={styles.hiScore}
to={ROUTE_PATHS.leaderboard}
aria-label="Open leaderboard. Global high score."
>
<div className={styles.hiScoreLabel}>HI SCORE</div>
<div className={styles.hiScoreValue}>
{hiScoreLoading ? '···' : topScore != null ? topScore.toLocaleString() : '—'}
</div>
</Link>
<nav className={styles.menu} style={menuPanelStyle} aria-label="Main menu">
{MAIN_MENU_ENTRIES.map((entry, index) => {
const selected = index === selectedIndex
const isStart = index === 0
return (
<button
key={entry.id}
type="button"
className={`${styles.menuItem} font-title${selected ? ` ${styles.menuItemSelected}` : ''}`}
style={selected ? selectedMenuItemStyle : undefined}
aria-current={selected ? 'true' : undefined}
onClick={() => {
playSound(isStart ? SFX_BUTTON_START : SFX_BUTTON_DEFAULT)
setSelectedIndex(index)
navigate(entry.to)
}}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={styles.menuCaret} aria-hidden>
</span>
<span className={styles.menuLabel}>{entry.label}</span>
</button>
)
})}
</nav>
<div className={styles.stage}>
<section className={styles.avatarDock} aria-label="Player preview">
<div className={styles.avatarFrame}>
<div className={styles.avatarSilhouette} />
<p className={styles.avatarCaption}>PLAYER</p>
<p className={styles.avatarHint}>Pose preview (MediaPipe) goes here</p>
<div className={styles.menuWrap} ref={menuRef}>
<span className={styles.menuTriangle} style={{ top: `${indicatorTop}px` }} aria-hidden />
<nav className={styles.menu} aria-label="Main menu">
{MAIN_MENU_ENTRIES.map((entry, index) => {
const meta = MENU_META[entry.id]
const selected = selectedIndex === index
const flashed = flashIndex === index
return (
<button
key={entry.id}
type="button"
ref={(node) => {
menuItemRefs.current[index] = node
}}
className={`${styles.menuItem}${selected ? ` ${styles.menuItemSelected}` : ''}${flashed ? ` ${styles.menuItemFlash}` : ''}`}
onClick={() => setSelectedIndex(index)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={`${styles.menuBadge} ${meta.badgeClass}`} aria-hidden>
{meta.badge}
</span>
<span className={styles.menuText}>
<span className={styles.menuLabel}>{meta.label}</span>
<span className={styles.menuSubLabel}>{meta.subLabel}</span>
</span>
</button>
)
})}
</nav>
</div>
<div className={styles.dPad} aria-hidden>
<span className={styles.dPadCell} data-dir="up" />
<span className={styles.dPadCell} data-dir="left" />
<span className={styles.dPadCell} data-dir="center" />
<span className={styles.dPadCell} data-dir="right" />
<span className={styles.dPadCell} data-dir="down" />
<div className={styles.navHint} aria-hidden>
<span className={styles.keyBadge}></span>
<span className={styles.keyBadge}></span>
<span className={styles.navHintText}>navigate · ENTER select</span>
</div>
{/* Rainbow EQ bars removed */}
</aside>
<section className={styles.centerPanel} aria-label="Avatar preview">
{/* Mediapipe ready badge removed */}
<canvas ref={canvasRef} className={styles.avatarCanvas} width={360} height={612} />
</section>
</div>
</div>
<p className={styles.keyHint}>
<span className={styles.keyHintKeys}>UP / DOWN</span> move · <span className={styles.keyHintKeys}>ENTER</span> select
</p>
<aside className={styles.rightPanel}>
<article className={styles.scoreCard}>
<p className={styles.scoreEyebrow}>🌍 Global</p>
<p className={styles.scoreTitle}>HI SCORE</p>
<p className={styles.scoreValue}>
{hiScoreLoading ? '···' : topScore != null ? topScore.toLocaleString() : '—'}
</p>
<p className={styles.scorePlayer}> {topNameFromDB ?? 'ACE_DANCER'}</p>
</article>
</aside>
</main>
</div>
</div>
)
}
}