Skip to content

src/client/world.ts

431 lines · 16.1 KB · TypeScript
Raw
import * as THREE from 'three'

export const LAYOUT = {
	runway: { x: 0, z0: -60, z1: 2500, halfWidth: 22 },
	launchBox: { x: 0, z0: 8, z1: 22, halfWidth: 4 },
	lineup: { x: -95, z: [300, 314, 328, 342, 356], halfLane: 11 },
	ramp: { x: 95, z: 220, radius: 12, halfWidth: 4.5, maxAngle: (160 * Math.PI) / 180 },
	canyon: { x0: -270, x1: -130, z0: 720, z1: 840, depth: 70 },
	kicker: { x: -200, z0: 660, z1: 720, height: 7, halfWidth: 9 },
}

export type Obstacle = { min: THREE.Vector3; max: THREE.Vector3 }

/** Ground height at x/z. Returns -Infinity over the canyon. */
export function groundHeight(x: number, z: number): number {
	const c = LAYOUT.canyon
	if (x > c.x0 && x < c.x1 && z > c.z0 && z < c.z1) return -Infinity
	const k = LAYOUT.kicker
	if (Math.abs(x - k.x) < k.halfWidth && z > k.z0 && z < k.z1) {
		const t = (z - k.z0) / (k.z1 - k.z0)
		return k.height * t * t * 0.4 + k.height * t * 0.6
	}
	return 0
}

export function inCanyonFootprint(x: number, z: number) {
	const c = LAYOUT.canyon
	return x > c.x0 && x < c.x1 && z > c.z0 && z < c.z1
}

function canvasTexture(w: number, h: number, draw: (g: CanvasRenderingContext2D) => void) {
	const c = document.createElement('canvas')
	c.width = w
	c.height = h
	const g = c.getContext('2d')!
	draw(g)
	const tex = new THREE.CanvasTexture(c)
	tex.colorSpace = THREE.SRGBColorSpace
	tex.anisotropy = 8
	return tex
}

function label(text: string, color = '#e9c46a') {
	const tex = canvasTexture(512, 128, (g) => {
		g.fillStyle = 'rgba(10,10,20,0.75)'
		g.beginPath()
		g.roundRect(4, 4, 504, 120, 24)
		g.fill()
		g.strokeStyle = color
		g.lineWidth = 6
		g.stroke()
		g.fillStyle = '#fff'
		g.font = '900 56px system-ui, sans-serif'
		g.textAlign = 'center'
		g.textBaseline = 'middle'
		g.fillText(text, 256, 68)
	})
	const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }))
	sprite.scale.set(16, 4, 1)
	return sprite
}

export function buildWorld(scene: THREE.Scene, lowGpu = false) {
	const obstacles: Array<Obstacle> = []

	// Sky dome with a dusk gradient.
	const skyGeo = new THREE.SphereGeometry(4000, lowGpu ? 16 : 32, lowGpu ? 8 : 16)
	const skyMat = new THREE.ShaderMaterial({
		side: THREE.BackSide,
		depthWrite: false,
		uniforms: {},
		vertexShader: 'varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }',
		fragmentShader: `varying vec3 vP;
			void main(){
				float h = vP.y;
				vec3 horizon = vec3(1.0,0.55,0.32);
				vec3 mid = vec3(0.42,0.27,0.55);
				vec3 top = vec3(0.04,0.05,0.16);
				vec3 c = mix(horizon, mid, smoothstep(0.0,0.18,h));
				c = mix(c, top, smoothstep(0.15,0.6,h));
				c = mix(vec3(0.18,0.12,0.12), c, smoothstep(-0.05,0.0,h));
				float sun = pow(max(dot(normalize(vP), normalize(vec3(-0.6,0.08,1.0))),0.0), 220.0);
				gl_FragColor = vec4(c + vec3(1.0,0.75,0.45)*sun*1.4, 1.0);
			}`,
	})
	scene.add(new THREE.Mesh(skyGeo, skyMat))
	scene.fog = new THREE.Fog(0x8a5a6a, 300, 2600)

	// Lighting.
	scene.add(new THREE.HemisphereLight(0xffd7c0, 0x3a2a30, 1.1))
	const sun = new THREE.DirectionalLight(0xffc89a, 2.4)
	sun.position.set(-60, 90, 120)
	sun.castShadow = !lowGpu
	sun.shadow.mapSize.set(2048, 2048)
	const sc = sun.shadow.camera
	sc.left = -40
	sc.right = 40
	sc.top = 40
	sc.bottom = -40
	sc.near = 1
	sc.far = 400
	sun.shadow.bias = -0.0005
	scene.add(sun)
	scene.add(sun.target)

	// Desert ground with a hole for the canyon.
	const groundTex = canvasTexture(lowGpu ? 256 : 512, lowGpu ? 256 : 512, (g) => {
		g.fillStyle = '#9b7a5c'
		g.fillRect(0, 0, 512, 512)
		for (let i = 0; i < 9000; i++) {
			const v = Math.random()
			g.fillStyle = v > 0.5 ? 'rgba(255,230,200,0.06)' : 'rgba(60,35,20,0.08)'
			g.fillRect(Math.random() * 512, Math.random() * 512, 2 + Math.random() * 4, 2 + Math.random() * 4)
		}
	})
	groundTex.wrapS = groundTex.wrapT = THREE.RepeatWrapping
	groundTex.repeat.set(300, 300)
	const S = 3500
	const shape = new THREE.Shape([
		new THREE.Vector2(-S, -S), new THREE.Vector2(S, -S), new THREE.Vector2(S, S), new THREE.Vector2(-S, S),
	])
	const c = LAYOUT.canyon
	// Shape lives in x/y; after rotating -90deg about X, world z = -y.
	shape.holes.push(new THREE.Path([
		new THREE.Vector2(c.x0, -c.z0), new THREE.Vector2(c.x0, -c.z1), new THREE.Vector2(c.x1, -c.z1), new THREE.Vector2(c.x1, -c.z0),
	]))
	const groundGeo = new THREE.ShapeGeometry(shape)
	groundGeo.rotateX(-Math.PI / 2)
	const uv = groundGeo.attributes.uv as THREE.BufferAttribute
	const pos = groundGeo.attributes.position as THREE.BufferAttribute
	for (let i = 0; i < uv.count; i++) uv.setXY(i, (pos.getX(i) + S) / (2 * S), (pos.getZ(i) + S) / (2 * S))
	const ground = new THREE.Mesh(groundGeo, new THREE.MeshStandardMaterial({ map: groundTex, roughness: 1 }))
	ground.receiveShadow = true
	scene.add(ground)

	// Canyon walls, floor and river.
	const rock = new THREE.MeshStandardMaterial({ color: 0x7a4a33, roughness: 1 })
	const D = c.depth
	const wallSpecs: Array<[number, number, number, number]> = [
		[(c.x0 + c.x1) / 2, c.z0, c.x1 - c.x0, 1],
		[(c.x0 + c.x1) / 2, c.z1, c.x1 - c.x0, 1],
		[c.x0, (c.z0 + c.z1) / 2, 1, c.z1 - c.z0],
		[c.x1, (c.z0 + c.z1) / 2, 1, c.z1 - c.z0],
	]
	for (const [x, z, w, d] of wallSpecs) {
		const wall = new THREE.Mesh(new THREE.BoxGeometry(w, D, d), rock)
		wall.position.set(x, -D / 2, z)
		scene.add(wall)
	}
	const floor = new THREE.Mesh(new THREE.PlaneGeometry(c.x1 - c.x0, c.z1 - c.z0).rotateX(-Math.PI / 2), rock)
	floor.position.set((c.x0 + c.x1) / 2, -D, (c.z0 + c.z1) / 2)
	scene.add(floor)
	const river = new THREE.Mesh(
		new THREE.PlaneGeometry(c.x1 - c.x0, 18).rotateX(-Math.PI / 2),
		new THREE.MeshStandardMaterial({ color: 0x3b7fa8, roughness: 0.2, metalness: 0.3, emissive: 0x0a2233 }),
	)
	river.position.set((c.x0 + c.x1) / 2, -D + 0.2, (c.z0 + c.z1) / 2)
	scene.add(river)

	// Runway.
	const r = LAYOUT.runway
	const runwayTex = canvasTexture(256, 1024, (g) => {
		g.fillStyle = '#2b2b30'
		g.fillRect(0, 0, 256, 1024)
		for (let i = 0; i < 4000; i++) {
			g.fillStyle = `rgba(255,255,255,${Math.random() * 0.04})`
			g.fillRect(Math.random() * 256, Math.random() * 1024, 2, 2)
		}
		g.fillStyle = '#f2f2f2'
		g.fillRect(6, 0, 6, 1024)
		g.fillRect(244, 0, 6, 1024)
		g.fillStyle = '#e9c46a'
		for (let y = 0; y < 1024; y += 128) g.fillRect(124, y, 8, 64)
	})
	runwayTex.wrapS = runwayTex.wrapT = THREE.RepeatWrapping
	const len = r.z1 - r.z0
	runwayTex.repeat.set(1, len / 60)
	const runway = new THREE.Mesh(
		new THREE.PlaneGeometry(r.halfWidth * 2, len).rotateX(-Math.PI / 2),
		new THREE.MeshStandardMaterial({ map: runwayTex, roughness: 0.85 }),
	)
	runway.position.set(r.x, 0.02, (r.z0 + r.z1) / 2)
	runway.receiveShadow = true
	scene.add(runway)

	// Pads under each challenge area.
	const padMat = new THREE.MeshStandardMaterial({ color: 0x34343a, roughness: 0.9 })
	const pad = (x: number, z: number, w: number, d: number) => {
		const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d).rotateX(-Math.PI / 2), padMat)
		m.position.set(x, 0.015, z)
		m.receiveShadow = true
		scene.add(m)
	}
	pad(LAYOUT.lineup.x, 300, 26, 360)
	pad(LAYOUT.ramp.x, 160, 14, 130)
	pad(LAYOUT.kicker.x, 560, 20, 200)
	pad(LAYOUT.kicker.x, 920, 30, 160)

	// Launch box.
	const lb = LAYOUT.launchBox
	const boxMat = new THREE.MeshBasicMaterial({ color: 0xe9c46a })
	for (const z of [lb.z0, lb.z1]) {
		const line = new THREE.Mesh(new THREE.PlaneGeometry(lb.halfWidth * 2, 0.4).rotateX(-Math.PI / 2), boxMat)
		line.position.set(lb.x, 0.04, z)
		scene.add(line)
	}
	for (const x of [-lb.halfWidth, lb.halfWidth]) {
		const line = new THREE.Mesh(new THREE.PlaneGeometry(0.4, lb.z1 - lb.z0).rotateX(-Math.PI / 2), boxMat)
		line.position.set(lb.x + x, 0.04, (lb.z0 + lb.z1) / 2)
		scene.add(line)
	}
	// Start gantry with three lights.
	const gantry = new THREE.Group()
	const steel = new THREE.MeshStandardMaterial({ color: 0x55565c, metalness: 0.8, roughness: 0.4 })
	for (const x of [-9, 9]) {
		const post = new THREE.Mesh(new THREE.BoxGeometry(0.4, 7, 0.4), steel)
		post.position.set(x, 3.5, 0)
		gantry.add(post)
	}
	const beam = new THREE.Mesh(new THREE.BoxGeometry(18.4, 0.8, 0.6), steel)
	beam.position.y = 7
	gantry.add(beam)
	const lightMats: Array<THREE.MeshStandardMaterial> = []
	for (let i = 0; i < 3; i++) {
		const m = new THREE.MeshStandardMaterial({ color: 0x220000, emissive: 0x000000 })
		const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.35, 16, 8), m)
		bulb.position.set(-1.2 + i * 1.2, 7, -0.4)
		gantry.add(bulb)
		lightMats.push(m)
	}
	gantry.position.set(0, 0, lb.z1 + 6)
	scene.add(gantry)

	// Distance boards along the runway, every 250 m.
	for (let z = 250; z < r.z1; z += 250) {
		for (const side of [-1, 1]) {
			const board = label(`${z} m`, '#9ee7ff')
			board.scale.set(8, 2, 1)
			board.position.set(side * (r.halfWidth + 6), 3, z)
			scene.add(board)
		}
	}
	// 60 mph marker style edge lights.
	const edgeGeo = new THREE.SphereGeometry(0.18, 8, 6)
	const edgeMat = new THREE.MeshBasicMaterial({ color: 0xffe6a8 })
	const edgeCount = Math.floor(len / 30) * 2
	const edges = new THREE.InstancedMesh(edgeGeo, edgeMat, edgeCount)
	const m4 = new THREE.Matrix4()
	let ei = 0
	for (let z = r.z0; z < r.z1 && ei < edgeCount; z += 30) {
		for (const side of [-1, 1]) {
			if (ei >= edgeCount) break
			m4.makeTranslation(side * (r.halfWidth + 0.6), 0.25, z)
			edges.setMatrixAt(ei++, m4)
		}
	}
	scene.add(edges)

	// Traffic lineup: five generic parked vehicles across the lane.
	const lu = LAYOUT.lineup
	const vehicleSpecs: Array<{ len: number; h: number; color: number; kind: 'pod' | 'wedge' | 'cab' | 'suv' | 'sedan' }> = [
		{ len: 4.2, h: 1.5, color: 0xc8b46a, kind: 'pod' },
		{ len: 5.7, h: 1.9, color: 0xa8adb4, kind: 'wedge' },
		{ len: 7.2, h: 3.8, color: 0xe8e8e8, kind: 'cab' },
		{ len: 4.8, h: 1.65, color: 0x2d5fa8, kind: 'suv' },
		{ len: 4.7, h: 1.45, color: 0xb02a2a, kind: 'sedan' },
	]
	vehicleSpecs.forEach((spec, i) => {
		const z = lu.z[i]!
		const v = buildVehicle(spec.len, spec.h, spec.color, spec.kind)
		v.position.set(lu.x, 0, z)
		v.rotation.y = Math.PI / 2
		scene.add(v)
		obstacles.push({
			min: new THREE.Vector3(lu.x - spec.len / 2, 0, z - 1.0),
			max: new THREE.Vector3(lu.x + spec.len / 2, spec.h, z + 1.0),
		})
	})

	// Inverted ramp: a curl that tips past vertical.
	const rp = LAYOUT.ramp
	const segs = 48
	const verts: Array<number> = []
	const idx: Array<number> = []
	for (let i = 0; i <= segs; i++) {
		const a = (i / segs) * rp.maxAngle
		const y = rp.radius * (1 - Math.cos(a))
		const z = rp.z + rp.radius * Math.sin(a)
		const ny = Math.cos(a)
		const nz = -Math.sin(a)
		// Offset slightly along the normal so the car sits on the surface.
		verts.push(rp.x - rp.halfWidth, y - ny * 0.05, z - nz * 0.05, rp.x + rp.halfWidth, y - ny * 0.05, z - nz * 0.05)
		if (i < segs) {
			const b = i * 2
			idx.push(b, b + 2, b + 1, b + 1, b + 2, b + 3)
		}
	}
	const rampGeo = new THREE.BufferGeometry()
	rampGeo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3))
	rampGeo.setIndex(idx)
	rampGeo.computeVertexNormals()
	const rampMat = new THREE.MeshStandardMaterial({ color: 0xdedbd2, roughness: 0.6, metalness: 0.2, side: THREE.DoubleSide })
	const ramp = new THREE.Mesh(rampGeo, rampMat)
	ramp.castShadow = true
	ramp.receiveShadow = true
	scene.add(ramp)
	// Rails and trusses.
	const railMat = new THREE.MeshStandardMaterial({ color: 0xe9c46a, emissive: 0x3a2a00, metalness: 0.6, roughness: 0.4 })
	for (const side of [-1, 1]) {
		const curve = new THREE.CatmullRomCurve3(
			Array.from({ length: 25 }, (_, i) => {
				const a = (i / 24) * rp.maxAngle
				return new THREE.Vector3(rp.x + side * (rp.halfWidth + 0.2), rp.radius * (1 - Math.cos(a)) + 0.4 * Math.cos(a), rp.z + rp.radius * Math.sin(a) - 0.4 * Math.sin(a))
			}),
		)
		scene.add(new THREE.Mesh(new THREE.TubeGeometry(curve, 64, 0.15, 8), railMat))
		for (let i = 2; i <= 24; i += 3) {
			const a = (i / 24) * rp.maxAngle
			const y = rp.radius * (1 - Math.cos(a))
			const z = rp.z + rp.radius * Math.sin(a)
			const strut = new THREE.Mesh(new THREE.BoxGeometry(0.3, y, 0.3), steel)
			strut.position.set(rp.x + side * (rp.halfWidth + 0.3), y / 2, z + 0.3)
			scene.add(strut)
		}
	}

	// Canyon kicker.
	const k = LAYOUT.kicker
	const kSegs = 24
	const kv: Array<number> = []
	const ki: Array<number> = []
	for (let i = 0; i <= kSegs; i++) {
		const z = k.z0 + ((k.z1 - k.z0) * i) / kSegs
		const y = groundHeight(k.x, z - 0.001)
		kv.push(k.x - k.halfWidth, y, z, k.x + k.halfWidth, y, z, k.x - k.halfWidth, 0, z, k.x + k.halfWidth, 0, z)
		if (i < kSegs) {
			const b = i * 4
			ki.push(b, b + 4, b + 1, b + 1, b + 4, b + 5) // top
			ki.push(b, b + 2, b + 4, b + 4, b + 2, b + 6) // left side
			ki.push(b + 1, b + 5, b + 3, b + 3, b + 5, b + 7) // right side
		}
	}
	const last = kSegs * 4
	ki.push(last, last + 1, last + 2, last + 2, last + 1, last + 3)
	const kGeo = new THREE.BufferGeometry()
	kGeo.setAttribute('position', new THREE.Float32BufferAttribute(kv, 3))
	kGeo.setIndex(ki)
	kGeo.computeVertexNormals()
	const kicker = new THREE.Mesh(kGeo, new THREE.MeshStandardMaterial({ color: 0xd8d2c4, roughness: 0.7, side: THREE.DoubleSide }))
	kicker.castShadow = true
	kicker.receiveShadow = true
	scene.add(kicker)

	// Signs.
	const signs: Array<[string, number, number, number]> = [
		['LAUNCH BOX', 0, 12, 2],
		['TRAFFIC HOP', lu.x, 10, 270],
		['INVERTED RAMP', rp.x, 30, 205],
		['CANYON JUMP', k.x, 14, 630],
	]
	for (const [text, x, y, z] of signs) {
		const s = label(text)
		s.position.set(x, y, z)
		scene.add(s)
	}

	// Distant mesas and light towers for depth.
	const mesaMat = new THREE.MeshStandardMaterial({ color: 0x6b4336, roughness: 1, flatShading: true })
	for (let i = 0; i < 40; i++) {
		const a = (i / 40) * Math.PI * 2 + Math.random() * 0.1
		const d = 1400 + Math.random() * 900
		const h = 40 + Math.random() * 160
		const m = new THREE.Mesh(new THREE.CylinderGeometry(60 + Math.random() * 80, 120 + Math.random() * 140, h, 7), mesaMat)
		m.position.set(Math.cos(a) * d, h / 2 - 2, 600 + Math.sin(a) * d)
		scene.add(m)
	}
	const towerTop = new THREE.MeshBasicMaterial({ color: 0xfff1c9 })
	for (const [x, z] of [[-40, 60], [40, 60], [-60, 330], [140, 250], [-240, 640], [-150, 900]] as const) {
		const t = new THREE.Mesh(new THREE.BoxGeometry(0.6, 22, 0.6), steel)
		t.position.set(x, 11, z)
		scene.add(t)
		const head = new THREE.Mesh(new THREE.BoxGeometry(3, 1.2, 1), towerTop)
		head.position.set(x, 22.5, z)
		scene.add(head)
	}

	return { sun, obstacles, lightMats }
}

function buildVehicle(len: number, h: number, color: number, kind: string) {
	const g = new THREE.Group()
	const paint = new THREE.MeshStandardMaterial({ color, metalness: 0.5, roughness: 0.35 })
	const glass = new THREE.MeshStandardMaterial({ color: 0x15202a, metalness: 0.8, roughness: 0.1 })
	const tire = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9 })
	const w = kind === 'cab' ? 2.5 : 1.95
	const add = (m: THREE.Mesh) => {
		m.castShadow = true
		m.receiveShadow = true
		g.add(m)
		return m
	}
	if (kind === 'cab') {
		add(new THREE.Mesh(new THREE.BoxGeometry(w, h - 0.8, 3.2), paint)).position.set(0, (h - 0.8) / 2 + 0.6, len / 2 - 1.6)
		add(new THREE.Mesh(new THREE.BoxGeometry(w * 0.98, 0.9, 2.2), glass)).position.set(0, h - 1.2, len / 2 - 0.4)
		add(new THREE.Mesh(new THREE.BoxGeometry(w * 0.8, 0.5, len - 3.2), paint)).position.set(0, 1.0, -1.6)
	} else if (kind === 'wedge') {
		const s = new THREE.Shape()
		s.moveTo(-len / 2, 0.4)
		s.lineTo(len / 2, 0.4)
		s.lineTo(len / 2, 1.0)
		s.lineTo(0, h)
		s.lineTo(-len / 2, 1.3)
		s.closePath()
		const geo = new THREE.ExtrudeGeometry(s, { depth: w, bevelEnabled: false })
		geo.translate(0, 0, -w / 2)
		geo.rotateY(-Math.PI / 2)
		add(new THREE.Mesh(geo, paint))
	} else {
		const bodyH = kind === 'pod' ? h * 0.55 : h * 0.5
		add(new THREE.Mesh(new THREE.BoxGeometry(w, bodyH, len), paint)).position.y = bodyH / 2 + 0.3
		const cabinLen = kind === 'pod' ? len * 0.55 : len * 0.5
		add(new THREE.Mesh(new THREE.BoxGeometry(w * 0.9, h - bodyH - 0.3, cabinLen), glass)).position.set(0, bodyH + 0.3 + (h - bodyH - 0.3) / 2, kind === 'suv' ? -0.3 : 0)
	}
	for (const x of [-w / 2, w / 2]) {
		for (const z of [len / 2 - 0.9, -len / 2 + 0.9]) {
			const t = add(new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 0.28, 16).rotateZ(Math.PI / 2), tire))
			t.position.set(x, 0.38, z)
		}
	}
	return g
}