feat: add sine scroller and tracker panel hud
Implement Task 10: HUD components for LE BUS demoscene page. - Scroller.tsx: canvas-2D sine-wave text animation at bottom - TrackerPanel.tsx: play/mute control panel (top-right) - modPlayer.ts: interface stubs (ModState, ModPlayer) - CSS styling for both components appended to style.css
This commit is contained in:
@@ -6,6 +6,8 @@ import { Passengers } from './scene/Passengers'
|
||||
import { Sky } from './scene/Sky'
|
||||
import { Stops } from './scene/stops/Stops'
|
||||
import { useRideInput } from './ride/useRideInput'
|
||||
import { Scroller } from './hud/Scroller'
|
||||
import { TrackerPanel } from './hud/TrackerPanel'
|
||||
|
||||
export default function App() {
|
||||
useRideInput()
|
||||
@@ -26,6 +28,8 @@ export default function App() {
|
||||
<Sky />
|
||||
<Stops />
|
||||
</Canvas>
|
||||
<Scroller />
|
||||
<TrackerPanel player={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ModState = 'idle' | 'loading' | 'playing' | 'paused' | 'unavailable'
|
||||
|
||||
export interface ModPlayer {
|
||||
state: ModState
|
||||
toggle(): Promise<ModState>
|
||||
setMuted(muted: boolean): void
|
||||
onChange(fn: (s: ModState) => void): void
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { SCROLLER_TEXT } from '../content/greetings'
|
||||
|
||||
const CHAR_W = 26
|
||||
const SPEED = 2.2
|
||||
|
||||
export function Scroller() {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
useEffect(() => {
|
||||
const cv = ref.current!
|
||||
const ctx = cv.getContext('2d')!
|
||||
let raf = 0
|
||||
let x = window.innerWidth
|
||||
const resize = () => {
|
||||
cv.width = window.innerWidth
|
||||
cv.height = 72
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
const total = SCROLLER_TEXT.length * CHAR_W
|
||||
const loop = (now: number) => {
|
||||
ctx.clearRect(0, 0, cv.width, cv.height)
|
||||
ctx.font = 'bold 40px "Courier New", monospace'
|
||||
ctx.fillStyle = '#ffd23f'
|
||||
x -= SPEED
|
||||
if (x < -total) x = window.innerWidth
|
||||
for (let i = 0; i < SCROLLER_TEXT.length; i++) {
|
||||
const cx = x + i * CHAR_W
|
||||
if (cx < -CHAR_W || cx > cv.width) continue
|
||||
const cy = 42 + Math.sin(cx * 0.018 + now * 0.004) * 14
|
||||
ctx.fillText(SCROLLER_TEXT[i], cx, cy)
|
||||
}
|
||||
raf = requestAnimationFrame(loop)
|
||||
}
|
||||
raf = requestAnimationFrame(loop)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}, [])
|
||||
return <canvas id="scroller" ref={ref} aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ModPlayer, ModState } from '../audio/modPlayer'
|
||||
|
||||
export function TrackerPanel({ player }: { player: ModPlayer | null }) {
|
||||
const [state, setState] = useState<ModState>(player ? player.state : 'unavailable')
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (player) player.onChange(setState)
|
||||
}, [player])
|
||||
|
||||
const label =
|
||||
state === 'playing' ? '⏸' : state === 'loading' ? '…' : '▶'
|
||||
const status =
|
||||
state === 'unavailable' ? 'NO MOD' : state === 'loading' ? 'LOADING' : 'RIDE.MOD'
|
||||
|
||||
return (
|
||||
<div id="tracker" role="group" aria-label="Musique tracker">
|
||||
<button
|
||||
disabled={!player || state === 'unavailable' || state === 'loading'}
|
||||
onClick={() => player?.toggle()}
|
||||
aria-label="Lecture / pause du module"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<button
|
||||
disabled={!player || state === 'unavailable'}
|
||||
onClick={() => {
|
||||
player?.setMuted(!muted)
|
||||
setMuted(!muted)
|
||||
}}
|
||||
aria-label="Couper le son"
|
||||
>
|
||||
{muted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<span className="status">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -64,3 +64,26 @@ body.demo-on #fallback {
|
||||
}
|
||||
.panel a.sign:hover, .panel button.sign:hover { background: #ffd23f; color: #05010d; }
|
||||
.panel ul { margin: 6px 0; padding-left: 18px; font-size: 0.8rem; }
|
||||
|
||||
#scroller { position: fixed; left: 0; bottom: 0; width: 100%; height: 72px; pointer-events: none; }
|
||||
#tracker {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
background: rgba(5, 1, 13, 0.85);
|
||||
border: 3px solid #ffd23f;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
#tracker button {
|
||||
background: none;
|
||||
border: 2px solid #ffd23f;
|
||||
color: #ffd23f;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
min-width: 2.2rem;
|
||||
}
|
||||
#tracker button:disabled { opacity: 0.4; cursor: default; }
|
||||
#tracker .status { font-size: 0.7rem; color: #9be564; }
|
||||
|
||||
Reference in New Issue
Block a user