refactor: add common controller interface

main
John Montagu, the 4th Earl of Sandvich 2026-04-11 12:52:55 -07:00
parent 212bda8d5e
commit 07e5d4ee4d
7 changed files with 98 additions and 14 deletions

View File

@ -1,7 +1,20 @@
import { AppRoutes } from './app/AppRoutes'
import { useKeyboardInput } from './input/useKeyboardInput'
import { usePoseInput } from './input/usePoseInput'
function InputControllers() {
useKeyboardInput()
usePoseInput()
return null
}
function App() {
return <AppRoutes />
return (
<>
<InputControllers />
<AppRoutes />
</>
)
}
export default App

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import type { CSSProperties } from 'react'
import { useGameInput } from '../../input/useGameInput'
import { ARHud } from './ARHud'
import { SONGS } from './song-data'
import { SongCarousel } from './SongCarousel'
@ -14,21 +15,21 @@ function wrapIndex(index: number, length: number): number {
export function SongSelectScreen() {
const [currentIndex, setCurrentIndex] = useState(0)
const activeSong = SONGS[currentIndex]
const handleConfirm = () => {}
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft') {
useGameInput((action) => {
if (action === 'LEFT') {
setCurrentIndex((prev) => wrapIndex(prev - 1, SONGS.length))
}
if (event.key === 'ArrowRight') {
setCurrentIndex((prev) => wrapIndex(prev + 1, SONGS.length))
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
if (action === 'RIGHT') {
setCurrentIndex((prev) => wrapIndex(prev + 1, SONGS.length))
}
if (action === 'BUTTON_A') {
handleConfirm()
}
})
const cssVars = useMemo(
() =>
@ -60,7 +61,7 @@ export function SongSelectScreen() {
<SongInfoBar
song={activeSong}
onConfirm={() => {}}
onConfirm={handleConfirm}
/>
</div>
</main>

View File

@ -0,0 +1 @@
export type GameAction = 'LEFT' | 'RIGHT' | 'UP' | 'DOWN' | 'BUTTON_A' | 'BUTTON_B'

View File

@ -0,0 +1,16 @@
import type { GameAction } from './actions'
type GameActionListener = (action: GameAction) => void
const listeners = new Set<GameActionListener>()
export function emit(action: GameAction): void {
listeners.forEach((listener) => listener(action))
}
export function subscribe(listener: GameActionListener): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}

View File

@ -0,0 +1,7 @@
import { useEffect } from 'react'
import type { GameAction } from './actions'
import { subscribe } from './inputBus'
export function useGameInput(onAction: (action: GameAction) => void): void {
useEffect(() => subscribe(onAction), [onAction])
}

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react'
import type { GameAction } from './actions'
import { emit } from './inputBus'
const KEY_TO_ACTION: Partial<Record<string, GameAction>> = {
ArrowLeft: 'LEFT',
ArrowRight: 'RIGHT',
ArrowUp: 'UP',
ArrowDown: 'DOWN',
Enter: 'BUTTON_A',
Escape: 'BUTTON_B',
}
export function useKeyboardInput(): void {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const action = KEY_TO_ACTION[event.key]
if (!action) return
emit(action)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [])
}

View File

@ -0,0 +1,21 @@
import { useEffect } from 'react'
import { emit } from './inputBus'
export function usePoseInput(): void {
useEffect(() => {
const emitAction = (action: Parameters<typeof emit>[0]) => {
emit(action)
}
// MediaPipe integration point:
// Convert detected pose/gesture events into GameAction values and call `emitAction(action)`.
// Example mapping idea: swipe-left -> 'LEFT', swipe-right -> 'RIGHT',
// hand-raise -> 'BUTTON_A', cancel gesture -> 'BUTTON_B'.
// Keep all MediaPipe-specific setup/teardown in this hook so UI components remain input-agnostic.
void emitAction
return () => {
// Cleanup MediaPipe resources/listeners here when integration is added.
}
}, [])
}