Neon City — 2D Open World
by LaserBuilder681901 lines59.1 KB
<!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.