METRO LEGACY: AGENT
by LaserBuilder682345 lines70.5 KB
<!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="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>METRO LEGACY: AGENT</title>
<style>
html,body{margin:0;height:100%;background:#07080c;overflow:hidden;
font-family:"Courier New",monospace;
-webkit-user-select:none;user-select:none;
-webkit-touch-callout:none;touch-action:none;
overscroll-behavior:none;}
#wrap{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
touch-action:none;}
canvas{display:block;image-rendering:auto;
box-shadow:0 0 60px rgba(255,60,40,.15), 0 0 0 1px #1a1d24;
background:#000;cursor:crosshair;
touch-action:none;
-webkit-user-select:none;user-select:none;}
@media (pointer:coarse){
canvas{cursor:default;}
}
</style>
</head>
<body>
<div id="wrap"><canvas id="game" width="960" height="540"></canvas></div>
<script>
/* ============================================================
METRO LEGACY: AGENT — "A Day To Die Hard"
MOBILE BUILD — Killer Bean Unleashed style dual joysticks
============================================================ */
const cvs = document.getElementById('game');
const ctx = cvs.getContext('2d');
const W = 960, H = 540;
function resize(){
const s = Math.min(window.innerWidth/W, window.innerHeight/H);
cvs.style.width = (W*s)+'px';
cvs.style.height = (H*s)+'px';
}
window.addEventListener('resize', resize); resize();
window.addEventListener('orientationchange', ()=>{ setTimeout(resize, 120); });
/* ---------- AUDIO ---------- */
let actx = null;
function initAudio(){
if(!actx){ try{ actx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} }
if(actx && actx.state === 'suspended') actx.resume();
}
function tone(freq, dur, type='square', vol=0.045, slide=0){
if(!actx) return;
const o=actx.createOscillator(), g=actx.createGain();
o.type=type;
o.frequency.setValueAtTime(freq, actx.currentTime);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq+slide), actx.currentTime+dur);
g.gain.setValueAtTime(vol, actx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime+dur);
o.connect(g); g.connect(actx.destination);
o.start(); o.stop(actx.currentTime+dur);
}
function noise(dur, vol=0.07){
if(!actx) return;
const n = Math.floor(actx.sampleRate*dur);
const buf = actx.createBuffer(1,n,actx.sampleRate);
const d = buf.getChannelData(0);
for(let i=0;i<n;i++) d[i]=(Math.random()*2-1)*(1-i/n);
const s=actx.createBufferSource(); s.buffer=buf;
const g=actx.createGain(); g.gain.value=vol;
s.connect(g); g.connect(actx.destination); s.start();
}
const SFX = {
shot(){ tone(880,0.05,'square',0.035,-600); noise(0.04,0.03); },
smg(){ tone(1100,0.035,'square',0.025,-700); },
ak(){ tone(520,0.06,'sawtooth',0.04,-300); noise(0.05,0.04); },
hit(){ tone(200,0.08,'square',0.05,-120); },
hurt(){ tone(140,0.18,'sawtooth',0.06,-80); },
die(){ tone(300,0.25,'square',0.05,-240); },
pick(){ tone(700,0.08,'triangle',0.05,500); },
rescue(){ tone(600,0.1,'triangle',0.05,400); setTimeout(()=>tone(900,0.12,'triangle',0.05,300),90); },
boom(){ noise(0.55,0.16); tone(90,0.4,'sawtooth',0.07,-60); },
clear(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.14,'triangle',0.05),i*100)); }
};
/* ---------- INPUT ---------- */
const keys = {};
const mouse = { x: W/2, y: H/2, down:false };
/* ---- Touch / mobile ---- */
const STICK_R = 68;
const touch = {
using: false,
left: { active:false, id:null, bx:0, by:0, kx:0, ky:0, nx:0, ny:0, mag:0 },
right: { active:false, id:null, bx:0, by:0, kx:0, ky:0, angle:0, mag:0 },
swapBtn: { x: W-56, y: 168, r: 32, flash: 0 },
jumpBtn: { x: W-56, y: 260, r: 32, flash: 0, down:false }
};
function getCanvasPos(clientX, clientY){
const r = cvs.getBoundingClientRect();
return {
x: (clientX - r.left) * (W / r.width),
y: (clientY - r.top) * (H / r.height)
};
}
function touchStart(e){
e.preventDefault();
initAudio();
touch.using = true;
for(const t of e.changedTouches){
const p = getCanvasPos(t.clientX, t.clientY);
/* Menu taps */
if(game.state === 'menu'){
if(p.x > W/2-240 && p.x < W/2+240){
if(p.y > 288 && p.y < 344){ startStory(); return; }
if(p.y > 352 && p.y < 408){ startEndless(); return; }
}
continue;
}
/* Dead / win restart */
if(game.state === 'dead' || game.state === 'win'){
if(game.mode === 'story') loadStoryLevel(game.levelIndex);
else loadEndlessFloor(game.endlessFloor, false);
return;
}
if(game.state !== 'play') continue;
/* Swap weapon button */
if(Math.hypot(p.x - touch.swapBtn.x, p.y - touch.swapBtn.y) < touch.swapBtn.r + 14){
swapWeapon();
touch.swapBtn.flash = 0.25;
continue;
}
/* Jump button */
if(Math.hypot(p.x - touch.jumpBtn.x, p.y - touch.jumpBtn.y) < touch.jumpBtn.r + 14){
touch.jumpBtn.down = true;
touch.jumpBtn.flash = 0.25;
continue;
}
/* Left half → floating move stick */
if(p.x < W/2 && !touch.left.active){
touch.left.active = true;
touch.left.id = t.identifier;
touch.left.bx = p.x; touch.left.by = p.y;
touch.left.kx = p.x; touch.left.ky = p.y;
touch.left.nx = 0; touch.left.ny = 0; touch.left.mag = 0;
}
/* Right half → floating aim/fire stick */
else if(p.x >= W/2 && !touch.right.active){
touch.right.active = true;
touch.right.id = t.identifier;
touch.right.bx = p.x; touch.right.by = p.y;
touch.right.kx = p.x; touch.right.ky = p.y;
touch.right.angle = 0; touch.right.mag = 0;
}
}
}
function touchMove(e){
e.preventDefault();
for(const t of e.changedTouches){
const p = getCanvasPos(t.clientX, t.clientY);
if(touch.left.active && t.identifier === touch.left.id){
const dx = p.x - touch.left.bx;
const dy = p.y - touch.left.by;
const d = Math.hypot(dx, dy);
const clamped = Math.min(d, STICK_R);
const a = d > 0.01 ? Math.atan2(dy, dx) : 0;
touch.left.kx = touch.left.bx + Math.cos(a) * clamped;
touch.left.ky = touch.left.by + Math.sin(a) * clamped;
touch.left.mag = clamped / STICK_R;
touch.left.nx = Math.cos(a) * touch.left.mag;
touch.left.ny = Math.sin(a) * touch.left.mag;
}
if(touch.right.active && t.identifier === touch.right.id){
const dx = p.x - touch.right.bx;
const dy = p.y - touch.right.by;
const d = Math.hypot(dx, dy);
const clamped = Math.min(d, STICK_R);
const a = d > 0.01 ? Math.atan2(dy, dx) : 0;
touch.right.kx = touch.right.bx + Math.cos(a) * clamped;
touch.right.ky = touch.right.by + Math.sin(a) * clamped;
touch.right.mag = clamped / STICK_R;
touch.right.angle = a;
}
}
}
function touchEnd(e){
e.preventDefault();
for(const t of e.changedTouches){
if(touch.left.active && t.identifier === touch.left.id){
touch.left.active = false; touch.left.mag = 0;
touch.left.nx = 0; touch.left.ny = 0;
}
if(touch.right.active && t.identifier === touch.right.id){
touch.right.active = false; touch.right.mag = 0;
}
if(touch.jumpBtn.down){ touch.jumpBtn.down = false; }
}
}
cvs.addEventListener('touchstart', touchStart, {passive:false});
cvs.addEventListener('touchmove', touchMove, {passive:false});
cvs.addEventListener('touchend', touchEnd, {passive:false});
cvs.addEventListener('touchcancel',touchEnd, {passive:false});
/* ---- Keyboard / mouse (desktop) ---- */
window.addEventListener('keydown', e=>{
initAudio();
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
if(!keys[e.code]) handleKeyPress(e.code);
keys[e.code]=true;
});
window.addEventListener('keyup', e=>{ keys[e.code]=false; });
cvs.addEventListener('mousemove', e=>{
if(touch.using) return;
const r = cvs.getBoundingClientRect();
mouse.x = (e.clientX-r.left) * (W/r.width);
mouse.y = (e.clientY-r.top) * (H/r.height);
});
cvs.addEventListener('mousedown', e=>{
if(touch.using) return;
initAudio(); mouse.down=true;
});
window.addEventListener('mouseup', ()=>{ mouse.down=false; });
cvs.addEventListener('contextmenu', e=>e.preventDefault());
/* ---------- HELPERS ---------- */
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const rand = (a,b)=> a + Math.random()*(b-a);
function overlap(a,b){
return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y;
}
function ground(w){ return {x:0, y:470, w:w, h:120}; }
function desk(x){ return {x:x, y:410, w:76, h:60}; }
function desk2(x){ return {x:x, y:410, w:130, h:60}; }
function ledge(x,y,w){ return {x:x, y:y, w:w, h:16}; }
function rack(x){ return {x:x, y:380, w:44, h:90}; }
function vent(x){ return {x:x, y:410, w:64, h:60}; }
function losBlocked(x1,y1,x2,y2,plats){
const steps = 10;
for(let i=1;i<steps;i++){
const t = i/steps;
const px = x1 + (x2-x1)*t;
const py = y1 + (y2-y1)*t;
for(const p of plats){
if(px>p.x && px<p.x+p.w && py>p.y && py<p.y+p.h) return true;
}
}
return false;
}
/* ============================================================
WEAPONS
============================================================ */
const WEAPONS = {
pistol: { label:'M9 PISTOL', dmg:28, rate:230, speed:14, spread:0.012, auto:false, ammoMax:Infinity, sfx:'shot', col:'#ffe27a', size:3 },
smg: { label:'MP5 SMG', dmg:15, rate:78, speed:16, spread:0.065, auto:true, ammoMax:260, sfx:'smg', col:'#ffd24a', size:3 },
ak: { label:'AK-47', dmg:26, rate:110, speed:17, spread:0.048, auto:true, ammoMax:180, sfx:'ak', col:'#ffb347', size:4 },
};
/* ============================================================
ENEMY TYPES
============================================================ */
const ENEMY_TYPES = {
goon: { hp:52, speed:1.15, range:430, rate:820, dmg:8, col:'#3b4049', acc:0.13, burst:1, prefDist:230 },
rifle: { hp:78, speed:1.00, range:580, rate:1050, dmg:11, col:'#4a3f35', acc:0.10, burst:2, prefDist:300 },
heavy: { hp:145, speed:0.70, range:480, rate:1350, dmg:15, col:'#33402f', acc:0.11, burst:3, prefDist:200 },
};
/* ============================================================
STORY LEVELS
============================================================ */
const STORY_LEVELS = [
{
title:'LOBBY LOCKDOWN', sub:'Floors 1–5 · Metro Tower, Lagos',
dark:false, width:2800, exitX:2700,
platforms:[
ground(2800), desk(400), desk(760), ledge(960,350,240), desk(1300),
desk(1620), ledge(1840,355,220), desk(2200), desk(2480)
],
enemies:[
{x:620,type:'goon'},{x:900,type:'goon'},{x:1420,type:'rifle'},
{x:1720,type:'goon'},{x:2100,type:'rifle'},{x:2450,type:'goon'}
],
hostages:[],
pickups:[{x:1150,type:'health'},{x:1980,type:'ammo'}],
intro:[
'Control, we have movement in the lobby. Somebody is inside.',
'Who is this guy? He is not one of ours.',
'Find him. Kill him. We are on the clock.'
]
},
{
title:'OFFICE HOSTAGE', sub:'Floor 15 · Open-plan floor',
dark:false, width:3300, exitX:3200,
platforms:[
ground(3300),
desk2(320), desk2(700), ledge(1050,340,260), desk2(1450), desk2(1800),
ledge(2150,335,240), desk2(2500), desk2(2850)
],
enemies:[
{x:520,type:'goon'},{x:950,type:'rifle'},{x:1300,type:'goon'},
{x:1650,type:'heavy'},{x:2000,type:'rifle'},{x:2350,type:'goon'},
{x:2650,type:'rifle'},{x:2950,type:'goon'}
],
hostages:[
{x:260},{x:820},{x:1180},{x:1550},{x:1900},
{x:2250},{x:2600},{x:2800},{x:3050},{x:3120}
],
pickups:[{x:1080,type:'health'},{x:2180,type:'smg'},{x:2700,type:'health'}],
intro:[
'They have staff on the fifteenth floor. Dozens of them.',
'Do NOT hit the civilians. I repeat, do NOT hit the civilians.',
'Somebody get me a name. Who is doing this?'
]
},
{
title:'SERVER ROOM', sub:'Floor 25 · Power cut — flashlight only',
dark:true, width:3100, exitX:3000,
platforms:[
ground(3100),
rack(300), rack(500), ledge(760,350,220), rack(1100), rack(1300),
rack(1500), ledge(1780,340,240), rack(2150), rack(2350), ledge(2620,350,220), rack(2880)
],
enemies:[
{x:420,type:'goon'},{x:700,type:'rifle'},{x:1000,type:'goon'},
{x:1250,type:'heavy'},{x:1600,type:'rifle'},{x:1950,type:'goon'},
{x:2250,type:'rifle'},{x:2550,type:'heavy'},{x:2850,type:'goon'}
],
hostages:[],
pickups:[{x:1180,type:'health'},{x:2050,type:'ak'},{x:2500,type:'health'}],
intro:[
'He cut the main power. Smart. Or stupid.',
'Everyone switch to thermal. Find him in the dark.',
'He cannot see us either. This is our floor.'
]
},
{
title:'ROOFTOP HELIPAD', sub:'Floor 40 · Open air — 40 storeys up',
dark:false, width:3100, exitX:0, boss:'heli',
platforms:[
ground(3100),
vent(400), vent(900), ledge(1300,370,220), vent(1800), vent(2300), ledge(2600,370,200)
],
enemies:[
{x:700,type:'goon'},{x:1500,type:'rifle'},{x:2100,type:'goon'}
],
hostages:[],
pickups:[{x:1000,type:'health'},{x:2400,type:'ak'},{x:2700,type:'health'}],
intro:[
'The chopper is airborne. He is not getting away.',
'Sir, the target is on the roof — he is shooting at our own men!',
'Then land the bird and finish him!'
]
},
{
title:'PENTHOUSE', sub:'Floor 50 · The Chairman',
dark:false, width:3100, exitX:0, boss:'chairman',
platforms:[
ground(3100),
desk2(400), ledge(800,350,240), desk2(1250), desk2(1650),
ledge(2050,345,240), desk2(2450), desk2(2800)
],
enemies:[
{x:600,type:'heavy'},{x:1000,type:'rifle'},{x:1500,type:'heavy'},
{x:1900,type:'rifle'},{x:2300,type:'heavy'}
],
hostages:[{x:2980, label:'NGOZI'}],
pickups:[{x:900,type:'health'},{x:1750,type:'ak'},{x:2200,type:'health'}],
intro:[
'Mr. Chairman, the building is sealed. Police cannot get in.',
'Good. Then nobody is coming for you.',
'Except one man, sir. One man in a suit.'
]
}
];
/* ============================================================
GAME STATE
============================================================ */
const game = {
state:'menu',
mode:'story',
levelIndex:0,
level:null,
player:null,
bullets:[], particles:[], pickups:[], texts:[], grenades:[],
camX:0, shake:0, hitFlash:0,
score:0, hostagesSaved:0, hostagesLost:0, kills:0,
subtitle:null, subT:0,
endlessFloor:1, endlessTimer:0,
clearedT:0, deadT:0,
firstBlood:false, lowHealthWarned:false,
timer:0
};
function say(text, dur=3.6){
game.subtitle = text;
game.subT = dur;
}
/* ============================================================
LEVEL BUILDING
============================================================ */
function buildLevel(def, endless){
const lvl = {
title: def.title, sub: def.sub, dark: !!def.dark,
width: def.width,
platforms: def.platforms.map(p=>({...p})),
exitX: def.exitX || 0,
hasExit: !endless,
bossType: def.boss || null,
boss: null,
enemies: [], hostages: [],
intro: def.intro || [],
endless: !!endless,
defPickups: (def.pickups||[]).map(p=>({...p}))
};
(def.enemies||[]).forEach(e=>{
lvl.enemies.push(makeEnemy(e.x, 380, e.type));
});
(def.hostages||[]).forEach(h=>{
lvl.hostages.push({
x:h.x, y:410, w:18, h:40, saved:false, alive:true,
label: h.label || null, bob: Math.random()*6.28
});
});
if(def.boss === 'heli'){
lvl.boss = {
type:'heli', x:2200, y:130, w:210, h:96,
hp:560, maxHp:560, t:0, fireCd:1.6, spawnCd:7, dir:-1,
hitFlash:0, alive:true
};
}
if(def.boss === 'chairman'){
lvl.boss = {
type:'chairman', x:2650, y:400, w:26, h:56,
hp:460, maxHp:460, vx:0, vy:0, onGround:false, facing:-1,
fireCd:1.2, burst:0, burstLeft:0, burstCd:0, nadeCd:3.0,
hitFlash:0, alive:true, walkT:0
};
}
return lvl;
}
function makeEnemy(x, y, type){
const T = ENEMY_TYPES[type] || ENEMY_TYPES.goon;
return {
x, y, w:22, h:46, vx:0, vy:0, onGround:false,
type, hp:T.hp, maxHp:T.hp, speed:T.speed, range:T.range,
rate:T.rate, dmg:T.dmg, col:T.col, acc:T.acc, burst:T.burst, prefDist:T.prefDist,
facing:-1, dir:-1, fireCd:rand(0.4,1.4), sees:false, aimTime:0,
alive:true, hitFlash:0, walkT:0,
patrolMin: x-140, patrolMax: x+140,
burstLeft:0, burstCd:0
};
}
/* ---------- ENDLESS GENERATOR ---------- */
function genEndlessFloor(n){
const width = 2200 + n*70;
const plats = [ground(width)];
let x = 380;
while(x < width-400){
const r = Math.random();
if(r < 0.45) plats.push(desk(x));
else if(r < 0.7) plats.push(desk2(x));
else plats.push(ledge(x, 340+Math.random()*30, 200));
x += rand(260, 400);
}
const count = Math.min(4 + Math.floor(n*1.35), 16);
const enemies = [];
for(let i=0;i<count;i++){
const ex = 520 + (i * ((width-900)/Math.max(1,count-1)));
const r = Math.random();
let t = 'goon';
if(n >= 2 && r < 0.35) t = 'rifle';
if(n >= 4 && r < 0.18) t = 'heavy';
enemies.push({ x: ex + rand(-60,60), type:t });
}
const pickups = [];
if(n % 2 === 0) pickups.push({x: rand(600, width-400), type:'health'});
if(n % 3 === 0) pickups.push({x: rand(600, width-400), type: n%2? 'ak':'smg'});
return {
title:'NIGHT SHIFT — FLOOR ' + n,
sub:'Endless · Metro Tower never sleeps',
width, exitX:0, dark: n>=5 && n%4===0,
platforms: plats, enemies, hostages:[], pickups,
intro: n===1
? ['The building never emptied. They just keep coming.', 'Elevators. Every floor. All night.']
: null,
boss: (n % 7 === 0) ? 'chairman' : (n % 5 === 0 ? 'heli' : null)
};
}
/* ============================================================
START / RESET
============================================================ */
function startStory(){
game.mode='story';
game.levelIndex=0;
game.score=0; game.kills=0; game.hostagesSaved=0; game.hostagesLost=0;
loadStoryLevel(0);
}
function loadStoryLevel(i){
game.levelIndex = i;
const def = STORY_LEVELS[i];
game.level = buildLevel(def, false);
spawnPlayer(90);
game.state='play';
game.bullets=[]; game.particles=[]; game.texts=[]; game.grenades=[];
game.pickups = game.level.defPickups.map(p=>({...p, bob:Math.random()*6.28}));
game.camX=0; game.shake=0; game.hitFlash=0;
game.firstBlood=false; game.lowHealthWarned=false;
game.subtitle=null; game.subT=0;
game.timer = 0;
(def.intro||[]).forEach((line,idx)=>{
setTimeout(()=>{ if(game.state==='play') say(line, 3.4); }, 900 + idx*2600);
});
}
function startEndless(){
game.mode='endless';
game.endlessFloor=1;
game.score=0; game.kills=0; game.hostagesSaved=0; game.hostagesLost=0;
loadEndlessFloor(1, true);
}
function loadEndlessFloor(n, fresh){
game.endlessFloor = n;
const def = genEndlessFloor(n);
game.level = buildLevel(def, true);
spawnPlayer(90);
if(fresh) game.player.hp = 100;
else game.player.hp = Math.min(100, game.player.hp + 22);
game.state='play';
game.bullets=[]; game.particles=[]; game.texts=[]; game.grenades=[];
game.pickups = game.level.defPickups.map(p=>({...p, bob:Math.random()*6.28}));
game.camX=0; game.shake=0; game.hitFlash=0;
game.endlessTimer=0;
game.timer = 0;
if(def.intro){
def.intro.forEach((line,idx)=>{
setTimeout(()=>{ if(game.state==='play') say(line,3.4); }, 900 + idx*2600);
});
} else {
say('FLOOR ' + n + ' — they know you are coming.', 2.4);
}
}
function spawnPlayer(x){
game.player = {
x, y:380, w:20, h:44, vx:0, vy:0, onGround:false,
hp:100, maxHp:100, weapon:'pistol', ammo:Infinity,
weapons:{ pistol:Infinity, smg:0, ak:0 },
fireCd:0, invuln:0, aim:0, muzzle:0, walkT:0, hurtT:0,
alive:true, limp:false, recoil:0, _held:false
};
}
/* ============================================================
COLLISION
============================================================ */
function moveEntity(e, plats, step){
e.x += e.vx * step;
for(const p of plats){
if(overlap(e,p)){
if(e.vx > 0) e.x = p.x - e.w;
else if(e.vx < 0) e.x = p.x + p.w;
e.vx = 0;
}
}
e.vy += 0.62 * step;
if(e.vy > 18) e.vy = 18;
e.y += e.vy * step;
e.onGround = false;
for(const p of plats){
if(overlap(e,p)){
if(e.vy > 0){ e.y = p.y - e.h; e.vy = 0; e.onGround = true; }
else if(e.vy < 0){ e.y = p.y + p.h; e.vy = 0; }
}
}
}
/* ============================================================
PARTICLES / TEXT
============================================================ */
function blood(x,y,n,dir){
for(let i=0;i<n;i++){
game.particles.push({
x, y, vx: rand(-3,3) + (dir||0)*1.5, vy: rand(-4,1.5),
life: rand(0.3,0.7), max:0.7, col:'#b3131b', size: rand(2,4), grav:0.35
});
}
}
function spark(x,y,n){
for(let i=0;i<n;i++){
game.particles.push({
x, y, vx: rand(-4,4), vy: rand(-4,2),
life: rand(0.15,0.35), max:0.35, col:'#ffd27a', size: rand(1.5,3), grav:0.15
});
}
}
function explode(x,y){
for(let i=0;i<34;i++){
game.particles.push({
x, y, vx: rand(-7,7), vy: rand(-7,4),
life: rand(0.3,0.9), max:0.9,
col: ['#ffd24a','#ff8a3d','#ff4a2a','#888'][Math.floor(Math.random()*4)],
size: rand(3,7), grav:0.22
});
}
game.shake = Math.max(game.shake, 14);
SFX.boom();
}
function floatText(x,y,text,col){
game.texts.push({x,y,text,col:col||'#fff',life:1.2,max:1.2});
}
/* ============================================================
SHOOTING
============================================================ */
function playerShoot(){
const p = game.player;
if(p.fireCd > 0 || !p.alive) return;
const w = WEAPONS[p.weapon];
if(p.weapons[p.weapon] <= 0 && p.weapon !== 'pistol'){
p.weapon = 'pistol';
say('Out of ammo — back to the pistol.', 2);
return;
}
const a = p.aim + rand(-w.spread, w.spread);
const mx = p.x + p.w/2 + Math.cos(a)*26;
const my = p.y + 16 + Math.sin(a)*26;
game.bullets.push({
x:mx, y:my, vx: Math.cos(a)*w.speed, vy: Math.sin(a)*w.speed,
dmg:w.dmg, friendly:true, life:1.4, col:w.col, size:w.size
});
p.fireCd = w.rate/1000;
p.muzzle = 0.055;
p.recoil = 1;
if(p.weapon !== 'pistol') p.weapons[p.weapon]--;
SFX[w.sfx]();
spark(mx,my,2);
game.shake = Math.max(game.shake, w.dmg > 20 ? 3 : 2);
}
function enemyShoot(e){
const p = game.player;
const ex = e.x + e.w/2, ey = e.y + 16;
const px = p.x + p.w/2, py = p.y + 20;
let a = Math.atan2(py-ey, px-ex) + rand(-e.acc, e.acc);
const spd = 9;
game.bullets.push({
x: ex + Math.cos(a)*18, y: ey + Math.sin(a)*18,
vx: Math.cos(a)*spd, vy: Math.sin(a)*spd,
dmg:e.dmg, friendly:false, life:2.0, col:'#ff7a5a', size:3
});
SFX.shot();
}
/* ============================================================
SWAP WEAPON
============================================================ */
function swapWeapon(){
const p = game.player;
if(!p || !p.alive) return;
const order = ['pistol','smg','ak'];
let i = order.indexOf(p.weapon);
for(let n=1;n<=3;n++){
const nw = order[(i+n)%3];
if(p.weapons[nw] > 0){
p.weapon = nw;
floatText(p.x, p.y-16, WEAPONS[nw].label, '#ffd24a');
SFX.pick();
break;
}
}
}
/* ============================================================
UPDATE
============================================================ */
function update(dt){
game.timer += dt;
if(game.subT > 0){
game.subT -= dt;
if(game.subT <= 0) game.subtitle = null;
}
if(game.shake > 0) game.shake = Math.max(0, game.shake - dt*45);
if(game.hitFlash > 0) game.hitFlash = Math.max(0, game.hitFlash - dt*3.5);
if(touch.swapBtn.flash > 0) touch.swapBtn.flash -= dt;
if(touch.jumpBtn.flash > 0) touch.jumpBtn.flash -= dt;
if(game.state === 'cleared'){ game.clearedT -= dt; if(game.clearedT<=0) advanceLevel(); return; }
if(game.state === 'dead'){ game.deadT -= dt; return; }
if(game.state !== 'play') return;
const step = dt * 60;
const p = game.player;
const lvl = game.level;
/* ---------- PLAYER ---------- */
if(p.alive){
const speedBase = p.hp < 35 ? 2.0 : 3.4;
p.limp = p.hp < 35;
let mv = 0;
if(keys['KeyA'] || keys['ArrowLeft']) mv -= 1;
if(keys['KeyD'] || keys['ArrowRight']) mv += 1;
if(touch.left.active) mv += touch.left.nx * 1.5;
mv = clamp(mv, -1, 1);
p.vx = mv * speedBase;
const wantJump = keys['KeyW'] || keys['Space'] || keys['ArrowUp']
|| (touch.left.active && touch.left.ny < -0.55)
|| touch.jumpBtn.down;
if(wantJump && p.onGround){
p.vy = -13.4;
p.onGround = false;
}
// Aim
if(touch.right.active && touch.right.mag > 0.18){
p.aim = touch.right.angle;
} else if(!touch.using){
const cx = p.x - game.camX + p.w/2;
const cy = p.y + 16;
p.aim = Math.atan2(mouse.y - cy, mouse.x - cx);
}
// Firing
p.fireCd -= dt;
const w = WEAPONS[p.weapon];
let wantFire = mouse.down || keys['KeyK'] || keys['KeyJ'] || keys['Enter'];
let autoFire = false;
if(touch.right.active && touch.right.mag > 0.32){
wantFire = true;
autoFire = true;
}
if(wantFire){
if(w.auto || autoFire) playerShoot();
else if(!p._held) playerShoot();
}
p._held = wantFire;
if(p.muzzle > 0) p.muzzle -= dt;
if(p.invuln > 0) p.invuln -= dt;
if(p.hurtT > 0) p.hurtT -= dt;
if(p.recoil > 0) p.recoil = Math.max(0, p.recoil - dt*8);
if(mv !== 0) p.walkT += dt*10; else p.walkT = 0;
moveEntity(p, lvl.platforms, step);
p.x = clamp(p.x, 0, lvl.width - p.w);
if(p.y > 700){ p.hp = 0; killPlayer(); }
if(p.hp < 35 && !game.lowHealthWarned){
game.lowHealthWarned = true;
say('You are hit bad. Find something to wrap that wound.', 3.5);
}
}
/* ---------- ENEMIES ---------- */
for(const e of lvl.enemies){
if(!e.alive) continue;
e.fireCd -= dt;
if(e.hitFlash > 0) e.hitFlash -= dt;
const px = p.x + p.w/2, py = p.y + 20;
const ex = e.x + e.w/2, ey = e.y + 16;
const dx = px - ex, dy = py - ey;
const dist = Math.abs(dx);
e.sees = p.alive && dist < e.range && Math.abs(dy) < 110
&& !losBlocked(ex, ey, px, py, lvl.platforms);
if(e.sees){
if(e.aimTime <= 0 && e.fireCd > 0) e.aimTime = 0.35;
e.facing = Math.sign(dx) || 1;
e.aimTime -= dt;
if(dist > e.prefDist + 40) e.vx = e.facing * e.speed;
else if(dist < e.prefDist - 70) e.vx = -e.facing * e.speed * 0.7;
else e.vx = 0;
if(e.burstLeft > 0){
e.burstCd -= dt;
if(e.burstCd <= 0){ enemyShoot(e); e.burstLeft--; e.burstCd = 0.10; }
} else if(e.fireCd <= 0 && e.aimTime <= 0){
e.burstLeft = e.burst;
e.burstCd = 0;
e.fireCd = e.rate/1000;
}
} else {
e.vx = e.dir * e.speed * 0.55;
if(e.x < e.patrolMin){ e.dir = 1; }
if(e.x > e.patrolMax){ e.dir = -1; }
e.facing = e.dir;
e.aimTime = 0.35;
}
e.walkT += dt * Math.abs(e.vx) * 6;
moveEntity(e, lvl.platforms, step);
e.x = clamp(e.x, 0, lvl.width - e.w);
}
/* ---------- BOSS ---------- */
const B = lvl.boss;
if(B && B.alive){
if(B.hitFlash > 0) B.hitFlash -= dt;
if(B.type === 'heli'){
B.t += dt;
B.x += B.dir * 62 * dt;
if(B.x < 1500) B.dir = 1;
if(B.x > 2750) B.dir = -1;
B.y = 130 + Math.sin(B.t*1.4)*22;
B.fireCd -= dt;
if(B.fireCd <= 0){
B.fireCd = Math.max(0.55, 1.5 - (1 - B.hp/B.maxHp)*0.7);
for(let i=-2;i<=2;i++){
const a = Math.PI/2 + i*0.22;
game.bullets.push({
x: B.x + B.w/2, y: B.y + B.h,
vx: Math.cos(a)*7, vy: Math.sin(a)*7,
dmg: 10, friendly:false, life:2.4, col:'#ff9a5a', size:4
});
}
SFX.ak();
}
B.spawnCd -= dt;
if(B.spawnCd <= 0){
B.spawnCd = 8;
const aliveGoons = lvl.enemies.filter(x=>x.alive).length;
if(aliveGoons < 5){
const ne = makeEnemy(clamp(p.x - 420, 60, lvl.width-100), 300, 'goon');
lvl.enemies.push(ne);
say('He is dropping more men on the roof!', 2.6);
}
}
}
if(B.type === 'chairman'){
B.t = (B.t||0) + dt;
B.fireCd -= dt;
B.nadeCd -= dt;
B.walkT += dt*4;
const dx = (p.x + p.w/2) - (B.x + B.w/2);
B.facing = Math.sign(dx) || -1;
const dist = Math.abs(dx);
if(dist > 320) B.vx = B.facing * 1.3;
else if(dist < 160) B.vx = -B.facing * 1.0;
else B.vx = 0;
if(B.burstLeft > 0){
B.burstCd -= dt;
if(B.burstCd <= 0){
const a = Math.atan2((p.y+20)-(B.y+18), (p.x+p.w/2)-(B.x+B.w/2)) + rand(-0.09,0.09);
game.bullets.push({
x: B.x + B.w/2 + Math.cos(a)*20, y: B.y+18 + Math.sin(a)*20,
vx: Math.cos(a)*13, vy: Math.sin(a)*13,
dmg: 12, friendly:false, life:2, col:'#ffb347', size:4
});
SFX.ak();
B.burstLeft--; B.burstCd = 0.09;
}
} else if(B.fireCd <= 0 && dist < 620){
B.burstLeft = 4;
B.fireCd = 1.5;
}
if(B.nadeCd <= 0 && dist < 700){
B.nadeCd = rand(3.5, 5.5);
const a = Math.atan2((p.y)-(B.y), dx) - 0.35;
game.grenades.push({
x:B.x+B.w/2, y:B.y+10,
vx: Math.cos(a)*9, vy: Math.sin(a)*9 - 2,
life: 4, r:6, spin:0
});
say('Grenade!', 1.4);
}
moveEntity(B, lvl.platforms, step);
B.x = clamp(B.x, 100, lvl.width - 60);
}
}
/* ---------- GRENADES ---------- */
for(let i=game.grenades.length-1;i>=0;i--){
const g = game.grenades[i];
g.vy += 0.5*step;
g.x += g.vx*step;
g.y += g.vy*step;
g.spin += dt*10;
g.life -= dt;
let hitGround = false;
if(g.y > 470 - g.r){ g.y = 470 - g.r; hitGround = true; }
for(const pl of lvl.platforms){
if(g.x>pl.x && g.x<pl.x+pl.w && g.y>pl.y && g.y<pl.y+pl.h){ hitGround = true; break; }
}
if(hitGround || g.life <= 0){
explode(g.x, g.y);
if(p.alive){
const d = Math.hypot((p.x+p.w/2)-g.x, (p.y+p.h/2)-g.y);
if(d < 110) damagePlayer(Math.round(30 * (1 - d/110)));
}
for(const e of lvl.enemies){
if(!e.alive) continue;
const d = Math.hypot((e.x+e.w/2)-g.x, (e.y+e.h/2)-g.y);
if(d < 110) hurtEnemy(e, Math.round(60*(1-d/110)));
}
if(lvl.boss && lvl.boss.alive){
const bb = lvl.boss;
const d = Math.hypot((bb.x+bb.w/2)-g.x, (bb.y+bb.h/2)-g.y);
if(d < 130) hurtBoss(Math.round(45*(1-d/130)));
}
game.grenades.splice(i,1);
}
}
/* ---------- BULLETS ---------- */
for(let i=game.bullets.length-1;i>=0;i--){
const b = game.bullets[i];
b.x += b.vx * step;
b.y += b.vy * step;
b.life -= dt;
let dead = b.life <= 0 || b.x < -50 || b.x > lvl.width+50 || b.y < -200 || b.y > 620;
if(!dead){
for(const pl of lvl.platforms){
if(b.x>pl.x && b.x<pl.x+pl.w && b.y>pl.y && b.y<pl.y+pl.h){
spark(b.x,b.y,4); dead = true; break;
}
}
}
if(!dead && b.friendly){
for(const e of lvl.enemies){
if(!e.alive) continue;
if(b.x > e.x && b.x < e.x+e.w && b.y > e.y && b.y < e.y+e.h){
hurtEnemy(e, b.dmg);
blood(b.x, b.y, 6, Math.sign(b.vx));
dead = true; break;
}
}
if(!dead && lvl.boss && lvl.boss.alive){
const B2 = lvl.boss;
if(b.x > B2.x && b.x < B2.x+B2.w && b.y > B2.y && b.y < B2.y+B2.h){
hurtBoss(b.dmg);
spark(b.x,b.y,5);
dead = true;
}
}
if(!dead){
for(const h of lvl.hostages){
if(!h.alive || h.saved) continue;
if(b.x > h.x && b.x < h.x+h.w && b.y > h.y && b.y < h.y+h.h){
h.alive = false;
game.hostagesLost++;
game.score = Math.max(0, game.score - 250);
floatText(h.x, h.y-10, 'CIVILIAN DOWN', '#ff5a5a');
say('You just shot a civilian. Control, he is shooting everybody!', 3.2);
blood(b.x,b.y,10, Math.sign(b.vx));
dead = true; break;
}
}
}
}
if(!dead && !b.friendly && p.alive){
if(b.x > p.x && b.x < p.x+p.w && b.y > p.y && b.y < p.y+p.h){
damagePlayer(b.dmg);
blood(b.x,b.y,5,-Math.sign(b.vx));
dead = true;
}
}
if(dead) game.bullets.splice(i,1);
}
/* ---------- PICKUPS ---------- */
for(let i=game.pickups.length-1;i>=0;i--){
const pk = game.pickups[i];
if(p.alive && overlap(p, {x:pk.x-8,y:pk.y-8,w:32,h:32})){
applyPickup(pk);
game.pickups.splice(i,1);
}
}
/* ---------- HOSTAGES ---------- */
for(const h of lvl.hostages){
if(h.saved || !h.alive) continue;
if(p.alive && overlap(p, h)){
h.saved = true;
game.hostagesSaved++;
game.score += 150;
floatText(h.x, h.y-14, '+150 RESCUED', '#6dffa0');
SFX.rescue();
if(game.hostagesSaved === 1) say('Go! Get out of here! Head for the stairs!', 2.6);
else if(game.hostagesSaved % 4 === 0) say('Control, civilians are coming down. He is letting them go.', 3);
}
}
/* ---------- EXIT ---------- */
if(lvl.hasExit && lvl.exitX > 0 && p.alive){
if(p.x + p.w > lvl.exitX && p.x < lvl.exitX + 60){
levelCleared();
}
}
/* ---------- ENDLESS PROGRESSION ---------- */
if(game.mode === 'endless'){
const aliveEnemies = lvl.enemies.filter(e=>e.alive).length;
const bossAlive = lvl.boss && lvl.boss.alive;
if(aliveEnemies === 0 && !bossAlive){
game.endlessTimer += dt;
if(game.endlessTimer > 1.1){
game.score += 200 * game.endlessFloor;
loadEndlessFloor(game.endlessFloor + 1, false);
SFX.clear();
}
}
}
/* ---------- PARTICLES ---------- */
for(let i=game.particles.length-1;i>=0;i--){
const pt = game.particles[i];
pt.vy += pt.grav * step;
pt.x += pt.vx * step;
pt.y += pt.vy * step;
pt.life -= dt;
if(pt.life <= 0) game.particles.splice(i,1);
}
for(let i=game.texts.length-1;i>=0;i--){
const t = game.texts[i];
t.y -= 22*dt;
t.life -= dt;
if(t.life <= 0) game.texts.splice(i,1);
}
/* ---------- CAMERA ---------- */
const target = clamp(p.x + p.w/2 - W/2, 0, Math.max(0, lvl.width - W));
game.camX += (target - game.camX) * Math.min(1, dt*7);
}
/* ============================================================
DAMAGE / DEATH
============================================================ */
function damagePlayer(amount){
const p = game.player;
if(!p.alive || p.invuln > 0) return;
p.hp -= amount;
p.invuln = 0.25;
p.hurtT = 0.3;
game.hitFlash = 0.5;
game.shake = Math.max(game.shake, 7);
SFX.hurt();
if(p.hp <= 0){ p.hp = 0; killPlayer(); }
}
function killPlayer(){
const p = game.player;
if(!p.alive) return;
p.alive = false;
game.state = 'dead';
game.deadT = 2.2;
blood(p.x+10, p.y+20, 26, 0);
SFX.die();
say('...', 0.1);
}
function hurtEnemy(e, dmg){
if(!e.alive) return;
e.hp -= dmg;
e.hitFlash = 0.12;
if(e.hp <= 0){
e.alive = false;
game.kills++;
game.score += 100;
blood(e.x+11, e.y+20, 14, Math.sign(e.vx)||1);
SFX.hit();
if(Math.random() < 0.35){
const lines = [
'He got Musa! He got Musa!',
'Where is he?! Where is he coming from?!',
'He is behind us! Watch the flank!',
'This guy is not normal. He is not normal!',
'Fall back! Fall back to the next floor!',
'I am not dying for this building!'
];
say(lines[Math.floor(Math.random()*lines.length)], 2.6);
}
if(Math.random() < 0.22){
game.pickups.push({x:e.x, y:e.y+10, type:'ammo', bob:Math.random()*6.28});
}
}
}
function hurtBoss(dmg){
const B = game.level.boss;
if(!B || !B.alive) return;
B.hp -= dmg;
B.hitFlash = 0.1;
if(B.hp <= 0){
B.hp = 0;
B.alive = false;
explode(B.x + B.w/2, B.y + B.h/2);
setTimeout(()=>explode(B.x + B.w/2 + rand(-40,40), B.y + B.h/2 + rand(-30,30)), 160);
setTimeout(()=>explode(B.x + B.w/2 + rand(-60,60), B.y + B.h/2 + rand(-30,30)), 330);
game.score += 1500;
if(B.type === 'heli'){
say('The chopper is going down! Get clear of the pad!', 3.5);
game.shake = 26;
} else {
say('Chairman is down. Somebody get the hostages out.', 3.5);
}
if(game.mode === 'story' && game.levelIndex === 4){
setTimeout(()=>{ if(game.state==='play') winGame(); }, 1800);
} else {
setTimeout(()=>{ if(game.state==='play') levelCleared(); }, 1600);
}
}
}
/* ============================================================
PICKUPS
============================================================ */
function applyPickup(pk){
const p = game.player;
SFX.pick();
if(pk.type === 'health'){
p.hp = Math.min(p.maxHp, p.hp + 35);
game.lowHealthWarned = false;
floatText(pk.x, pk.y-10, '+35 HP', '#6dffa0');
} else if(pk.type === 'smg'){
p.weapons.smg = WEAPONS.smg.ammoMax;
p.weapon = 'smg';
floatText(pk.x, pk.y-10, 'MP5 SMG', '#ffd24a');
say('MP5. Now we are talking.', 2.2);
} else if(pk.type === 'ak'){
p.weapons.ak = WEAPONS.ak.ammoMax;
p.weapon = 'ak';
floatText(pk.x, pk.y-10, 'AK-47', '#ffb347');
say('AK-47 off a dead man. It will do.', 2.2);
} else if(pk.type === 'ammo'){
const cur = p.weapon;
if(cur !== 'pistol'){
p.weapons[cur] = Math.min(WEAPONS[cur].ammoMax, p.weapons[cur] + 60);
floatText(pk.x, pk.y-10, '+60 AMMO', '#ffd24a');
} else {
p.weapons.smg = Math.min(WEAPONS.smg.ammoMax, (p.weapons.smg||0) + 50);
floatText(pk.x, pk.y-10, '+50 SMG', '#ffd24a');
}
}
}
/* ============================================================
LEVEL FLOW
============================================================ */
function levelCleared(){
if(game.state !== 'play') return;
game.state = 'cleared';
game.clearedT = 2.6;
SFX.clear();
if(game.mode === 'story'){
game.score += 400;
const lvl = game.level;
if(lvl.hasExit) say('Floor secure. Take the stairs. Keep moving.', 3);
}
}
function advanceLevel(){
if(game.mode === 'story'){
const next = game.levelIndex + 1;
if(next >= STORY_LEVELS.length){ winGame(); return; }
loadStoryLevel(next);
}
}
function winGame(){
game.state = 'win';
game.score += 3000;
SFX.clear();
}
/* ============================================================
DRAWING
============================================================ */
function drawSky(){
const g = ctx.createLinearGradient(0,0,0,H);
g.addColorStop(0,'#0b1020');
g.addColorStop(0.42,'#1c2440');
g.addColorStop(0.72,'#6b3a34');
g.addColorStop(1,'#c4623a');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
ctx.fillStyle = 'rgba(255,255,255,0.5)';
for(let i=0;i<50;i++){
const sx = (i*173 + 37) % W;
const sy = (i*91) % 190;
ctx.fillRect(sx, sy, 1.5, 1.5);
}
ctx.fillStyle = 'rgba(255,230,200,0.85)';
ctx.beginPath(); ctx.arc(780, 92, 26, 0, 6.283); ctx.fill();
ctx.fillStyle = 'rgba(11,16,32,0.9)';
ctx.beginPath(); ctx.arc(770, 84, 24, 0, 6.283); ctx.fill();
}
function drawSkyline(par, col, baseY, spacing, hMin, hMax, seed){
ctx.fillStyle = col;
for(let i=0;i<40;i++){
const bx = ((i*spacing - game.camX*par) % (W + spacing*2)) - spacing;
const bh = hMin + ((i*seed*37) % (hMax-hMin));
ctx.fillRect(bx, baseY - bh, spacing - 6, bh + 200);
ctx.fillStyle = 'rgba(255,200,120,0.13)';
for(let wy = baseY - bh + 10; wy < baseY - 6; wy += 16){
for(let wx = bx + 5; wx < bx + spacing - 14; wx += 13){
if(((wx*wy + seed) % 7) < 3) ctx.fillRect(wx, wy, 5, 7);
}
}
ctx.fillStyle = col;
}
}
function drawBackground(){
drawSky();
drawSkyline(0.12, '#141a2c', 470, 92, 130, 260, 3);
drawSkyline(0.26, '#0e1322', 470, 74, 90, 200, 7);
drawSkyline(0.45, '#080b14', 470, 58, 60, 150, 11);
}
function drawInterior(){
const cam = game.camX;
ctx.fillStyle = '#14161c';
ctx.fillRect(0, 0, W, 62);
ctx.fillStyle = '#1d2028';
ctx.fillRect(0, 56, W, 6);
const startX = Math.floor(cam/300)*300;
for(let wx = startX; wx < cam + W + 300; wx += 300){
const sx = wx - cam;
ctx.strokeStyle = '#22262f';
ctx.lineWidth = 8;
ctx.strokeRect(sx + 20, 70, 260, 250);
ctx.lineWidth = 3;
ctx.strokeStyle = '#1a1d24';
ctx.beginPath();
ctx.moveTo(sx+150, 70); ctx.lineTo(sx+150, 320);
ctx.moveTo(sx+20, 195); ctx.lineTo(sx+280, 195);
ctx.stroke();
}
ctx.lineWidth = 1;
}
function drawPlatforms(plats){
const cam = game.camX;
for(const p of plats){
const sx = p.x - cam;
if(sx > W + 60 || sx + p.w < -60) continue;
if(p.h > 100){
ctx.fillStyle = '#1c1f26';
ctx.fillRect(sx, p.y, p.w, p.h);
ctx.fillStyle = '#2a2f39';
ctx.fillRect(sx, p.y, p.w, 7);
ctx.fillStyle = '#0f1116';
for(let tx = p.x; tx < p.x+p.w; tx += 48){
ctx.fillRect(tx - cam + 2, p.y + 10, 44, p.h - 14);
}
} else if(p.h >= 50 && p.w <= 140){
ctx.fillStyle = '#3a2a1e';
ctx.fillRect(sx, p.y, p.w, 12);
ctx.fillStyle = '#4a3626';
ctx.fillRect(sx, p.y, p.w, 4);
ctx.fillStyle = '#232833';
ctx.fillRect(sx+5, p.y+12, 7, p.h-12);
ctx.fillRect(sx+p.w-12, p.y+12, 7, p.h-12);
ctx.fillStyle = '#1a1e26';
ctx.fillRect(sx + p.w/2 - 12, p.y - 20, 24, 16);
ctx.fillStyle = '#2a4a5a';
ctx.fillRect(sx + p.w/2 - 9, p.y - 17, 18, 10);
} else if(p.h === 16){
ctx.fillStyle = '#2b303b';
ctx.fillRect(sx, p.y, p.w, p.h);
ctx.fillStyle = '#3d4450';
ctx.fillRect(sx, p.y, p.w, 4);
} else {
ctx.fillStyle = '#191d24';
ctx.fillRect(sx, p.y, p.w, p.h);
ctx.fillStyle = '#252b35';
ctx.fillRect(sx+3, p.y+3, p.w-6, p.h-6);
for(let ly = p.y+8; ly < p.y+p.h-8; ly += 10){
ctx.fillStyle = '#3a4a2a';
ctx.fillRect(sx+7, ly, p.w-14, 3);
ctx.fillStyle = Math.random()<0.5? '#ff4a3a' : '#4aff7a';
ctx.fillRect(sx+p.w-12, ly, 4, 3);
}
}
}
}
function drawExit(){
const lvl = game.level;
if(!lvl.hasExit || !lvl.exitX) return;
const sx = lvl.exitX - game.camX;
if(sx < -120 || sx > W + 120) return;
const pulse = 0.5 + Math.sin(game.timer*4)*0.5;
ctx.save();
ctx.globalAlpha = 0.25 + pulse*0.3;
ctx.fillStyle = '#4aff9a';
ctx.fillRect(sx, 350, 60, 120);
ctx.restore();
ctx.fillStyle = '#0d1a12';
ctx.fillRect(sx, 350, 60, 120);
ctx.strokeStyle = '#4aff9a';
ctx.lineWidth = 3;
ctx.strokeRect(sx, 350, 60, 120);
ctx.lineWidth = 1;
ctx.fillStyle = '#4aff9a';
ctx.font = 'bold 13px Courier New';
ctx.textAlign = 'center';
ctx.fillText('EXIT', sx + 30, 340);
ctx.beginPath();
ctx.moveTo(sx+30, 320 - pulse*6);
ctx.lineTo(sx+22, 332 - pulse*6);
ctx.lineTo(sx+38, 332 - pulse*6);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
}
function drawPlayer(){
const p = game.player;
if(!p.alive) {
const sx = p.x - game.camX;
ctx.save();
ctx.translate(sx + 10, p.y + p.h);
ctx.rotate(-1.4);
ctx.fillStyle = '#15151a';
ctx.fillRect(-10, -44, 20, 44);
ctx.fillStyle = '#7a4a2b';
ctx.fillRect(-6, -54, 12, 12);
ctx.restore();
return;
}
const sx = p.x - game.camX;
const sy = p.y;
const blink = p.invuln > 0 && Math.floor(game.timer*30)%2===0;
if(blink) ctx.globalAlpha = 0.55;
const walk = Math.sin(p.walkT) * (p.onGround ? 1 : 0);
const limpOff = p.limp ? 2 : 0;
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.beginPath();
ctx.ellipse(sx+10, sy+p.h+2, 14, 4, 0, 0, 6.283);
ctx.fill();
ctx.fillStyle = '#121216';
ctx.fillRect(sx+2 + walk*2, sy+26, 7, 18 - limpOff);
ctx.fillRect(sx+11 - walk*2, sy+26, 7, 18);
ctx.fillStyle = '#08080a';
ctx.fillRect(sx+1 + walk*2, sy+42-limpOff, 9, 4);
ctx.fillRect(sx+10 - walk*2, sy+42, 9, 4);
ctx.fillStyle = '#1a1a20';
ctx.fillRect(sx, sy+10, 20, 18);
ctx.fillStyle = p.hurtT > 0 ? '#c9c9d4' : '#e6e6ee';
ctx.fillRect(sx+7, sy+10, 6, 18);
ctx.fillStyle = '#a81f28';
ctx.fillRect(sx+9, sy+11, 3, 15);
if(p.hp < 60){
ctx.fillStyle = 'rgba(150,20,25,0.75)';
ctx.fillRect(sx+5, sy+14, 5, 4);
if(p.hp < 35) ctx.fillRect(sx+11, sy+20, 6, 5);
}
ctx.fillStyle = '#141419';
ctx.fillRect(sx-1, sy+10, 22, 6);
ctx.fillStyle = '#7d4c2c';
ctx.fillRect(sx+4, sy-2, 12, 13);
ctx.fillStyle = '#100e0d';
ctx.fillRect(sx+3, sy-4, 14, 5);
ctx.fillRect(sx+3, sy-4, 3, 10);
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(sx+4, sy+8, 12, 3);
ctx.save();
ctx.translate(sx+10, sy+16);
ctx.rotate(p.aim);
const rec = p.recoil * 3;
ctx.fillStyle = '#1a1a20';
ctx.fillRect(-2 - rec, -3, 16, 6);
ctx.fillStyle = '#2c2c34';
const gunLen = p.weapon === 'ak' ? 24 : (p.weapon === 'smg' ? 18 : 13);
ctx.fillRect(12 - rec, -3, gunLen, 5);
ctx.fillStyle = '#1a1a20';
ctx.fillRect(12 - rec, 1, 5, 5);
if(p.weapon === 'ak'){
ctx.fillStyle = '#3a2a18';
ctx.fillRect(16 - rec, 0, 8, 6);
}
if(p.muzzle > 0){
const a = p.muzzle / 0.055;
ctx.fillStyle = `rgba(255,225,140,${a})`;
ctx.beginPath();
ctx.arc(12 - rec + gunLen + 4, -0.5, 9*a, 0, 6.283);
ctx.fill();
ctx.fillStyle = `rgba(255,255,255,${a*0.8})`;
ctx.beginPath();
ctx.arc(12 - rec + gunLen + 4, -0.5, 4*a, 0, 6.283);
ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
function drawEnemy(e){
if(!e.alive){
const sx = e.x - game.camX;
ctx.save();
ctx.translate(sx+11, e.y+e.h);
ctx.rotate(1.5);
ctx.fillStyle = e.col;
ctx.fillRect(-11, -46, 22, 46);
ctx.fillStyle = '#6b4127';
ctx.fillRect(-6, -56, 12, 12);
ctx.restore();
ctx.fillStyle = 'rgba(120,15,20,0.5)';
ctx.beginPath(); ctx.ellipse(sx+11, e.y+e.h+3, 22, 6, 0, 0, 6.283); ctx.fill();
return;
}
const sx = e.x - game.camX;
const sy = e.y;
const walk = Math.sin(e.walkT)*1.5;
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(sx+11, sy+e.h+2, 13, 4, 0, 0, 6.283); ctx.fill();
ctx.fillStyle = '#22252b';
ctx.fillRect(sx+2 + walk, sy+28, 8, 18);
ctx.fillRect(sx+12 - walk, sy+28, 8, 18);
ctx.fillStyle = e.hitFlash > 0 ? '#ffffff' : e.col;
ctx.fillRect(sx, sy+11, 22, 19);
ctx.fillStyle = e.hitFlash > 0 ? '#ffdddd' : '#20232a';
ctx.fillRect(sx+3, sy+14, 16, 12);
ctx.fillStyle = '#6b4127';
ctx.fillRect(sx+5, sy-1, 12, 12);
ctx.fillStyle = '#b3202a';
ctx.fillRect(sx+4, sy+1, 14, 5);
ctx.fillRect(sx+ (e.facing>0?14:2), sy+3, 6, 3);
ctx.fillStyle = '#141210';
ctx.fillRect(sx+4, sy-3, 14, 4);
ctx.save();
ctx.translate(sx + (e.facing>0 ? 20 : 2), sy+18);
ctx.scale(e.facing, 1);
ctx.fillStyle = '#1a1a20';
ctx.fillRect(0,-2,6,5);
ctx.fillStyle = '#33333c';
ctx.fillRect(4,-2, e.type==='rifle'?20:13, 4);
ctx.restore();
if(e.sees && e.aimTime > 0){
ctx.fillStyle = '#ff4a4a';
ctx.font = 'bold 16px Courier New';
ctx.fillText('!', sx+8, sy-10);
}
if(e.maxHp > 60 && e.hp < e.maxHp){
const w = 30;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(sx-4, sy-14, w, 4);
ctx.fillStyle = '#ff5a4a';
ctx.fillRect(sx-4, sy-14, w*(e.hp/e.maxHp), 4);
}
}
function drawHostage(h){
const sx = h.x - game.camX;
const sy = h.y + Math.sin(game.timer*2 + h.bob)*1.5;
if(!h.alive){
ctx.fillStyle = '#3a3a44';
ctx.fillRect(sx, sy+20, 18, 20);
return;
}
if(h.saved){
ctx.globalAlpha = 0.4;
ctx.fillStyle = '#6dffa0';
ctx.fillRect(sx+2, sy+8, 14, 32);
ctx.globalAlpha = 1;
return;
}
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath(); ctx.ellipse(sx+9, h.y+h.h+2, 11, 3, 0, 0, 6.283); ctx.fill();
ctx.fillStyle = '#3a4150';
ctx.fillRect(sx+2, sy+26, 6, 14);
ctx.fillRect(sx+10, sy+26, 6, 14);
const shirtCol = h.label ? '#d4a017' : ['#3f7dbf','#7dbf5a','#bf6a3f','#9a5abf'][Math.floor(h.x)%4];
ctx.fillStyle = shirtCol;
ctx.fillRect(sx+1, sy+10, 16, 17);
ctx.fillStyle = '#6b4127';
ctx.fillRect(sx+4, sy, 11, 11);
ctx.fillStyle = '#141210';
ctx.fillRect(sx+3, sy-2, 13, 4);
ctx.fillStyle = '#6b4127';
ctx.fillRect(sx-4, sy+2, 5, 5);
ctx.fillRect(sx+16, sy+2, 5, 5);
const pulse = 0.5 + Math.sin(game.timer*5)*0.5;
ctx.fillStyle = `rgba(109,255,160,${0.4+pulse*0.5})`;
ctx.font = 'bold 12px Courier New';
ctx.textAlign = 'center';
ctx.fillText('▼', sx+9, sy-8);
ctx.textAlign = 'left';
if(h.label){
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 11px Courier New';
ctx.fillText(h.label, sx+9, sy-20);
}
}
function drawBoss(){
const B = game.level.boss;
if(!B) return;
const sx = B.x - game.camX;
if(B.type === 'heli'){
if(!B.alive){
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(sx, B.y+20, B.w, 40);
return;
}
const flash = B.hitFlash > 0;
ctx.fillStyle = flash ? '#ffffff' : '#23272e';
ctx.fillRect(sx, B.y+20, B.w, 46);
ctx.fillStyle = flash ? '#ffffff' : '#1a1e26';
ctx.fillRect(sx+18, B.y+26, 62, 30);
ctx.fillStyle = 'rgba(120,200,255,0.35)';
ctx.fillRect(sx+22, B.y+30, 54, 22);
ctx.fillStyle = flash ? '#ffffff' : '#23272e';
ctx.fillRect(sx+B.w-10, B.y+34, 56, 12);
ctx.fillStyle = '#3a3f48';
ctx.beginPath();
ctx.ellipse(sx+B.w+46, B.y+40, 10, 22, 0, 0, 6.283);
ctx.fill();
const rot = (game.timer*30) % 100;
ctx.strokeStyle = 'rgba(200,200,210,0.45)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(sx - 60 + rot*1.2, B.y+14);
ctx.lineTo(sx + B.w + 60 - rot*1.2, B.y+14);
ctx.stroke();
ctx.lineWidth = 1;
ctx.fillStyle = '#15181e';
ctx.fillRect(sx+B.w/2-4, B.y+8, 8, 14);
ctx.fillRect(sx+4, B.y+66, B.w-8, 6);
ctx.save();
ctx.globalAlpha = 0.09;
ctx.fillStyle = '#ffffcc';
ctx.beginPath();
ctx.moveTo(sx+B.w/2, B.y+B.h);
ctx.lineTo(sx+B.w/2 - 180, 470);
ctx.lineTo(sx+B.w/2 + 180, 470);
ctx.closePath();
ctx.fill();
ctx.restore();
}
if(B.type === 'chairman'){
if(!B.alive){
ctx.save();
ctx.translate(sx+13, B.y+B.h);
ctx.rotate(1.5);
ctx.fillStyle = '#141418';
ctx.fillRect(-13, -56, 26, 56);
ctx.fillStyle = '#6b4127';
ctx.fillRect(-7, -68, 14, 14);
ctx.restore();
return;
}
const flash = B.hitFlash > 0;
const sy = B.y;
const walk = Math.sin(B.walkT)*1.5;
ctx.fillStyle = '#0f0f14';
ctx.fillRect(sx+2 + walk, sy+30, 9, 24);
ctx.fillRect(sx+15 - walk, sy+30, 9, 24);
ctx.fillStyle = flash ? '#ffffff' : '#101014';
ctx.fillRect(sx-2, sy+10, 30, 24);
ctx.fillStyle = '#eeeef4';
ctx.fillRect(sx+10, sy+12, 7, 20);
ctx.fillStyle = '#d4a017';
ctx.fillRect(sx+12, sy+13, 4, 18);
ctx.fillStyle = '#5e3a22';
ctx.fillRect(sx+6, sy-4, 16, 15);
ctx.fillStyle = '#cfcfd4';
ctx.fillRect(sx+5, sy-7, 18, 5);
ctx.fillStyle = '#0a0a0c';
ctx.fillRect(sx+7, sy+2, 6, 4);
ctx.fillRect(sx+15, sy+2, 6, 4);
ctx.save();
ctx.translate(sx + (B.facing>0 ? 27 : 0), sy+20);
ctx.scale(B.facing, 1);
ctx.fillStyle = '#22222a';
ctx.fillRect(0,-3, 22, 5);
ctx.restore();
ctx.strokeStyle = `rgba(212,160,23,${0.3+Math.sin(game.timer*3)*0.2})`;
ctx.lineWidth = 2;
ctx.strokeRect(sx-6, sy-12, 38, 72);
ctx.lineWidth = 1;
}
}
function drawBullets(){
for(const b of game.bullets){
const sx = b.x - game.camX;
if(sx < -40 || sx > W+40) continue;
ctx.fillStyle = b.col;
ctx.beginPath();
ctx.arc(sx, b.y, b.size, 0, 6.283);
ctx.fill();
ctx.globalAlpha = 0.35;
ctx.strokeStyle = b.col;
ctx.lineWidth = b.size*0.8;
ctx.beginPath();
ctx.moveTo(sx, b.y);
ctx.lineTo(sx - b.vx*1.2, b.y - b.vy*1.2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
}
}
function drawGrenades(){
for(const g of game.grenades){
const sx = g.x - game.camX;
ctx.save();
ctx.translate(sx, g.y);
ctx.rotate(g.spin);
ctx.fillStyle = '#3a4a2a';
ctx.fillRect(-5, -6, 10, 12);
ctx.fillStyle = '#8a8a5a';
ctx.fillRect(-2, -8, 4, 3);
ctx.restore();
if(Math.floor(game.timer*10)%2===0){
ctx.fillStyle = '#ff3a2a';
ctx.beginPath(); ctx.arc(sx, g.y-10, 2.5, 0, 6.283); ctx.fill();
}
}
}
function drawPickups(){
for(const pk of game.pickups){
const sx = pk.x - game.camX;
const bob = Math.sin(game.timer*3 + (pk.bob||0))*4;
const sy = pk.y + bob;
const pulse = 0.5 + Math.sin(game.timer*5)*0.5;
ctx.save();
ctx.globalAlpha = 0.35 + pulse*0.3;
let col = '#6dffa0';
if(pk.type === 'smg' || pk.type === 'ammo') col = '#ffd24a';
if(pk.type === 'ak') col = '#ffb347';
ctx.fillStyle = col;
ctx.beginPath(); ctx.arc(sx, sy, 16, 0, 6.283); ctx.fill();
ctx.restore();
if(pk.type === 'health'){
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(sx-9, sy-7, 18, 14);
ctx.fillStyle = '#c81f28';
ctx.fillRect(sx-2, sy-5, 4, 10);
ctx.fillRect(sx-6, sy-1, 12, 4);
} else if(pk.type === 'smg' || pk.type === 'ak'){
ctx.fillStyle = '#2c2c34';
ctx.fillRect(sx-11, sy-3, 20, 6);
ctx.fillRect(sx+2, sy+1, 5, 5);
ctx.fillStyle = '#3a2a18';
ctx.fillRect(sx-4, sy+2, 5, 6);
} else {
ctx.fillStyle = '#6a5a2a';
ctx.fillRect(sx-8, sy-6, 16, 12);
ctx.fillStyle = '#ffd24a';
ctx.fillRect(sx-5, sy-3, 10, 6);
}
}
}
function drawParticles(){
for(const p of game.particles){
const a = p.life / p.max;
ctx.globalAlpha = a;
ctx.fillStyle = p.col;
ctx.fillRect(p.x - game.camX - p.size/2, p.y - p.size/2, p.size, p.size);
}
ctx.globalAlpha = 1;
}
function drawTexts(){
ctx.font = 'bold 13px Courier New';
ctx.textAlign = 'center';
for(const t of game.texts){
const a = t.life / t.max;
ctx.globalAlpha = a;
ctx.fillStyle = t.col;
ctx.fillText(t.text, t.x - game.camX, t.y);
}
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
/* ============================================================
TOUCH CONTROL DRAWING
============================================================ */
function drawStick(st, isAim){
const baseCol = isAim ? 'rgba(255,90,70,' : 'rgba(120,200,255,';
ctx.save();
// outer
ctx.beginPath();
ctx.arc(st.bx, st.by, STICK_R, 0, 6.283);
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.fill();
ctx.strokeStyle = baseCol + '0.5)';
ctx.lineWidth = 3;
ctx.stroke();
// inner ring
ctx.beginPath();
ctx.arc(st.bx, st.by, STICK_R*0.55, 0, 6.283);
ctx.strokeStyle = baseCol + '0.22)';
ctx.lineWidth = 1.5;
ctx.stroke();
// knob
ctx.beginPath();
ctx.arc(st.kx, st.ky, 30, 0, 6.283);
ctx.fillStyle = baseCol + '0.45)';
ctx.fill();
ctx.strokeStyle = baseCol + '0.95)';
ctx.lineWidth = 2.5;
ctx.stroke();
// knob inner highlight
ctx.beginPath();
ctx.arc(st.kx - 5, st.ky - 5, 12, 0, 6.283);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fill();
// direction hint
if(isAim && st.mag > 0.25){
ctx.save();
ctx.translate(st.bx, st.by);
ctx.rotate(st.angle);
ctx.strokeStyle = 'rgba(255,90,70,0.7)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(STICK_R*0.55, 0);
ctx.lineTo(STICK_R - 4, 0);
ctx.stroke();
// arrowhead
ctx.beginPath();
ctx.moveTo(STICK_R - 4, 0);
ctx.lineTo(STICK_R - 14, -6);
ctx.lineTo(STICK_R - 14, 6);
ctx.closePath();
ctx.fillStyle = 'rgba(255,90,70,0.8)';
ctx.fill();
ctx.restore();
}
if(!isAim && st.mag < 0.2){
// up-jump hint
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.font = 'bold 11px Courier New';
ctx.textAlign = 'center';
ctx.fillText('JUMP', st.bx, st.by - STICK_R - 8);
ctx.textAlign = 'left';
}
ctx.restore();
}
function drawStickHint(x, y, label, col){
ctx.save();
ctx.globalAlpha = 0.28;
ctx.beginPath();
ctx.arc(x, y, STICK_R*0.78, 0, 6.283);
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 22, 0, 6.283);
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.globalAlpha = 0.55;
ctx.fillStyle = col;
ctx.font = 'bold 11px Courier New';
ctx.textAlign = 'center';
ctx.fillText(label, x, y + STICK_R*0.78 + 16);
ctx.textAlign = 'left';
ctx.restore();
}
function drawTouchControls(){
if(!touch.using) return;
const p = game.player;
if(!p) return;
// Left stick
if(touch.left.active) drawStick(touch.left, false);
else drawStickHint(W*0.18, H - 118, 'MOVE', 'rgba(120,200,255,0.9)');
// Right stick
if(touch.right.active) drawStick(touch.right, true);
else drawStickHint(W*0.82, H - 118, 'AIM · FIRE', 'rgba(255,90,70,0.9)');
// Swap weapon button
const sb = touch.swapBtn;
const flash = sb.flash > 0;
ctx.save();
ctx.beginPath();
ctx.arc(sb.x, sb.y, sb.r, 0, 6.283);
ctx.fillStyle = flash ? 'rgba(255,210,74,0.7)' : 'rgba(0,0,0,0.42)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,210,74,0.9)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 11px Courier New';
ctx.textAlign = 'center';
ctx.fillText('SWAP', sb.x, sb.y - 3);
ctx.fillStyle = '#fff';
ctx.font = 'bold 13px Courier New';
const lbl = p.weapon === 'pistol' ? 'M9' : (p.weapon === 'smg' ? 'MP5' : 'AK');
ctx.fillText(lbl, sb.x, sb.y + 12);
ctx.textAlign = 'left';
ctx.restore();
// Jump button
const jb = touch.jumpBtn;
const jflash = jb.flash > 0 || jb.down;
ctx.save();
ctx.beginPath();
ctx.arc(jb.x, jb.y, jb.r, 0, 6.283);
ctx.fillStyle = jflash ? 'rgba(120,255,180,0.65)' : 'rgba(0,0,0,0.42)';
ctx.fill();
ctx.strokeStyle = 'rgba(109,255,160,0.9)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = '#6dffa0';
ctx.font = 'bold 20px Courier New';
ctx.textAlign = 'center';
ctx.fillText('▲', jb.x, jb.y + 7);
ctx.font = 'bold 9px Courier New';
ctx.fillText('JUMP', jb.x, jb.y + 22);
ctx.textAlign = 'left';
ctx.restore();
}
/* ============================================================
HUD
============================================================ */
function drawHUD(){
const p = game.player;
const lvl = game.level;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(18, 16, 254, 30);
ctx.strokeStyle = '#2a2f38';
ctx.strokeRect(18.5, 16.5, 253, 29);
const hpPct = p.hp / p.maxHp;
let hpCol = '#4aff9a';
if(hpPct < 0.6) hpCol = '#ffd24a';
if(hpPct < 0.35) hpCol = '#ff4a4a';
ctx.fillStyle = hpCol;
ctx.fillRect(23, 21, 244 * hpPct, 20);
ctx.fillStyle = '#07080c';
ctx.font = 'bold 14px Courier New';
ctx.fillText('VITALS ' + Math.ceil(p.hp) + '%', 30, 36);
const w = WEAPONS[p.weapon];
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(18, 52, 254, 26);
ctx.strokeStyle = '#2a2f38';
ctx.strokeRect(18.5, 52.5, 253, 25);
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 13px Courier New';
const ammoTxt = p.weapon === 'pistol' ? '∞' : p.weapons[p.weapon];
ctx.fillText(w.label + ' ▸ ' + ammoTxt, 26, 70);
ctx.textAlign = 'right';
ctx.fillStyle = '#e8e8ee';
ctx.font = 'bold 15px Courier New';
ctx.fillText('SCORE ' + game.score, W-22, 32);
ctx.fillStyle = '#8a93a3';
ctx.font = '12px Courier New';
if(game.mode === 'story'){
ctx.fillText('FLOOR ' + (game.levelIndex+1) + ' / ' + STORY_LEVELS.length, W-22, 52);
} else {
ctx.fillText('NIGHT SHIFT · WAVE ' + game.endlessFloor, W-22, 52);
}
ctx.textAlign = 'left';
const t = game.timer;
if(t < 4.5){
const a = Math.min(1, (4.5-t)/1.2);
ctx.globalAlpha = a;
ctx.textAlign = 'center';
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 26px Courier New';
ctx.fillText(lvl.title, W/2, 130);
ctx.fillStyle = '#8a93a3';
ctx.font = '14px Courier New';
ctx.fillText(lvl.sub, W/2, 154);
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
if(lvl.hostages.length){
const saved = game.hostagesSaved;
const lost = game.hostagesLost;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(W-232, 68, 214, 26);
ctx.strokeStyle = '#2a2f38';
ctx.strokeRect(W-231.5, 68.5, 213, 25);
ctx.fillStyle = '#6dffa0';
ctx.font = 'bold 13px Courier New';
ctx.fillText('RESCUED ' + saved, W-222, 86);
if(lost > 0){
ctx.fillStyle = '#ff5a5a';
ctx.textAlign = 'right';
ctx.fillText('LOST ' + lost, W-28, 86);
ctx.textAlign = 'left';
}
}
const B = lvl.boss;
if(B && B.alive){
const bw = 460, bx = (W-bw)/2, by = 92;
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(bx-3, by-3, bw+6, 22);
ctx.fillStyle = '#2a1010';
ctx.fillRect(bx, by, bw, 16);
ctx.fillStyle = '#ff3a2a';
ctx.fillRect(bx, by, bw * (B.hp/B.maxHp), 16);
ctx.strokeStyle = '#5a2020';
ctx.strokeRect(bx+0.5, by+0.5, bw-1, 15);
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Courier New';
ctx.textAlign = 'center';
ctx.fillText(B.type === 'heli' ? 'HOSTILE HELICOPTER' : 'THE CHAIRMAN', W/2, by+12);
ctx.textAlign = 'left';
}
// crosshair (desktop only)
if(!touch.using && p.alive){
const mx = mouse.x, my = mouse.y;
ctx.strokeStyle = 'rgba(255,90,70,0.9)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(mx, my, 9, 0, 6.283);
ctx.moveTo(mx-15, my); ctx.lineTo(mx-5, my);
ctx.moveTo(mx+5, my); ctx.lineTo(mx+15, my);
ctx.moveTo(mx, my-15); ctx.lineTo(mx, my-5);
ctx.moveTo(mx, my+5); ctx.lineTo(mx, my+15);
ctx.stroke();
ctx.lineWidth = 1;
}
if(game.subtitle){
const a = Math.min(1, game.subT/0.5);
ctx.globalAlpha = a;
ctx.font = 'bold 15px Courier New';
const tw = ctx.measureText(game.subtitle).width;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(W/2 - tw/2 - 16, H-82, tw + 32, 30);
ctx.strokeStyle = '#3a2020';
ctx.strokeRect(W/2 - tw/2 - 16.5, H-82.5, tw + 33, 31);
ctx.fillStyle = '#ffd24a';
ctx.textAlign = 'center';
ctx.fillText(game.subtitle, W/2, H-61);
ctx.textAlign = 'left';
ctx.globalAlpha = 1;
}
}
/* ============================================================
SCREENS
============================================================ */
function drawMenuButton(x, y, w, h, label, sub, hot){
const pulse = hot ? 0.5 + Math.sin(game.timer*4)*0.5 : 0;
ctx.fillStyle = `rgba(20,26,36,0.85)`;
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = hot ? `rgba(255,90,60,${0.6+pulse*0.4})` : '#2a3040';
ctx.lineWidth = hot ? 3 : 2;
ctx.strokeRect(x+0.5, y+0.5, w-1, h-1);
ctx.lineWidth = 1;
ctx.fillStyle = hot ? '#ffd24a' : '#e8e8ee';
ctx.font = 'bold 20px Courier New';
ctx.textAlign = 'center';
ctx.fillText(label, x + w/2, y + h/2 - 2);
ctx.fillStyle = '#8a93a3';
ctx.font = '11px Courier New';
ctx.fillText(sub, x + w/2, y + h/2 + 18);
ctx.textAlign = 'left';
}
function drawMenu(){
drawBackground();
ctx.fillStyle = 'rgba(6,8,14,0.62)';
ctx.fillRect(0,0,W,H);
ctx.fillStyle = '#0a0d16';
ctx.fillRect(W/2-90, 120, 180, 420);
for(let y=140; y<520; y+=22){
for(let x=W/2-80; x<W/2+80; x+=24){
if(((x*y)%11) < 5){
ctx.fillStyle = Math.random()<0.05 ? '#ffb347' : '#1a2440';
ctx.fillRect(x, y, 14, 12);
}
}
}
ctx.textAlign = 'center';
ctx.fillStyle = '#ff3a2a';
ctx.font = 'bold 15px Courier New';
ctx.fillText('M E T R O L E G A C Y', W/2, 106);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 58px Courier New';
ctx.fillText('AGENT', W/2, 166);
ctx.fillStyle = '#d4a017';
ctx.font = 'bold 17px Courier New';
ctx.fillText('"A DAY TO DIE HARD"', W/2, 198);
ctx.fillStyle = '#8a93a3';
ctx.font = '13px Courier New';
ctx.fillText('ONE TOWER. 24 HOURS. NO BACKUP.', W/2, 226);
drawMenuButton(W/2-240, 288, 480, 56, 'STORY MODE', 'DIE HARD · FIVE FLOORS', true);
drawMenuButton(W/2-240, 352, 480, 56, 'NIGHT SHIFT', 'ENDLESS · SURVIVE THE WAVES', true);
ctx.fillStyle = '#7a8393';
ctx.font = 'bold 12px Courier New';
ctx.fillText('◀ LEFT TOUCH = MOVE · JUMP UP ON STICK · RIGHT TOUCH = AIM & FIRE', W/2, 442);
ctx.fillStyle = '#5a6373';
ctx.font = '11px Courier New';
ctx.fillText('BUTTONS: SWAP WEAPON · JUMP | KEYBOARD: A/D · W/SPACE · MOUSE · K', W/2, 464);
ctx.fillStyle = '#3a4250';
ctx.font = '11px Courier New';
ctx.fillText('TAP A MODE TO BEGIN', W/2, 505);
ctx.textAlign = 'left';
}
function drawCleared(){
ctx.fillStyle = 'rgba(0,0,0,0.72)';
ctx.fillRect(0,0,W,H);
ctx.textAlign = 'center';
const lvl = game.level;
const isLast = game.mode === 'story' && game.levelIndex === STORY_LEVELS.length-1;
ctx.fillStyle = '#4aff9a';
ctx.font = 'bold 42px Courier New';
ctx.fillText(isLast ? 'TOWER SECURED' : 'FLOOR CLEARED', W/2, 200);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px Courier New';
ctx.fillText(lvl.title, W/2, 240);
ctx.font = '14px Courier New';
ctx.fillStyle = '#8a93a3';
ctx.fillText('HOSTILES DOWN: ' + game.kills, W/2, 292);
if(lvl.hostages.length){
ctx.fillStyle = '#6dffa0';
ctx.fillText('CIVILIANS RESCUED: ' + game.hostagesSaved, W/2, 318);
if(game.hostagesLost){
ctx.fillStyle = '#ff5a5a';
ctx.fillText('CIVILIANS LOST: ' + game.hostagesLost, W/2, 344);
}
}
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 20px Courier New';
ctx.fillText('SCORE: ' + game.score, W/2, 392);
ctx.fillStyle = '#ffffff';
ctx.font = '13px Courier New';
ctx.fillText(game.mode === 'story' ? 'CLIMBING TO THE NEXT FLOOR...' : '', W/2, 450);
ctx.textAlign = 'left';
}
function drawDead(){
ctx.fillStyle = `rgba(60,0,0,${Math.min(0.75, (2.2-game.deadT)*0.6)})`;
ctx.fillRect(0,0,W,H);
ctx.textAlign = 'center';
ctx.fillStyle = '#ff3a2a';
ctx.font = 'bold 52px Courier New';
ctx.fillText('YOU DIED HARD', W/2, 230);
ctx.fillStyle = '#ffffff';
ctx.font = '16px Courier New';
ctx.fillText(game.mode === 'story'
? 'The tower stays silent. The hostages stay hostages.'
: 'You cleared ' + (game.endlessFloor-1) + ' floors before they got you.', W/2, 280);
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 22px Courier New';
ctx.fillText('SCORE: ' + game.score, W/2, 340);
const pulse = 0.5 + Math.sin(game.timer*4)*0.5;
ctx.fillStyle = `rgba(255,255,255,${0.5+pulse*0.5})`;
ctx.font = 'bold 15px Courier New';
ctx.fillText(touch.using ? 'TAP ANYWHERE TO RESTART' : 'PRESS R TO RESTART M FOR MENU', W/2, 410);
ctx.textAlign = 'left';
}
function drawWin(){
ctx.fillStyle = 'rgba(0,0,0,0.78)';
ctx.fillRect(0,0,W,H);
ctx.textAlign = 'center';
const pulse = 0.6 + Math.sin(game.timer*3)*0.4;
ctx.fillStyle = `rgba(109,255,160,${pulse})`;
ctx.font = 'bold 50px Courier New';
ctx.fillText('DAWN', W/2, 180);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 20px Courier New';
ctx.fillText('THE CHAIRMAN IS DEAD. THE TOWER IS YOURS.', W/2, 230);
ctx.fillStyle = '#8a93a3';
ctx.font = '14px Courier New';
ctx.fillText('Ngozi is safe. The hostages walk out into Lagos morning traffic.', W/2, 264);
ctx.fillText('The police arrive forty minutes late.', W/2, 288);
ctx.fillStyle = '#ffd24a';
ctx.font = 'bold 24px Courier New';
ctx.fillText('FINAL SCORE: ' + game.score, W/2, 360);
ctx.fillStyle = '#ffffff';
ctx.font = '14px Courier New';
ctx.fillText('HOSTILES ELIMINATED: ' + game.kills, W/2, 400);
ctx.fillText('CIVILIANS RESCUED: ' + game.hostagesSaved, W/2, 424);
const pulse2 = 0.5 + Math.sin(game.timer*4)*0.5;
ctx.fillStyle = `rgba(255,255,255,${0.5+pulse2*0.5})`;
ctx.font = 'bold 15px Courier New';
ctx.fillText(touch.using ? 'TAP ANYWHERE TO PLAY AGAIN' : 'PRESS R TO PLAY AGAIN M FOR MENU', W/2, 480);
ctx.textAlign = 'left';
}
/* ============================================================
RENDER
============================================================ */
function render(){
ctx.clearRect(0,0,W,H);
if(game.state === 'menu'){
drawMenu();
return;
}
const lvl = game.level;
const p = game.player;
ctx.save();
let shakeX = 0, shakeY = 0;
if(game.shake > 0){
shakeX = rand(-game.shake, game.shake);
shakeY = rand(-game.shake, game.shake);
}
ctx.translate(shakeX, shakeY);
drawBackground();
drawInterior();
drawPlatforms(lvl.platforms);
drawExit();
drawPickups();
for(const h of lvl.hostages) drawHostage(h);
for(const e of lvl.enemies) drawEnemy(e);
drawBoss();
drawGrenades();
drawBullets();
drawPlayer();
drawParticles();
drawTexts();
ctx.restore();
if(lvl.dark && p){
const px = p.x - game.camX + p.w/2;
const py = p.y + 16;
const g = ctx.createRadialGradient(px, py, 30, px, py, 330);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(0.45, 'rgba(0,0,0,0.45)');
g.addColorStop(0.78, 'rgba(0,0,0,0.88)');
g.addColorStop(1, 'rgba(0,0,0,0.97)');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,H);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.translate(px, py);
ctx.rotate(p.aim);
const cone = ctx.createLinearGradient(0,0,300,0);
cone.addColorStop(0,'rgba(255,240,200,0.10)');
cone.addColorStop(1,'rgba(255,240,200,0)');
ctx.fillStyle = cone;
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(300, -70);
ctx.lineTo(300, 70);
ctx.closePath();
ctx.fill();
ctx.restore();
}
if(game.hitFlash > 0){
ctx.fillStyle = `rgba(180,20,20,${game.hitFlash*0.45})`;
ctx.fillRect(0,0,W,H);
}
if(p && p.alive && p.hp < 35){
const a = 0.25 + Math.sin(game.timer*5)*0.08;
const vg = ctx.createRadialGradient(W/2,H/2,140,W/2,H/2,520);
vg.addColorStop(0,'rgba(120,0,0,0)');
vg.addColorStop(1,`rgba(120,0,0,${a})`);
ctx.fillStyle = vg;
ctx.fillRect(0,0,W,H);
}
drawHUD();
drawTouchControls();
if(game.state === 'cleared') drawCleared();
if(game.state === 'dead') drawDead();
if(game.state === 'win') drawWin();
}
/* ============================================================
KEY HANDLING
============================================================ */
function handleKeyPress(code){
if(game.state === 'menu'){
if(code === 'Digit1' || code === 'Numpad1'){ startStory(); }
if(code === 'Digit2' || code === 'Numpad2'){ startEndless(); }
return;
}
if(game.state === 'dead'){
if(code === 'KeyR'){
if(game.mode === 'story') loadStoryLevel(game.levelIndex);
else loadEndlessFloor(game.endlessFloor, false);
}
if(code === 'KeyM'){ game.state='menu'; game.timer=0; }
return;
}
if(game.state === 'win'){
if(code === 'KeyR') startStory();
if(code === 'KeyM'){ game.state='menu'; game.timer=0; }
return;
}
if(game.state === 'play'){
if(code === 'KeyQ'){ swapWeapon(); }
}
}
/* ============================================================
MAIN LOOP
============================================================ */
let lastT = performance.now();
function loop(now){
const dt = Math.min(0.05, (now - lastT)/1000);
lastT = now;
if(game.state === 'menu') game.timer += dt;
else update(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
/* ---------- INIT ---------- */
game.state = 'menu';
game.timer = 0;
/* Detect touch device on load so we can hint appropriately */
if('ontouchstart' in window || navigator.maxTouchPoints > 0){
touch.using = true;
}
/* Prevent page scroll/zoom on iOS */
document.addEventListener('touchmove', e=>{
if(e.target === cvs) e.preventDefault();
}, {passive:false});
document.addEventListener('gesturestart', e=>e.preventDefault());
document.addEventListener('dblclick', e=>e.preventDefault());
</script>
</body>
</html>Game Source: METRO LEGACY: AGENT
Creator: LaserBuilder68
Libraries: none
Complexity: complex (2345 lines, 70.5 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: metro-legacy-agent-laserbuilder68" to link back to the original. Then publish at arcadelab.ai/publish.