City Carnage - Day 14
Three.js + PhysicsThe game uses Three.js to create an immersive 3D city environment. We set up the scene with proper lighting, shadows, and fog for atmosphere.
// Create the 3D scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
scene.fog = new THREE.Fog(0x1a1a2e, 50, 150);
// Perspective camera for 3D view
camera = new THREE.PerspectiveCamera(
60, // Field of view
1, // Aspect ratio (updated on resize)
0.1, // Near clipping plane
500 // Far clipping plane
);
// WebGL renderer with shadows
renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true
});
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
We use multiple light sources to create a realistic city atmosphere:
// Ambient light for base illumination
const ambient = new THREE.AmbientLight(0x404060, 0.6);
scene.add(ambient);
// Directional light (sun) with shadows
const sun = new THREE.DirectionalLight(0xffffcc, 0.8);
sun.position.set(50, 100, 50);
sun.castShadow = true;
// Shadow map configuration for quality
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
sun.shadow.camera.near = 10;
sun.shadow.camera.far = 300;
// Point lights for street lamps
for (let i = 0; i < 20; i++) {
const light = new THREE.PointLight(0xffaa44, 0.5, 30);
light.position.set(
(Math.random() - 0.5) * CITY_SIZE,
8,
(Math.random() - 0.5) * CITY_SIZE
);
scene.add(light);
}
Fog not only adds atmosphere but also improves performance by hiding distant objects that would otherwise be rendered.
The car uses a simplified physics model that feels responsive while remaining easy to control. We track position, rotation, and velocity.
const carState = {
x: 0, // World X position
z: 0, // World Z position
rotation: 0, // Y-axis rotation (radians)
speed: 0, // Current speed
maxSpeed: 1.5, // Maximum forward speed
acceleration: 0.03,
deceleration: 0.01,
turnSpeed: 0.04,
friction: 0.98 // Speed decay when not accelerating
};
function updateCar() {
// Acceleration input
if (keys.up) {
carState.speed += carState.acceleration;
}
if (keys.down) {
carState.speed -= carState.acceleration;
}
// Clamp speed (reverse is slower)
carState.speed = Math.max(
-carState.maxSpeed / 2,
Math.min(carState.maxSpeed, carState.speed)
);
// Apply friction when coasting
if (!keys.up && !keys.down) {
carState.speed *= carState.friction;
}
// Steering (only effective when moving)
if (Math.abs(carState.speed) > 0.01) {
const turnFactor = carState.speed > 0 ? 1 : -1;
if (keys.left) carState.rotation += carState.turnSpeed * turnFactor;
if (keys.right) carState.rotation -= carState.turnSpeed * turnFactor;
}
// Update position based on speed and rotation
carState.x += Math.sin(carState.rotation) * carState.speed;
carState.z += Math.cos(carState.rotation) * carState.speed;
// Apply to 3D mesh
car.position.x = carState.x;
car.position.z = carState.z;
car.rotation.y = carState.rotation;
}
Car Movement Vector
The city is generated procedurally using a grid-based approach. Roads form a grid pattern, and buildings fill the blocks between them.
const CITY_SIZE = 200; // Total city size
const BLOCK_SIZE = 30; // Size of each city block
const ROAD_WIDTH = 10; // Width of roads
function createRoads() {
const roadMat = new THREE.MeshLambertMaterial({
color: 0x333344
});
// Create grid of roads
for (let x = -CITY_SIZE/2; x <= CITY_SIZE/2; x += BLOCK_SIZE + ROAD_WIDTH) {
// Vertical roads
const roadV = new THREE.Mesh(
new THREE.PlaneGeometry(ROAD_WIDTH, CITY_SIZE),
roadMat
);
roadV.rotation.x = -Math.PI / 2;
roadV.position.set(x, 0.01, 0);
scene.add(roadV);
// Add center line markings
const lineMat = new THREE.MeshBasicMaterial({ color: 0xffff44 });
const line = new THREE.Mesh(
new THREE.PlaneGeometry(0.3, CITY_SIZE),
lineMat
);
line.rotation.x = -Math.PI / 2;
line.position.set(x, 0.02, 0);
scene.add(line);
}
}
function createBuildings() {
const colors = [0x445566, 0x556677, 0x667788];
for (let x = -CITY_SIZE/2 + ROAD_WIDTH; x < CITY_SIZE/2; x += BLOCK_SIZE + ROAD_WIDTH) {
for (let z = -CITY_SIZE/2 + ROAD_WIDTH; z < CITY_SIZE/2; z += BLOCK_SIZE + ROAD_WIDTH) {
// Multiple buildings per block
const numBuildings = 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < numBuildings; i++) {
const height = 10 + Math.random() * 40;
const width = 5 + Math.random() * 10;
const depth = 5 + Math.random() * 10;
const building = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshLambertMaterial({
color: colors[Math.floor(Math.random() * colors.length)]
})
);
// Random position within block
building.position.set(
x + (Math.random() - 0.5) * (BLOCK_SIZE - width),
height / 2,
z + (Math.random() - 0.5) * (BLOCK_SIZE - depth)
);
building.castShadow = true;
scene.add(building);
}
}
}
}
By storing building dimensions in userData, we can later use them for precise collision detection without recalculating.
Pedestrians are simple 3D humanoid figures with autonomous movement and walking animations.
function createPedestrian(x, z) {
const ped = new THREE.Group();
// Random appearance
const skinColors = [0xf5d0c5, 0xd4a574, 0x8b5a3c];
const shirtColors = [0xff6b6b, 0x4ecdc4, 0xffe66d];
// Body parts
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.6, 1, 0.4),
new THREE.MeshLambertMaterial({ color: shirtColor })
);
body.position.y = 1.2;
ped.add(body);
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.25, 8, 8),
new THREE.MeshLambertMaterial({ color: skinColor })
);
head.position.y = 2;
ped.add(head);
// Legs for animation
const legL = new THREE.Mesh(legGeo, legMat);
legL.position.set(-0.15, 0.35, 0);
ped.add(legL);
// Store movement data
const angle = Math.random() * Math.PI * 2;
ped.userData = {
vx: Math.cos(angle) * 0.02,
vz: Math.sin(angle) * 0.02,
walkPhase: Math.random() * Math.PI * 2,
hit: false
};
return ped;
}
function updatePedestrians() {
pedestrians.forEach(ped => {
if (ped.userData.hit) return;
// Move pedestrian
ped.position.x += ped.userData.vx;
ped.position.z += ped.userData.vz;
// Walking animation using sine wave
ped.userData.walkPhase += 0.1;
const legL = ped.children[3];
const legR = ped.children[4];
// Opposite leg movement creates walking motion
legL.rotation.x = Math.sin(ped.userData.walkPhase) * 0.3;
legR.rotation.x = -Math.sin(ped.userData.walkPhase) * 0.3;
// Face movement direction
ped.rotation.y = Math.atan2(ped.userData.vx, ped.userData.vz);
// Random direction changes
if (Math.random() < 0.005) {
const angle = Math.random() * Math.PI * 2;
ped.userData.vx = Math.cos(angle) * 0.02;
ped.userData.vz = Math.sin(angle) * 0.02;
}
});
}
Collision detection uses transformed bounding boxes to accurately detect hits even when the car is rotated.
function checkCollisions() {
// Transform pedestrian position to car's local space
const cos = Math.cos(carState.rotation);
const sin = Math.sin(carState.rotation);
pedestrians.forEach(ped => {
if (ped.userData.hit) return;
// Distance from car center
const dx = ped.position.x - carState.x;
const dz = ped.position.z - carState.z;
// Transform to car's local coordinate space
const localX = dx * cos + dz * sin;
const localZ = -dx * sin + dz * cos;
// Check against car dimensions in local space
if (Math.abs(localX) < 1.5 && Math.abs(localZ) < 2.5) {
// Collision detected!
ped.userData.hit = true;
// Score based on speed
const points = 100 + Math.floor(Math.abs(carState.speed) * 100);
score += points;
}
});
}
Coordinate Transformation
function animateHit(ped, forceX, forceZ) {
let vy = 0.3; // Initial upward velocity
let vx = forceX + (Math.random() - 0.5) * 0.2;
let vz = forceZ + (Math.random() - 0.5) * 0.2;
let rotX = (Math.random() - 0.5) * 0.3;
let rotZ = (Math.random() - 0.5) * 0.3;
function animFrame() {
ped.position.x += vx;
ped.position.y += vy;
ped.position.z += vz;
ped.rotation.x += rotX;
ped.rotation.z += rotZ;
vy -= 0.02; // Gravity
if (ped.position.y > 0) {
requestAnimationFrame(animFrame);
} else {
ped.position.y = 0;
// Remove after delay, respawn new pedestrian
}
}
animFrame();
}
The force direction uses the car's rotation to push pedestrians in the direction of travel, creating realistic impact physics.
The camera follows behind the car using smooth interpolation, creating a cinematic chase-cam feel.
// Camera follows behind the car
const camDist = 20; // Distance behind car
const camHeight = 12; // Height above ground
// Calculate camera position based on car rotation
camera.position.x = carState.x - Math.sin(carState.rotation) * camDist;
camera.position.z = carState.z - Math.cos(carState.rotation) * camDist;
camera.position.y = camHeight;
// Always look at the car
camera.lookAt(carState.x, 2, carState.z);
The camera position is calculated using trigonometry to place it behind the car regardless of which direction the car is facing:
sin(rotation) gives the X component of the car's forward vectorcos(rotation) gives the Z component of the car's forward vectorCamera Position Calculation
For smoother camera movement, you could add lerp (linear interpolation) to gradually move the camera to its target position instead of snapping directly.