Skip to content

src/client/car.ts

195 lines · 6.2 KB · TypeScript
Raw
import * as THREE from 'three'

export type CarModel = {
	root: THREE.Group
	body: THREE.Group
	wheels: Array<THREE.Group>
	frontWheels: Array<THREE.Group>
	wing: THREE.Group
	rearNozzles: Array<THREE.Object3D>
	liftNozzles: Array<THREE.Object3D>
	tailLight: THREE.MeshStandardMaterial
}

/** A generic, stylized wedge roadster built from primitives. Local forward is +Z. */
export function buildCar(): CarModel {
	const root = new THREE.Group()
	const body = new THREE.Group()
	root.add(body)

	const paint = new THREE.MeshPhysicalMaterial({
		color: 0xf3f1ec,
		metalness: 0.15,
		roughness: 0.32,
		clearcoat: 1,
		clearcoatRoughness: 0.12,
	})
	const dark = new THREE.MeshStandardMaterial({ color: 0x1b1c20, metalness: 0.4, roughness: 0.6 })
	const titanium = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, metalness: 1, roughness: 0.28 })
	const glass = new THREE.MeshPhysicalMaterial({
		color: 0xd9a441,
		metalness: 0.9,
		roughness: 0.08,
		clearcoat: 1,
		transparent: true,
		opacity: 0.92,
	})

	// Side profile (x = length, y = height), extruded across the width.
	const profile = new THREE.Shape()
	const pts: Array<[number, number]> = [
		[2.25, 0.3],
		[2.32, 0.46],
		[1.5, 0.66],
		[0.55, 0.86],
		[-0.9, 0.9],
		[-1.85, 0.8],
		[-2.22, 0.74],
		[-2.28, 0.42],
		[-2.1, 0.24],
		[2.0, 0.22],
	]
	profile.moveTo(pts[0]![0], pts[0]![1])
	for (const [x, y] of pts.slice(1)) profile.lineTo(x, y)
	profile.closePath()
	const width = 1.92
	const shellGeo = new THREE.ExtrudeGeometry(profile, {
		depth: width - 0.24,
		bevelEnabled: true,
		bevelThickness: 0.12,
		bevelSize: 0.08,
		bevelSegments: 3,
		steps: 1,
	})
	shellGeo.translate(0, 0, -(width - 0.24) / 2)
	shellGeo.rotateY(-Math.PI / 2)
	const shell = new THREE.Mesh(shellGeo, paint)
	shell.castShadow = true
	shell.receiveShadow = true
	body.add(shell)

	// Canopy with gold-tinted glass.
	const canopyShape = new THREE.Shape()
	canopyShape.moveTo(1.05, 0.7)
	canopyShape.quadraticCurveTo(0.1, 1.32, -1.1, 0.95)
	canopyShape.lineTo(-1.2, 0.82)
	canopyShape.lineTo(1.05, 0.7)
	const canopyGeo = new THREE.ExtrudeGeometry(canopyShape, {
		depth: 1.1,
		bevelEnabled: true,
		bevelThickness: 0.06,
		bevelSize: 0.06,
		bevelSegments: 3,
	})
	canopyGeo.translate(0, 0, -0.55)
	canopyGeo.rotateY(-Math.PI / 2)
	const canopy = new THREE.Mesh(canopyGeo, glass)
	canopy.castShadow = true
	body.add(canopy)

	// Side intakes and titanium accents.
	for (const side of [-1, 1]) {
		const intake = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.18, 0.9), dark)
		intake.position.set(side * 1.0, 0.52, -0.7)
		body.add(intake)
		const trim = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, 3.6), titanium)
		trim.position.set(side * 1.0, 0.3, 0.1)
		body.add(trim)
	}

	// Concealed headlight slits.
	const headMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0xdff4ff, emissiveIntensity: 2.2 })
	for (const side of [-1, 1]) {
		const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.035, 0.05), headMat)
		head.position.set(side * 0.6, 0.47, 2.29)
		head.rotation.x = 0.25
		body.add(head)
	}

	// Full-length tail light bar.
	const tailLight = new THREE.MeshStandardMaterial({ color: 0x330000, emissive: 0xff2a2a, emissiveIntensity: 1.6 })
	const tail = new THREE.Mesh(new THREE.BoxGeometry(1.86, 0.05, 0.05), tailLight)
	tail.position.set(0, 0.66, -2.31)
	body.add(tail)

	// Diffuser.
	const diffuser = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.14, 0.4), dark)
	diffuser.position.set(0, 0.28, -2.1)
	body.add(diffuser)

	// Active rear wing (deploys in track mode).
	const wing = new THREE.Group()
	const blade = new THREE.Mesh(new THREE.BoxGeometry(1.9, 0.04, 0.42), paint)
	blade.castShadow = true
	blade.position.y = 0.36
	wing.add(blade)
	for (const side of [-1, 1]) {
		const post = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.36, 0.14), titanium)
		post.position.set(side * 0.55, 0.18, 0)
		wing.add(post)
	}
	wing.position.set(0, 0.62, -1.85)
	body.add(wing)

	// Thruster nozzles: two rear plus eight lift nozzles around the car (ten total).
	const nozzleGeo = new THREE.CylinderGeometry(0.07, 0.11, 0.14, 12, 1, true)
	const nozzleMat = new THREE.MeshStandardMaterial({ color: 0x2a2b30, metalness: 0.9, roughness: 0.3, side: THREE.DoubleSide })
	const rearNozzles: Array<THREE.Object3D> = []
	for (const side of [-1, 1]) {
		const n = new THREE.Mesh(nozzleGeo, nozzleMat)
		n.rotation.x = Math.PI / 2
		n.position.set(side * 0.38, 0.4, -2.36)
		body.add(n)
		rearNozzles.push(n)
	}
	const liftNozzles: Array<THREE.Object3D> = []
	const liftSpots: Array<[number, number]> = [
		[-0.85, 1.75], [0.85, 1.75], [-0.95, 0.6], [0.95, 0.6],
		[-0.95, -0.6], [0.95, -0.6], [-0.85, -1.75], [0.85, -1.75],
	]
	for (const [x, z] of liftSpots) {
		const n = new THREE.Mesh(nozzleGeo, nozzleMat)
		n.position.set(x, 0.18, z)
		body.add(n)
		liftNozzles.push(n)
	}

	// Super-light wheels with thin spokes.
	const tireGeo = new THREE.CylinderGeometry(0.36, 0.36, 0.3, 28)
	tireGeo.rotateZ(Math.PI / 2)
	const tireMat = new THREE.MeshStandardMaterial({ color: 0x111114, roughness: 0.9 })
	const rimMat = new THREE.MeshStandardMaterial({ color: 0xc9ccd2, metalness: 1, roughness: 0.22 })
	const wheels: Array<THREE.Group> = []
	const frontWheels: Array<THREE.Group> = []
	for (const [x, z] of [[-0.9, 1.45], [0.9, 1.45], [-0.92, -1.45], [0.92, -1.45]] as const) {
		const steer = new THREE.Group()
		steer.position.set(x, 0.36, z)
		const spin = new THREE.Group()
		steer.add(spin)
		const tire = new THREE.Mesh(tireGeo, tireMat)
		tire.castShadow = true
		spin.add(tire)
		const side = Math.sign(x)
		const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.04, 12).rotateZ(Math.PI / 2), rimMat)
		hub.position.x = side * 0.16
		spin.add(hub)
		for (let i = 0; i < 10; i++) {
			const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.015, 0.29, 0.025), rimMat)
			spoke.position.set(side * 0.155, 0, 0)
			const pivot = new THREE.Group()
			pivot.rotation.x = (i / 10) * Math.PI * 2
			spoke.position.y = 0.14
			pivot.add(spoke)
			spin.add(pivot)
		}
		const rimRing = new THREE.Mesh(new THREE.TorusGeometry(0.28, 0.02, 6, 32).rotateY(Math.PI / 2), rimMat)
		rimRing.position.x = side * 0.155
		spin.add(rimRing)
		root.add(steer)
		wheels.push(spin)
		if (z > 0) frontWheels.push(steer)
	}

	body.position.y = 0.04
	return { root, body, wheels, frontWheels, wing, rearNozzles, liftNozzles, tailLight }
}