🎮ArcadeLab

Neon City — 2D Open World

by LaserBuilder68
1901 lines59.1 KB
▶ Play
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title></title>
</head>

<body>
  
</body>

</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#05070d">
<title>Neon City — 2D Open World</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  html,body{
    width:100%;height:100%;overflow:hidden;background:#05070d;
    font-family:'Segoe UI',system-ui,-apple-system,'Helvetica Neue',Arial,sans-serif;
    user-select:none;-webkit-user-select:none;touch-action:none;
  }
  canvas#game{display:block;position:fixed;inset:0;}

  #ui{position:fixed;inset:0;pointer-events:none;z-index:10;}

  .panel{
    background:rgba(9,14,26,.72);
    border:1px solid rgba(255,255,255,.12);
    border-radius:16px;
    backdrop-filter:blur(12px);
    -webkit-backdrop-filter:blur(12px);
    box-shadow:0 8px 32px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.08);
  }

  #stats{
    position:absolute;top:14px;left:14px;padding:12px 16px 11px;
    min-width:186px;color:#fff;
  }
  #money{
    font-size:24px;font-weight:800;letter-spacing:.5px;
    color:#ffd166;text-shadow:0 0 16px rgba(255,209,102,.65);
    margin-bottom:9px;display:flex;align-items:center;gap:6px;
  }
  #money small{font-size:13px;opacity:.6;font-weight:600;letter-spacing:1px;color:#9fb3d1;text-shadow:none}
  .bar-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
  .bar-icon{font-size:13px;width:16px;text-align:center;filter:drop-shadow(0 0 4px rgba(255,255,255,.35))}
  .bar{flex:1;height:9px;background:rgba(255,255,255,.1);border-radius:6px;overflow:hidden;
       box-shadow:inset 0 1px 3px rgba(0,0,0,.6)}
  .fill{height:100%;width:100%;border-radius:6px;transition:width .18s ease-out}
  .fill.hp{background:linear-gradient(90deg,#ff2d55,#ff8fa3);box-shadow:0 0 10px rgba(255,45,85,.75)}
  .fill.ar{background:linear-gradient(90deg,#4cc9f0,#8ce8ff);box-shadow:0 0 10px rgba(76,201,240,.75)}
  #weapon{
    margin-top:8px;font-size:11px;letter-spacing:2px;font-weight:700;color:#8fb3d9;
    display:flex;align-items:center;gap:6px;
  }
  #weapon b{color:#4cc9f0;text-shadow:0 0 10px rgba(76,201,240,.8)}

  #top-center{position:absolute;top:14px;left:50%;transform:translateX(-50%);
              display:flex;flex-direction:column;align-items:center;gap:10px;width:max-content;max-width:92vw}
  #top-buttons{display:flex;gap:8px;pointer-events:auto}
  .pill{
    pointer-events:auto;cursor:pointer;border:1px solid rgba(255,255,255,.18);
    background:rgba(20,28,45,.8);color:#dbe7f7;font:700 11px/1 'Segoe UI',system-ui,sans-serif;
    letter-spacing:1.4px;padding:10px 16px;border-radius:999px;
    backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
    box-shadow:0 4px 18px rgba(0,0,0,.45);transition:transform .1s, box-shadow .2s, background .2s;
  }
  .pill:active{transform:scale(.94)}
  .pill.gold{background:linear-gradient(135deg,rgba(255,209,102,.95),rgba(255,160,60,.95));color:#241a00;border-color:rgba(255,255,255,.4);
             box-shadow:0 0 22px rgba(255,180,60,.55)}
  .pill.small{padding:10px 12px;font-size:13px;letter-spacing:0}

  #mission{padding:10px 18px;text-align:center;display:none;max-width:80vw}
  #mission.show{display:block;animation:pop .35s cubic-bezier(.2,1.4,.5,1)}
  #mission-title{font-size:11px;letter-spacing:2.4px;font-weight:800;color:#ffd166;
                 text-shadow:0 0 14px rgba(255,209,102,.6);margin-bottom:3px}
  #mission-desc{font-size:12.5px;color:#c9d8ec;font-weight:600}
  @keyframes pop{from{transform:scale(.85);opacity:0}to{transform:scale(1);opacity:1}}

  #top-right{position:absolute;top:14px;right:14px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
  #wanted{font-size:19px;letter-spacing:2px;color:#ff2d55;height:24px;
          text-shadow:0 0 12px rgba(255,45,85,.95),0 0 26px rgba(255,45,85,.6);transition:opacity .3s}
  #minimap{
    width:140px;height:140px;border-radius:50%;overflow:hidden;
    border:2px solid rgba(120,190,255,.45);
    box-shadow:0 0 26px rgba(60,150,255,.4), inset 0 0 30px rgba(0,0,0,.8);
    background:#070c14;
  }
  #minimap canvas{display:block;width:140px;height:140px}

  #joy-base{
    position:absolute;bottom:24px;left:24px;width:132px;height:132px;border-radius:50%;
    background:radial-gradient(circle at 50% 50%, rgba(90,150,255,.14), rgba(10,20,40,.32));
    border:2px solid rgba(140,200,255,.35);
    box-shadow:0 0 26px rgba(60,140,255,.28), inset 0 0 26px rgba(0,0,0,.45);
    pointer-events:auto;touch-action:none;
  }
  #joy-stick{
    position:absolute;top:50%;left:50%;width:62px;height:62px;border-radius:50%;
    transform:translate(-50%,-50%);
    background:radial-gradient(circle at 35% 30%, #cfe4ff, #5f9ae0 60%, #2b5b96);
    box-shadow:0 0 22px rgba(110,180,255,.75), inset 0 -3px 8px rgba(0,0,0,.4);
    transition:transform .04s linear;
  }

  .act{
    position:absolute;pointer-events:auto;cursor:pointer;border-radius:50%;
    display:flex;align-items:center;justify-content:center;
    font-weight:800;letter-spacing:1.6px;color:#fff;
    border:2px solid rgba(255,255,255,.35);
    transition:transform .08s;
    touch-action:none;
  }
  .act:active{transform:scale(.9)}

  #btn-fire{
    bottom:28px;right:26px;width:96px;height:96px;font-size:14px;
    background:radial-gradient(circle at 35% 30%, #ff7a8f, #ef2d4f 55%, #a30f28);
    box-shadow:0 0 34px rgba(255,45,85,.65), inset 0 -5px 12px rgba(0,0,0,.4);
  }
  #btn-fire.disabled{opacity:.28;filter:grayscale(.8);pointer-events:none}

  #btn-enter{
    bottom:138px;right:38px;width:76px;height:76px;font-size:11px;
    background:radial-gradient(circle at 35% 30%, #55e6a5, #06a86f 60%, #05603f);
    box-shadow:0 0 28px rgba(6,214,160,.55), inset 0 -5px 12px rgba(0,0,0,.4);
  }

  #toast{
    position:absolute;top:34%;left:50%;transform:translate(-50%,-16px);
    font-size:19px;font-weight:800;letter-spacing:1.6px;color:#ffd166;
    text-shadow:0 0 22px currentColor, 0 3px 10px rgba(0,0,0,.8);
    opacity:0;transition:opacity .3s, transform .3s;pointer-events:none;white-space:nowrap;
  }
  #toast.show{opacity:1;transform:translate(-50%,0)}

  #shop{
    position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.92);
    width:min(94vw,420px);padding:22px;display:none;flex-direction:column;gap:11px;
    background:linear-gradient(160deg, rgba(22,30,50,.97), rgba(10,14,26,.99));
    border:1.5px solid rgba(255,209,102,.45);border-radius:22px;z-index:50;
    box-shadow:0 24px 70px rgba(0,0,0,.85), 0 0 60px rgba(255,180,60,.18);
    pointer-events:auto;opacity:0;transition:opacity .2s, transform .2s;
  }
  #shop.open{display:flex;opacity:1;transform:translate(-50%,-50%) scale(1)}
  #shop h2{
    text-align:center;font-size:16px;letter-spacing:3px;color:#ffd166;margin-bottom:6px;
    text-shadow:0 0 20px rgba(255,209,102,.65);
  }
  .shop-item{
    display:flex;align-items:center;gap:12px;
    background:linear-gradient(135deg, rgba(60,80,120,.5), rgba(30,40,65,.5));
    border:1px solid rgba(255,255,255,.09);border-radius:14px;padding:12px 14px;
  }
  .shop-item .ico{font-size:22px;filter:drop-shadow(0 0 8px rgba(255,255,255,.3))}
  .shop-item .info{flex:1}
  .shop-item .nm{font-size:13px;font-weight:700;color:#e8f1ff;letter-spacing:.4px}
  .shop-item .ds{font-size:11px;color:#8ba3c4;margin-top:2px}
  .shop-item button{
    background:linear-gradient(135deg,#06d6a0,#059b75);color:#04231a;border:none;
    font-weight:800;font-size:12px;letter-spacing:.6px;padding:9px 14px;border-radius:10px;
    cursor:pointer;min-width:74px;box-shadow:0 4px 16px rgba(6,214,160,.4);transition:transform .1s;
  }
  .shop-item button:active{transform:scale(.94)}
  .shop-item button:disabled{background:#2b3547;color:#5f6f88;box-shadow:none;cursor:not-allowed}
  #close-shop{
    margin-top:8px;padding:13px;border:none;border-radius:14px;cursor:pointer;
    background:linear-gradient(135deg,#ef476f,#b32b4b);color:#fff;font-weight:800;
    letter-spacing:2px;font-size:13px;box-shadow:0 6px 24px rgba(239,71,111,.45);
    transition:transform .1s;
  }
  #close-shop:active{transform:scale(.97)}

  @media (max-width:420px){
    #stats{min-width:150px;padding:10px 13px}
    #money{font-size:20px}
    #minimap{width:112px;height:112px}
    #minimap canvas{width:112px;height:112px}
    #joy-base{width:118px;height:118px;bottom:18px;left:18px}
    #joy-stick{width:54px;height:54px}
    #btn-fire{width:84px;height:84px;bottom:22px;right:20px}
    #btn-enter{width:66px;height:66px;bottom:120px;right:30px;font-size:10px}
  }
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="ui">
  <div id="stats" class="panel">
    <div id="money">$<span id="money-val">5,000</span><small>CASH</small></div>
    <div class="bar-row"><span class="bar-icon">❤️</span><div class="bar"><div class="fill hp" id="hp-bar"></div></div></div>
    <div class="bar-row"><span class="bar-icon">🛡️</span><div class="bar"><div class="fill ar" id="ar-bar"></div></div></div>
    <div id="weapon">🔫 <b id="weapon-name">PISTOL</b></div>
  </div>

  <div id="top-center">
    <div id="top-buttons">
      <button class="pill" id="btn-switch">FRANKLIN</button>
      <button class="pill gold" id="btn-shop">🛒 SHOP</button>
      <button class="pill small" id="btn-sound">🔊</button>
    </div>
    <div id="mission" class="panel">
      <div id="mission-title">MISSION</div>
      <div id="mission-desc">—</div>
    </div>
  </div>

  <div id="top-right">
    <div id="wanted"></div>
    <div id="minimap"><canvas id="mm"></canvas></div>
  </div>

  <div id="joy-base"><div id="joy-stick"></div></div>
  <div class="act" id="btn-fire">FIRE</div>
  <div class="act" id="btn-enter">ENTER</div>

  <div id="toast"></div>
</div>

<div id="shop">
  <h2>🛒 24/7 STORE</h2>
  <div class="shop-item">
    <div class="ico">🍔</div>
    <div class="info"><div class="nm">Burger</div><div class="ds">Restores 50 health</div></div>
    <button data-item="food">$50</button>
  </div>
  <div class="shop-item">
    <div class="ico">🛡️</div>
    <div class="info"><div class="nm">Body Armor</div><div class="ds">+50 armor points</div></div>
    <button data-item="armor">$150</button>
  </div>
  <div class="shop-item">
    <div class="ico">💊</div>
    <div class="info"><div class="nm">Med Kit</div><div class="ds">Fully restores health</div></div>
    <button data-item="medkit">$300</button>
  </div>
  <div class="shop-item">
    <div class="ico">🔫</div>
    <div class="info"><div class="nm">SMG</div><div class="ds">Rapid fire · 14 dmg</div></div>
    <button data-item="smg" id="buy-smg">$800</button>
  </div>
  <button id="close-shop">CLOSE</button>
</div>

<script>
"use strict";

/* ============================================================
   NEON CITY — 2D Open World (corrected build)
   ============================================================ */

/* ---------------- Canvas ---------------- */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mm = document.getElementById('mm');
const mmCtx = mm.getContext('2d');

let W = 0, H = 0, DPR = 1;

function resize(){
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width  = Math.floor(W * DPR);
  canvas.height = Math.floor(H * DPR);
  canvas.style.width  = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();

const MM_SIZE = 140;
mm.width  = MM_SIZE * DPR;
mm.height = MM_SIZE * DPR;
mm.style.width  = MM_SIZE + 'px';
mm.style.height = MM_SIZE + 'px';

/* ---------------- Helpers ---------------- */
const rand    = (a, b) => a + Math.random() * (b - a);
const randInt = (a, b) => Math.floor(rand(a, b + 1));
const clamp   = (v, a, b) => v < a ? a : (v > b ? b : v);
const lerp    = (a, b, t) => a + (b - a) * t;
const dist2   = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
const TAU = Math.PI * 2;

function rr(c, x, y, w, h, r){
  r = Math.min(r, w / 2, h / 2);
  c.beginPath();
  c.moveTo(x + r, y);
  c.arcTo(x + w, y,     x + w, y + h, r);
  c.arcTo(x + w, y + h, x,     y + h, r);
  c.arcTo(x,     y + h, x,     y,     r);
  c.arcTo(x,     y,     x + w, y,     r);
  c.closePath();
}

/* ---------------- World constants ---------------- */
const GRID    = 8;
const BLOCK   = 460;
const ROAD_W  = 100;
const LANE    = 26;
const WORLD   = GRID * BLOCK;

/* ---------------- Game state ---------------- */
let camera   = { x: 0, y: 0 };
let shake    = 0;
let time     = 0;
let dayTime  = 0.30;
const DAY_LENGTH = 150;
let nightFactor = 0;

/* Weather (fixed: was declared but never updated) */
let rainFactor = 0;
let rainTarget = 0;
let weatherTimer = 14;

let buildings   = [];
let parks       = [];
let trees       = [];
let parkingLots = [];
let cars        = [];
let peds        = [];
let police      = [];
let bullets     = [];
let particles   = [];

let wanted = 0;
let wantedTimer = 0;
let policeSpawnCd = 0;

let firing = false;
let mouseActive = false;
let mouseTimer = 0;
const mouse = { x: 0, y: 0 };

const keys = Object.create(null);

/* ---------------- Characters & weapons ---------------- */
const characters = [
  { name: 'FRANKLIN', hue: 145, speed: 252 },
  { name: 'MICHAEL',  hue: 205, speed: 234 },
  { name: 'TREVOR',   hue: 28,  speed: 270 }
];
let charIndex = 0;

const WEAPONS = [
  { name: 'PISTOL', dmg: 22, rate: 0.24,  speed: 1500, spread: 0.030, color: '#ffd166' },
  { name: 'SMG',    dmg: 14, rate: 0.075, speed: 1750, spread: 0.095, color: '#4cc9f0' }
];

/* ---------------- Player ---------------- */
const player = {
  x: WORLD / 2,
  y: WORLD / 2,
  aim: -Math.PI / 2,
  hp: 100, maxHp: 100,
  armor: 0, maxArmor: 100,
  money: 5000,
  vehicle: null,
  weaponIndex: 0,
  fireCd: 0,
  ownsSMG: false
};

/* ============================================================
   AUDIO
   ============================================================ */
let actx = null;
let soundOn = true;

function initAudio(){
  if (!actx){
    try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){ actx = null; }
  }
  if (actx && actx.state === 'suspended') actx.resume();
}

function sfx(kind){
  if (!soundOn || !actx) return;
  const t = actx.currentTime;
  const o = actx.createOscillator();
  const g = actx.createGain();
  o.connect(g); g.connect(actx.destination);

  switch(kind){
    case 'shoot':
      o.type = 'square';
      o.frequency.setValueAtTime(440, t);
      o.frequency.exponentialRampToValueAtTime(70, t + 0.09);
      g.gain.setValueAtTime(0.05, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.11);
      o.start(t); o.stop(t + 0.12);
      break;
    case 'hit':
      o.type = 'triangle';
      o.frequency.setValueAtTime(180, t);
      o.frequency.exponentialRampToValueAtTime(50, t + 0.12);
      g.gain.setValueAtTime(0.09, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.14);
      o.start(t); o.stop(t + 0.15);
      break;
    case 'pickup':
      o.type = 'sine';
      o.frequency.setValueAtTime(620, t);
      o.frequency.exponentialRampToValueAtTime(1180, t + 0.1);
      g.gain.setValueAtTime(0.07, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.16);
      o.start(t); o.stop(t + 0.17);
      break;
    case 'buy':
      o.type = 'sine';
      o.frequency.setValueAtTime(520, t);
      o.frequency.setValueAtTime(780, t + 0.07);
      g.gain.setValueAtTime(0.08, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.2);
      o.start(t); o.stop(t + 0.21);
      break;
    case 'win':
      o.type = 'triangle';
      o.frequency.setValueAtTime(523, t);
      o.frequency.setValueAtTime(659, t + 0.1);
      o.frequency.setValueAtTime(784, t + 0.2);
      o.frequency.setValueAtTime(1047, t + 0.3);
      g.gain.setValueAtTime(0.09, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.5);
      o.start(t); o.stop(t + 0.52);
      break;
    case 'engine':
      o.type = 'sawtooth';
      o.frequency.setValueAtTime(90, t);
      o.frequency.exponentialRampToValueAtTime(160, t + 0.18);
      g.gain.setValueAtTime(0.05, t);
      g.gain.exponentialRampToValueAtTime(0.0008, t + 0.2);
      o.start(t); o.stop(t + 0.21);
      break;
    default:
      o.disconnect(); g.disconnect();
  }
}

/* ============================================================
   CITY GENERATION
   ============================================================ */
const BUILDING_HUES = [205, 220, 240, 262, 285, 310, 335, 12, 35, 180, 160, 195];

function generateCity(){
  buildings = []; parks = []; trees = []; parkingLots = [];

  for (let gx = 0; gx < GRID; gx++){
    for (let gy = 0; gy < GRID; gy++){
      const bx = gx * BLOCK + ROAD_W / 2;
      const by = gy * BLOCK + ROAD_W / 2;
      const bw = BLOCK - ROAD_W;
      const bh = BLOCK - ROAD_W;
      const roll = Math.random();

      if (roll < 0.10){
        parks.push({ x: bx, y: by, w: bw, h: bh });
        const n = randInt(7, 14);
        for (let i = 0; i < n; i++){
          trees.push({
            x: bx + 26 + Math.random() * (bw - 52),
            y: by + 26 + Math.random() * (bh - 52),
            r: rand(13, 25),
            hue: rand(95, 158)
          });
        }
      } else if (roll < 0.17){
        parkingLots.push({ x: bx, y: by, w: bw, h: bh });
      } else {
        const cols = Math.random() < 0.45 ? 1 : 2;
        const rows = Math.random() < 0.45 ? 1 : 2;
        const gap = 16;
        const cw = bw / cols, ch = bh / rows;

        for (let i = 0; i < cols; i++){
          for (let j = 0; j < rows; j++){
            if (cols * rows > 1 && Math.random() < 0.18) continue;

            const w = cw - gap * 2;
            const h = ch - gap * 2;
            const hue = BUILDING_HUES[randInt(0, BUILDING_HUES.length - 1)];

            const b = {
              x: bx + i * cw + gap,
              y: by + j * ch + gap,
              w, h, hue,
              light: rand(29, 43),
              details: [],
              windows: []
            };

            const dn = randInt(2, 4);
            for (let k = 0; k < dn; k++){
              b.details.push({
                x: rand(0.14, 0.68), y: rand(0.14, 0.68),
                w: rand(0.10, 0.24), h: rand(0.10, 0.24)
              });
            }

            const step = 26;
            for (let wx = 14; wx <= w - 14; wx += step){
              for (let wy = 14; wy <= h - 14; wy += step){
                const innerX = wx > 38 && wx < w - 38;
                const innerY = wy > 38 && wy < h - 38;
                if (innerX && innerY) continue;
                b.windows.push({ x: wx, y: wy, lit: Math.random() < 0.55 });
              }
            }

            buildings.push(b);
          }
        }
      }
    }
  }
}

function hitsBuilding(x, y, pad){
  pad = pad || 0;
  for (let i = 0; i < buildings.length; i++){
    const b = buildings[i];
    if (x > b.x - pad && x < b.x + b.w + pad &&
        y > b.y - pad && y < b.y + b.h + pad) return b;
  }
  return null;
}

/* ============================================================
   ENTITY SPAWNING
   ============================================================ */
function randomRoadPoint(minDistFromPlayer){
  for (let tries = 0; tries < 30; tries++){
    const horiz = Math.random() < 0.5;
    const line = randInt(0, GRID) * BLOCK;
    const pos  = Math.random() * WORLD;
    const x = horiz ? pos : line;
    const y = horiz ? line : pos;
    if (dist2(x, y, player.x, player.y) > minDistFromPlayer * minDistFromPlayer){
      return { x, y, horiz };
    }
  }
  const line = randInt(1, GRID - 1) * BLOCK;
  return { x: Math.random() * WORLD, y: line, horiz: true };
}

function makeTrafficCar(){
  const p = randomRoadPoint(650);
  const car = {
    x: p.x, y: p.y,
    angle: 0,
    hue: randInt(0, 359),
    type: 'traffic',
    w: 44, h: 24,
    speed: 0,
    driver: null,
    axis: p.horiz ? 'h' : 'v',
    dir: Math.random() < 0.5 ? 1 : -1,
    baseSpeed: rand(130, 230)
  };
  if (car.axis === 'h'){
    car.y = Math.round(car.y / BLOCK) * BLOCK + (car.dir > 0 ? LANE : -LANE);
    car.angle = car.dir > 0 ? 0 : Math.PI;
  } else {
    car.x = Math.round(car.x / BLOCK) * BLOCK + (car.dir > 0 ? -LANE : LANE);
    car.angle = car.dir > 0 ? Math.PI / 2 : -Math.PI / 2;
  }
  return car;
}

function makeParkedCar(){
  const p = randomRoadPoint(300);
  return {
    x: p.x, y: p.y,
    angle: p.horiz ? 0 : Math.PI / 2,
    hue: randInt(0, 359),
    type: 'parked',
    w: 44, h: 24,
    speed: 0,
    driver: null,
    axis: 'h', dir: 1,
    baseSpeed: 0
  };
}

function makePed(gang){
  const p = randomRoadPoint(280);
  return {
    x: p.x, y: p.y,
    angle: rand(0, TAU),
    speed: gang ? 55 : 45,
    hue: gang ? 0 : randInt(0, 359),
    sat: gang ? 78 : randInt(35, 70),
    gang: !!gang,
    r: 8,
    timer: rand(1, 3)
  };
}

/* ============================================================
   PARTICLES
   ============================================================ */
function addParticle(x, y, vx, vy, life, color, size, glow){
  if (particles.length > 420) particles.shift();
  particles.push({ x, y, vx, vy, life, maxLife: life, color, size, glow: !!glow });
}

function spawnSparks(x, y){
  for (let i = 0; i < 7; i++){
    const a = rand(0, TAU), s = rand(60, 300);
    addParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, rand(0.15, 0.4), '#ffd166', rand(1.5, 3), true);
  }
}

function spawnBlood(x, y){
  for (let i = 0; i < 9; i++){
    const a = rand(0, TAU), s = rand(40, 220);
    addParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, rand(0.25, 0.55), '#ff2d55', rand(2, 4.5));
  }
}

function spawnMuzzle(x, y, a){
  for (let i = 0; i < 5; i++){
    const na = a + rand(-0.35, 0.35), s = rand(180, 420);
    addParticle(x, y, Math.cos(na) * s, Math.sin(na) * s, rand(0.06, 0.16), '#fff2b0', rand(2, 4), true);
  }
}

function spawnCash(x, y){
  for (let i = 0; i < 10; i++){
    const a = rand(0, TAU), s = rand(50, 190);
    addParticle(x, y, Math.cos(a) * s, Math.sin(a) * s, rand(0.4, 0.8), '#06d6a0', rand(2, 4), true);
  }
}

/* ============================================================
   MISSIONS
   ============================================================ */
function randomIntersection(minDist){
  let best = { x: BLOCK * 2, y: BLOCK * 2 };
  for (let i = 0; i < 30; i++){
    const x = randInt(1, GRID - 1) * BLOCK;
    const y = randInt(1, GRID - 1) * BLOCK;
    if (dist2(x, y, player.x, player.y) > minDist * minDist) return { x, y };
    best = { x, y };
  }
  return best;
}

const missions = [
  { title: 'WELCOME TO THE CITY', desc: 'Drive to the yellow marker', type: 'reach',   marker: null },
  { title: 'GANG CLEANUP',        desc: 'Take out 3 gang members (red)', type: 'kill', need: 3, count: 0 },
  { title: 'HOT WHEELS',          desc: 'Steal a car and deliver it to the drop-off', type: 'deliver', marker: null }
];
let missionIndex = 0;

/* ============================================================
   INIT WORLD
   ============================================================ */
generateCity();

missions[0].marker = randomIntersection(600);
missions[2].marker = randomIntersection(700);

for (let i = 0; i < 26; i++) cars.push(makeTrafficCar());
for (let i = 0; i < 14; i++) cars.push(makeParkedCar());
for (let i = 0; i < 42; i++) peds.push(makePed(false));
for (let i = 0; i < 8;  i++) peds.push(makePed(true));

camera.x = player.x - W / 2;
camera.y = player.y - H / 2;

/* ============================================================
   INPUT
   ============================================================ */
window.addEventListener('keydown', e => {
  keys[e.code] = true;
  initAudio();
  if (e.code === 'Enter' || e.code === 'KeyF') doEnterExit();
  if (e.code === 'KeyE') toggleShop();
  if (e.code === 'Space'){ firing = true; e.preventDefault(); }
});
window.addEventListener('keyup', e => {
  keys[e.code] = false;
  if (e.code === 'Space') firing = false;
});

canvas.addEventListener('pointermove', e => {
  if (e.pointerType === 'mouse'){
    mouse.x = e.clientX; mouse.y = e.clientY;
    mouseActive = true; mouseTimer = 2.2;
  }
});
canvas.addEventListener('pointerdown', e => {
  initAudio();
  if (e.pointerType === 'mouse' && e.button === 0){ firing = true; }
});
window.addEventListener('pointerup', () => { firing = false; });

/* ---- Joystick ---- */
const joyBase = document.getElementById('joy-base');
const joyStick = document.getElementById('joy-stick');
const joy = { active: false, x: 0, y: 0, maxR: 48 };

function joyUpdate(e){
  if (!joy.active) return;
  const r = joyBase.getBoundingClientRect();
  let dx = e.clientX - (r.left + r.width / 2);
  let dy = e.clientY - (r.top + r.height / 2);
  const d = Math.hypot(dx, dy);
  if (d > joy.maxR){ dx = dx / d * joy.maxR; dy = dy / d * joy.maxR; }
  joyStick.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
  joy.x = dx / joy.maxR;
  joy.y = dy / joy.maxR;
}

function joyReset(){
  joy.active = false; joy.x = 0; joy.y = 0;
  joyStick.style.transform = 'translate(-50%,-50%)';
}

joyBase.addEventListener('pointerdown', e => {
  e.preventDefault(); initAudio();
  joy.active = true;
  try { joyBase.setPointerCapture(e.pointerId); } catch(err){}
  joyUpdate(e);
});
joyBase.addEventListener('pointermove', e => { if (joy.active){ e.preventDefault(); joyUpdate(e); } });
joyBase.addEventListener('pointerup', joyReset);
joyBase.addEventListener('pointercancel', joyReset);

/* ---- Action buttons ---- */
const fireBtn = document.getElementById('btn-fire');
fireBtn.addEventListener('pointerdown', e => { e.preventDefault(); initAudio(); firing = true; });
fireBtn.addEventListener('pointerup',   e => { e.preventDefault(); firing = false; });
fireBtn.addEventListener('pointercancel', () => { firing = false; });

document.getElementById('btn-enter').addEventListener('pointerdown', e => {
  e.preventDefault(); doEnterExit();
});

document.getElementById('btn-switch').addEventListener('pointerdown', e => {
  e.preventDefault();
  if (player.vehicle) { toast('EXIT THE VEHICLE FIRST', '#ff8fa3'); return; }
  charIndex = (charIndex + 1) % characters.length;
  const c = characters[charIndex];
  const btn = document.getElementById('btn-switch');
  btn.textContent = c.name;
  btn.style.color = `hsl(${c.hue},85%,72%)`;
  toast(c.name, `hsl(${c.hue},85%,68%)`);
  sfx('pickup');
});

document.getElementById('btn-sound').addEventListener('pointerdown', e => {
  e.preventDefault();
  soundOn = !soundOn;
  e.currentTarget.textContent = soundOn ? '🔊' : '🔇';
  if (soundOn) { initAudio(); sfx('pickup'); }
});

document.getElementById('btn-shop').addEventListener('pointerdown', e => {
  e.preventDefault(); toggleShop();
});

/* ============================================================
   SHOP
   ============================================================ */
const shopEl = document.getElementById('shop');
let shopOpen = false;

function toggleShop(){
  shopOpen = !shopOpen;
  shopEl.classList.toggle('open', shopOpen);
  if (shopOpen) refreshShop();
}

function refreshShop(){
  const smgBtn = document.getElementById('buy-smg');
  smgBtn.disabled = player.ownsSMG;
  smgBtn.textContent = player.ownsSMG ? 'OWNED' : '$800';
}

document.getElementById('close-shop').addEventListener('pointerdown', e => {
  e.preventDefault(); toggleShop();
});

shopEl.querySelectorAll('button[data-item]').forEach(btn => {
  btn.addEventListener('pointerdown', e => {
    e.preventDefault();
    buy(btn.dataset.item);
  });
});

function buy(item){
  initAudio();
  switch(item){
    case 'food':
      if (player.money < 50) return toast('NOT ENOUGH CASH', '#ff8fa3');
      if (player.hp >= player.maxHp) return toast('HEALTH IS FULL', '#ff8fa3');
      player.money -= 50;
      player.hp = Math.min(player.maxHp, player.hp + 50);
      toast('+50 HEALTH', '#06d6a0');
      break;
    case 'armor':
      if (player.money < 150) return toast('NOT ENOUGH CASH', '#ff8fa3');
      if (player.armor >= player.maxArmor) return toast('ARMOR IS FULL', '#ff8fa3');
      player.money -= 150;
      player.armor = Math.min(player.maxArmor, player.armor + 50);
      toast('+50 ARMOR', '#4cc9f0');
      break;
    case 'medkit':
      if (player.money < 300) return toast('NOT ENOUGH CASH', '#ff8fa3');
      if (player.hp >= player.maxHp) return toast('HEALTH IS FULL', '#ff8fa3');
      player.money -= 300;
      player.hp = player.maxHp;
      toast('HEALTH FULLY RESTORED', '#06d6a0');
      break;
    case 'smg':
      if (player.ownsSMG) return;
      if (player.money < 800) return toast('NOT ENOUGH CASH', '#ff8fa3');
      player.money -= 800;
      player.ownsSMG = true;
      player.weaponIndex = 1;
      toast('SMG ACQUIRED', '#4cc9f0');
      break;
  }
  sfx('buy');
  updateHUD();
  refreshShop();
}

/* ============================================================
   HUD
   ============================================================ */
const moneyEl  = document.getElementById('money-val');
const hpBarEl  = document.getElementById('hp-bar');
const arBarEl  = document.getElementById('ar-bar');
const weaponEl = document.getElementById('weapon-name');
const wantedEl = document.getElementById('wanted');
const missionEl = document.getElementById('mission');
const missionTitleEl = document.getElementById('mission-title');
const missionDescEl  = document.getElementById('mission-desc');
const toastEl = document.getElementById('toast');

const hudCache = { money: -1, hp: -1, armor: -1, weapon: -1, wanted: -1 };

function updateHUD(){
  const m = Math.floor(player.money);
  if (m !== hudCache.money){ moneyEl.textContent = m.toLocaleString('en-US'); hudCache.money = m; }

  const hp = Math.round(player.hp);
  if (hp !== hudCache.hp){
    hpBarEl.style.width = clamp(player.hp / player.maxHp * 100, 0, 100) + '%';
    hudCache.hp = hp;
  }
  const ar = Math.round(player.armor);
  if (ar !== hudCache.armor){
    arBarEl.style.width = clamp(player.armor / player.maxArmor * 100, 0, 100) + '%';
    hudCache.armor = ar;
  }
  if (player.weaponIndex !== hudCache.weapon){
    weaponEl.textContent = WEAPONS[player.weaponIndex].name;
    hudCache.weapon = player.weaponIndex;
  }
  if (wanted !== hudCache.wanted){
    wantedEl.textContent = wanted > 0 ? '★'.repeat(wanted) : '';
    hudCache.wanted = wanted;
  }
}

let toastTimer = null;
function toast(msg, color){
  toastEl.textContent = msg;
  toastEl.style.color = color || '#ffd166';
  toastEl.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2000);
}

function showMission(){
  const m = missions[missionIndex];
  if (!m){ missionEl.classList.remove('show'); return; }
  missionTitleEl.textContent = m.title;
  if (m.type === 'kill') missionDescEl.textContent = `${m.desc}  (${m.count}/${m.need})`;
  else missionDescEl.textContent = m.desc;
  missionEl.classList.add('show');
}

function updateMissionUI(){
  const m = missions[missionIndex];
  if (!m) return;
  if (m.type === 'kill') missionDescEl.textContent = `${m.desc}  (${m.count}/${m.need})`;
}

function completeMission(){
  const m = missions[missionIndex];
  if (!m) return;
  player.money += 500;
  toast('MISSION COMPLETE  +$500', '#06d6a0');
  sfx('win');
  missionIndex++;
  updateHUD();
  if (missionIndex < missions.length){
    setTimeout(showMission, 500);
  } else {
    missionEl.classList.remove('show');
    setTimeout(() => toast('ALL MISSIONS COMPLETE — FREE ROAM', '#ffd166'), 700);
  }
}

/* ============================================================
   WANTED SYSTEM
   ============================================================ */
function addWanted(n){
  const before = wanted;
  wanted = Math.min(5, wanted + n);
  wantedTimer = 8;
  if (wanted > before) sfx('hit');
}

/* ============================================================
   VEHICLES
   ============================================================ */
function updateTraffic(car, dt){
  const step = car.baseSpeed * dt;

  if (car.axis === 'h'){
    const prevX = car.x;
    car.x += car.dir * step;

    const wantY = Math.round(car.y / BLOCK) * BLOCK + (car.dir > 0 ? LANE : -LANE);
    car.y += (wantY - car.y) * Math.min(1, dt * 3);
    car.angle = car.dir > 0 ? 0 : Math.PI;

    if (Math.floor(prevX / BLOCK) !== Math.floor(car.x / BLOCK) && Math.random() < 0.42){
      car.axis = 'v';
      car.dir = Math.random() < 0.5 ? 1 : -1;
      car.x = Math.round(car.x / BLOCK) * BLOCK + (car.dir > 0 ? -LANE : LANE);
    }
  } else {
    const prevY = car.y;
    car.y += car.dir * step;

    const wantX = Math.round(car.x / BLOCK) * BLOCK + (car.dir > 0 ? -LANE : LANE);
    car.x += (wantX - car.x) * Math.min(1, dt * 3);
    car.angle = car.dir > 0 ? Math.PI / 2 : -Math.PI / 2;

    if (Math.floor(prevY / BLOCK) !== Math.floor(car.y / BLOCK) && Math.random() < 0.42){
      car.axis = 'h';
      car.dir = Math.random() < 0.5 ? 1 : -1;
      car.y = Math.round(car.y / BLOCK) * BLOCK + (car.dir > 0 ? LANE : -LANE);
    }
  }

  if (car.x < -80 || car.x > WORLD + 80 || car.y < -80 || car.y > WORLD + 80){
    const p = randomRoadPoint(800);
    car.x = p.x; car.y = p.y;
    car.axis = p.horiz ? 'h' : 'v';
    car.dir = Math.random() < 0.5 ? 1 : -1;
    if (car.axis === 'h'){
      car.y = Math.round(car.y / BLOCK) * BLOCK + (car.dir > 0 ? LANE : -LANE);
    } else {
      car.x = Math.round(car.x / BLOCK) * BLOCK + (car.dir > 0 ? -LANE : LANE);
    }
  }
}

function doEnterExit(){
  initAudio();

  if (player.vehicle){
    const v = player.vehicle;
    v.driver = null;
    v.type = 'parked';
    v.speed = 0;
    const side = v.angle + Math.PI / 2;
    let nx = v.x + Math.cos(side) * 34;
    let ny = v.y + Math.sin(side) * 34;
    if (hitsBuilding(nx, ny, 8)){ nx = v.x - Math.cos(side) * 34; ny = v.y - Math.sin(side) * 34; }
    player.x = clamp(nx, 10, WORLD - 10);
    player.y = clamp(ny, 10, WORLD - 10);
    player.vehicle = null;
    fireBtn.classList.remove('disabled');
    toast('VEHICLE LEFT', '#8fb3d9');
    return;
  }

  let closest = null, bestD = 90 * 90;
  for (const c of cars){
    if (c.driver) continue;
    const d = dist2(c.x, c.y, player.x, player.y);
    if (d < bestD){ bestD = d; closest = c; }
  }

  if (closest){
    closest.driver = 'player';
    closest.type = 'player';
    closest.speed = 0;
    player.vehicle = closest;
    /* FIX: reset firing & mouse aim when entering vehicle */
    firing = false;
    mouseActive = false;
    mouseTimer = 0;
    fireBtn.classList.add('disabled');
    sfx('engine');
    toast('VEHICLE ACQUIRED', '#06d6a0');
  } else {
    toast('NO VEHICLE NEARBY', '#ff8fa3');
  }
}

function updateDriving(v, ix, iy, dt){
  const throttle = -iy;
  const steer = ix;

  v.speed += throttle * 1150 * dt;
  v.speed *= (1 - 1.6 * dt);
  if (Math.abs(throttle) < 0.06) v.speed *= (1 - 1.1 * dt);
  v.speed = clamp(v.speed, -230, 570);

  const turnRate = 2.7 * Math.min(1, Math.abs(v.speed) / 190);
  v.angle += steer * turnRate * dt * (v.speed >= 0 ? 1 : -1);

  const nx = v.x + Math.cos(v.angle) * v.speed * dt;
  const ny = v.y + Math.sin(v.angle) * v.speed * dt;

  if (hitsBuilding(nx, ny, 14)){
    const impact = Math.abs(v.speed);
    if (impact > 90){
      shake = Math.min(shake + impact * 0.035, 16);
      spawnSparks(v.x + Math.cos(v.angle) * 22, v.y + Math.sin(v.angle) * 22);
      sfx('hit');
    }
    v.speed *= -0.32;
  } else {
    v.x = nx; v.y = ny;
  }

  v.x = clamp(v.x, 20, WORLD - 20);
  v.y = clamp(v.y, 20, WORLD - 20);

  player.x = v.x;
  player.y = v.y;
}

/* ============================================================
   UPDATE
   ============================================================ */
function computeNight(t){
  const c = Math.cos((t - 0.5) * TAU);
  return Math.pow(clamp((1 - c) / 2, 0, 1), 1.35);
}

function damagePlayer(amount){
  if (player.armor > 0){
    const absorbed = Math.min(player.armor, amount * 0.7);
    player.armor -= absorbed;
    player.hp -= (amount - absorbed);
  } else {
    player.hp -= amount;
  }
  shake = Math.min(shake + 3, 14);
  if (player.hp <= 0) respawnPlayer();
}

function respawnPlayer(){
  player.hp = player.maxHp * 0.6;
  player.armor = 0;
  player.money = Math.max(0, player.money - 500);
  wanted = 0;
  police.length = 0;
  toast('WASTED  —$500', '#ff2d55');
  if (player.vehicle){ player.vehicle.driver = null; player.vehicle.type = 'parked'; player.vehicle = null; }
  fireBtn.classList.remove('disabled');
  const p = randomIntersection(0);
  player.x = p.x; player.y = p.y;
  updateHUD();
}

function update(dt){
  time += dt;
  dayTime = (dayTime + dt / DAY_LENGTH) % 1;
  nightFactor = computeNight(dayTime);

  /* -------- weather (FIXED: now actually updates) -------- */
  weatherTimer -= dt;
  if (weatherTimer <= 0){
    weatherTimer = rand(18, 40);
    rainTarget = Math.random() < 0.32 ? rand(0.5, 1) : 0;
  }
  rainFactor += (rainTarget - rainFactor) * Math.min(1, dt * 0.65);

  if (mouseTimer > 0){ mouseTimer -= dt; if (mouseTimer <= 0) mouseActive = false; }

  /* -------- input vector -------- */
  let ix = 0, iy = 0;
  if (keys['KeyA'] || keys['ArrowLeft'])  ix -= 1;
  if (keys['KeyD'] || keys['ArrowRight']) ix += 1;
  if (keys['KeyW'] || keys['ArrowUp'])    iy -= 1;
  if (keys['KeyS'] || keys['ArrowDown'])  iy += 1;
  ix += joy.x; iy += joy.y;
  const mag = Math.hypot(ix, iy);
  if (mag > 1){ ix /= mag; iy /= mag; }

  /* -------- aim -------- */
  if (mouseActive && !player.vehicle){
    player.aim = Math.atan2(mouse.y + camera.y - player.y, mouse.x + camera.x - player.x);
  } else if (mag > 0.15){
    player.aim = Math.atan2(iy, ix);
  }

  /* -------- movement -------- */
  if (player.vehicle){
    updateDriving(player.vehicle, ix, iy, dt);
  } else {
    const spd = characters[charIndex].speed;
    const dx = ix * spd * dt;
    const dy = iy * spd * dt;
    if (dx && !hitsBuilding(player.x + dx, player.y, 9)) player.x += dx;
    if (dy && !hitsBuilding(player.x, player.y + dy, 9)) player.y += dy;
    player.x = clamp(player.x, 12, WORLD - 12);
    player.y = clamp(player.y, 12, WORLD - 12);
  }

  /* -------- shooting -------- */
  player.fireCd -= dt;
  if (firing && player.fireCd <= 0 && !player.vehicle){
    const w = WEAPONS[player.weaponIndex];
    player.fireCd = w.rate;
    const a = player.aim + rand(-w.spread, w.spread);
    const mx = player.x + Math.cos(a) * 22;
    const my = player.y + Math.sin(a) * 22;

    bullets.push({
      x: mx, y: my, px: mx, py: my,
      vx: Math.cos(a) * w.speed,
      vy: Math.sin(a) * w.speed,
      life: 0.85, dmg: w.dmg, from: 'player', color: w.color
    });

    spawnMuzzle(mx, my, a);
    shake = Math.min(shake + 1.4, 9);
    sfx('shoot');
    if (Math.random() < 0.05) addWanted(1);
  }

  /* -------- traffic -------- */
  for (const c of cars){
    if (c.driver === 'player') continue;
    if (c.type === 'traffic') updateTraffic(c, dt);
  }

  /* -------- peds -------- */
  for (const p of peds){
    const d = Math.hypot(player.x - p.x, player.y - p.y);
    if (wanted > 0 && d < 340){
      p.angle = Math.atan2(p.y - player.y, p.x - player.x) + rand(-0.35, 0.35);
      p.speed = 145;
    } else {
      p.speed = p.gang ? 58 : 46;
      p.timer -= dt;
      if (p.timer <= 0){ p.timer = rand(1.4, 4); p.angle += rand(-1.7, 1.7); }
    }
    const dx = Math.cos(p.angle) * p.speed * dt;
    const dy = Math.sin(p.angle) * p.speed * dt;
    if (!hitsBuilding(p.x + dx, p.y, 8)) p.x += dx; else p.angle += Math.PI * 0.75;
    if (!hitsBuilding(p.x, p.y + dy, 8)) p.y += dy; else p.angle += Math.PI * 0.75;
    p.x = clamp(p.x, 12, WORLD - 12);
    p.y = clamp(p.y, 12, WORLD - 12);
  }

  /* -------- bullets -------- */
  for (let i = bullets.length - 1; i >= 0; i--){
    const b = bullets[i];
    b.px = b.x; b.py = b.y;
    b.x += b.vx * dt;
    b.y += b.vy * dt;
    b.life -= dt;

    let dead = b.life <= 0;

    if (!dead && b.from === 'player'){
      for (let j = peds.length - 1; j >= 0; j--){
        const p = peds[j];
        if (dist2(b.x, b.y, p.x, p.y) < 17 * 17){
          spawnBlood(p.x, p.y);
          sfx('hit');
          peds.splice(j, 1);
          addWanted(1);
          player.money += p.gang ? 45 : 5;
          if (p.gang){
            spawnCash(p.x, p.y);
            const m = missions[missionIndex];
            if (m && m.type === 'kill'){ m.count++; updateMissionUI(); }
          }
          updateHUD();
          dead = true;
          break;
        }
      }
      if (!dead){
        for (let j = police.length - 1; j >= 0; j--){
          const p = police[j];
          if (dist2(b.x, b.y, p.x, p.y) < 19 * 19){
            spawnBlood(p.x, p.y);
            police.splice(j, 1);
            addWanted(2);
            player.money += 120;
            updateHUD();
            sfx('hit');
            dead = true;
            break;
          }
        }
      }
    }

    if (!dead && b.from === 'cop'){
      const tx = player.vehicle ? player.vehicle.x : player.x;
      const ty = player.vehicle ? player.vehicle.y : player.y;
      const rad = player.vehicle ? 30 : 17;
      if (dist2(b.x, b.y, tx, ty) < rad * rad){
        damagePlayer(b.dmg);
        spawnBlood(b.x, b.y);
        dead = true;
      }
    }

    if (!dead && hitsBuilding(b.x, b.y, 0)){
      spawnSparks(b.x, b.y);
      dead = true;
    }

    if (dead) bullets.splice(i, 1);
  }

  /* -------- police -------- */
  if (wanted > 0){
    wantedTimer -= dt;
    if (wantedTimer <= 0){
      wanted--;
      wantedTimer = 8;
    }

    policeSpawnCd -= dt;
    const maxCops = wanted * 2;
    if (policeSpawnCd <= 0 && police.length < maxCops){
      policeSpawnCd = 1.6;
      const a = rand(0, TAU);
      const d = rand(750, 1000);
      const sx = clamp(player.x + Math.cos(a) * d, 60, WORLD - 60);
      const sy = clamp(player.y + Math.sin(a) * d, 60, WORLD - 60);
      police.push({ x: sx, y: sy, speed: rand(150, 200), shootCd: rand(0.8, 1.8), hue: 210 });
    }
  } else {
    policeSpawnCd = 0;
    /* FIX: clear police when wanted drops to 0 */
    if (police.length > 0) police.length = 0;
  }

  for (const p of police){
    const tx = player.vehicle ? player.vehicle.x : player.x;
    const ty = player.vehicle ? player.vehicle.y : player.y;
    const dx = tx - p.x, dy = ty - p.y;
    const d = Math.hypot(dx, dy) || 1;

    if (d > 210){
      const step = p.speed * dt;
      const mx = dx / d * step, my = dy / d * step;
      if (!hitsBuilding(p.x + mx, p.y, 10)) p.x += mx;
      if (!hitsBuilding(p.x, p.y + my, 10)) p.y += my;
    }

    p.shootCd -= dt;
    if (p.shootCd <= 0 && d < 480){
      p.shootCd = rand(0.9, 1.9);
      const a = Math.atan2(dy, dx) + rand(-0.09, 0.09);
      bullets.push({
        x: p.x + Math.cos(a) * 16, y: p.y + Math.sin(a) * 16,
        px: p.x + Math.cos(a) * 16, py: p.y + Math.sin(a) * 16,
        vx: Math.cos(a) * 1100, vy: Math.sin(a) * 1100,
        life: 0.8, dmg: 8, from: 'cop', color: '#7fb6ff'
      });
      spawnMuzzle(p.x + Math.cos(a) * 16, p.y + Math.sin(a) * 16, a);
    }
  }

  /* -------- particles -------- */
  for (let i = particles.length - 1; i >= 0; i--){
    const p = particles[i];
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vx *= (1 - 2.4 * dt);
    p.vy *= (1 - 2.4 * dt);
    p.life -= dt;
    if (p.life <= 0) particles.splice(i, 1);
  }

  /* -------- ped respawn -------- */
  if (peds.length < 46 && Math.random() < dt * 3){
    peds.push(makePed(false));
  }
  if (peds.filter(p => p.gang).length < 7 && Math.random() < dt * 1.4){
    peds.push(makePed(true));
  }

  /* -------- missions -------- */
  const m = missions[missionIndex];
  if (m){
    if (m.type === 'reach'){
      if (dist2(player.x, player.y, m.marker.x, m.marker.y) < 62 * 62) completeMission();
    } else if (m.type === 'kill'){
      if (m.count >= m.need) completeMission();
    } else if (m.type === 'deliver'){
      if (player.vehicle && dist2(player.vehicle.x, player.vehicle.y, m.marker.x, m.marker.y) < 72 * 72){
        completeMission();
      }
    }
  }

  /* -------- camera -------- */
  const targetX = player.x - W / 2;
  const targetY = player.y - H / 2;
  const k = 1 - Math.pow(0.0012, dt);
  camera.x = lerp(camera.x, targetX, k);
  camera.y = lerp(camera.y, targetY, k);

  shake *= Math.pow(0.0009, dt);
  if (shake < 0.05) shake = 0;

  updateHUD();
}

/* ============================================================
   RENDER
   ============================================================ */
function drawBlocks(){
  const s = BLOCK - ROAD_W;
  for (let gx = 0; gx < GRID; gx++){
    for (let gy = 0; gy < GRID; gy++){
      const x = gx * BLOCK + ROAD_W / 2 - camera.x;
      const y = gy * BLOCK + ROAD_W / 2 - camera.y;
      if (x > W || y > H || x + s < 0 || y + s < 0) continue;
      ctx.fillStyle = '#222b3b';
      ctx.fillRect(x, y, s, s);
      ctx.fillStyle = 'rgba(255,255,255,0.022)';
      ctx.fillRect(x + 7, y + 7, s - 14, s - 14);
    }
  }
}

function drawRoads(){
  ctx.fillStyle = '#161c27';
  for (let i = 0; i <= GRID; i++){
    const x = i * BLOCK - ROAD_W / 2 - camera.x;
    if (x < W && x + ROAD_W > 0) ctx.fillRect(x, -60, ROAD_W, H + 120);
  }
  for (let j = 0; j <= GRID; j++){
    const y = j * BLOCK - ROAD_W / 2 - camera.y;
    if (y < H && y + ROAD_W > 0) ctx.fillRect(-60, y, W + 120, ROAD_W);
  }

  ctx.strokeStyle = 'rgba(255,205,80,0.30)';
  ctx.lineWidth = 3;
  ctx.setLineDash([26, 30]);
  ctx.beginPath();
  for (let i = 0; i <= GRID; i++){
    const x = i * BLOCK - camera.x;
    if (x < -40 || x > W + 40) continue;
    ctx.moveTo(x, -60); ctx.lineTo(x, H + 60);
  }
  ctx.stroke();
  ctx.beginPath();
  for (let j = 0; j <= GRID; j++){
    const y = j * BLOCK - camera.y;
    if (y < -40 || y > H + 40) continue;
    ctx.moveTo(-60, y); ctx.lineTo(W + 60, y);
  }
  ctx.stroke();
  ctx.setLineDash([]);
}

function drawParksAndLots(){
  for (const p of parks){
    const x = p.x - camera.x, y = p.y - camera.y;
    if (x > W || y > H || x + p.w < 0 || y + p.h < 0) continue;
    const g = ctx.createLinearGradient(x, y, x, y + p.h);
    g.addColorStop(0, '#1d5c3a');
    g.addColorStop(1, '#123c26');
    ctx.fillStyle = g;
    ctx.fillRect(x, y, p.w, p.h);
    ctx.strokeStyle = 'rgba(200,190,150,0.22)';
    ctx.lineWidth = 16;
    ctx.beginPath();
    ctx.moveTo(x, y + p.h * 0.5);
    ctx.lineTo(x + p.w, y + p.h * 0.5);
    ctx.stroke();
  }

  for (const p of parkingLots){
    const x = p.x - camera.x, y = p.y - camera.y;
    if (x > W || y > H || x + p.w < 0 || y + p.h < 0) continue;
    ctx.fillStyle = '#1c2431';
    ctx.fillRect(x, y, p.w, p.h);
    ctx.strokeStyle = 'rgba(255,255,255,0.10)';
    ctx.lineWidth = 2;
    for (let i = 1; i < 6; i++){
      ctx.beginPath();
      ctx.moveTo(x + (p.w / 6) * i, y + 12);
      ctx.lineTo(x + (p.w / 6) * i, y + p.h - 12);
      ctx.stroke();
    }
  }
}

function drawTrees(){
  for (const t of trees){
    const x = t.x - camera.x, y = t.y - camera.y;
    if (x < -40 || y < -40 || x > W + 40 || y > H + 40) continue;
    ctx.fillStyle = 'rgba(0,0,0,0.30)';
    ctx.beginPath(); ctx.arc(x + 4, y + 5, t.r, 0, TAU); ctx.fill();
    const g = ctx.createRadialGradient(x - t.r * 0.35, y - t.r * 0.35, t.r * 0.15, x, y, t.r);
    g.addColorStop(0, `hsl(${t.hue}, 55%, 52%)`);
    g.addColorStop(1, `hsl(${t.hue}, 60%, 26%)`);
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(x, y, t.r, 0, TAU); ctx.fill();
  }
}

function drawBuildings(){
  for (const b of buildings){
    const x = b.x - camera.x, y = b.y - camera.y;
    if (x > W + 60 || y > H + 60 || x + b.w < -60 || y + b.h < -60) continue;

    ctx.fillStyle = 'rgba(0,0,0,0.32)';
    ctx.fillRect(x + 11, y + 13, b.w, b.h);

    const g = ctx.createLinearGradient(x, y, x + b.w, y + b.h);
    g.addColorStop(0, `hsl(${b.hue}, 34%, ${b.light + 9}%)`);
    g.addColorStop(1, `hsl(${b.hue}, 40%, ${b.light - 9}%)`);
    ctx.fillStyle = g;
    ctx.fillRect(x, y, b.w, b.h);

    ctx.fillStyle = `hsla(${b.hue}, 32%, ${b.light - 13}%, 0.85)`;
    for (const d of b.details){
      ctx.fillRect(x + b.w * d.x, y + b.h * d.y, b.w * d.w, b.h * d.h);
    }

    for (const win of b.windows){
      if (win.lit && nightFactor > 0.12){
        ctx.fillStyle = `rgba(255, 213, 130, ${0.5 + nightFactor * 0.5})`;
      } else if (win.lit){
        ctx.fillStyle = `hsla(${b.hue}, 50%, 68%, 0.55)`;
      } else {
        ctx.fillStyle = `hsla(${b.hue}, 45%, 19%, 0.72)`;
      }
      ctx.fillRect(x + win.x, y + win.y, 10, 10);
    }

    ctx.strokeStyle = `hsla(${b.hue}, 45%, 72%, 0.22)`;
    ctx.lineWidth = 2;
    ctx.strokeRect(x + 1, y + 1, b.w - 2, b.h - 2);
  }
}

function drawCar(c){
  const x = c.x - camera.x, y = c.y - camera.y;
  if (x < -90 || y < -90 || x > W + 90 || y > H + 90) return;

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(c.angle);

  const w = c.w, h = c.h;

  ctx.fillStyle = 'rgba(0,0,0,0.36)';
  rr(ctx, -w / 2 + 3, -h / 2 + 4, w, h, 7);
  ctx.fill();

  ctx.fillStyle = '#0a0d13';
  ctx.fillRect(-w / 2 + 6,  -h / 2 - 3, 11, 6);
  ctx.fillRect(-w / 2 + 6,   h / 2 - 3, 11, 6);
  ctx.fillRect( w / 2 - 17, -h / 2 - 3, 11, 6);
  ctx.fillRect( w / 2 - 17,  h / 2 - 3, 11, 6);

  const g = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
  if (c.driver === 'player'){
    const hue = characters[charIndex].hue;
    g.addColorStop(0, `hsl(${hue}, 85%, 66%)`);
    g.addColorStop(1, `hsl(${hue}, 80%, 40%)`);
  } else {
    g.addColorStop(0, `hsl(${c.hue}, 78%, 64%)`);
    g.addColorStop(1, `hsl(${c.hue}, 72%, 38%)`);
  }
  ctx.fillStyle = g;
  rr(ctx, -w / 2, -h / 2, w, h, 7);
  ctx.fill();

  ctx.fillStyle = 'rgba(14, 22, 38, 0.86)';
  rr(ctx, -w * 0.17, -h / 2 + 4, w * 0.34, h - 8, 4);
  ctx.fill();

  ctx.fillStyle = 'rgba(160, 210, 255, 0.20)';
  ctx.fillRect(-w * 0.14, -h / 2 + 6, w * 0.28, 3);

  ctx.fillStyle = '#ffeeb0';
  ctx.fillRect(w / 2 - 5, -h / 2 + 3, 4, 5);
  ctx.fillRect(w / 2 - 5,  h / 2 - 8, 4, 5);

  ctx.fillStyle = '#ff4d5e';
  ctx.fillRect(-w / 2 + 1, -h / 2 + 3, 3, 5);
  ctx.fillRect(-w / 2 + 1,  h / 2 - 8, 3, 5);

  ctx.restore();

  if (c.driver === 'player'){
    ctx.strokeStyle = `hsla(${characters[charIndex].hue}, 90%, 70%, 0.55)`;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(x, y, 34, 0, TAU);
    ctx.stroke();
  }
}

function drawPeds(){
  for (const p of peds){
    const x = p.x - camera.x, y = p.y - camera.y;
    if (x < -30 || y < -30 || x > W + 30 || y > H + 30) continue;

    ctx.fillStyle = 'rgba(0,0,0,0.36)';
    ctx.beginPath();
    ctx.ellipse(x + 1.5, y + 2.5, 7, 7, 0, 0, TAU);
    ctx.fill();

    if (p.gang){
      ctx.fillStyle = 'rgba(255,45,85,0.18)';
      ctx.beginPath();
      ctx.arc(x, y, 14 + Math.sin(time * 5) * 2, 0, TAU);
      ctx.fill();
    }

    ctx.fillStyle = `hsl(${p.hue}, ${p.sat}%, 58%)`;
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, TAU);
    ctx.fill();

    ctx.strokeStyle = p.gang ? '#ff2d55' : 'rgba(0,0,0,0.45)';
    ctx.lineWidth = p.gang ? 2 : 1;
    ctx.stroke();
  }
}

function drawPolice(){
  for (const p of police){
    const x = p.x - camera.x, y = p.y - camera.y;
    if (x < -40 || y < -40 || x > W + 40 || y > H + 40) continue;

    ctx.fillStyle = 'rgba(0,0,0,0.4)';
    ctx.beginPath();
    ctx.ellipse(x + 2, y + 3, 9, 9, 0, 0, TAU);
    ctx.fill();

    ctx.fillStyle = '#2b3c5e';
    ctx.beginPath(); ctx.arc(x, y, 9, 0, TAU); ctx.fill();
    ctx.strokeStyle = '#8fb3d9'; ctx.lineWidth = 2; ctx.stroke();

    const flash = Math.floor(performance.now() / 190) % 2;
    ctx.fillStyle = flash ? '#ff2d55' : '#2d7bff';
    ctx.beginPath(); ctx.arc(x, y - 12, 4, 0, TAU); ctx.fill();
  }
}

function drawPlayer(){
  if (player.vehicle) return;

  const x = player.x - camera.x;
  const y = player.y - camera.y;
  const hue = characters[charIndex].hue;

  ctx.fillStyle = 'rgba(0,0,0,0.42)';
  ctx.beginPath();
  ctx.ellipse(x + 2, y + 4, 12, 12, 0, 0, TAU);
  ctx.fill();

  ctx.strokeStyle = 'rgba(255,255,255,0.14)';
  ctx.lineWidth = 2;
  ctx.setLineDash([7, 9]);
  ctx.beginPath();
  ctx.moveTo(x, y);
  ctx.lineTo(x + Math.cos(player.aim) * 85, y + Math.sin(player.aim) * 85);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(player.aim);
  ctx.fillStyle = '#d5e2f5';
  ctx.fillRect(7, -2.5, 20, 5);
  ctx.fillStyle = '#8a9ab5';
  ctx.fillRect(7, -2.5, 6, 5);
  ctx.restore();

  const g = ctx.createRadialGradient(x - 4, y - 4, 2, x, y, 14);
  g.addColorStop(0, `hsl(${hue}, 92%, 74%)`);
  g.addColorStop(1, `hsl(${hue}, 78%, 46%)`);
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(x, y, 12, 0, TAU); ctx.fill();
  ctx.strokeStyle = 'rgba(255,255,255,0.9)';
  ctx.lineWidth = 2;
  ctx.stroke();
}

function drawBullets(){
  for (const b of bullets){
    const x = b.x - camera.x, y = b.y - camera.y;
    if (x < -40 || y < -40 || x > W + 40 || y > H + 40) continue;

    ctx.strokeStyle = b.color;
    ctx.lineWidth = 2.5;
    ctx.beginPath();
    ctx.moveTo(b.px - camera.x, b.py - camera.y);
    ctx.lineTo(x, y);
    ctx.stroke();

    ctx.fillStyle = '#ffffff';
    ctx.beginPath(); ctx.arc(x, y, 1.8, 0, TAU); ctx.fill();
  }
}

function drawParticles(){
  for (const p of particles){
    const x = p.x - camera.x, y = p.y - camera.y;
    if (x < -30 || y < -30 || x > W + 30 || y > H + 30) continue;

    const a = clamp(p.life / p.maxLife, 0, 1);
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    if (p.glow){
      ctx.shadowBlur = 10;
      ctx.shadowColor = p.color;
    }
    ctx.beginPath();
    ctx.arc(x, y, p.size * (0.5 + a * 0.5), 0, TAU);
    ctx.fill();
    ctx.shadowBlur = 0;
  }
  ctx.globalAlpha = 1;
}

function drawMissionMarker(){
  const m = missions[missionIndex];
  if (!m || !m.marker) return;

  const x = m.marker.x - camera.x;
  const y = m.marker.y - camera.y;
  if (x < -120 || y < -120 || x > W + 120 || y > H + 120) return;

  const pulse = 1 + Math.sin(time * 3) * 0.14;

  const g = ctx.createRadialGradient(x, y, 0, x, y, 70 * pulse);
  g.addColorStop(0, 'rgba(255,209,102,0.42)');
  g.addColorStop(1, 'rgba(255,209,102,0)');
  ctx.fillStyle = g;
  ctx.beginPath(); ctx.arc(x, y, 70 * pulse, 0, TAU); ctx.fill();

  ctx.strokeStyle = 'rgba(255,209,102,0.85)';
  ctx.lineWidth = 3;
  ctx.beginPath(); ctx.arc(x, y, 24 * pulse, 0, TAU); ctx.stroke();

  ctx.fillStyle = '#ffd166';
  ctx.beginPath(); ctx.arc(x, y, 11, 0, TAU); ctx.fill();

  ctx.beginPath();
  ctx.moveTo(x, y - 46 - Math.sin(time * 3) * 5);
  ctx.lineTo(x - 11, y - 62 - Math.sin(time * 3) * 5);
  ctx.lineTo(x + 11, y - 62 - Math.sin(time * 3) * 5);
  ctx.closePath();
  ctx.fill();
}

function drawNightOverlay(){
  if (nightFactor > 0.02){
    ctx.fillStyle = `rgba(6, 10, 34, ${nightFactor * 0.68})`;
    ctx.fillRect(-60, -60, W + 120, H + 120);
    const dusk = Math.max(0, 1 - Math.abs(nightFactor - 0.45) * 2.3);
    if (dusk > 0.01){
      ctx.fillStyle = `rgba(255, 120, 40, ${dusk * 0.12})`;
      ctx.fillRect(-60, -60, W + 120, H + 120);
    }
  }
}

function drawLights(){
  if (nightFactor < 0.08) return;
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';

  for (const c of cars){
    const fx = c.x + Math.cos(c.angle) * c.w * 0.62 - camera.x;
    const fy = c.y + Math.sin(c.angle) * c.w * 0.62 - camera.y;
    if (fx < -170 || fy < -170 || fx > W + 170 || fy > H + 170) continue;
    const g = ctx.createRadialGradient(fx, fy, 0, fx, fy, 135);
    g.addColorStop(0, `rgba(255, 232, 165, ${0.30 * nightFactor})`);
    g.addColorStop(1, 'rgba(255, 232, 165, 0)');
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(fx, fy, 135, 0, TAU); ctx.fill();
  }

  for (const p of police){
    const x = p.x - camera.x, y = p.y - camera.y;
    const flash = Math.floor(performance.now() / 190) % 2;
    const col = flash ? '255,45,85' : '45,123,255';
    const g = ctx.createRadialGradient(x, y, 0, x, y, 85);
    g.addColorStop(0, `rgba(${col},0.35)`);
    g.addColorStop(1, `rgba(${col},0)`);
    ctx.fillStyle = g;
    ctx.beginPath(); ctx.arc(x, y, 85, 0, TAU); ctx.fill();
  }

  ctx.restore();
}

function drawRain(){
  if (rainFactor < 0.02) return;
  ctx.strokeStyle = `rgba(170, 205, 255, ${0.30 * rainFactor})`;
  ctx.lineWidth = 1.4;
  ctx.beginPath();
  const count = Math.floor(70 * rainFactor);
  for (let i = 0; i < count; i++){
    const x = Math.random() * W;
    const y = Math.random() * H;
    ctx.moveTo(x, y);
    ctx.lineTo(x - 6, y + 18);
  }
  ctx.stroke();
}

/* ---------------- Minimap ---------------- */
function drawMinimap(){
  const S = MM_SIZE;
  const cx = S / 2, cy = S / 2;
  const VIEW_R = 950;
  const scale = (S / 2) / VIEW_R;

  mmCtx.save();
  mmCtx.setTransform(DPR, 0, 0, DPR, 0, 0);
  mmCtx.clearRect(0, 0, S, S);

  mmCtx.beginPath();
  mmCtx.arc(cx, cy, S / 2, 0, TAU);
  mmCtx.clip();

  mmCtx.fillStyle = '#0a1018';
  mmCtx.fillRect(0, 0, S, S);

  mmCtx.save();
  mmCtx.translate(cx, cy);
  mmCtx.scale(scale, scale);
  mmCtx.translate(-player.x, -player.y);

  mmCtx.strokeStyle = '#2a3547';
  mmCtx.lineWidth = ROAD_W;
  for (let i = 0; i <= GRID; i++){
    const x = i * BLOCK;
    mmCtx.moveTo(x, -ROAD_W); mmCtx.lineTo(x, WORLD + ROAD_W);
    const y = i * BLOCK;
    mmCtx.moveTo(-ROAD_W, y); mmCtx.lineTo(WORLD + ROAD_W, y);
  }
  mmCtx.stroke();

  mmCtx.fillStyle = '#1c5c3a';
  for (const p of parks){
    if (dist2(p.x, p.y, player.x, player.y) > (VIEW_R * 1.4) ** 2) continue;
    mmCtx.fillRect(p.x, p.y, p.w, p.h);
  }

  mmCtx.fillStyle = '#41506b';
  for (const b of buildings){
    if (dist2(b.x, b.y, player.x, player.y) > (VIEW_R * 1.4) ** 2) continue;
    mmCtx.fillRect(b.x, b.y, b.w, b.h);
  }

  mmCtx.restore();

  const m = missions[missionIndex];
  if (m && m.marker){
    let dx = (m.marker.x - player.x) * scale;
    let dy = (m.marker.y - player.y) * scale;
    const d = Math.hypot(dx, dy);
    const lim = S / 2 - 9;
    if (d > lim){ dx = dx / d * lim; dy = dy / d * lim; }
    mmCtx.fillStyle = '#ffd166';
    mmCtx.shadowBlur = 10;
    mmCtx.shadowColor = '#ffd166';
    mmCtx.beginPath(); mmCtx.arc(cx + dx, cy + dy, 4.5, 0, TAU); mmCtx.fill();
    mmCtx.shadowBlur = 0;
  }

  mmCtx.fillStyle = '#ff2d55';
  for (const p of police){
    const dx = (p.x - player.x) * scale;
    const dy = (p.y - player.y) * scale;
    if (Math.hypot(dx, dy) > S / 2 - 6) continue;
    mmCtx.beginPath(); mmCtx.arc(cx + dx, cy + dy, 2.6, 0, TAU); mmCtx.fill();
  }

  mmCtx.save();
  mmCtx.translate(cx, cy);
  mmCtx.rotate(player.aim + Math.PI / 2);
  mmCtx.fillStyle = '#ffffff';
  mmCtx.shadowBlur = 10;
  mmCtx.shadowColor = '#7fd1ff';
  mmCtx.beginPath();
  mmCtx.moveTo(0, -7);
  mmCtx.lineTo(5, 6);
  mmCtx.lineTo(0, 3);
  mmCtx.lineTo(-5, 6);
  mmCtx.closePath();
  mmCtx.fill();
  mmCtx.restore();

  mmCtx.restore();
}

/* ============================================================
   MAIN RENDER
   ============================================================ */
function render(){
  ctx.save();
  if (shake > 0.1){
    ctx.translate(rand(-shake, shake), rand(-shake, shake));
  }

  ctx.fillStyle = '#0d1219';
  ctx.fillRect(-80, -80, W + 160, H + 160);

  drawBlocks();
  drawRoads();
  drawParksAndLots();
  drawTrees();
  drawBuildings();
  drawMissionMarker();

  drawParticles();

  for (const c of cars) drawCar(c);
  drawPeds();
  drawPolice();
  drawPlayer();
  drawBullets();

  drawNightOverlay();
  drawLights();
  drawRain();

  ctx.restore();

  const vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.42, W / 2, H / 2, Math.max(W, H) * 0.78);
  vg.addColorStop(0, 'rgba(0,0,0,0)');
  vg.addColorStop(1, 'rgba(0,0,0,0.55)');
  ctx.fillStyle = vg;
  ctx.fillRect(0, 0, W, H);

  drawMinimap();
}

/* ============================================================
   MAIN LOOP
   ============================================================ */
let lastT = performance.now();

function loop(now){
  const dt = Math.min((now - lastT) / 1000, 0.05);
  lastT = now;
  update(dt);
  render();
  requestAnimationFrame(loop);
}

/* Boot */
updateHUD();
showMission();
setTimeout(() => toast('WELCOME TO NEON CITY', '#ffd166'), 400);
requestAnimationFrame(loop);

['pointerdown', 'keydown', 'touchstart'].forEach(ev => {
  window.addEventListener(ev, initAudio, { once: true, passive: true });
});
</script>
</body>
</html>

Game Source: Neon City — 2D Open World

Creator: LaserBuilder68

Libraries: none

Complexity: complex (1901 lines, 59.1 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: neon-city-2d-open-world-laserbuilder68" to link back to the original. Then publish at arcadelab.ai/publish.