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 { Sky } from './scene/Sky'
|
||||||
import { Stops } from './scene/stops/Stops'
|
import { Stops } from './scene/stops/Stops'
|
||||||
import { useRideInput } from './ride/useRideInput'
|
import { useRideInput } from './ride/useRideInput'
|
||||||
|
import { Scroller } from './hud/Scroller'
|
||||||
|
import { TrackerPanel } from './hud/TrackerPanel'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useRideInput()
|
useRideInput()
|
||||||
@@ -26,6 +28,8 @@ export default function App() {
|
|||||||
<Sky />
|
<Sky />
|
||||||
<Stops />
|
<Stops />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
<Scroller />
|
||||||
|
<TrackerPanel player={null} />
|
||||||
</div>
|
</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 a.sign:hover, .panel button.sign:hover { background: #ffd23f; color: #05010d; }
|
||||||
.panel ul { margin: 6px 0; padding-left: 18px; font-size: 0.8rem; }
|
.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