feat: replace primitives with kenney cc0 models

Swap hand-built boxes/cylinders for Kenney Car Kit, City Kit Roads,
and Furniture Kit low-poly models across the bus, stops, and world,
keeping ride/camera logic and Html panels untouched.
This commit is contained in:
L'électron rare
2026-07-09 13:42:22 +02:00
parent d0fe0ec9dd
commit ae77d8f623
18 changed files with 247 additions and 95 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -31
View File
@@ -1,13 +1,28 @@
import { useRef } from 'react' import { useRef } from 'react'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { Clone, useGLTF } from '@react-three/drei'
import { Group, Vector3 } from 'three' import { Group, Vector3 } from 'three'
import { pointAt, tangentAt } from './path' import { pointAt, tangentAt } from './path'
import { useRide } from '../ride/store' import { useRide } from '../ride/store'
const ahead = new Vector3() const ahead = new Vector3()
// Kenney Car Kit van: body ~2.75 units long natively (wheel-front z=+0.76
// to body rear z=-1.4). Scale it up to match the old ~4.6-unit footprint.
const VAN_SCALE = 1.7
// wheel-front-{left,right} nodes sit at z=+0.76 (native), the body rear at
// z=-1.4 — the model's own "nose" points down its local +Z axis. For a
// plain (non-camera) Object3D, Object3D.lookAt orients local +Z toward the
// look target, so this group's +Z is already the direction of travel after
// the lookAt below: the van needs no corrective yaw.
const VAN_FORWARD_CORRECTION = 0
// roof top sits at ~2.3 world units after scaling (body top 1.35 * 1.7)
const ROOF_Y = 2.3
export function Bus() { export function Bus() {
const ref = useRef<Group>(null) const ref = useRef<Group>(null)
const { scene } = useGLTF('/models/van.glb')
useFrame((state) => { useFrame((state) => {
const t = useRide.getState().t const t = useRide.getState().t
const g = ref.current! const g = ref.current!
@@ -18,43 +33,19 @@ export function Bus() {
}) })
return ( return (
<group ref={ref} name="bus"> <group ref={ref} name="bus">
{/* body, two-tone Électron Fou paint job */} <Clone object={scene} scale={VAN_SCALE} rotation={[0, VAN_FORWARD_CORRECTION, 0]} />
<mesh position={[0, 1, 0]}> {/* crazy antenna: a zap sphere on a pole, mounted on the roof — the one
<boxGeometry args={[2.2, 1.5, 4.6]} /> fun accent kept from the old hand-built bus */}
<meshStandardMaterial color="#ff5c00" roughness={0.4} /> <mesh position={[0.5, ROOF_Y + 0.5, -1.7]}>
</mesh>
<mesh position={[0, 1.9, 0.3]}>
<boxGeometry args={[2.2, 0.5, 3.4]} />
<meshStandardMaterial color="#fff3d6" roughness={0.5} />
</mesh>
{/* windshield */}
<mesh position={[0, 1.35, 2.31]}>
<boxGeometry args={[1.8, 0.7, 0.05]} />
<meshStandardMaterial color="#7ec8ff" emissive="#153a5e" roughness={0.1} />
</mesh>
{/* wheels */}
{([[-1.1, 1.5], [1.1, 1.5], [-1.1, -1.5], [1.1, -1.5]] as const).map(([x, z], i) => (
<mesh key={i} position={[x, 0.45, z]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.45, 0.45, 0.3, 12]} />
<meshStandardMaterial color="#111" roughness={0.9} />
</mesh>
))}
{/* headlights */}
{[-0.7, 0.7].map((x, i) => (
<mesh key={i} position={[x, 0.9, 2.32]}>
<sphereGeometry args={[0.16, 8, 8]} />
<meshStandardMaterial color="#fffbe6" emissive="#ffd23f" emissiveIntensity={2} />
</mesh>
))}
{/* crazy antenna: a zap sphere on a pole */}
<mesh position={[0.8, 2.6, -1.6]}>
<cylinderGeometry args={[0.03, 0.03, 1, 6]} /> <cylinderGeometry args={[0.03, 0.03, 1, 6]} />
<meshStandardMaterial color="#999" /> <meshStandardMaterial color="#999" />
</mesh> </mesh>
<mesh position={[0.8, 3.2, -1.6]}> <mesh position={[0.5, ROOF_Y + 1.15, -1.7]}>
<sphereGeometry args={[0.18, 8, 8]} /> <sphereGeometry args={[0.18, 8, 8]} />
<meshStandardMaterial color="#9be564" emissive="#9be564" emissiveIntensity={2.5} /> <meshStandardMaterial color="#9be564" emissive="#9be564" emissiveIntensity={2.5} />
</mesh> </mesh>
</group> </group>
) )
} }
useGLTF.preload('/models/van.glb')
+31 -1
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Instance, Instances } from '@react-three/drei' import { Clone, Instance, Instances, useGLTF } from '@react-three/drei'
import { TubeGeometry, Vector3 } from 'three' import { TubeGeometry, Vector3 } from 'three'
import { busPath, pointAt, tangentAt } from './path' import { busPath, pointAt, tangentAt } from './path'
import { mulberry32 } from './rng' import { mulberry32 } from './rng'
@@ -10,6 +10,28 @@ interface Item {
scale: [number, number, number] scale: [number, number, number]
} }
interface Lamp {
pos: [number, number, number]
rotY: number
}
const LAMP_TS = [0.06, 0.18, 0.3, 0.42, 0.55, 0.68, 0.8, 0.92]
const LAMP_SCALE = 6
const LAMP_OFFSET = 5
function lampLayout(): Lamp[] {
return LAMP_TS.map((t, i) => {
const p = pointAt(t)
const tan = tangentAt(t)
const n = new Vector3(-tan.z, 0, tan.x) // unit normal, tangent is already unit length
const side = i % 2 === 0 ? 1 : -1
const pos = p.clone().addScaledVector(n, side * LAMP_OFFSET)
// face the lamp's curved arm back toward the road regardless of path curvature
const rotY = Math.atan2(side * n.x, side * n.z)
return { pos: [pos.x, 0, pos.z], rotY }
})
}
function layout(seed: number, count: number, minOff: number, maxOff: number): Item[] { function layout(seed: number, count: number, minOff: number, maxOff: number): Item[] {
const rng = mulberry32(seed) const rng = mulberry32(seed)
const items: Item[] = [] const items: Item[] = []
@@ -35,7 +57,9 @@ export function World() {
const caps = useMemo(() => layout(4242, 50, 8, 38), []) const caps = useMemo(() => layout(4242, 50, 8, 38), [])
const resistors = useMemo(() => layout(90210, 50, 7, 36), []) const resistors = useMemo(() => layout(90210, 50, 7, 36), [])
const vias = useMemo(() => layout(555, 80, 6, 44), []) const vias = useMemo(() => layout(555, 80, 6, 44), [])
const lamps = useMemo(() => lampLayout(), [])
const trace = useMemo(() => new TubeGeometry(busPath, 200, 0.5, 6, false), []) const trace = useMemo(() => new TubeGeometry(busPath, 200, 0.5, 6, false), [])
const { scene: lampScene } = useGLTF('/models/light-curved.glb')
return ( return (
<group> <group>
@@ -80,6 +104,12 @@ export function World() {
<Instance key={i} position={[it.pos[0], 0.05, it.pos[2]]} /> <Instance key={i} position={[it.pos[0], 0.05, it.pos[2]]} />
))} ))}
</Instances> </Instances>
{/* street lamps lining the road */}
{lamps.map((l, i) => (
<Clone key={i} object={lampScene} position={l.pos} rotation={[0, l.rotY, 0]} scale={LAMP_SCALE} />
))}
</group> </group>
) )
} }
useGLTF.preload('/models/light-curved.glb')
+71 -29
View File
@@ -1,14 +1,46 @@
import { useRef } from 'react' import { useRef } from 'react'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { Clone, useGLTF } from '@react-three/drei'
import { Mesh, MeshStandardMaterial } from 'three' import { Mesh, MeshStandardMaterial } from 'three'
import { StopPanel, stopAnchor } from './StopPanel' import { StopPanel, stopAnchor } from './StopPanel'
import { STOPS } from '../../content/stops' import { STOPS } from '../../content/stops'
import { MUSIC } from '../../content/music' import { MUSIC } from '../../content/music'
// Kenney furniture-kit pieces are authored with a corner origin (spanning
// +X, +Y, -Z from [0,0,0]) rather than a centered pivot — these helpers
// re-center each clone at a given world position.
function centered(w: number, h: number, d: number, x: number, yBottom: number, z: number): [number, number, number] {
return [x - w / 2, yBottom, z + d / 2]
}
const SPEAKER_SCALE = 5
const SPK_W = 0.148 * SPEAKER_SCALE
const SPK_H = 0.6364 * SPEAKER_SCALE
const SPK_D = 0.148 * SPEAKER_SCALE
const COL_GAP = SPK_W * 1.08
const COL_X = [-COL_GAP / 2, COL_GAP / 2]
const ROW_Y = [0, SPK_H]
const STACK_TOP = ROW_Y[1] + SPK_H
const SMALL_SCALE = 4.4
const SMALL_W = 0.148 * SMALL_SCALE
const SMALL_D = 0.1332 * SMALL_SCALE
const GEAR_SCALE = 3.2
const RADIO_W = 0.315 * GEAR_SCALE
const RADIO_D = 0.0975 * GEAR_SCALE
const TV_W = 0.41 * GEAR_SCALE
const TV_D = 0.27 * GEAR_SCALE
const TUBE_Y = STACK_TOP + 1.0
export function StopElectronFou() { export function StopElectronFou() {
const tubes = useRef<Array<Mesh | null>>([]) const tubes = useRef<Array<Mesh | null>>([])
const needles = useRef<Array<Mesh | null>>([]) const ampPos = stopAnchor(STOPS[2], 8)
const ampPos = stopAnchor(STOPS[2], 12) const { scene: speakerScene } = useGLTF('/models/speaker.glb')
const { scene: speakerSmallScene } = useGLTF('/models/speakerSmall.glb')
const { scene: radioScene } = useGLTF('/models/radio.glb')
const { scene: tvScene } = useGLTF('/models/televisionVintage.glb')
useFrame((state) => { useFrame((state) => {
const time = state.clock.elapsedTime const time = state.clock.elapsedTime
@@ -17,9 +49,6 @@ export function StopElectronFou() {
const mat = m.material as MeshStandardMaterial const mat = m.material as MeshStandardMaterial
mat.emissiveIntensity = 1.4 + Math.sin(time * 4 + i * 1.7) * 0.9 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 ( return (
@@ -29,35 +58,48 @@ export function StopElectronFou() {
<p>{MUSIC.blurb}</p> <p>{MUSIC.blurb}</p>
<a className="sign" href={MUSIC.listenUrl}> {MUSIC.listenLabel}</a> <a className="sign" href={MUSIC.listenUrl}> {MUSIC.listenLabel}</a>
</StopPanel> </StopPanel>
{/* the giant tube amp behind the panel */} {/* the wall of speakers behind the panel */}
<group position={ampPos}> <group position={ampPos}>
<mesh position={[0, 2.2, 0]}> {ROW_Y.map((y, ri) =>
<boxGeometry args={[9, 4.4, 3.2]} /> COL_X.map((x, ci) => (
<meshStandardMaterial color="#17171c" roughness={0.5} /> <Clone
</mesh> key={`${ri}-${ci}`}
<mesh position={[0, 2.2, 1.65]}> object={speakerScene}
<boxGeometry args={[8.2, 3.4, 0.1]} /> scale={SPEAKER_SCALE}
<meshStandardMaterial color="#3b2c1a" roughness={0.9} /> position={centered(SPK_W, SPK_H, SPK_D, x, y, 0)}
</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]} /> <Clone
object={speakerSmallScene}
scale={SMALL_SCALE}
position={centered(SMALL_W, 0, SMALL_D, 0, STACK_TOP, 0.45)}
/>
<Clone
object={radioScene}
scale={GEAR_SCALE}
position={centered(RADIO_W, 0, RADIO_D, -0.55, STACK_TOP, 0)}
rotation={[0, 0.3, 0]}
/>
<Clone
object={tvScene}
scale={GEAR_SCALE}
position={centered(TV_W, 0, TV_D, 0.55, STACK_TOP, 0)}
rotation={[0, -0.4, 0]}
/>
{/* pulsing emissive tubes rising behind the stack — demoscene heartbeat */}
{[-0.9, -0.32, 0.32, 0.9].map((x, i) => (
<mesh key={i} ref={(el) => { tubes.current[i] = el }} position={[x, TUBE_Y, -0.5]}>
<cylinderGeometry args={[0.11, 0.14, 2.2, 10]} />
<meshStandardMaterial color="#331100" emissive="#ff5c00" emissiveIntensity={1.4} transparent opacity={0.92} /> <meshStandardMaterial color="#331100" emissive="#ff5c00" emissiveIntensity={1.4} transparent opacity={0.92} />
</mesh> </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> </group>
</> </>
) )
} }
useGLTF.preload('/models/speaker.glb')
useGLTF.preload('/models/speakerSmall.glb')
useGLTF.preload('/models/radio.glb')
useGLTF.preload('/models/televisionVintage.glb')
+53 -14
View File
@@ -1,22 +1,61 @@
import { StopPanel } from './StopPanel' import { useMemo } from 'react'
import { Clone, useGLTF } from '@react-three/drei'
import { StopPanel, stopAnchor } from './StopPanel'
import { STOPS } from '../../content/stops' import { STOPS } from '../../content/stops'
import { GREETINGS } from '../../content/greetings' import { GREETINGS } from '../../content/greetings'
import { useRide } from '../../ride/store' import { useRide } from '../../ride/store'
// "end of the road" wink: cones + barriers clustered near the terminus
const CONES = [
{ dx: 3, dz: 1, scale: 1.4, rotY: 0.2 },
{ dx: 3.8, dz: -0.4, scale: 1.2, rotY: 1.1 },
{ dx: 2.6, dz: -1.6, scale: 1.5, rotY: 2.4 },
] as const
const BARRIERS = [
{ dx: 4.6, dz: 0.4, scale: 7.5, rotY: -0.4 },
{ dx: 4.9, dz: -1.4, scale: 7.2, rotY: 0.6 },
] as const
export function StopGreetings() { export function StopGreetings() {
const base = useMemo(() => stopAnchor(STOPS[3]), [])
const { scene: coneScene } = useGLTF('/models/cone.glb')
const { scene: barrierScene } = useGLTF('/models/construction-barrier.glb')
return ( return (
<StopPanel stop={STOPS[3]} width={380}> <>
<h2>Greetings</h2> <StopPanel stop={STOPS[3]} width={380}>
<p>Respect éternel à :</p> <h2>Greetings</h2>
<ul> <p>Respect éternel à :</p>
{GREETINGS.map((g) => ( <ul>
<li key={g}>{g}</li> {GREETINGS.map((g) => (
))} <li key={g}>{g}</li>
</ul> ))}
<p className="muted">Code + design : Clément Saillant. Aucun cookie n'a é blessé.</p> </ul>
<button className="sign" onClick={() => useRide.getState().reset()}> <p className="muted">Code + design : Clément Saillant. Aucun cookie n'a été blessé.</p>
RIDE AGAIN ? <button className="sign" onClick={() => useRide.getState().reset()}>
</button> ⟲ RIDE AGAIN ?
</StopPanel> </button>
</StopPanel>
{CONES.map((c, i) => (
<Clone
key={i}
object={coneScene}
position={[base.x + c.dx, base.y, base.z + c.dz]}
rotation={[0, c.rotY, 0]}
scale={c.scale}
/>
))}
{BARRIERS.map((b, i) => (
<Clone
key={i}
object={barrierScene}
position={[base.x + b.dx, base.y, base.z + b.dz]}
rotation={[0, b.rotY, 0]}
scale={b.scale}
/>
))}
</>
) )
} }
useGLTF.preload('/models/cone.glb')
useGLTF.preload('/models/construction-barrier.glb')
+33 -8
View File
@@ -1,16 +1,41 @@
import { StopPanel } from './StopPanel' import { useMemo } from 'react'
import { Clone, useGLTF } from '@react-three/drei'
import { StopPanel, stopAnchor } from './StopPanel'
import { STOPS } from '../../content/stops' import { STOPS } from '../../content/stops'
import { LINKS } from '../../content/links' import { LINKS } from '../../content/links'
// small decorative sign forest behind the panel — décor only, the real
// links stay in the Html panel below
const FOREST = [
{ dx: -3.5, dz: -2.5, scale: 4.2, rotY: 0.4 },
{ dx: 4, dz: -3.5, scale: 5.5, rotY: -0.9 },
{ dx: 1.5, dz: -5, scale: 3.4, rotY: 1.8 },
] as const
export function StopIdentities() { export function StopIdentities() {
const base = useMemo(() => stopAnchor(STOPS[1]), [])
const { scene } = useGLTF('/models/sign-highway.glb')
return ( return (
<StopPanel stop={STOPS[1]} width={380}> <>
<h2>Identités</h2> <StopPanel stop={STOPS[1]} width={380}>
{LINKS.map((l) => ( <h2>Identités</h2>
<a key={l.id} className="sign" href={l.href} {...(l.rel ? { rel: l.rel } : {})}> {LINKS.map((l) => (
{l.label} <a key={l.id} className="sign" href={l.href} {...(l.rel ? { rel: l.rel } : {})}>
</a> {l.label}
</a>
))}
</StopPanel>
{FOREST.map((f, i) => (
<Clone
key={i}
object={scene}
position={[base.x + f.dx, base.y, base.z + f.dz]}
rotation={[0, f.rotY, 0]}
scale={f.scale}
/>
))} ))}
</StopPanel> </>
) )
} }
useGLTF.preload('/models/sign-highway.glb')
+37 -12
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { Html, Text } from '@react-three/drei' import { Clone, Html, Text, useGLTF } from '@react-three/drei'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import type { Stop } from '../../content/stops' import type { Stop } from '../../content/stops'
import { pointAt, tangentAt } from '../path' import { pointAt, tangentAt } from '../path'
@@ -13,6 +13,31 @@ export function stopAnchor(stop: Stop, lateral = 6): Vector3 {
return p.clone().addScaledVector(n, lateral) return p.clone().addScaledVector(n, lateral)
} }
// sign-highway-wide.glb: twin posts (0..0.707 tall) framing a board around
// the 0.41-0.53 band, board front face on its native -X side. Scaled up so
// the board reads clearly above the Html panel (which stays untouched).
const SIGN_SCALE = 8
const BOARD_Y = 0.5 * SIGN_SCALE
const BOARD_Z = 0.11 * SIGN_SCALE
const LAMP_SCALE = 6
// small deterministic hash so each stop's lamp gets a stable yaw variation
function hash(id: string): number {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0
return h
}
// face the lamp's curved arm back toward the road, plus a per-stop wobble
function lampYaw(stop: Stop): number {
const tan = tangentAt(stop.t)
const n = new Vector3(-tan.z, 0, tan.x)
const base = Math.atan2(n.x, n.z)
const wobble = ((hash(stop.id) % 1000) / 1000 - 0.5) * 0.6
return base + wobble
}
export function StopPanel({ stop, children, width = 340 }: { export function StopPanel({ stop, children, width = 340 }: {
stop: Stop stop: Stop
children: ReactNode children: ReactNode
@@ -20,24 +45,24 @@ export function StopPanel({ stop, children, width = 340 }: {
}) { }) {
const pos = useMemo(() => stopAnchor(stop), [stop]) const pos = useMemo(() => stopAnchor(stop), [stop])
const active = useRide((s) => s.stop?.id === stop.id) const active = useRide((s) => s.stop?.id === stop.id)
const { scene: signScene } = useGLTF('/models/sign-highway-wide.glb')
const { scene: lampScene } = useGLTF('/models/light-curved.glb')
return ( return (
<group position={pos}> <group position={pos}>
{/* pole */} {/* bus-stop sign: billboard-on-posts (native board faces its local
<mesh position={[0, 2.5, 0]}> -X; rotate 90° so the face reads along +Z toward the road) */}
<cylinderGeometry args={[0.12, 0.12, 5, 8]} /> <Clone object={signScene} scale={SIGN_SCALE} rotation={[0, Math.PI / 2, 0]} />
<meshStandardMaterial color="#c0c0c0" metalness={0.8} roughness={0.3} /> <Text position={[0, BOARD_Y, BOARD_Z + 0.05]} fontSize={0.6} color="#05010d" anchorX="center" anchorY="middle">
</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} {stop.title}
</Text> </Text>
<Html transform position={[0, 2.6, 0.4]} distanceFactor={10} style={{ width }}> <Html transform position={[0, 2.6, 0.4]} distanceFactor={10} style={{ width }}>
<div className={`panel${active ? ' panel--active' : ''}`}>{children}</div> <div className={`panel${active ? ' panel--active' : ''}`}>{children}</div>
</Html> </Html>
{/* street lamp beside the stop */}
<Clone object={lampScene} scale={LAMP_SCALE} position={[5.2, 0, -1]} rotation={[0, lampYaw(stop), 0]} />
</group> </group>
) )
} }
useGLTF.preload('/models/sign-highway-wide.glb')
useGLTF.preload('/models/light-curved.glb')