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:
L'électron rare
2026-07-09 11:50:24 +02:00
parent f424026117
commit fc5621bd24
5 changed files with 116 additions and 0 deletions
+4
View File
@@ -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>
)
}
+8
View File
@@ -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
}
+42
View File
@@ -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" />
}
+39
View File
@@ -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>
)
}
+23
View File
@@ -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; }