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 { useFrame } from '@react-three/fiber'
import { Clone, useGLTF } from '@react-three/drei'
import { Group, Vector3 } from 'three'
import { pointAt, tangentAt } from './path'
import { useRide } from '../ride/store'
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() {
const ref = useRef<Group>(null)
const { scene } = useGLTF('/models/van.glb')
useFrame((state) => {
const t = useRide.getState().t
const g = ref.current!
@@ -18,43 +33,19 @@ export function Bus() {
})
return (
<group ref={ref} name="bus">
{/* body, two-tone Électron Fou paint job */}
<mesh position={[0, 1, 0]}>
<boxGeometry args={[2.2, 1.5, 4.6]} />
<meshStandardMaterial color="#ff5c00" roughness={0.4} />
</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]}>
<Clone object={scene} scale={VAN_SCALE} rotation={[0, VAN_FORWARD_CORRECTION, 0]} />
{/* crazy antenna: a zap sphere on a pole, mounted on the roof — the one
fun accent kept from the old hand-built bus */}
<mesh position={[0.5, ROOF_Y + 0.5, -1.7]}>
<cylinderGeometry args={[0.03, 0.03, 1, 6]} />
<meshStandardMaterial color="#999" />
</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]} />
<meshStandardMaterial color="#9be564" emissive="#9be564" emissiveIntensity={2.5} />
</mesh>
</group>
)
}
useGLTF.preload('/models/van.glb')
+31 -1
View File
@@ -1,5 +1,5 @@
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 { busPath, pointAt, tangentAt } from './path'
import { mulberry32 } from './rng'
@@ -10,6 +10,28 @@ interface Item {
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[] {
const rng = mulberry32(seed)
const items: Item[] = []
@@ -35,7 +57,9 @@ export function World() {
const caps = useMemo(() => layout(4242, 50, 8, 38), [])
const resistors = useMemo(() => layout(90210, 50, 7, 36), [])
const vias = useMemo(() => layout(555, 80, 6, 44), [])
const lamps = useMemo(() => lampLayout(), [])
const trace = useMemo(() => new TubeGeometry(busPath, 200, 0.5, 6, false), [])
const { scene: lampScene } = useGLTF('/models/light-curved.glb')
return (
<group>
@@ -80,6 +104,12 @@ export function World() {
<Instance key={i} position={[it.pos[0], 0.05, it.pos[2]]} />
))}
</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>
)
}
useGLTF.preload('/models/light-curved.glb')
+71 -29
View File
@@ -1,14 +1,46 @@
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { Clone, useGLTF } from '@react-three/drei'
import { Mesh, MeshStandardMaterial } from 'three'
import { StopPanel, stopAnchor } from './StopPanel'
import { STOPS } from '../../content/stops'
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() {
const tubes = useRef<Array<Mesh | null>>([])
const needles = useRef<Array<Mesh | null>>([])
const ampPos = stopAnchor(STOPS[2], 12)
const ampPos = stopAnchor(STOPS[2], 8)
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) => {
const time = state.clock.elapsedTime
@@ -17,9 +49,6 @@ export function StopElectronFou() {
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 (
@@ -29,35 +58,48 @@ export function StopElectronFou() {
<p>{MUSIC.blurb}</p>
<a className="sign" href={MUSIC.listenUrl}> {MUSIC.listenLabel}</a>
</StopPanel>
{/* the giant tube amp behind the panel */}
{/* the wall of speakers 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]} />
{ROW_Y.map((y, ri) =>
COL_X.map((x, ci) => (
<Clone
key={`${ri}-${ci}`}
object={speakerScene}
scale={SPEAKER_SCALE}
position={centered(SPK_W, SPK_H, SPK_D, x, y, 0)}
/>
)),
)}
<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} />
</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>
</>
)
}
useGLTF.preload('/models/speaker.glb')
useGLTF.preload('/models/speakerSmall.glb')
useGLTF.preload('/models/radio.glb')
useGLTF.preload('/models/televisionVintage.glb')
+40 -1
View File
@@ -1,10 +1,27 @@
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 { GREETINGS } from '../../content/greetings'
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() {
const base = useMemo(() => stopAnchor(STOPS[3]), [])
const { scene: coneScene } = useGLTF('/models/cone.glb')
const { scene: barrierScene } = useGLTF('/models/construction-barrier.glb')
return (
<>
<StopPanel stop={STOPS[3]} width={380}>
<h2>Greetings</h2>
<p>Respect éternel à :</p>
@@ -18,5 +35,27 @@ export function StopGreetings() {
⟲ RIDE AGAIN ?
</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')
+26 -1
View File
@@ -1,9 +1,22 @@
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 { 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() {
const base = useMemo(() => stopAnchor(STOPS[1]), [])
const { scene } = useGLTF('/models/sign-highway.glb')
return (
<>
<StopPanel stop={STOPS[1]} width={380}>
<h2>Identités</h2>
{LINKS.map((l) => (
@@ -12,5 +25,17 @@ export function StopIdentities() {
</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}
/>
))}
</>
)
}
useGLTF.preload('/models/sign-highway.glb')
+37 -12
View File
@@ -1,6 +1,6 @@
import type { ReactNode } 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 type { Stop } from '../../content/stops'
import { pointAt, tangentAt } from '../path'
@@ -13,6 +13,31 @@ export function stopAnchor(stop: Stop, lateral = 6): Vector3 {
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 }: {
stop: Stop
children: ReactNode
@@ -20,24 +45,24 @@ export function StopPanel({ stop, children, width = 340 }: {
}) {
const pos = useMemo(() => stopAnchor(stop), [stop])
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 (
<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">
{/* bus-stop sign: billboard-on-posts (native board faces its local
-X; rotate 90° so the face reads along +Z toward the road) */}
<Clone object={signScene} scale={SIGN_SCALE} rotation={[0, Math.PI / 2, 0]} />
<Text position={[0, BOARD_Y, BOARD_Z + 0.05]} fontSize={0.6} 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>
{/* street lamp beside the stop */}
<Clone object={lampScene} scale={LAMP_SCALE} position={[5.2, 0, -1]} rotation={[0, lampYaw(stop), 0]} />
</group>
)
}
useGLTF.preload('/models/sign-highway-wide.glb')
useGLTF.preload('/models/light-curved.glb')