feat: add the four content stops in 3d

Context: the ride passed 4 stops (boot, identities, electron
fou, greetings) but rendered no content at any of them.

Approach: shared StopPanel component places a pole, a yellow
sign with the stop title, and a drei Html transform panel
anchored beside the road via stopAnchor(); each stop composes
it with its own content module.

Changes:
- StopPanel.tsx: pole + sign + Html panel, panel--active class
  driven by useRide selector on the active stop id
- StopBoot.tsx: avatar art, name, tagline, bio lines
- StopIdentities.tsx: identity links list
- StopElectronFou.tsx: music blurb + pulsing tube amp with VU
  needles animated via useFrame
- StopGreetings.tsx: greetings list + ride-again reset button
- Stops.tsx: mounts the four stop components
- style.css: .panel styling, active glow, sign buttons/links
- App.tsx: mount <Stops /> in the Canvas, drop the placeholder

Impact: the ride now has readable, interactive content at every
stop; build (tsc -b + vite build) and full vitest suite (12/12)
pass.
This commit is contained in:
L'électron rare
2026-07-09 11:43:06 +02:00
parent 2c4a4bd168
commit f424026117
8 changed files with 214 additions and 1 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { World } from './scene/World'
import { Bus } from './scene/Bus'
import { Passengers } from './scene/Passengers'
import { Sky } from './scene/Sky'
import { Stops } from './scene/stops/Stops'
import { useRideInput } from './ride/useRideInput'
export default function App() {
@@ -23,7 +24,7 @@ export default function App() {
<Bus />
<Passengers />
<Sky />
{/* Stops (Task 9) mount here */}
<Stops />
</Canvas>
</div>
)
+22
View File
@@ -0,0 +1,22 @@
import { StopPanel } from './StopPanel'
import { STOPS } from '../../content/stops'
import { BIO_LINES, NAME, TAGLINE } from '../../content/bio'
const AVATAR = String.raw`
.--.
( ⚡ ) >_ clément
'--'
`
export function StopBoot() {
return (
<StopPanel stop={STOPS[0]}>
<pre className="avatar">{AVATAR}</pre>
<h2>{NAME}</h2>
<p className="muted">{TAGLINE}</p>
{BIO_LINES.map((l) => (
<p key={l}>{l}</p>
))}
</StopPanel>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { Mesh, MeshStandardMaterial } from 'three'
import { StopPanel, stopAnchor } from './StopPanel'
import { STOPS } from '../../content/stops'
import { MUSIC } from '../../content/music'
export function StopElectronFou() {
const tubes = useRef<Array<Mesh | null>>([])
const needles = useRef<Array<Mesh | null>>([])
const ampPos = stopAnchor(STOPS[2], 12)
useFrame((state) => {
const time = state.clock.elapsedTime
tubes.current.forEach((m, i) => {
if (!m) return
const mat = m.material as MeshStandardMaterial
mat.emissiveIntensity = 1.4 + Math.sin(time * 4 + i * 1.7) * 0.9
})
needles.current.forEach((m, i) => {
if (m) m.rotation.z = Math.sin(time * 6 + i) * 0.5
})
})
return (
<>
<StopPanel stop={STOPS[2]}>
<h2>{MUSIC.title}</h2>
<p>{MUSIC.blurb}</p>
<a className="sign" href={MUSIC.listenUrl}> {MUSIC.listenLabel}</a>
</StopPanel>
{/* the giant tube amp behind the panel */}
<group position={ampPos}>
<mesh position={[0, 2.2, 0]}>
<boxGeometry args={[9, 4.4, 3.2]} />
<meshStandardMaterial color="#17171c" roughness={0.5} />
</mesh>
<mesh position={[0, 2.2, 1.65]}>
<boxGeometry args={[8.2, 3.4, 0.1]} />
<meshStandardMaterial color="#3b2c1a" roughness={0.9} />
</mesh>
{[-3.2, -1.1, 1.1, 3.2].map((x, i) => (
<mesh key={i} ref={(el) => { tubes.current[i] = el }} position={[x, 5.2, 0]}>
<cylinderGeometry args={[0.5, 0.6, 1.8, 10]} />
<meshStandardMaterial color="#331100" emissive="#ff5c00" emissiveIntensity={1.4} transparent opacity={0.92} />
</mesh>
))}
{[-2, 2].map((x, i) => (
<group key={i} position={[x, 2.2, 1.75]}>
<mesh>
<boxGeometry args={[1.6, 1, 0.05]} />
<meshStandardMaterial color="#f5e6c8" emissive="#4a4230" />
</mesh>
<mesh ref={(el) => { needles.current[i] = el }} position={[0, -0.2, 0.05]}>
<boxGeometry args={[0.05, 0.7, 0.02]} />
<meshStandardMaterial color="#e03131" />
</mesh>
</group>
))}
</group>
</>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { StopPanel } from './StopPanel'
import { STOPS } from '../../content/stops'
import { GREETINGS } from '../../content/greetings'
import { useRide } from '../../ride/store'
export function StopGreetings() {
return (
<StopPanel stop={STOPS[3]} width={380}>
<h2>Greetings</h2>
<p>Respect éternel à :</p>
<ul>
{GREETINGS.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
<p className="muted">Code + design : Clément Saillant. Aucun cookie n'a é blessé.</p>
<button className="sign" onClick={() => useRide.getState().reset()}>
RIDE AGAIN ?
</button>
</StopPanel>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { StopPanel } from './StopPanel'
import { STOPS } from '../../content/stops'
import { LINKS } from '../../content/links'
export function StopIdentities() {
return (
<StopPanel stop={STOPS[1]} width={380}>
<h2>Identités</h2>
{LINKS.map((l) => (
<a key={l.id} className="sign" href={l.href} {...(l.rel ? { rel: l.rel } : {})}>
{l.label}
</a>
))}
</StopPanel>
)
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import { Html, Text } from '@react-three/drei'
import { Vector3 } from 'three'
import type { Stop } from '../../content/stops'
import { pointAt, tangentAt } from '../path'
import { useRide } from '../../ride/store'
export function stopAnchor(stop: Stop, lateral = 6): Vector3 {
const p = pointAt(stop.t)
const tan = tangentAt(stop.t)
const n = new Vector3(-tan.z, 0, tan.x)
return p.clone().addScaledVector(n, lateral)
}
export function StopPanel({ stop, children, width = 340 }: {
stop: Stop
children: ReactNode
width?: number
}) {
const pos = useMemo(() => stopAnchor(stop), [stop])
const active = useRide((s) => s.stop?.id === stop.id)
return (
<group position={pos}>
{/* pole */}
<mesh position={[0, 2.5, 0]}>
<cylinderGeometry args={[0.12, 0.12, 5, 8]} />
<meshStandardMaterial color="#c0c0c0" metalness={0.8} roughness={0.3} />
</mesh>
{/* bus-stop sign */}
<mesh position={[0, 5.4, 0]}>
<boxGeometry args={[3.6, 1.2, 0.15]} />
<meshStandardMaterial color="#ffd23f" emissive="#453400" />
</mesh>
<Text position={[0, 5.4, 0.12]} fontSize={0.55} color="#05010d" anchorX="center" anchorY="middle">
{stop.title}
</Text>
<Html transform position={[0, 2.6, 0.4]} distanceFactor={10} style={{ width }}>
<div className={`panel${active ? ' panel--active' : ''}`}>{children}</div>
</Html>
</group>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { StopBoot } from './StopBoot'
import { StopIdentities } from './StopIdentities'
import { StopElectronFou } from './StopElectronFou'
import { StopGreetings } from './StopGreetings'
export function Stops() {
return (
<>
<StopBoot />
<StopIdentities />
<StopElectronFou />
<StopGreetings />
</>
)
}
+31
View File
@@ -33,3 +33,34 @@ body.demo-on #fallback {
#demo { position: fixed; inset: 0; }
#demo canvas { image-rendering: pixelated; width: 100%; height: 100%; }
.panel {
background: rgba(5, 1, 13, 0.92);
border: 3px solid #ffd23f;
color: #ffd23f;
font-family: "Courier New", monospace;
padding: 14px 16px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.panel--active { border-color: #9be564; box-shadow: 0 0 24px #9be56466; }
.panel h2 { margin: 0 0 8px; color: #ff5c00; font-size: 1.1rem; }
.panel p { margin: 6px 0; font-size: 0.8rem; line-height: 1.45; }
.panel .muted { color: #8a8a8a; }
.panel .avatar { color: #9be564; font-size: 0.7rem; line-height: 1.1; margin: 0; }
.panel a.sign, .panel button.sign {
display: block;
margin: 6px 0;
padding: 6px 8px;
border: 2px solid #ffd23f;
background: none;
color: #ffd23f;
font: inherit;
font-size: 0.8rem;
text-decoration: none;
cursor: pointer;
text-align: left;
width: 100%;
box-sizing: border-box;
}
.panel a.sign:hover, .panel button.sign:hover { background: #ffd23f; color: #05010d; }
.panel ul { margin: 6px 0; padding-left: 18px; font-size: 0.8rem; }