🎮ArcadeLab

City Heist

by LunarGalaxy87
779 lines30.4 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>City Heist</title>
<style>
  html,body{margin:0;padding:0;overflow:hidden;background:#111;font-family:Arial,sans-serif;touch-action:none;}
  #hud{position:fixed;top:10px;left:10px;color:#fff;z-index:10;text-shadow:1px 1px 2px #000;font-size:16px;}
  #wanted{position:fixed;top:10px;right:10px;color:#ff3b3b;z-index:10;font-size:22px;text-shadow:1px 1px 2px #000;}
  #msg{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;font-size:28px;z-index:20;text-shadow:2px 2px 4px #000;display:none;text-align:center;}
  #controls{position:fixed;bottom:20px;left:0;right:0;display:flex;justify-content:space-between;padding:0 20px;z-index:10;}
  .pad{display:flex;gap:10px;}
  .btn{width:60px;height:60px;background:rgba(255,255,255,0.2);border:2px solid rgba(255,255,255,0.5);border-radius:12px;color:#fff;font-size:24px;display:flex;align-items:center;justify-content:center;user-select:none;}
  #err{position:fixed;bottom:0;left:0;right:0;background:#900;color:#fff;font-size:12px;padding:5px;z-index:999;display:none;white-space:pre-wrap;}
  #buyPrompt{position:fixed;bottom:110px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;padding:12px 18px;border-radius:10px;font-size:16px;z-index:15;display:none;text-align:center;}
  #buyPrompt button{margin-top:8px;padding:8px 16px;font-size:16px;border-radius:8px;border:none;background:#33cc66;color:#fff;}
  #speedFx{position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:5;background:radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,0.35) 100%);opacity:0;transition:opacity 0.15s;}
  #loadScreen{position:fixed;top:0;left:0;right:0;bottom:0;background:linear-gradient(160deg,#0d1220,#1a2035);z-index:1000;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity 0.6s ease;}
  #loadScreen.hidden{opacity:0;pointer-events:none;}
  #loadTitle{color:#fff;font-size:32px;font-weight:bold;letter-spacing:2px;margin-bottom:24px;text-shadow:0 0 20px rgba(255,200,100,0.4);}
  #loadBarTrack{width:min(70vw,320px);height:8px;background:rgba(255,255,255,0.15);border-radius:6px;overflow:hidden;}
  #loadBarFill{height:100%;width:0%;background:linear-gradient(90deg,#ffcc66,#ff9955);border-radius:6px;transition:width 0.25s ease;}
  #loadStatus{color:#aab;font-size:13px;margin-top:14px;letter-spacing:1px;}
</style>
</head>
<body>
<div id="hud">Cash: $<span id="cash">0</span><br>Mission: <span id="mission">Drive around town</span><br>Owned: <span id="ownedCount">0</span></div>
<div id="wanted"></div>
<div id="msg"></div>
<div id="buyPrompt"></div>
<div id="speedFx"></div>
<div id="err"></div>

<div id="loadScreen">
  <div id="loadTitle">CITY HEIST</div>
  <div id="loadBarTrack"><div id="loadBarFill"></div></div>
  <div id="loadStatus">Loading engine…</div>
</div>

<div id="controls">
  <div class="pad">
    <div class="btn" id="left">◀</div>
    <div class="btn" id="right">▶</div>
  </div>
  <div class="pad">
    <div class="btn" id="brake">🛑</div>
    <div class="btn" id="gas">▲</div>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" onerror="document.getElementById('err').style.display='block'; document.getElementById('err').textContent='Failed to load Three.js from CDN — check your internet connection and reload.';"></script>
<script>
if(typeof THREE === 'undefined'){
  document.getElementById('err').style.display='block';
  document.getElementById('err').textContent='THREE failed to load — try reloading the page.';
  var lsFail = document.getElementById('loadScreen');
  if(lsFail) lsFail.style.display = 'none';
} else {
try{
window.CITY_HEIST_STAGE = 'scene-setup';
function setLoadProgress(pct, label){
  var fill = document.getElementById('loadBarFill');
  var status = document.getElementById('loadStatus');
  if(fill) fill.style.width = pct + '%';
  if(status && label) status.textContent = label;
}
setLoadProgress(10, 'Loading engine…');
window.onerror = function(msg, source, lineno, colno, error){
  var e=document.getElementById('err');
  e.style.display='block';
  var detail = (error && error.stack) ? error.stack : msg;
  e.textContent = 'Error: ' + detail + ' (line ' + lineno + ':' + colno + ')';
};
window.addEventListener('unhandledrejection', function(ev){
  var e=document.getElementById('err');
  e.style.display='block';
  e.textContent = 'Promise error: ' + ev.reason;
});

// ---------- Setup ----------
var scene = new THREE.Scene();

var camera = new THREE.PerspectiveCamera(65, window.innerWidth/window.innerHeight, 0.1, 1000);
var renderer = new THREE.WebGLRenderer({antialias:false, powerPreference:'high-performance'});
renderer.setPixelRatio(1);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
renderer.domElement.style.position = 'fixed';
renderer.domElement.style.top = '0';
renderer.domElement.style.left = '0';

var RENDER_SCALE = 0.65; // internal render resolution scale for perf + upscale look
function applyRendererSize(){
  var w = window.innerWidth, h = window.innerHeight;
  renderer.setSize(w*RENDER_SCALE, h*RENDER_SCALE, false);
  renderer.domElement.style.width = w + 'px';
  renderer.domElement.style.height = h + 'px';
  camera.aspect = w/h;
  camera.updateProjectionMatrix();
}
applyRendererSize();
window.addEventListener('resize', applyRendererSize);

// dusk-ish sky for dramatic lighting + working headlights
scene.background = new THREE.Color(0x1a2035);
scene.fog = new THREE.Fog(0x1a2035, 25, 110);

var hemi = new THREE.HemisphereLight(0x8899cc, 0x222233, 0.6);
scene.add(hemi);
var sun = new THREE.DirectionalLight(0xffe8cc, 0.7);
sun.position.set(60,50,-40);
sun.castShadow = true;
sun.shadow.mapSize.set(512,512);
sun.shadow.camera.left=-80; sun.shadow.camera.right=80;
sun.shadow.camera.top=80; sun.shadow.camera.bottom=-80;
sun.shadow.bias = -0.0005;
scene.add(sun);

// ---------- City grid ----------
var CITY_SIZE = 240;
var BLOCK = 24;
var ROAD_W = 8;

// procedural window-lit facade texture for buildings (adds realism vs flat color)
function makeBuildingTexture(baseColor){
  var c = document.createElement('canvas');
  c.width = 128; c.height = 256;
  var ctx = c.getContext('2d');
  ctx.fillStyle = '#' + baseColor.toString(16).padStart(6,'0');
  ctx.fillRect(0,0,128,256);
  var rows = 16, cols = 6;
  for(var r=0;r<rows;r++){
    for(var cI=0;cI<cols;cI++){
      var lit = Math.random() < 0.35;
      ctx.fillStyle = lit ? 'rgba(255,230,150,0.9)' : 'rgba(20,25,35,0.6)';
      ctx.fillRect(cI*(128/cols)+4, r*(256/rows)+4, 128/cols-8, 256/rows-8);
    }
  }
  var tex = new THREE.CanvasTexture(c);
  tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping;
  return tex;
}

// environment cube camera for real-time car paint reflections
var envCubeTarget = new THREE.WebGLCubeRenderTarget(64, {format:THREE.RGBFormat});
var envCubeCamera = new THREE.CubeCamera(0.1, 200, envCubeTarget);

var groundMat = new THREE.MeshLambertMaterial({color:0x2e2e2e});
var ground = new THREE.Mesh(new THREE.PlaneGeometry(CITY_SIZE*2, CITY_SIZE*2), groundMat);
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
scene.add(ground);

var roadMat = new THREE.MeshLambertMaterial({color:0x3a3a3a});
var sidewalkMat = new THREE.MeshLambertMaterial({color:0x999999});
var buildingMats = [0x8899aa,0xaa8866,0x778866,0x997788,0x668899].map(function(c){
  return new THREE.MeshStandardMaterial({map: makeBuildingTexture(c), color:0xffffff, roughness:0.85});
});

var buildings = [];
var roadCellsH = []; // horizontal road segment centers
var roadCellsV = [];

for(var gx=-CITY_SIZE/2; gx<CITY_SIZE/2; gx+=BLOCK){  for(var gz=-CITY_SIZE/2; gz<CITY_SIZE/2; gz+=BLOCK){
    var roadH = new THREE.Mesh(new THREE.PlaneGeometry(BLOCK,ROAD_W), roadMat);
    roadH.rotation.x=-Math.PI/2; roadH.position.set(gx+BLOCK/2,0.01,gz);
    scene.add(roadH);
    roadCellsH.push({x:gx+BLOCK/2, z:gz});

    var roadV = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_W,BLOCK), roadMat);
    roadV.rotation.x=-Math.PI/2; roadV.position.set(gx,0.01,gz+BLOCK/2);
    scene.add(roadV);
    roadCellsV.push({x:gx, z:gz+BLOCK/2});

    // sidewalk strip around block interior
    var sw = new THREE.Mesh(new THREE.PlaneGeometry(BLOCK-ROAD_W-2, BLOCK-ROAD_W-2), sidewalkMat);
    sw.rotation.x=-Math.PI/2; sw.position.set(gx+BLOCK/2, 0.005, gz+BLOCK/2);
    scene.add(sw);

    if(Math.random()>0.15){
      var h = 6 + Math.random()*30;
      var w = 8 + Math.random()*6;
      var mat = buildingMats[Math.floor(Math.random()*buildingMats.length)];
      var b = new THREE.Mesh(new THREE.BoxGeometry(w,h,w), mat);
      b.position.set(gx+BLOCK/2 + (Math.random()-0.5)*2, h/2, gz+BLOCK/2 + (Math.random()-0.5)*2);
      b.castShadow = true; b.receiveShadow = true;
      scene.add(b);
      var isProperty = Math.random() < 0.25;
      var propType = h > 20 ? 'apartment' : 'house';
      var price = propType==='apartment' ? Math.round((800 + Math.random()*1200)/10)*10 : Math.round((300 + Math.random()*500)/10)*10;
      buildings.push({mesh:b, w:w, d:w, isProperty:isProperty, propType:propType, price:price, owned:false});
      if(isProperty){
        var signGeo = new THREE.PlaneGeometry(1.4,0.6);
        var signMat = new THREE.MeshBasicMaterial({color: propType==='apartment'?0x3399ff:0x33cc66, side:THREE.DoubleSide});
        var sign = new THREE.Mesh(signGeo, signMat);
        sign.position.set(b.position.x, 2.2, b.position.z + w/2 + 0.05);
        scene.add(sign);
        buildings[buildings.length-1].sign = sign;
      }
    }
    // street lamp at every other intersection for night ambience
    if(Math.random() < 0.12){
      var poleMat = new THREE.MeshStandardMaterial({color:0x222222, metalness:0.6, roughness:0.5});
      var pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08,0.08,4.5,8), poleMat);
      pole.position.set(gx-1.5, 2.25, gz-1.5);
      pole.castShadow = true;
      scene.add(pole);
      var lampGlow = new THREE.PointLight(0xffcc77, 1.2, 14);
      lampGlow.position.set(gx-1.5, 4.4, gz-1.5);
      scene.add(lampGlow);
      var bulb = new THREE.Mesh(new THREE.SphereGeometry(0.18,8,8), new THREE.MeshStandardMaterial({color:0xffddaa, emissive:0xffcc66, emissiveIntensity:1.5}));
      bulb.position.copy(lampGlow.position);
      scene.add(bulb);
    }
  }
}

// ---------- Realistic-ish car builder ----------
var carPalette = [0xdd2222,0x2255ff,0xffffff,0x222222,0xffcc00,0x00994d,0x888888,0x8b0000];

function makeCar(color, isPlayer){
  var car = new THREE.Group();
  var bodyMat = isPlayer
    ? new THREE.MeshPhysicalMaterial({color:color, metalness:0.7, roughness:0.2, clearcoat:1, clearcoatRoughness:0.1, envMap: envCubeTarget.texture, envMapIntensity:1.2})
    : new THREE.MeshStandardMaterial({color:color, metalness:0.5, roughness:0.4});
  var glassMat = new THREE.MeshPhysicalMaterial({color:0x0a1018, metalness:0.2, roughness:0.05, transparent:true, opacity:0.7, transmission:0.3});
  var trimMat = new THREE.MeshStandardMaterial({color:0x111111, metalness:0.6, roughness:0.4});
  var lightMatF = new THREE.MeshStandardMaterial({color:0xffffee, emissive:0xffffcc, emissiveIntensity:1.4});
  var lightMatR = new THREE.MeshStandardMaterial({color:0x660000, emissive:0xff0000, emissiveIntensity:1.0});

  // lower body (rounded via two stacked boxes + bevel illusion using scale)
  var lower = new THREE.Mesh(new THREE.BoxGeometry(1.9,0.55,4.2), bodyMat);
  lower.position.y = 0.45;
  lower.castShadow = isPlayer;
  car.add(lower);

  // hood/trunk taper using extra boxes
  var hood = new THREE.Mesh(new THREE.BoxGeometry(1.8,0.1,1.1), bodyMat);
  hood.position.set(0,0.73,-1.55);
  car.add(hood);
  var trunk = new THREE.Mesh(new THREE.BoxGeometry(1.8,0.1,1.0), bodyMat);
  trunk.position.set(0,0.73,1.6);
  car.add(trunk);

  // cabin (greenhouse), tapered narrower than body
  var cabin = new THREE.Mesh(new THREE.BoxGeometry(1.5,0.55,2.1), bodyMat);
  cabin.position.set(0,1.05,-0.15);
  cabin.castShadow = isPlayer;
  car.add(cabin);

  // windows
  var windshield = new THREE.Mesh(new THREE.BoxGeometry(1.42,0.42,0.06), glassMat);
  windshield.position.set(0,1.05,-1.18);
  windshield.rotation.x = -0.25;
  car.add(windshield);
  var rearWindow = new THREE.Mesh(new THREE.BoxGeometry(1.42,0.4,0.06), glassMat);
  rearWindow.position.set(0,1.05,0.88);
  rearWindow.rotation.x = 0.3;
  car.add(rearWindow);
  var sideL = new THREE.Mesh(new THREE.BoxGeometry(0.05,0.4,1.8), glassMat);
  sideL.position.set(-0.76,1.05,-0.15);
  car.add(sideL);
  var sideR = sideL.clone();
  sideR.position.x = 0.76;
  car.add(sideR);

  // bumpers
  var bumperF = new THREE.Mesh(new THREE.BoxGeometry(1.95,0.35,0.25), trimMat);
  bumperF.position.set(0,0.35,-2.05);
  car.add(bumperF);
  var bumperR = bumperF.clone();
  bumperR.position.z = 2.05;
  car.add(bumperR);

  // headlights / taillights
  var hlGeo = new THREE.BoxGeometry(0.3,0.15,0.08);
  var hlL = new THREE.Mesh(hlGeo, lightMatF); hlL.position.set(-0.7,0.55,-2.08); car.add(hlL);
  var hlR = hlL.clone(); hlR.position.x = 0.7; car.add(hlR);
  var tlGeo = new THREE.BoxGeometry(0.3,0.15,0.08);
  var tlL = new THREE.Mesh(tlGeo, lightMatR); tlL.position.set(-0.7,0.55,2.08); car.add(tlL);
  var tlR = tlL.clone(); tlR.position.x = 0.7; car.add(tlR);

  // real headlight spotlights (player only — perf) + taillight glow
  if(isPlayer){
    var headL = new THREE.SpotLight(0xfff4dd, 6, 30, Math.PI/7, 0.4, 1.5);
    headL.position.set(-0.7,0.6,-2.1);
    var headLTarget = new THREE.Object3D();
    headLTarget.position.set(-0.7,0,-12);
    car.add(headLTarget);
    headL.target = headLTarget;
    car.add(headL);
    var headR = headL.clone();
    headR.position.set(0.7,0.6,-2.1);
    var headRTarget = new THREE.Object3D();
    headRTarget.position.set(0.7,0,-12);
    car.add(headRTarget);
    headR.target = headRTarget;
    car.add(headR);
  }
  var tailGlow = new THREE.PointLight(0xff2222, 0.5, 2.5);
  tailGlow.position.set(0,0.55,2.1);
  car.add(tailGlow);

  // wheels with rims
  var wheelGeo = new THREE.CylinderGeometry(0.42,0.42,0.32,16);
  var wheelMat = new THREE.MeshStandardMaterial({color:0x0a0a0a, roughness:0.9});
  var rimMat = new THREE.MeshStandardMaterial({color:0xcccccc, metalness:0.9, roughness:0.2});
  var wheelPositions = [[-0.98,0.42,1.35],[0.98,0.42,1.35],[-0.98,0.42,-1.35],[0.98,0.42,-1.35]];
  car.wheels = [];
  wheelPositions.forEach(function(p){
    var wGroup = new THREE.Group();
    var w = new THREE.Mesh(wheelGeo, wheelMat);
    w.rotation.z = Math.PI/2;
    w.castShadow = isPlayer;
    wGroup.add(w);
    var rim = new THREE.Mesh(new THREE.CylinderGeometry(0.2,0.2,0.34,10), rimMat);
    rim.rotation.z = Math.PI/2;
    wGroup.add(rim);
    wGroup.position.set(p[0],p[1],p[2]);
    car.add(wGroup);
    car.wheels.push(wGroup);
  });

  car.userData.color = color;
  return car;
}

var player = makeCar(0xcc1122, true);
player.position.set(0,0,0);
scene.add(player);

var playerState = {speed:0, heading:0, cash:0, wanted:0, ownedCount:0};
var nearbyProperty = null;

function updateBuyPrompt(){
  var el = document.getElementById('buyPrompt');
  nearbyProperty = null;
  for(var i=0;i<buildings.length;i++){
    var bdef = buildings[i];
    if(!bdef.isProperty || bdef.owned) continue;
    var dx = bdef.mesh.position.x - player.position.x;
    var dz = bdef.mesh.position.z - player.position.z;
    if(Math.sqrt(dx*dx+dz*dz) < bdef.w/2 + 5){
      nearbyProperty = bdef;
      break;
    }
  }
  if(nearbyProperty){
    el.style.display = 'block';
    el.innerHTML = 'Buy this ' + nearbyProperty.propType + ' for $' + nearbyProperty.price + '?<br><button id="buyBtn">Buy</button>';
    var btn = document.getElementById('buyBtn');
    btn.onclick = function(){
      if(nearbyProperty && !nearbyProperty.owned && playerState.cash >= nearbyProperty.price){
        playerState.cash -= nearbyProperty.price;
        nearbyProperty.owned = true;
        playerState.ownedCount++;
        document.getElementById('cash').textContent = playerState.cash;
        document.getElementById('ownedCount').textContent = playerState.ownedCount;
        if(nearbyProperty.sign){
          nearbyProperty.sign.material.color.set(0xffd700);
        }
        showMsg('You bought a ' + nearbyProperty.propType + '!');
        el.style.display = 'none';
      } else if(nearbyProperty && playerState.cash < nearbyProperty.price){
        showMsg("Can't afford it yet");
      }
    };
  } else {
    el.style.display = 'none';
  }
}

// ---------- NPC traffic cars ----------
var npcCars = [];
function nearestRoadSnap(x,z){
  // snap to nearest road lane center
  var bx = Math.round((x)/BLOCK)*BLOCK;
  var bz = Math.round((z)/BLOCK)*BLOCK;
  return {x:bx, z:bz};
}
function spawnNpcCar(){
  var color = carPalette[Math.floor(Math.random()*carPalette.length)];
  var c = makeCar(color, false);
  var horiz = Math.random()<0.5;
  var idx = Math.floor(Math.random()* (horiz?roadCellsH.length:roadCellsV.length));
  var cell = horiz ? roadCellsH[idx] : roadCellsV[idx];
  c.position.set(cell.x + (Math.random()-0.5)*2, 0, cell.z + (Math.random()-0.5)*2);
  c.rotation.y = horiz ? Math.PI/2 : 0;
  scene.add(c);
  npcCars.push({mesh:c, dir:horiz?'h':'v', speed: 4+Math.random()*3, sign: Math.random()<0.5?1:-1});
}
setLoadProgress(35, 'Building the city…');
for(var i=0;i<7;i++) spawnNpcCar();

// ---------- NPC pedestrians ----------
var peds = [];
function makePed(){
  var g = new THREE.Group();
  var skinMat = new THREE.MeshStandardMaterial({color:0xffcc99, roughness:0.8});
  var shirtColors = [0x3355aa,0xaa3355,0x33aa55,0xaaaa33,0x555555,0x996633];
  var shirtMat = new THREE.MeshStandardMaterial({color:shirtColors[Math.floor(Math.random()*shirtColors.length)], roughness:0.8});
  var pantsMat = new THREE.MeshStandardMaterial({color:0x222233, roughness:0.8});
  var body = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.7,8), shirtMat);
  body.position.y = 0.9;
  body.castShadow = false;
  g.add(body);
  var head = new THREE.Mesh(new THREE.SphereGeometry(0.16,10,10), skinMat);
  head.position.y = 1.42;
  head.castShadow = false;
  g.add(head);
  var legs = new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.15,0.6,8), pantsMat);
  legs.position.y = 0.35;
  g.add(legs);
  return g;
}
function spawnPed(){
  var p = makePed();
  var angle = Math.random()*Math.PI*2;
  var dist = 10+Math.random()*60;
  p.position.set(Math.cos(angle)*dist, 0, Math.sin(angle)*dist);
  var heading = Math.random()*Math.PI*2;
  scene.add(p);
  peds.push({mesh:p, heading:heading, speed:1+Math.random()*0.8, timer:2+Math.random()*3});
}
setLoadProgress(60, 'Spawning citizens…');
for(var i=0;i<10;i++) spawnPed();

// ---------- Cops ----------
var cops = [];
function spawnCop(){
  var c = makeCar(0x1133cc, false);
  var lightbar = new THREE.Mesh(new THREE.BoxGeometry(0.9,0.15,0.4), new THREE.MeshStandardMaterial({color:0x222222}));
  lightbar.position.set(0,1.4,-0.15);
  c.add(lightbar);
  var redLight = new THREE.Mesh(new THREE.BoxGeometry(0.4,0.12,0.35), new THREE.MeshStandardMaterial({color:0xff0000, emissive:0xff0000, emissiveIntensity:1}));
  redLight.position.set(-0.25,1.4,-0.15);
  c.add(redLight);
  var blueLight = new THREE.Mesh(new THREE.BoxGeometry(0.4,0.12,0.35), new THREE.MeshStandardMaterial({color:0x0000ff, emissive:0x0000ff, emissiveIntensity:1}));
  blueLight.position.set(0.25,1.4,-0.15);
  c.add(blueLight);
  c.userData.lights = [redLight, blueLight];
  var angle = Math.random()*Math.PI*2;
  c.position.set(player.position.x + Math.cos(angle)*40, 0, player.position.z + Math.sin(angle)*40);
  scene.add(c);
  cops.push({mesh:c, speed:0});
}

// ---------- Controls ----------
var input = {left:false, right:false, gas:false, brake:false};
function bindBtn(id, key){
  var el = document.getElementById(id);
  el.addEventListener('touchstart', function(e){e.preventDefault(); input[key]=true;});
  el.addEventListener('touchend', function(e){e.preventDefault(); input[key]=false;});
  el.addEventListener('mousedown', function(){input[key]=true;});
  el.addEventListener('mouseup', function(){input[key]=false;});
  el.addEventListener('mouseleave', function(){input[key]=false;});
}
bindBtn('left','left'); bindBtn('right','right'); bindBtn('gas','gas'); bindBtn('brake','brake');

window.addEventListener('keydown', function(e){
  if(e.key==='ArrowLeft'||e.key==='a') input.left=true;
  if(e.key==='ArrowRight'||e.key==='d') input.right=true;
  if(e.key==='ArrowUp'||e.key==='w') input.gas=true;
  if(e.key==='ArrowDown'||e.key==='s') input.brake=true;
});
window.addEventListener('keyup', function(e){
  if(e.key==='ArrowLeft'||e.key==='a') input.left=false;
  if(e.key==='ArrowRight'||e.key==='d') input.right=false;
  if(e.key==='ArrowUp'||e.key==='w') input.gas=false;
  if(e.key==='ArrowDown'||e.key==='s') input.brake=false;
});

// ---------- Cash pickups ----------
var cashDrops = [];
function spawnCashDrop(){
  var geo = new THREE.OctahedronGeometry(0.6);
  var mat = new THREE.MeshBasicMaterial({color:0xffd700});
  var m = new THREE.Mesh(geo, mat);
  var angle = Math.random()*Math.PI*2;
  var dist = 15 + Math.random()*30;
  m.position.set(player.position.x + Math.cos(angle)*dist, 1, player.position.z + Math.sin(angle)*dist);
  scene.add(m);
  cashDrops.push(m);
}
setLoadProgress(85, 'Placing cash drops…');
for(var i=0;i<6;i++) spawnCashDrop();

function showMsg(text){
  var el = document.getElementById('msg');
  el.textContent = text;
  el.style.display = 'block';
  setTimeout(function(){ el.style.display='none'; }, 1500);
}

function applyCarDamage(car){
  if(!car.userData.damage) car.userData.damage = 0;
  car.userData.damage = Math.min(1, car.userData.damage + 0.25);
  var dmg = car.userData.damage;
  // darken and dent body meshes
  car.children.forEach(function(part){
    if(part.material && part.material.color && part.geometry && part.geometry.type==='BoxGeometry'){
      part.material.color.multiplyScalar(1 - dmg*0.15);
      part.scale.x = 1 - dmg*0.08*Math.random();
      part.scale.y = 1 - dmg*0.08*Math.random();
    }
  });
  spawnSmoke(car.position);
}

var smokes = [];
function spawnSmoke(pos){
  var geo = new THREE.SphereGeometry(0.3,6,6);
  var mat = new THREE.MeshBasicMaterial({color:0x555555, transparent:true, opacity:0.6});
  var m = new THREE.Mesh(geo, mat);
  m.position.set(pos.x, 1.2, pos.z);
  scene.add(m);
  smokes.push({mesh:m, life:1.0});
}

function carCollision(a, b, aIsNpcObj, bIsNpcObj){
  var dx = a.position.x - b.position.x;
  var dz = a.position.z - b.position.z;
  var dist = Math.sqrt(dx*dx+dz*dz);
  if(dist < 2.6 && dist > 0.001){
    var push = (2.6-dist)/2;
    var nx = dx/dist, nz = dz/dist;
    a.position.x += nx*push; a.position.z += nz*push;
    b.position.x -= nx*push; b.position.z -= nz*push;
    applyCarDamage(a);
    applyCarDamage(b);
    return true;
  }
  return false;
}
function collidesBuilding(x,z){
  for(var i=0;i<buildings.length;i++){
    var b = buildings[i].mesh;
    if(Math.abs(x-b.position.x) < buildings[i].w/2+1.2 && Math.abs(z-b.position.z) < buildings[i].d/2+1.2){
      return true;
    }
  }
  return false;
}

// ---------- Game loop ----------
var clock = new THREE.Clock();

function spinWheels(car, dist){
  if(!car.wheels) return;
  car.wheels.forEach(function(w){ w.children[0].rotation.x -= dist*2; w.children[1].rotation.x -= dist*2; });
}

function update(dt){
  // player physics
  var accel = 12, maxSpeed = 26, turnSpeed = 1.8, friction = 6;
  if(input.gas) playerState.speed += accel*dt;
  else if(input.brake) playerState.speed -= accel*1.5*dt;
  else {
    if(playerState.speed>0) playerState.speed -= friction*dt;
    if(playerState.speed<0) playerState.speed += friction*dt;
  }
  playerState.speed = Math.max(-maxSpeed/2, Math.min(maxSpeed, playerState.speed));

  var turnFactor = Math.min(1, Math.abs(playerState.speed)/6);
  if(input.left) playerState.heading += turnSpeed*dt*turnFactor*(playerState.speed>=0?1:-1);
  if(input.right) playerState.heading -= turnSpeed*dt*turnFactor*(playerState.speed>=0?1:-1);

  var nx = player.position.x + Math.sin(playerState.heading)*playerState.speed*dt;
  var nz = player.position.z + Math.cos(playerState.heading)*playerState.speed*dt;
  if(!collidesBuilding(nx,nz)){
    player.position.x = nx;
    player.position.z = nz;
    spinWheels(player, playerState.speed*dt);
  } else {
    playerState.speed *= -0.3;
  }
  player.rotation.y = playerState.heading;

  // camera follow
  var camDist = 12, camHeight = 6;
  var camX = player.position.x - Math.sin(playerState.heading)*camDist;
  var camZ = player.position.z - Math.cos(playerState.heading)*camDist;
  camera.position.lerp(new THREE.Vector3(camX, camHeight, camZ), 0.1);
  camera.lookAt(player.position.x, 1, player.position.z);

  // NPC cars drive along their lane back and forth
  npcCars.forEach(function(npc){
    var m = npc.mesh;
    var moveDist = npc.speed*npc.sign*dt;
    if(npc.dir==='h'){
      m.position.x += moveDist;
      m.rotation.y = npc.sign>0 ? Math.PI/2 : -Math.PI/2;
    } else {
      m.position.z += moveDist;
      m.rotation.y = npc.sign>0 ? 0 : Math.PI;
    }
    spinWheels(m, moveDist);
    // bounce within city bounds
    if(Math.abs(m.position.x) > CITY_SIZE/2 || Math.abs(m.position.z) > CITY_SIZE/2){
      npc.sign *= -1;
    }
    // collision with player
    var hitPlayer = carCollision(player, m);
    if(hitPlayer){
      playerState.speed *= -0.4;
      npc.speed = Math.max(1, npc.speed*0.5);
      showMsg('Crash!');
    }
    // simple avoid-player nudge (soft)
    var d = m.position.distanceTo(player.position);
    if(d < 3.5 && !hitPlayer){
      npc.sign *= (Math.random()<0.02 ? -1 : 1);
    }
  });

  // NPC vs NPC collisions
  for(var i=0;i<npcCars.length;i++){
    for(var j=i+1;j<npcCars.length;j++){
      if(carCollision(npcCars[i].mesh, npcCars[j].mesh)){
        npcCars[i].sign *= -1;
        npcCars[j].sign *= -1;
      }
    }
  }

  // smoke particles
  for(var i=smokes.length-1;i>=0;i--){
    var s = smokes[i];
    s.life -= dt*0.7;
    s.mesh.position.y += dt*0.5;
    s.mesh.material.opacity = Math.max(0, s.life*0.6);
    s.mesh.scale.setScalar(1 + (1-s.life)*1.5);
    if(s.life<=0){
      scene.remove(s.mesh);
      smokes.splice(i,1);
    }
  }

  // pedestrians wander
  peds.forEach(function(p){
    if(p.down){
      p.downTimer -= dt;
      p.mesh.position.x += p.knockX*dt;
      p.mesh.position.z += p.knockZ*dt;
      p.knockX *= 0.9; p.knockZ *= 0.9;
      p.mesh.rotation.x = Math.min(Math.PI/2, p.mesh.rotation.x + dt*8);
      if(p.downTimer<=0){
        p.down = false;
        p.mesh.rotation.x = 0;
      }
      return;
    }
    p.timer -= dt;
    if(p.timer<=0){
      p.heading = Math.random()*Math.PI*2;
      p.timer = 2+Math.random()*3;
    }
    var nx2 = p.mesh.position.x + Math.sin(p.heading)*p.speed*dt;
    var nz2 = p.mesh.position.z + Math.cos(p.heading)*p.speed*dt;
    if(!collidesBuilding(nx2,nz2)){
      p.mesh.position.x = nx2;
      p.mesh.position.z = nz2;
      p.mesh.rotation.y = p.heading;
    } else {
      p.heading += Math.PI/2;
    }
    // hit by player
    var pd = p.mesh.position.distanceTo(player.position);
    if(pd < 1.6 && Math.abs(playerState.speed) > 3){
      p.down = true;
      p.downTimer = 3+Math.random()*2;
      var dx3 = p.mesh.position.x - player.position.x;
      var dz3 = p.mesh.position.z - player.position.z;
      var len = Math.max(0.1, Math.sqrt(dx3*dx3+dz3*dz3));
      p.knockX = (dx3/len) * Math.abs(playerState.speed) * 1.2;
      p.knockZ = (dz3/len) * Math.abs(playerState.speed) * 1.2;
      showMsg('Yikes!');
    }
  });

  // cash pickups
  for(var i=cashDrops.length-1;i>=0;i--){
    var d2 = cashDrops[i];
    d2.rotation.y += dt*3;
    if(player.position.distanceTo(d2.position) < 2.5){
      scene.remove(d2);
      cashDrops.splice(i,1);
      playerState.cash += 100;
      document.getElementById('cash').textContent = playerState.cash;
      showMsg('+$100');
      spawnCashDrop();
      if(Math.random()<0.4){
        playerState.wanted = Math.min(3, playerState.wanted+1);
        spawnCop();
      }
    }
  }

  // cops chase and flash lights
  cops.forEach(function(cop){
    var dx = player.position.x - cop.mesh.position.x;
    var dz = player.position.z - cop.mesh.position.z;
    var dist = Math.sqrt(dx*dx+dz*dz);
    var targetHeading = Math.atan2(dx,dz);
    cop.mesh.rotation.y += (targetHeading - cop.mesh.rotation.y)*0.05;
    var spd = 14*dt;
    if(dist>3){
      cop.mesh.position.x += Math.sin(cop.mesh.rotation.y)*spd;
      cop.mesh.position.z += Math.cos(cop.mesh.rotation.y)*spd;
      spinWheels(cop.mesh, spd);
    }
    if(cop.mesh.userData.lights){
      var t = Math.floor(clock.elapsedTime*6)%2;
      cop.mesh.userData.lights[0].material.emissiveIntensity = t? 1:0.1;
      cop.mesh.userData.lights[1].material.emissiveIntensity = t? 0.1:1;
    }
    if(dist < 2.5 && playerState.wanted>0){
      playerState.wanted = Math.max(0, playerState.wanted-1);
      playerState.cash = Math.max(0, playerState.cash-50);
      document.getElementById('cash').textContent = playerState.cash;
      showMsg('Busted! -$50');
      cop.mesh.position.set(player.position.x+30, 0, player.position.z+30);
    }
  });

  document.getElementById('wanted').textContent = playerState.wanted>0 ? '★'.repeat(playerState.wanted) : '';
  updateBuyPrompt();

  // speed-based vignette/motion-blur feel
  var speedRatio = Math.min(1, Math.abs(playerState.speed)/26);
  document.getElementById('speedFx').style.opacity = (speedRatio*0.7).toFixed(2);
}

function animate(){
  requestAnimationFrame(animate);
  var dt = Math.min(0.05, clock.getDelta());
  try{
    update(dt);
    // update reflection env map from player's position every few frames for perf
    if(!window._envFrameCounter) window._envFrameCounter = 0;
    window._envFrameCounter++;
    if(window._envFrameCounter % 90 === 0){
      player.visible = false;
      envCubeCamera.position.copy(player.position);
      envCubeCamera.position.y += 1.5;
      envCubeCamera.update(renderer, scene);
      player.visible = true;
    }
    renderer.render(scene, camera);
  }catch(e){
    window.onerror(e.message);
  }
}
setLoadProgress(100, 'Ready');
animate();
setTimeout(function(){
  var ls = document.getElementById('loadScreen');
  if(ls){
    ls.classList.add('hidden');
    setTimeout(function(){ ls.style.display = 'none'; }, 700);
  }
}, 300);
} catch(e){
  var e2=document.getElementById('err');
  e2.style.display='block';
  e2.textContent = 'Error at stage [' + window.CITY_HEIST_STAGE + ']: ' + (e && e.message ? e.message : e) + (e && e.stack ? ('\n'+e.stack) : '');
  var ls2 = document.getElementById('loadScreen');
  if(ls2) ls2.style.display = 'none';
}
}
</script>
</body>
</html>

Game Source: City Heist

Creator: LunarGalaxy87

Libraries: three

Complexity: complex (779 lines, 30.4 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: city-heist-lunargalaxy87" to link back to the original. Then publish at arcadelab.ai/publish.