← Public packages
@kentcdodds/roadster
Unofficial fan game: drive a stylized thruster roadster - launch 0-60, boost, hover, flip and hop over traffic.
src/client/world.ts
431 lines · 16.1 KB · TypeScriptimport * 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
}