Feature/new homepage (#9)
* half working main menu * MVP homepage, need to make small fixes * Final homescreenmain
parent
4a2ae5ec6c
commit
088fb76160
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue