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:
+2
-1
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 été blessé.</p>
|
||||
<button className="sign" onClick={() => useRide.getState().reset()}>
|
||||
⟲ RIDE AGAIN ?
|
||||
</button>
|
||||
</StopPanel>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user